pl4y.store

6 - Shell - partie 2

launch/start-oqc.sh

.sh 54 lignes - 2,2KB

launch/start-oqc.sh
#!/bin/bash
# =============================================================================
#  launch/start-oqc.sh - lance qosmo-grgsm depuis osmo-operator
# =============================================================================
#
#  "oqc" = qosmo-grgsm, l'emulation Calypso (ARM7 + DSP TMS320C54x).
#  Ce fichier est le raccourci depuis osmo-operator : il trouve le depot et lui
#  passe la main. Toute la logique de lancement vit la-bas, dans run_modules/.
#
#      ./launch/start-oqc.sh                  profil calypso (defaut)
#      ./launch/start-oqc.sh --list           le plan, sans rien lancer
#      ./launch/start-oqc.sh --dry-run        deroule sans effet de bord
#      ./launch/start-oqc.sh --status         ou en est chaque etape
#      ./launch/start-oqc.sh --stop           arrete, en ordre inverse
#      ./launch/start-oqc.sh --check-paths    verifie les dependances de la machine
#
#  Les variables CALYPSO_* passees en prefixe traversent jusqu'a QEMU :
#      CALYPSO_MODE=native ./launch/start-oqc.sh
#
#  Ou est le depot : OQC_ROOT, sinon les emplacements habituels sont essayes.
#      OQC_ROOT=~/src/qosmo-grgsm ./launch/start-oqc.sh
#
#  A ne pas confondre avec start-direct.sh, qui orchestre TOUTE la pile
#  (coeur Osmocom + radio) et dont le mode "qemu" aboutit egalement ici.
# -----------------------------------------------------------------------------
set -euo pipefail

_find_oqc() {
    [ -n "${OQC_ROOT:-}" ] && { printf '%s\n' "$OQC_ROOT"; return 0; }
    local here; here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"   # launch/ -> racine du depot
    local c
    for c in "$here/../qosmo-grgsm" \
             "${GSM_ROOT:-/opt/GSM}/qosmo-grgsm" \
             "$HOME/qosmo-grgsm"; do
        [ -x "$c/run.sh" ] && { (cd "$c" && pwd); return 0; }
    done
    return 1
}

if ! OQC="$(_find_oqc)"; then
    cat >&2 <<'ERR'
qosmo-grgsm introuvable.

Cherche dans : ../qosmo-grgsm, $GSM_ROOT/qosmo-grgsm, ~/qosmo-grgsm

Indiquez son emplacement :
    OQC_ROOT=/chemin/vers/qosmo-grgsm ./launch/start-oqc.sh
ou recuperez-le :
    git clone https://github.com/bbaranoff/qosmo-grgsm qosmo-grgsm
ERR
    exit 2
fi

exec "$OQC/run.sh" "$@"

lib/audio.sh

.sh 429 lignes - 25KB

lib/audio.sh
# =============================================================================
#  lib/audio.sh - la chaine audio d'osmo-operator (PulseAudio + pont GAPK)
# =============================================================================
#
#  CE FICHIER EST UNE EXTRACTION, PAS UNE REECRITURE.
#  ensure_pulse / ensure_host_audio / ensure_gapk viennent telles quelles de
#  start-direct.sh.legacy (L566-722). Aucune ligne n'a ete retouchee.
#
#  POURQUOI LES PRESERVER.
#  Le mode `qemu` de start-direct.sh appelait ensure_gapk JUSTE AVANT de passer
#  la main a qosmo-grgsm/start-clean.sh (legacy L1077). C'est ce qui branche le RTP
#  du MGW sur le sink `gsm_audio` : sans lui, la pile monte, l'appel s'etablit,
#  et personne n'entend rien. Le chemin `qemu` devant continuer a marcher a
#  l'identique, cette precondition devait survivre au decoupage - on la deplace,
#  on ne la reinvente pas.
#
#  CE FICHIER NE FAIT RIEN AU SOURCE : il ne definit que des fonctions.
#  Appelant attendu : run_modules/25-audio.sh.
#
#  AUDIO=0 desactive toute la mise en place, exactement comme dans l'original.
# -----------------------------------------------------------------------------

: "${HERE:=/opt/GSM/osmo-operator}"
: "${LOG_DIR:=/root}"
: "${AUDIO:=1}"
: "${PULSE_SOCK:=/var/run/pulse/native}"
# L'original colorait ses messages ; sous `set -u` une couleur non definie
# ferait echouer la fonction avant meme d'avoir agi. On les neutralise.
RED='' GREEN='' YELLOW='' CYAN='' NC='' BOLD=''

# Les DEUX null-sinks de la chaine GSM, declares au meme endroit : gsm_audio
# (= alsa gsm_out, la voix qui SORT) et gsm_mic (= alsa gsm_in via
# gsm_mic.monitor, le micro silencieux qui ENTRE).
#
# [2026-08-12] gsm_mic n'etait cree QUE par scripts/pulse-gsm-setup.sh, que
# run.sh appelle ligne 422 - soit APRES osmo-start.sh (ligne 307). Un HLR qui ne
# demarre pas fait `exit 1` dans osmo-start.sh, le `set -euo pipefail` de run.sh
# tue tout, et le sink n'est jamais cree. Or gapk_io ABANDONNE LES DEUX SENS
# quand la capture echoue :
#     pq_alsa.c:168  Couldn't init ALSA device 'gsm_in': Input/output error
#     gapk_io.c:468  Failed to initialize GAPK I/O
# → appel parfaitement etabli mais TOTALEMENT muet, et l'erreur est enterree
# dans mobile.log. C'est le "ca marche sur un PC, pas sur l'autre" : le tirage
# au sort, c'est de savoir si le HLR est monte avant.
#
# Les deux sinks sont desormais SOLIDAIRES - ici, dans system.pa et dans le
# Dockerfile. Aucun ordre de script ne peut plus en perdre un.
GSM_SINKS="gsm_audio:GSM_Audio gsm_mic:GSM_Mic"

load_gsm_sinks() {
    local entry name desc
    for entry in $GSM_SINKS; do
        name="${entry%%:*}"; desc="${entry##*:}"
        pactl list short sinks 2>/dev/null | grep -qw "$name" || \
            pactl load-module module-null-sink sink_name="$name" \
                format=s16le rate=8000 channels=1 \
                sink_properties=device.description="$desc" >/dev/null 2>&1 || true
    done
}

# ── Loopback local : gsm_audio.monitor → la carte son de la machine ──────────
# [2026-08-14] CE MAILLON N'EXISTAIT NULLE PART. Le commentaire de ensure_pulse
# ("en local l'hote entend via le module-loopback vers ses enceintes") le
# SUPPOSE deja present, mais aucun chemin du depot ne le chargeait :
#   - enable_user_loopback() de start.sh n'est appelee par personne ;
#   - network/loopback.sh n'est lance que sciemment, a la main.
# Resultat mesure dans la VM osmo-egprs : gsm_audio RUNNING (mobile ecrit
# dedans), gsm_audio.monitor IDLE (personne ne lit), sortie ALSA SUSPENDED,
# /proc/asound/card0/pcm0p/sub0/status = "closed". Appel etabli, zero son.
# gsm_audio est un module-null-sink : sans consommateur, la voix descendante est
# jetee PAR CONSTRUCTION. Ce n'est pas une panne, c'est un maillon manquant.
#
# ⚠️ Le choix du sink n'est pas cosmetique : reboucler sur gsm_audio ou gsm_mic
# recree la boucle fermee du 08/08 (cf. ensure_gapk : "les mobiles ecrivent
# dans gsm_out, on les rejouerait dans leur propre uplink"). On exclut donc
# explicitement les deux null-sinks et on ne garde qu'une sortie materielle.
LOOPBACK_LATENCY_MSEC="${LOOPBACK_LATENCY_MSEC:-20}"

ensure_local_loopback() {
    [ "${AUDIO:-1}" = "1" ] || return 0
    [ "${AUDIO_LOCAL_LOOPBACK:-1}" = "1" ] || {
        echo -e "  ${YELLOW}[audio] loopback local desactive (AUDIO_LOCAL_LOOPBACK=0)${NC}"; return 0; }
    pactl info >/dev/null 2>&1 || return 0

    pactl list short sources 2>/dev/null | grep -qw 'gsm_audio.monitor' || {
        echo -e "  ${YELLOW}[audio] gsm_audio.monitor absent - loopback local ignore${NC}"; return 0; }

    # Sortie materielle = le sink par defaut, SAUF si c'est un de nos null-sinks.
    local sink
    sink="$(pactl get-default-sink 2>/dev/null || true)"
    case "$sink" in
        ''|gsm_audio|gsm_mic|@*)
            sink="$(pactl list short sinks 2>/dev/null \
                    | awk '$2 != "gsm_audio" && $2 != "gsm_mic" { print $2; exit }')" ;;
    esac
    [ -n "$sink" ] || {
        echo -e "  ${YELLOW}[audio] aucune sortie materielle - loopback local ignore${NC}"; return 0; }

    # Idempotent : ne pas empiler un 2e loopback (voix doublee + echo).
    if pactl list short modules 2>/dev/null | grep -F 'module-loopback' \
         | grep -F 'source=gsm_audio.monitor' | grep -qF "sink=${sink}"; then
        echo -e "  ${GREEN}[audio] loopback local deja en place → ${sink}${NC}"
        return 0
    fi

    if pactl load-module module-loopback \
            source=gsm_audio.monitor sink="$sink" \
            latency_msec="$LOOPBACK_LATENCY_MSEC" >/dev/null 2>&1; then
        echo -e "  ${GREEN}[audio] loopback local charge : gsm_audio.monitor → ${sink} (${LOOPBACK_LATENCY_MSEC} ms)${NC}"
    else
        echo -e "  ${YELLOW}[audio] echec du loopback local vers ${sink}${NC}"
    fi
}

# ── Micro local : la carte son de la machine → gsm_mic ─────────────────
# [2026-08-31] SYMETRIQUE de ensure_local_loopback, et il manquait pareil. Le
# montant (ce que le mobile EMET vers le reseau) part de gsm_in, c'est-a-dire de
# gsm_mic.monitor - un null-sink que PERSONNE n'alimentait depuis la machine.
# Le seul producteur etait la console web : le navigateur capturait le micro,
# poussait les echantillons a /opt/GSM/osmo-egprs-web/server.js, qui les
# reinjectait via `pacat --playback -d gsm_mic`. Chemin mesure le 31/08 :
# 3 a 4 secondes de retard sur la voix montante, alors que TOUT le trajet
# PulseAudio tient sous 100 ms (pacat 45 ms, capture du mobile 0 us, loopback
# descendant 20 ms). Le tampon n'est donc pas dans PulseAudio mais dans le
# transport navigateur -> node -> socket, qu'aucun reglage pulse n'atteint.
# Un module-loopback depuis la carte son supprime ce transport au lieu de
# l'accelerer : le micro alimente gsm_mic directement, en 20 ms.
#
# ⚠️ DEUX SOURCES A NE JAMAIS PRENDRE ICI :
#   - gsm_audio.monitor : c'est le DESCENDANT. Le rejouer dans le montant, c'est
#     exactement la boucle fermee du 08/08 (RMS x11 en 10 s) que le commentaire
#     d'/etc/asound.conf documente. On ne garde qu'une entree MATERIELLE.
#   - gsm_mic.monitor : le sink qu'on alimente - il se recopierait sur lui-meme.
#
# ⚠️ Boucle ACOUSTIQUE : le descendant sort sur les enceintes (loopback
# ci-dessus) et le micro les reentend. Au casque c'est sans objet ; sur
# haut-parleurs, couper l'un des deux ou baisser le volume.
AUDIO_LOCAL_MIC="${AUDIO_LOCAL_MIC:-1}"

ensure_local_mic() {
    [ "${AUDIO:-1}" = "1" ] || return 0
    [ "$AUDIO_LOCAL_MIC" = "1" ] || {
        echo -e "  ${YELLOW}[audio] micro local desactive (AUDIO_LOCAL_MIC=0)${NC}"; return 0; }
    pactl info >/dev/null 2>&1 || return 0

    pactl list short sinks 2>/dev/null | grep -qw 'gsm_mic' || {
        echo -e "  ${YELLOW}[audio] sink gsm_mic absent - micro local ignore${NC}"; return 0; }

    # Entree materielle = la source par defaut, SAUF si c'est un moniteur de
    # null-sink (voir l'avertissement ci-dessus). Repli : la premiere source
    # qui n'est pas un .monitor.
    local src
    src="$(pactl get-default-source 2>/dev/null || true)"
    case "$src" in
        ''|*.monitor|@*)
            src="$(pactl list short sources 2>/dev/null \
                    | awk '$2 !~ /\.monitor$/ { print $2; exit }')" ;;
    esac
    [ -n "$src" ] || {
        echo -e "  ${YELLOW}[audio] aucune entree materielle - micro local ignore${NC}"; return 0; }

    # Idempotent : un 2e loopback doublerait la voix montante.
    if pactl list short modules 2>/dev/null | grep -F 'module-loopback' \
         | grep -F "source=${src}" | grep -qF 'sink=gsm_mic'; then
        echo -e "  ${GREEN}[audio] micro local deja en place : ${src} → gsm_mic${NC}"
        return 0
    fi

    if pactl load-module module-loopback \
            source="$src" sink=gsm_mic \
            latency_msec="$LOOPBACK_LATENCY_MSEC" >/dev/null 2>&1; then
        echo -e "  ${GREEN}[audio] micro local charge : ${src} → gsm_mic (${LOOPBACK_LATENCY_MSEC} ms)${NC}"
        echo -e "  ${CYAN}       → la console web n'est plus necessaire pour parler.${NC}"
    else
        echo -e "  ${YELLOW}[audio] echec du micro local depuis ${src}${NC}"
    fi
}

# Post-condition : les PCM que `mobile` va reellement ouvrir s'ouvrent-ils ?
# Tester le sink avec `pactl list sinks` ne suffit pas - c'est le mapping ALSA
# de /etc/asound.conf qui casse (gsm_in → gsm_mic.monitor). On ouvre pour de
# vrai, 1 s de chaque cote, et on gueule si ca rate. Sans ca la panne est
# silencieuse jusqu'au premier appel muet.
assert_audio_devices() {
    command -v aplay >/dev/null 2>&1 || return 0
    local ko=0
    timeout 5 aplay  -D gsm_out -f S16_LE -r 8000 -c 1 -d 1 /dev/zero >/dev/null 2>&1 || {
        echo -e "  ${RED}[audio] KO : lecture 'gsm_out' impossible (sink gsm_audio ?)${NC}"; ko=1; }
    timeout 5 arecord -D gsm_in  -f S16_LE -r 8000 -c 1 -d 1 /dev/null  >/dev/null 2>&1 || {
        echo -e "  ${RED}[audio] KO : capture 'gsm_in' impossible (sink gsm_mic ?)${NC}"; ko=1; }
    if [ "$ko" = "1" ]; then
        echo -e "  ${RED}       → gapk_io va echouer et l'appel sera MUET DANS LES DEUX SENS.${NC}"
        echo -e "  ${RED}       → pactl list short sinks  (attendu : gsm_audio ET gsm_mic)${NC}"
        return 1
    fi
    echo -e "  ${GREEN}[audio] gsm_out (lecture) + gsm_in (capture) ouverts - chaine OK${NC}"
    return 0
}

ensure_pulse() {
    [ "${AUDIO:-1}" = "1" ] || { echo -e "  ${YELLOW}[audio] desactive (AUDIO=0)${NC}"; return 0; }

    # 0. Mapping ALSA gsm_out/gsm_in → sink PulseAudio gsm_audio (REQUIS cote hote
    #    en mode natif). Sans /etc/asound.conf, le `mobile` (io-handler gapk,
    #    alsa-output-dev gsm_out) ouvre un PCM ALSA inexistant → "Unknown PCM
    #    gsm_out" → l'audio TCH n'est jamais decode vers gsm_audio → silence
    #    navigateur. Le sink seul ne suffit pas : ce mapping doit exister.
    if [ -f "$HERE/configs/asound.conf" ] && ! cmp -s "$HERE/configs/asound.conf" /etc/asound.conf 2>/dev/null; then
        cp -f "$HERE/configs/asound.conf" /etc/asound.conf \
            && echo -e "  ${GREEN}[audio] /etc/asound.conf deploye (ALSA gsm_out/gsm_in → pulse gsm_audio)${NC}"
    fi

    export PULSE_SERVER="unix:${PULSE_SOCK}"
    if pactl info >/dev/null 2>&1; then
        # PulseAudio deja actif (service osmo-pulse au boot, ou un 'fake' solo
        # precedent). Le sink gsm_audio n'est PAS forcement charge dans CE demon
        # -> on le (re)charge a la volee s'il manque. SANS ca : l'audio ne marchait
        # qu'apres un 'fake' solo (qui avait pose le sink) - c'est le maillon qui
        # manquait a fake+qemu pour avoir l'audio de lui-meme.
        load_gsm_sinks
        # Dedoublonnage : sur une PipeWire/Pulse PARTAGEE entre containers, chaque
        # ensure_pulse charge son propre module-null-sink gsm_audio → doublons.
        # parec (flux /audio du dashboard) lit alors le monitor d'un sink que gapk
        # n'alimente PAS → audio muet pour les clients distants (navigateur/Windows ;
        # en local l'hote entend via le module-loopback vers ses enceintes, d'ou
        # "ca marche sur Linux mais pas sur Windows"). On garde UN seul gsm_audio
        # (le 1er module) et on decharge les suivants.
        # (le dedoublonnage vaut pour gsm_mic autant que pour gsm_audio : un
        #  gsm_mic en double et gapk capture le monitor du mauvais sink)
        local _entry _name
        for _entry in $GSM_SINKS; do
            _name="${_entry%%:*}"
            pactl list short modules 2>/dev/null \
                | awk -v s="$_name" '/module-null-sink/ && $0 ~ ("sink_name=" s "([ \t]|$)") {print $1}' \
                | tail -n +2 \
                | while read -r _m; do [ -n "$_m" ] && pactl unload-module "$_m" >/dev/null 2>&1 \
                    && echo -e "  ${YELLOW}[audio] sink ${_name} en double decharge (module $_m)${NC}"; done
        done
        echo -e "  ${GREEN}[audio] PulseAudio deja actif (sinks gsm_audio + gsm_mic uniques assures)${NC}"
        assert_audio_devices || true
        ensure_local_loopback
        ensure_local_mic
        return 0
    fi

    # 1. Installer pulseaudio si absent (le binaire demon, pas que les clients)
    if ! command -v pulseaudio >/dev/null 2>&1; then
        if command -v apt-get >/dev/null 2>&1; then
            echo -e "  ${YELLOW}[audio] installation pulseaudio...${NC}"
            DEBIAN_FRONTEND=noninteractive apt-get install -y -q \
                pulseaudio pulseaudio-utils alsa-utils >/dev/null 2>&1 || true
        fi
    fi
    command -v pulseaudio >/dev/null 2>&1 || {
        echo -e "  ${YELLOW}[audio] pulseaudio indisponible - audio ignore${NC}"; return 0; }

    # 2. Config system.pa : acces anonyme + les DEUX sinks null (idempotent).
    #    Les declarer ici plutot que de compter sur un load-module au runtime
    #    est ce qui rend la chaine robuste : ils existent des le demarrage du
    #    demon, y compris si le demon redemarre tout seul plus tard.
    local sp=/etc/pulse/system.pa
    if [ -f "$sp" ]; then
        grep -q 'auth-anonymous=1' "$sp" || sed -i \
            's|^load-module module-native-protocol-unix.*|load-module module-native-protocol-unix auth-anonymous=1 socket=/var/run/pulse/native|' "$sp"
        local _entry _name _desc
        for _entry in $GSM_SINKS; do
            _name="${_entry%%:*}"; _desc="${_entry##*:}"
            grep -q "sink_name=${_name}\b" "$sp" || \
                echo "load-module module-null-sink sink_name=${_name} format=s16le rate=8000 channels=1 sink_properties=device.description=${_desc}" >> "$sp"
        done
    fi

    # 3. (Re)demarrer le demon systeme - SERIALISE
    mkdir -p /var/run/pulse "$LOG_DIR"
    # ensure_pulse est appele en parallele (flux principal + ensure_gapk via la
    # session tmux 'gapk') → deux 'pulseaudio --system' se lancaient en meme
    # temps et se disputaient /var/run/pulse/native → "bind(): Address already
    # in use" → le module socket echoue → demon mort → injoignable.
    # flock garantit UN SEUL (re)demarrage a la fois ; un demon residuel detenant
    # un socket perime (parfois sous un autre uid) est tue et le socket efface
    # par root avant le bind.
    (
        flock 9
        if ! pactl info >/dev/null 2>&1; then
            pkill -x pulseaudio 2>/dev/null || true
            local _k=10
            while pgrep -x pulseaudio >/dev/null 2>&1 && [ $_k -gt 0 ]; do sleep 0.3; ((_k--)) || true; done
            pkill -9 -x pulseaudio 2>/dev/null || true
            rm -f /var/run/pulse/pid /var/run/pulse/native 2>/dev/null || true
            chown -R pulse:pulse /var/run/pulse 2>/dev/null || true
            pulseaudio --system --daemonize=yes --disallow-exit --exit-idle-time=-1 \
                --log-target="file:${LOG_DIR}/pulse-system.log" >/dev/null 2>&1 || true
            local r=10
            while [ $r -gt 0 ]; do pactl info >/dev/null 2>&1 && break; sleep 1; ((r--)) || true; done
        fi
    ) 9>/run/osmo-pulse.lock

    if pactl info >/dev/null 2>&1; then
        load_gsm_sinks
        echo -e "  ${GREEN}[audio] PulseAudio pret (sinks gsm_audio + gsm_mic) @ ${PULSE_SOCK}${NC}"
        assert_audio_devices || true
        ensure_local_loopback
        ensure_local_mic
    else
        echo -e "  ${YELLOW}[audio] PulseAudio injoignable - audio degrade${NC}"
        echo -e "  ${YELLOW}        → voir ${LOG_DIR}/pulse-system.log${NC}"
    fi
}

# ── Bridge audio : osmo-gapk auto (chemin reseau MGW RTP → sink gsm_audio) ────
# gapk auto poll le VTY OsmoMGW (4243) et bridge le RTP de CHAQUE appel vers
# alsa://gsm_out (= sink null PulseAudio gsm_audio). C'est ce maillon qui rend
# l'audio des appels "reseau" (ex: 600 = echo-test Asterisk via MGW) audible
# dans le dashboard web : server.js capte gsm_audio.monitor → /audio (MP3).
# Lance en session tmux DETACHEE 'gapk' : survit aux 'exec' (start-clean.sh /
# tmux attach) des modes qemu/hybride et poll jusqu'a ce que le MGW soit up.
# Idempotent (relance la session), non-fatal. AUDIO=0 → desactive.
# Pont voix conteneur → hote : parec(gsm_audio.monitor) | paplay(--server=relai).
# run.sh (no-process) tente ce pont AVANT que PulseAudio soit pret (course →
# "PulseAudio injoignable apres 30s" → pont jamais lance → VOIX MUETTE dans docker).
# On le (re)lance ICI, apres ensure_pulse (sink gsm_audio garanti up). Idempotent,
# gated sur HOST_AUDIO_RELAY (pose par start.sh = tcp:<gw>:4713).
ensure_host_audio() {
    local relay="${HOST_AUDIO_RELAY:-}"
    [ -n "$relay" ] || return 0
    if ! pactl --server="$relay" info >/dev/null 2>&1; then
        echo -e "  ${YELLOW}[host-audio] relai ${relay} injoignable - pont voix non lance${NC}"; return 0
    fi
    # ── UN SEUL PONT POUR TOUT LE BANC ──────────────────────────────────────
    # [2026-08-31] LE SON ETAIT ENTENDU EN DOUBLE A PARTIR DE DEUX OPERATEURS.
    # Le `pkill` plus bas s execute DANS le conteneur, qui a son propre espace de
    # PID : il ne voit JAMAIS le pont du voisin. Chaque operateur demarrait donc
    # le sien, et comme ils poussent tous vers le MEME PulseAudio d hote, on
    # entendait l appel deux fois a deux operateurs, trois fois a trois.
    # On interroge donc le SERVEUR PARTAGE, pas la table des processus locale :
    # un client deja connecte sous ce nom veut dire qu un pont tourne quelque
    # part, peu importe dans quel conteneur.
    #
    # ⚠️ scripts/run.sh (audio_bridge) porte le MEME garde-fou. Les deux copies
    # doivent garder LE MEME nom de client : c est par ce nom qu elles se voient.
    # C est precisement ce qui manquait ici - le correctif n avait ete pose que
    # sur run.sh, et cette copie-ci lancait un pont ANONYME, donc invisible au
    # garde-fou de l autre, qui en ajoutait un second.
    local _bridge_name="osmo-gsm-bridge"
    if pactl --server="$relay" list clients 2>/dev/null \
         | grep -q "application.name = \"${_bridge_name}\""; then
        echo -e "  ${GREEN}[host-audio] pont deja actif sur l hote (${relay}) - on n en ajoute pas un second${NC}"
        return 0
    fi
    # ── NI DOUBLON AVEC LE LOOPBACK DE L HOTE ───────────────────────────────
    # L hote charge deja son module-loopback gsm_audio.monitor -> carte son
    # (ensure_local_loopback, plus haut dans ce fichier). C est le MEME travail
    # que ce pont : meme source, meme sortie. Les deux ensemble, tout est entendu
    # DEUX FOIS - et le symptome survit a l arret d un conteneur, ce qui fait
    # chercher le doublon du mauvais cote. On garde le loopback : il est cote
    # hote et bien plus court (20 ms contre 250 ms pour le pont TCP).
    if pactl --server="$relay" list short modules 2>/dev/null \
         | grep -q 'source=gsm_audio.monitor'; then
        echo -e "  ${GREEN}[host-audio] loopback gsm_audio deja en place sur l hote - pont TCP inutile${NC}"
        return 0
    fi
    pkill -f "paplay --server=${relay}" 2>/dev/null || true          # idempotent (local)
    [ -f /run/host-audio.pid ] && kill -- "-$(cat /run/host-audio.pid)" 2>/dev/null || true
    # parec lit le pulse LOCAL (gsm_audio.monitor) ; paplay pousse vers l'hote.
    # --latency-msec : capture courte (30ms) + lecture TAMPONNEE (250ms) pour
    # absorber la gigue (ordonnancement/TCP/2 horloges pulse) - sinon voix HACHEE.
    # --client-name : c est LUI qui rend ce pont visible au garde-fou ci-dessus,
    # depuis n importe quel conteneur. Sans lui, chacun se croit seul.
    setsid env PULSE_SERVER="unix:${PULSE_SOCK}" sh -c '
      while true; do
        parec -d gsm_audio.monitor --latency-msec=30 --format=s16le --rate=8000 --channels=1 \
          | paplay --server='"${relay}"' --client-name='"${_bridge_name}"' --latency-msec=250 --raw --format=s16le --rate=8000 --channels=1
        sleep 1
      done' >"${LOG_DIR}/host-audio.log" 2>&1 &
    echo $! > /run/host-audio.pid
    echo -e "  ${GREEN}[host-audio] pont voix parec|paplay → hote (${relay})${NC}"
}

ensure_gapk() {
    [ "${AUDIO:-1}" = "1" ] || return 0
    command -v tmux      >/dev/null 2>&1 || { echo -e "  ${YELLOW}[gapk] tmux absent - bridge audio non lance${NC}"; return 0; }
    command -v osmo-gapk >/dev/null 2>&1 || { echo -e "  ${YELLOW}[gapk] osmo-gapk absent - bridge audio non lance${NC}"; return 0; }
    ensure_pulse   # gapk ecrit dans alsa://gsm_out = sink gsm_audio (idempotent)
    local gapk_sh="/etc/osmocom/gapk-start.sh"; [ -x "$gapk_sh" ] || gapk_sh="$HERE/scripts/gapk-start.sh"
    [ -x "$gapk_sh" ] || { echo -e "  ${YELLOW}[gapk] gapk-start.sh introuvable - bridge audio non lance${NC}"; return 0; }
    tmux kill-session -t gapk 2>/dev/null || true
    # [2026-08-10] Le format etait code en dur a "gsmfr", un nom qui n'existe
    # pas dans osmo-gapk : les deux process mouraient a l'analyse des arguments
    # ("Unsupported format: gsmfr") et le pont n'a jamais rien transporte.
    # Le nom du GSM FR est "gsm". Le 3e argument est le peripherique de
    # CAPTURE : il doit rester gsm_in (moniteur du null-sink gsm_mic). Le
    # pointer sur gsm_out rebranche la boucle fermee du 08/08 - les mobiles
    # ecrivent dans gsm_out, on les rejouerait dans leur propre uplink.
    tmux new-session -d -s gapk \
        "GAPK_ALSA_DEV=gsm_out GAPK_ALSA_DEV_IN=gsm_in PULSE_SERVER=unix:${PULSE_SOCK} bash '$gapk_sh' auto gsm gsm_out gsm_in 2>&1 | tee ${LOG_DIR}/gapk-auto.log"
    # [2026-08-26] LA FENETRE "exited".
    # `tmux new-session -d` rend 0 des que le serveur a pris la commande, pas
    # quand celle-ci tourne. Si gapk-start.sh rend la main tout de suite - format
    # refuse, sink pas encore la, binaire absent - la fenetre reste affichee avec
    # "[exited]" en travers, et le message vert ci-dessous annonce quand meme un
    # pont audio en place. On attend donc une seconde, et on regarde.
    # La session morte est retiree : une fenetre "exited" au milieu des autres
    # fait chercher une panne dans la pile alors que c'est le pont audio, seul,
    # qui n'a pas demarre - et le journal, lui, dit pourquoi.
    sleep 1
    if tmux has-session -t gapk 2>/dev/null && \
       [ "$(tmux list-panes -t gapk -F '#{pane_dead}' 2>/dev/null | head -1)" != "1" ]; then
        echo -e "  ${GREEN}[gapk] auto lance (RTP MGW → sink gsm_audio) - tmux 'gapk', log ${LOG_DIR}/gapk-auto.log${NC}"
    else
        tmux kill-session -t gapk 2>/dev/null || true
        echo -e "  ${YELLOW}[gapk] mort au demarrage - pas de pont audio (fenetre retiree)${NC}"
        echo -e "         ${CYAN}tail -20 ${LOG_DIR}/gapk-auto.log${NC}"
    fi
    ensure_host_audio   # (re)lance le pont voix → hote maintenant que pulse+sink sont up
}

# ══════════════════════════════════════════════════════════════════════════
# Modes 1 operateur (host loopback + enp0s3) : noproc/faketrx/virtphy/qemu/combine
# ══════════════════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════════════════
# Mode HYBRIDE faketrx-qemu (Approche C) : 2 osmo-bts-trx, 1 coeur
#   BTS#0 = pipeline QEMU INTOUCHE (run.sh : osmo-trx-ipc 5700, ARFCN 514, Calypso)
#   BTS#1 = side-car : osmo-bts-trx (unit-id 6002, base-port 5820/5720) + fake_trx
#           (-P 5720 -p 6720) + trxcon + mobile osmocom-bb (ARFCN 516, IMSI ...0002)
#   Les 2 MS s'enregistrent sur le meme osmo-bsc/MSC/HLR → appel intra-MSC.
#   1 BTS = 1 horloge (clk_s par process osmo-bts-trx) → pas de conflit d'horloge :
#   une seule osmo-bts-trx 2-PHY est IMPOSSIBLE (clk_s partage, reset ping-pong),
#   d'ou 2 process distincts. On NE TOUCHE NI qosmo-grgsm/run.sh NI osmo-bts-trx.cfg.
# ══════════════════════════════════════════════════════════════════════════

lib/gabarits.sh

.sh 493 lignes - 28KB

lib/gabarits.sh
# =============================================================================
#  lib/gabarits.sh - le moteur de gabarits de configuration d'osmo-operator
# =============================================================================
#
#  CE FICHIER EST UNE EXTRACTION, PAS UNE REECRITURE.
#  Les fonctions ci-dessous viennent telles quelles de start-direct.sh.legacy
#  (L65-76, L106-258, L499-511). Elles n'ont pas ete retouchees : le jour ou
#  un gabarit change, on veut pouvoir comparer ligne a ligne avec l'ancien.
#
#  POURQUOI LES SORTIR DU POINT D'ENTREE.
#  Elles sont la SEULE chose que start-direct.sh faisait et que personne
#  d'autre ne fait : substituer __ENCRYPTION__, __MCC__, __KI__, __ARFCN__ ...
#  dans configs/*.cfg puis les deposer dans /etc/osmocom, /etc/asterisk et
#  ~/.osmocom/bb. Sans elles, les demons du coeur demarrent sur les fichiers
#  qu'un run precedent a laisses - et `ENCRYPTION=a5 0` n'a aucun effet, en
#  silence. Le reste de start-direct.sh (lancer vingt processus) est repris
#  par le moteur ; ceci ne l'est pas, donc on le preserve a l'identique et on
#  le rend appelable depuis un module.
#
#  CE FICHIER NE FAIT RIEN AU SOURCE : il ne definit que des fonctions.
#  Le seul appelant attendu est run_modules/08-gabarits.sh.
#
#  DEPENDANCES D'ENVIRONNEMENT (posees par l'appelant, valeurs de l'ancien
#  script en defaut) : ENCRYPTION, HOST_IP, ALSA_OUTPUT, ALSA_INPUT.
#  Le repertoire courant doit etre celui d'osmo-operator : les fonctions lisent
#  `configs/*.cfg` et `scripts/*` en chemin RELATIF, comme dans l'original.
# -----------------------------------------------------------------------------

: "${ENCRYPTION:=a5 0}"
: "${HOST_IP:=127.0.0.1}"
: "${ALSA_OUTPUT:=default}"
: "${ALSA_INPUT:=default}"

# Ces fonctions ont une JUMELLE dans start.sh : elles doivent dire la meme
# chose. C'est CETTE copie que lit une machine qui reclone le depot a chaque
# demarrage - donc celle qui compte sur une ISO. Tant qu'elle a garde l'ancien
# plan prive (172.20.<op>.x), les configs regenerees au boot liaient
# 172.20.1.10, une adresse que plus rien ne pose : osmo-ggsn et osmo-sgsn
# refusaient de demarrer ("adresse introuvable localement") et osmo-pcu tombait
# avec eux.
#
#   backbone  172.20.0.<10+op>   le segment partage avec l'inter-STP (.10)
#   prive     192.168.<op+1>.x   un segment par operateur ; le +1 laisse
#                                192.168.1.0/24 au LAN du banc.
op_backbone_ip()  { echo "172.20.0.$((10 + $1))"; }
# ── L'INDEX DU SEGMENT PRIVE : LE NOEUD OU L'OPERATEUR, SELON L'HOTE ────────
# 192.168.<index+1>.x, et tout le desaccord tenait a « index ».
#
#   DOCKER   un hote porte N conteneurs operateurs, chacun dans son netns. Ce
#            qui les distingue est le NUMERO D'OPERATEUR ; le noeud, lui, est
#            commun a tous les conteneurs de la machine. index = operateur.
#   VM/NATIF la machine EST le noeud et ne porte qu'un operateur. Ce qui la
#            distingue de ses voisines est le NUMERO DE NOEUD. index = noeud.
#
# Sans cette distinction, qosmo-grgsm/run_modules/08-gabarits.sh appelait
# op_private_ip($OPERATOR_ID) et ecrivait 192.168.2.10 dans osmo-ggsn.cfg sur
# TOUTES les VM - operateur 1 partout - pendant que le plan de l'ISO reservait
# 192.168.<noeud+1>.x. Sur le noeud 1 les deux coincidaient et rien ne se
# voyait ; sur le noeud 2, le GGSN se liait a l'adresse du noeud 1.
# La bascule est ici, une fois, et les deux jumelles (start.sh, lib/gabarits.sh)
# en heritent.
_osmo_priv_index() {
    local op="${1:-1}"
    # [2026-08-31] L ORCHESTRATEUR N EST PAS DANS LE CONTENEUR QU IL CREE.
    # La detection ci-dessous demande "suis-je DANS un conteneur ?" et c est la
    # bonne question pour du code qui tourne a l interieur. Mais start.sh, lui,
    # provisionne les conteneurs DEPUIS L HOTE : il prenait donc la branche
    # VM/natif et rendait l index du NOEUD - 1 - pour tous les operateurs.
    # Les deux reseaux prives se retrouvaient sur 192.168.2.0/24, docker
    # refusait le second pour chevauchement, et la boucle s arretait net :
    #     ── Operateur 2 : ... Prive : 192.168.2.10
    #     ✗ Echec creation reseau gsm-net-op2 (192.168.2.0/24)
    # osmo-operator-2 n etait jamais cree, sans qu aucun message ne parle de
    # collision d adresses.
    # OSMO_PRIV_BY_OP=1 dit "je provisionne par operateur" ; start_bridge_mode
    # la pose. C est la seule chose que la detection ne pouvait pas deviner.
    [ "${OSMO_PRIV_BY_OP:-0}" = "1" ] && { printf '%s' "$op"; return; }
    # Meme detection que start-direct.sh : le couple /.dockerenv +
    # /etc/docker-entrypoint-cmd identifie un conteneur DE CE DEPOT ; le cgroup
    # sert de repli pour un conteneur quelconque.
    if [ -f /.dockerenv ] || grep -qa 'docker\|containerd' /proc/1/cgroup 2>/dev/null; then
        printf '%s' "$op"; return
    fi
    local n="${OSMO_WAN_NODE:-${WAN_NODE_ID:-}}"
    [ -n "$n" ] || n="$(awk -F= '/^OSMO_WAN_NODE=/{gsub(/[ \r\t]/,"",$2);v=$2} END{print v}' \
                        "${ROLE_FILE:-/etc/osmo-role}" 2>/dev/null)"
    [ -n "$n" ] || n="$(sed -n 's/^PLAN_NODE=//p' "${OSMOCOM_CFG:-/etc/osmocom}/radio-plan.env" 2>/dev/null | tail -1)"
    case "$n" in [1-9]) ;; *) n=1 ;; esac
    printf '%s' "$n"
}
op_private_ip()   { echo "192.168.$(($(_osmo_priv_index "$1") + 1)).10"; }
op_private_gw()   { echo "192.168.$(($(_osmo_priv_index "$1") + 1)).1"; }
op_private_net()  { echo "192.168.$(($(_osmo_priv_index "$1") + 1)).0/24"; }
op_netns()        { echo "osmo-op$1"; }
op_rctx_msc()     { echo $(( $1 * 100 + 10 )); }
op_rctx_stp()     { echo $(( $1 * 100 + 20 )); }
op_rctx_bsc()     { echo $(( $1 * 100 + 30 )); }
op_rctx_inter()   { echo $(( $1 * 100 + 50 )); }
# ── PLUS AUCUN DECALAGE DE PORTS ────────────────────────────────────────────
# [2026-08-31] Il y avait ici une detection : "5060 est-il pris ? alors decale
# les publications de +100". Elle ne servait qu a une chose - eviter que le
# 5060 publie d un conteneur n entre en conflit avec l Asterisk du natif.
#
# On ne publie PLUS aucun port SIP/RTP pour les conteneurs : ils sont sur un
# bridge routable et se joignent en direct, a leur propre adresse, sur le port
# nominal (verifie : 172.20.0.12:5060 et 172.20.0.13:5060 repondent 401 depuis
# l hote, sans la moindre publication). Le conflit ayant disparu avec sa cause,
# le decalage n a plus d objet - et il tirait avec lui une sonde instantanee,
# donc une course : lancee juste apres l arret du banc, elle voyait 5060 libre,
# ne decalait rien, et le conteneur mourait quand le natif remontait.
#
# Les fonctions ci-dessous gardent leur forme (generate_configs.sh s en sert
# encore pour ecrire les configs) mais rendent desormais les valeurs NOMINALES.
# OSMO_PORT_OFFSET=N reste disponible pour qui voudrait retablir un decalage.
PORT_OFFSET="${OSMO_PORT_OFFSET:-0}"
# Le RTP suit la meme regle : plage nominale, 200 ports par operateur.
linphone_sip_port()  { echo $(( 5060  + ${PORT_OFFSET:-0}      + ($1 - 1) )); }
linphone_rtp_start() { echo $(( 30000 + ${PORT_OFFSET:-0} * 10 + ($1 - 1) * 200 )); }
linphone_rtp_end()   { echo $(( 30000 + ${PORT_OFFSET:-0} * 10 + $1 * 200 - 1 )); }
# [2026-08-31] Les boucles de ce fichier enumeraient 1..N en dur, comme celles
# de start.sh avant leur decalage. Avec OP_ID_BASE=2 - les conteneurs prenant
# les rangs 2 et 3, le 1 restant au natif - l operateur 2 se fabriquait donc un
# trunk vers l "operateur 1" a 172.20.0.11, une adresse de dorsale que PERSONNE
# ne porte (le natif est sur l hote), et AUCUN trunk vers l operateur 3.
# Les appels inter-operateurs entre conteneurs ne pouvaient pas aboutir, et
# rien ne le disait : le trunk existe, il pointe simplement dans le vide.
# Les deux jumelles doivent donc partager la meme base.
generate_pjsip_interop_trunks() {
    local op_id=$1 n_operators=$2 remote_op remote_ip
    # ── LES OPERATEURS SOUS LA BASE SONT LES NATIFS ────────────────────────
    # [2026-08-31] La boucle partait DE la base. Avec OP_ID_BASE=2 - les
    # conteneurs prenant les rangs 2..N, le 1 restant au natif sur l hote -
    # op2 ne fabriquait qu un trunk vers op3 : RIEN vers le natif. Un appel
    # 100201 -> 100101 tombait dans le fourre-tout _X. de [interop_out] et
    # sortait en Congestion, sans qu une ligne dise que la route n existe pas.
    # Meme trou que sms-routing.conf, sur le chemin VOIX.
    for remote_op in $(seq 1 $(( ${OP_ID_BASE:-1} - 1 ))) \
                     $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
        [ "$remote_op" -eq "$op_id" ] && continue
        # Un operateur sous la base est NATIF : il tourne sur l hote, pas dans
        # un conteneur. Vu d un conteneur, l hote est la passerelle du bridge
        # docker - la meme adresse que le pont audio et que sms-routing.conf.
        # NB : match=172.20.0.1 n avale pas les REGISTER Linphone, qui arrivent
        # par la meme passerelle : pjsip.conf pose endpoint_identifier_order=
        # username,ip,anonymous - le username passe avant l IP.
        if [ "$remote_op" -lt "${OP_ID_BASE:-1}" ]; then
            remote_ip="${INTER_NET_GATEWAY:-172.20.0.1}"
        else
            remote_ip=$(op_backbone_ip "$remote_op")
        fi
        cat <<EOF

[interop-identify-op${remote_op}]
type=identify
endpoint=interop_trunk_op${remote_op}
match=${remote_ip}

[interop_trunk_op${remote_op}]
type=endpoint
transport=transport-udp
context=interop_in
disallow=all
allow=gsm
allow=ulaw
aors=interop_trunk_op${remote_op}
direct_media=no
rtp_symmetric=yes
force_rport=yes
media_encryption=no

[interop_trunk_op${remote_op}]
type=aor
contact=sip:${remote_ip}:5060
qualify_frequency=15
qualify_timeout=5.0
EOF
    done
}
generate_extensions_interop_out() {
    local op_id=$1 n_operators=$2 remote_op
    # Le prefixe porte le NOEUD : MSISDN = <noeud>00<operateur><rang>. Fige a
    # "600", le motif ne matchait plus rien des que le numero commencait par le
    # numero de noeud - les appels inter-operateurs sortaient en Congestion
    # sans qu'aucune ligne ne dise que c'est le motif qui n'accroche pas.
    local _pfx; _pfx="$(osmo_msisdn_pfx "$(osmo_node_id)")"
    printf '[interop_out]\n\n'
    # Un seul motif : les MSISDN font six chiffres, <noeud>00<operateur><rang>. Les
    # deux motifs _<op>XXXX / _<op>XXXXX visaient l'ancien plan a cinq chiffres,
    # ou le premier chiffre du numero ETAIT l'operateur - plus rien ne matchait.
    # ── LES OPERATEURS SOUS LA BASE SONT LES NATIFS ────────────────────────
    # [2026-08-31] La boucle partait DE la base. Avec OP_ID_BASE=2 - les
    # conteneurs prenant les rangs 2..N, le 1 restant au natif sur l hote -
    # op2 ne fabriquait qu un trunk vers op3 : RIEN vers le natif. Un appel
    # 100201 -> 100101 tombait dans le fourre-tout _X. de [interop_out] et
    # sortait en Congestion, sans qu une ligne dise que la route n existe pas.
    # Meme trou que sms-routing.conf, sur le chemin VOIX.
    for remote_op in $(seq 1 $(( ${OP_ID_BASE:-1} - 1 ))) \
                     $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
        [ "$remote_op" -eq "$op_id" ] && continue
        cat <<EOF
exten => _${_pfx}${remote_op}XX,1,NoOp(=== INTEROP OUT Op${remote_op}: \${EXTEN} ===)
 same => n,Dial(PJSIP/\${EXTEN}@interop_trunk_op${remote_op},,rT)
 same => n,Congestion()
 same => n,Hangup()

EOF
    done

    # ── LES SOFTPHONES AUSSI, ET SANS PREFIXE ───────────────────────────────
    # [2026-08-31] Depuis [internal], un Linphone ne pouvait joindre un autre
    # operateur qu'en composant 9 + numero (_9. du gabarit - dont l'exemple
    # « 920001 pour joindre 20001 sur Op2 » date d'ailleurs du plan a CINQ
    # chiffres, abandonne). Compose tel quel, 100201 tombait sur le fourre-tout
    # _X. de [internal] et partait au MSC LOCAL, qui le rendait en
    #     rx MNCC_SETUP_REQ for unknown subscriber number '100201'
    # avant de le ressortir par MNCC vers Asterisk. Le detour aboutissait, mais
    # il consommait une transaction MNCC pour rien et faisait dependre un appel
    # SIP->SIP du bon vouloir du MSC.
    # On declare donc la route explicitement. _<pfx><op>XX est PLUS SPECIFIQUE
    # que _X. : Asterisk la choisit d'office, sans qu'il faille toucher au
    # fourre-tout du gabarit. Gosub(sub-record) comme pour _9., sinon ces
    # appels-la seraient les seuls a ne pas etre enregistres.
    printf '[internal]\n\n'
    for remote_op in $(seq 1 $(( ${OP_ID_BASE:-1} - 1 ))) \
                     $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
        [ "$remote_op" -eq "$op_id" ] && continue
        cat <<EOF
exten => _${_pfx}${remote_op}XX,1,NoOp(=== SIP -> INTER-OP Op${remote_op}: \${EXTEN} - CallerID: \${CALLERID(all)} ===)
 same => n,Gosub(sub-record,s,1(\${EXTEN}))
 same => n,Goto(interop_out,\${EXTEN},1)

EOF
    done
    # PAS de fourre-tout _X. ici : le gabarit configs/extensions.conf en declare
    # deja un dans ce meme contexte. Deux extensions identiques dans un contexte
    # font refuser la seconde, avec six avertissements a chaque chargement :
    #     add_priority: Unable to register extension '_X.' priority 1
    #                   in 'interop_out', already in use
    # Rien ne cassait - le fourre-tout du gabarit restait en place - mais ces
    # lignes noyaient celles qui comptent.
}
_generate_sms_routing_conf_fallback() {
    # 'local i' n'est PAS cosmetique. Cette fonction est appelee, via
    # apply_config_templates, DEPUIS la boucle "for i in ..." qui demarre les
    # operateurs. Sans local, sa propre boucle ecrase la variable de l'appelant
    # et la laisse a n_operators : tous les conteneurs suivants calculaient
    # alors leurs ports avec le MEME indice, d'ou
    #     Bind for 0.0.0.0:5082 failed: port is already allocated
    # au deuxieme conteneur - et, avant l'erreur, deux operateurs annonces sur
    # les memes SIP/RTP/SMS.
    local i
    local op_id=$1 n_operators=$2 i j
    printf '# sms-routing.conf - Fallback\n\n[local]\noperator_id = %s\nsc_address  = 1999001%s444\n\n[operators]\n' "$op_id" "$op_id"
    # ── LES OPERATEURS SOUS LA BASE SONT LES NATIFS ─────────────────────────
    # [2026-08-31] start-multi.sh pose OP_ID_BASE=2 : les conteneurs prennent
    # les rangs 2..N et l'operateur 1 reste NATIF, sur l'hote. Comme la boucle
    # part de OP_ID_BASE, il n'apparaissait ni dans [operators] ni dans
    # [routes] - releve dans osmo-operator-2 : « 2 = ... / 3 = ... » et rien
    # pour le 1. Un SMS vers 100101 sortait donc en « No route for
    # destination », et le natif etait injoignable depuis tout le banc.
    # Il existe pourtant : il n'est simplement pas dans un conteneur. On le
    # pointe sur la passerelle du bridge docker, qui EST l'hote vu d'un
    # conteneur - la meme adresse que le pont audio (172.20.0.1).
    local _natif_gw="${INTER_NET_GATEWAY:-172.20.0.1}"
    for i in $(seq 1 $(( ${OP_ID_BASE:-1} - 1 ))); do printf '%s = %s\n' "$i" "$_natif_gw"; done
    for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do printf '%s = %s\n' "$i" "$(op_backbone_ip "$i")"; done
    printf '\n[routes]\n'
    for i in $(seq 1 $(( ${OP_ID_BASE:-1} - 1 ))); do
        for ms in 1 2; do printf '%s = %s\n' "$(osmo_msisdn "$(osmo_node_id)" "$i" "$ms")" "$i"; done
    done
    for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
        # Les MSISDN EXACTS, pas un prefixe : un prefixe trop court avalait
        # les numeros voisins, un prefixe absent laissait le relais rejeter
        # tout SMS local avec "No route for destination" (2026-07-29).
        for ms in 1 2; do printf '%s = %s\n' "$(osmo_msisdn "$(osmo_node_id)" "$i" "$ms")" "$i"; done   # MSISDN exacts <noeud>00<op><ms> (100101, 100102, 200101...)
    done
    printf '\n[relay]\nport = 7890\nconnect_timeout = 10\nretry_count = 3\nretry_delay = 5\n'
}
# [2026-08-03] apply_config_templates() a demenage dans generate_configs.sh :
# ce fichier en portait une copie, start.sh une autre. Une seule desormais, et
# ses valeurs (ARFCN, BSIC, KI, IMSI...) sont exposees dans globals.conf au lieu
# d'etre des formules enfouies dans la substitution.
_GAB_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
[ -r "$_GAB_ROOT/generate_configs.sh" ] && . "$_GAB_ROOT/generate_configs.sh"

# ── Install configs natif. $1=src $2=prefix racine (/ ou /etc/netns/<ns>) ──
# ── LES ADRESSES DE CONTENEUR N ONT RIEN A FAIRE SUR L HOTE ─────────────────
# [2026-08-31] Les gabarits sont ecrits pour un CONTENEUR : ils posent
# l adresse de dorsale de l operateur (172.20.0.<10+N>) et la passerelle du
# bridge docker (172.20.0.1). Sur l hote, aucune des deux n existe.
#
# Ces corrections vivaient dans apply_native_post_patches (generate_configs.sh),
# que start-direct.sh appelle. Mais qosmo-grgsm/run_modules/08-gabarits.sh, lui,
# n appelle QUE apply_config_templates + install_configs_native, et ne source
# meme pas generate_configs.sh : il reappliquait donc les gabarits bruts a
# chaque demarrage du natif et REMETTAIT les adresses de conteneur. Le symptome
# etait un natif affiche en 172.20.0.11 et un ASP qui ne montait jamais, une
# seconde apres qu on ait corrige le fichier a la main.
# (Ce module ne s executait plus depuis un renommage de depot, ce qui cachait
# le probleme derriere un [SKIP] : il est reparu en le reparant.)
#
# On accroche donc ici, dans install_configs_native - le passage OBLIGE du
# natif, quel que soit l appelant. Idempotent : rejouer ne change rien.
_gab_fixups_natifs() {
    local src="$1" f

    # 1. osmo-stp.cfg : le local-ip de l ASP vers l inter-STP. 127.0.0.1 est la
    #    seule adresse dont on soit certain sur un hote. On ne touche QUE le
    #    local-ip qui suit « asp asp-to-inter » : ceux du bloc d ecoute
    #    au-dessus (127.0.0.1 / 127.0.0.2) sont corrects.
    f="$src/osmocom/osmo-stp.cfg"
    if [ -f "$f" ]; then
        awk '
            /^[[:space:]]*asp[[:space:]]+asp-to-inter[[:space:]]/ { a = 1; print; next }
            a && /^[[:space:]]*local-ip[[:space:]]/ {
                sub(/local-ip[[:space:]]+.*/, "local-ip 127.0.0.1"); a = 0; print; next
            }
            /^[[:space:]]*(as|asp|cs7|listen)[[:space:]]/ && !/asp-to-inter/ { a = 0 }
            { print }
        ' "$f" > "$f.tmp" && mv -f "$f.tmp" "$f"
    fi

    # 2. mobile.cfg : la cible GSMTAP. 172.20.0.1 disparait avec le bridge, et
    #    les paquets partent alors dans le vide - Wireshark reste vide sur
    #    udp/4729 sans qu aucun message ne le dise.
    for f in "$src/osmocom/mobile.cfg" "$src/bb/mobile.cfg" "$src/bb/mobile_group1.cfg"; do
        [ -f "$f" ] || continue
        sed -i "/^gsmtap\$/,/^!\$/ s/^\([[:space:]]*remote-host[[:space:]]\+\).*/\1127.0.0.1/" "$f"
    done

    # 3. pjsip.conf : sur un hote plat il n y a pas de frontiere NAT a declarer.
    #    external_* fait REECRIRE Contact et SDP pour tout pair hors local_net -
    #    un Linphone du LAN recevait une adresse injoignable. Sans eux, pjsip
    #    repond avec l adresse de la socket, la bonne, par destination.
    f="$src/asterisk/pjsip.conf"
    if [ -f "$f" ]; then
        awk '
            /^\[transport-udp\]/ { t = 1; print; next }
            t && /^\[/            { t = 0 }
            t && /^[[:space:]]*external_(media|signaling)_address[[:space:]]*=/ { next }
            t && /^[[:space:]]*local_net[[:space:]]*=/ {
                if (!d) {
                    print "local_net=127.0.0.0/8"; print "local_net=10.0.0.0/8"
                    print "local_net=172.16.0.0/12"; print "local_net=192.168.0.0/16"
                    print "local_net=176.16.32.0/24"; d = 1
                }
                next
            }
            { print }
        ' "$f" > "$f.tmp" && mv -f "$f.tmp" "$f"
    fi

    # 4. sms-routing.conf : LE NATIF NE CONNAISSAIT QUE LUI-MEME.
    #    [2026-08-31] Releve sur l hote :
    #        [operators]   1 = 172.20.0.11
    #        [routes]      100101 = 1   100102 = 1
    #    Deux erreurs dans quatre lignes. 172.20.0.11 est l adresse de DORSALE
    #    DOCKER de l operateur 1 - elle n existe pas sur l hote, ou le natif
    #    tourne. Et aucun des autres operateurs n y figure : un SMS du natif
    #    vers 100201 sortait en « No route for destination ».
    #
    #    POURQUOI CE FICHIER ETAIT FAUX. apply_native_post_patches
    #    (generate_configs.sh) sait deja ecrire cette table - son propre
    #    commentaire decrit exactement le defaut. Mais elle n est appelee que
    #    par start-direct.sh --regen et build-iso.sh ; le demarrage ordinaire du
    #    natif passe par 08-gabarits.sh, qui ne fait qu appliquer les gabarits
    #    bruts. Le fichier repartait donc a chaque fois sur le fallback ecrit
    #    POUR UN CONTENEUR - le meme piege que les points 1 a 3 ci-dessus, d ou
    #    la correction au meme endroit : install_configs_native est le passage
    #    OBLIGE du natif, quel que soit l appelant.
    #
    #    LA SOURCE DE VERITE est osmo-multi.conf (ecrit par addition.sh) :
    #        MULTI_OPS="1:native::1.1.2:150 2:docker:172.20.0.12:... 3:docker:..."
    #    On garde le keying par OPERATEUR, celui des conteneurs : sur un banc a
    #    une machine tout le monde est sur le noeud 1, un keying par noeud les
    #    confondrait. Sans osmo-multi.conf (mono-operateur), on ne touche a rien.
    f="$src/osmocom/sms-routing.conf"
    local mc="${OSMO_MULTI_CONF:-/etc/osmocom/osmo-multi.conf}"
    if [ -f "$f" ] && [ -r "$mc" ]; then
        local _ops _node _self _sc _id _mode _ip _e
        _ops="$(sed -n 's/^MULTI_OPS=//p' "$mc" | tr -d '"' | tail -1)"
        _node="$(sed -n 's/^MULTI_NODE=//p' "$mc" | tail -1)"; case "$_node" in [1-9]) ;; *) _node=1 ;; esac
        # Identite : on la RELIT, on ne la recalcule pas - c est elle qui dit
        # quel operateur ce natif est, et elle a pu etre realignee par
        # start-multi.sh (aligner_natif).
        _self="$(sed -n 's/^operator_id[[:space:]]*=[[:space:]]*//p' "$f" | head -1)"
        _sc="$(sed -n 's/^sc_address[[:space:]]*=[[:space:]]*//p'  "$f" | head -1)"
        case "$_self" in [1-9]*) ;; *) _self=1 ;; esac
        [ -n "$_sc" ] || _sc="1999001${_self}444"
        if [ -n "$_ops" ]; then
            {
                printf '# sms-routing.conf - natif, reecrit par _gab_fixups_natifs\n'
                printf '# Table issue de %s (MULTI_OPS). Cle = numero d operateur.\n' "$mc"
                printf '# MSISDN = <noeud>00<operateur><rang du MS sur 2 chiffres>\n\n'
                printf '[local]\noperator_id = %s\nsc_address  = %s\n\n[operators]\n' "$_self" "$_sc"
                for _e in $_ops; do
                    IFS=: read -r _id _mode _ip _ <<< "$_e"
                    [ -n "$_id" ] || continue
                    # Le natif, c est NOUS : 127.0.0.1 est la seule adresse dont
                    # on soit certain sur un hote (meme choix qu au point 1).
                    if [ "$_mode" = native ] || [ "$_id" = "$_self" ]; then _ip=127.0.0.1; fi
                    [ -n "$_ip" ] || continue
                    printf '%s = %s\n' "$_id" "$_ip"
                done
                printf '\n[routes]\n'
                for _e in $_ops; do
                    IFS=: read -r _id _mode _ip _ <<< "$_e"
                    [ -n "$_id" ] || continue
                    for _m in 1 2; do
                        printf '%s = %s\n' "$(( _node * 100000 + _id * 100 + _m ))" "$_id"
                    done
                done
                printf '\n[relay]\nport = 7890\nconnect_timeout = 10\nretry_count = 3\nretry_delay = 5\n'
            } > "$f.tmp" && mv -f "$f.tmp" "$f"
        fi
    fi

    # 5. LE CHEMIN VOIX INTER-OPERATEUR : trunks SIP + [interop_out].
    #    [2026-08-31] Meme trou que le point 4, sur la VOIX. Releve sur l hote :
    #        [interop_out] du natif :  _X.  ->  Congestion()   (et rien d autre)
    #        pjsip.conf              :  aucun interop_trunk_opN
    #    Un appel 100101 -> 100201 sortait donc du MSC ...
    #        rx MNCC_SETUP_REQ for unknown subscriber number '100201'
    #    ... passait a osmo-sip-connector, tombait sur le fourre-tout, et
    #    revenait en
    #        INVITE got status(503), releasing leg
    #    Les SMS passaient (point 4), pas les appels : deux chemins, deux tables,
    #    une seule des deux corrigee.
    #
    #    On NE REECRIT PAS la logique : generate_extensions_interop_out et
    #    generate_pjsip_interop_trunks (plus haut dans ce fichier) savent deja
    #    la produire. Elles etaient seulement appelees avec le nombre
    #    d operateurs du CONTENEUR courant - le natif se croyait donc seul.
    #    On les rejoue ici avec la topologie reelle, comme au point 4.
    #    OP_ID_BASE=1 : vu du natif, tous les distants sont des conteneurs et
    #    portent bien une adresse de dorsale ; c est l inverse du cas conteneur,
    #    ou le natif est celui qui n en a pas.
    local mc5="${OSMO_MULTI_CONF:-/etc/osmocom/osmo-multi.conf}"
    if [ -r "$mc5" ]; then
        local _ops5 _self5 _n5=0 _e5 _i5
        _ops5="$(sed -n 's/^MULTI_OPS=//p' "$mc5" | tr -d '"' | tail -1)"
        _self5="$(sed -n 's/^operator_id[[:space:]]*=[[:space:]]*//p' "$src/osmocom/sms-routing.conf" 2>/dev/null | head -1)"
        case "$_self5" in [1-9]*) ;; *) _self5=1 ;; esac
        for _e5 in $_ops5; do
            IFS=: read -r _i5 _ <<< "$_e5"
            case "$_i5" in [1-9]*) [ "$_i5" -gt "$_n5" ] && _n5="$_i5" ;; esac
        done
        # Marqueur d idempotence : rejouer install_configs_native ne doit pas
        # empiler les memes extensions (Asterisk refuserait les doublons avec
        # « already in use » et noierait le journal).
        local _mk5='; --- interop natif : ajoute par _gab_fixups_natifs ---'
        if [ "$_n5" -gt 1 ]; then
            f="$src/asterisk/extensions.conf"
            if [ -f "$f" ] && ! grep -qF "$_mk5" "$f"; then
                { printf '\n%s\n' "$_mk5"
                  OP_ID_BASE=1 generate_extensions_interop_out "$_self5" "$_n5"; } >> "$f"
            fi
            f="$src/asterisk/pjsip.conf"
            if [ -f "$f" ] && ! grep -qF "$_mk5" "$f"; then
                { printf '\n%s\n' "$_mk5"
                  OP_ID_BASE=1 generate_pjsip_interop_trunks "$_self5" "$_n5"; } >> "$f"
            fi
        fi
    fi
}

install_configs_native() {
    local src=$1 root="${2:-}"
    _gab_fixups_natifs "$src"
    mkdir -p "${root}/etc/osmocom" "${root}/etc/asterisk" "$HOME/.osmocom/bb"
    cp -f "$src/osmocom"/*      "${root}/etc/osmocom/"  2>/dev/null || true
    cp -f "$src/asterisk"/*.conf "${root}/etc/asterisk/" 2>/dev/null || true
    [ -f "$src/bb/mobile.cfg" ]        && cp -f "$src/bb/mobile.cfg"        "$HOME/.osmocom/bb/mobile.cfg"
    [ -f "$src/bb/mobile_group1.cfg" ] && cp -f "$src/bb/mobile_group1.cfg" "$HOME/.osmocom/bb/mobile_group1.cfg"
    if [ -f configs/asound.conf ]; then
        cp -f configs/asound.conf "${root}/etc/asound.conf"
        ALSA_OUTPUT="gsm_out"; ALSA_INPUT="gsm_in"
    fi
}

detect_host_ip() {
    HOST_IP=$(ip route get 1.1.1.1 2>/dev/null \
        | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -1) || true
    [ -z "$HOST_IP" ] && HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
    HOST_IP="${HOST_IP:-127.0.0.1}"
}

# ── Auto-attach tmux ────────────────────────────────────────────────────────
# run.sh tourne en arriere-plan et cree la session tmux 'osmocom'
# (socket /tmp/osmocom_tmux). On attend qu'elle soit prete (run.sh termine)
# puis on s'y attache dans le terminal courant. $1 = log run.sh a surveiller.
#   AUTO_ATTACH=0   → desactive (reste en arriere-plan, message manuel)
#   Desactive aussi si pas de TTY (scripte/bg) ou deja dans un tmux.

navigation/test-console.sh

.sh 205 lignes - 8,1KB

navigation/test-console.sh
#!/usr/bin/env bash
# test-console.sh - passe en revue CHAQUE commande de la console SS7 et de ses
# outils, garde un journal par commande, et rend un rapport.
#
#   ./navigation/test-console.sh              tout, y compris le SS7 reel
#   ./navigation/test-console.sh --rapide     sans M3UA/MAP (lab a l'arret)
#   ./navigation/test-console.sh --dir CHEMIN journaux ailleurs
#
# Un test = une commande + un critere de reussite (motif attendu dans la
# sortie, ou code de retour). Chaque commande a SON fichier de journal, nomme
# par le numero et le nom du test : on peut relire exactement ce qui s'est
# passe sans relancer quoi que ce soit.
#
# Codes de sortie : 0 tout passe, sinon le nombre d'echecs.

set -u

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(dirname "$HERE")"
CONSOLE="$HERE/ss7-console.py"
DIAG="$HERE/ss7-diag.py"

RAPIDE=0
LOGDIR=""
while [ $# -gt 0 ]; do
    case "$1" in
        --rapide) RAPIDE=1 ;;
        --dir)    LOGDIR="${2:-}"; shift ;;
        --dir=*)  LOGDIR="${1#*=}" ;;
        -h|--help)
            sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
            exit 0 ;;
        *) echo "argument inconnu : $1" >&2; exit 2 ;;
    esac
    shift
done

STAMP="$(date +%Y%m%d-%H%M%S)"
LOGDIR="${LOGDIR:-$HERE/logs/$STAMP}"
mkdir -p "$LOGDIR"
RAPPORT="$LOGDIR/rapport.txt"

G='\033[0;32m'; R='\033[0;31m'; Y='\033[1;33m'; C='\033[0;36m'; B='\033[1m'; N='\033[0m'
PASS=0; FAIL=0; SKIP=0; NUM=0

dire() { echo -e "$*"; echo -e "$*" | sed 's/\x1b\[[0-9;]*m//g' >> "$RAPPORT"; }

# essai <nom> <motif attendu|-> <commande...>
#   motif "-" : on ne juge que le code de retour.
essai() {
    local nom="$1" motif="$2"; shift 2
    NUM=$((NUM + 1))
    local fichier
    fichier="$(printf '%02d-%s.log' "$NUM" "$nom")"
    local chemin="$LOGDIR/$fichier"
    {
        echo "# test   : $nom"
        echo "# commande : $*"
        echo "# attendu : ${motif}"
        echo "# date   : $(date '+%Y-%m-%d %H:%M:%S')"
        echo "# ---------------------------------------------------------------"
    } > "$chemin"
    local debut fin rc
    debut=$(date +%s)
    timeout 240 "$@" >> "$chemin" 2>&1
    rc=$?
    fin=$(date +%s)
    echo "# ---------------------------------------------------------------" >> "$chemin"
    echo "# code de retour : $rc   duree : $((fin - debut)) s" >> "$chemin"

    if [ "$rc" -eq 124 ]; then
        FAIL=$((FAIL + 1))
        dire "  ${R}✗${N} ${nom} — delai depasse (240 s)   ${C}${fichier}${N}"
        return
    fi
    if [ "$motif" = "-" ]; then
        if [ "$rc" -eq 0 ]; then
            PASS=$((PASS + 1)); dire "  ${G}✓${N} ${nom}   ${C}${fichier}${N}"
        else
            FAIL=$((FAIL + 1)); dire "  ${R}✗${N} ${nom} — code $rc   ${C}${fichier}${N}"
        fi
        return
    fi
    if grep -qE "$motif" "$chemin"; then
        PASS=$((PASS + 1)); dire "  ${G}✓${N} ${nom}   ${C}${fichier}${N}"
    else
        FAIL=$((FAIL + 1))
        dire "  ${R}✗${N} ${nom} — motif absent : ${motif}   ${C}${fichier}${N}"
    fi
}

ignore() { NUM=$((NUM + 1)); SKIP=$((SKIP + 1)); dire "  ${Y}-${N} $1"; }

titre() { dire ""; dire "${B}$1${N}"; dire "----------------------------------------------------------------------"; }

# ── le lab est-il debout ? ────────────────────────────────────────────────────
CONTENEURS="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -c '^osmo-' || true)"
HUB=""
if [ "${CONTENEURS:-0}" -gt 0 ]; then
    HUB="$(docker exec "$(docker ps --format '{{.Names}}' | grep '^osmo-operator-' | head -1)" \
           printenv OSMO_HUB_IP 2>/dev/null | tr -d '\r')"
fi

dire ""
dire "${C}======================================================================${N}"
dire "${C}  TEST DE LA CONSOLE SS7 - $(date '+%Y-%m-%d %H:%M:%S')${N}"
dire "${C}======================================================================${N}"
dire "  depot      : $REPO"
dire "  journaux   : $LOGDIR"
dire "  conteneurs : ${CONTENEURS:-0}   hub : ${HUB:-aucun}"
[ "$RAPIDE" = "1" ] && dire "  mode       : rapide (ni M3UA ni MAP)"

titre "1. LE PAQUET S'IMPORTE"
essai "import-modules" "modules ok" python3 -c "
import sys; sys.path.insert(0, '$REPO')
from navigation import ber, sccp, tcap, mapops, m3ua, ss7, topo, vty, diagram, quickcmd, responder
print('modules ok')"
essai "encodage-ber-sccp" "encodage ok" python3 -c "
import sys; sys.path.insert(0, '$REPO')
from navigation import sccp, tcap, mapops, ber
a = sccp.Address(pc='1.11.2', ssn=6)
assert sccp.Address.decode(a.encode()).pc == '1.11.2'
assert sccp.pc_to_int('1.11.2') == (1 << 11) | (11 << 3) | 2
assert sccp.untbcd(sccp.tbcd('600101')) == '600101'
u = sccp.udt(a, sccp.Address(pc='1.11.7', ssn=8), b'\x62\x03\x48\x01\x01')
d = sccp.decode(u); assert d.kind == 'UDT' and d.called.ssn == 6
m = tcap.parse(tcap.begin(b'\x01\x02\x03\x04', tcap.invoke(1, 45, b''), mapops.AC['shortMsgGateway']))
assert m.kind == 'begin' and m.components[0].opcode == 45
print('encodage ok')"
essai "aide-console" "usage: ss7-console" "$CONSOLE" --help
essai "aide-diag" "usage: ss7-diag" "$DIAG" --help

titre "2. INVENTAIRE ET CATALOGUE"
essai "console-ops" "sendRoutingInfoForSM" "$CONSOLE" ops
essai "console-list" "Hub inter-STP" "$CONSOLE" --no-probe list
essai "console-list-sonde" "Hub inter-STP" "$CONSOLE" list

titre "3. VTY (un test par demon)"
if [ "${CONTENEURS:-0}" -gt 0 ]; then
    essai "vty-stp"   "Routing|AS Name"   "$CONSOLE" vty 1 4239 "show cs7 instance 0 as all"
    essai "vty-stp-route" "Routing table" "$CONSOLE" vty 1 4239 "show cs7 instance 0 route"
    essai "vty-msc"   "IMSI|Subscriber"   "$CONSOLE" vty 1 4254 "show subscriber cache"
    essai "vty-hlr"   "IMSI|MSISDN"       "$CONSOLE" vty 1 4258 "show subscribers all"
    essai "vty-hlr-gsup" "VLR|GSUP|from"  "$CONSOLE" vty 1 4258 "show gsup-connections"
    essai "vty-bsc"   "BTS|bts"           "$CONSOLE" vty 1 4242 "show bts"
    essai "vty-inconnu-propre" "injoignable" "$CONSOLE" vty 1 4299 "show version"
else
    ignore "VTY : aucun conteneur ne tourne"
fi

titre "4. SS7 REEL (M3UA, DAUD, MAP)"
if [ "$RAPIDE" = "1" ]; then
    ignore "M3UA et MAP : mode rapide"
elif [ -z "$HUB" ]; then
    ignore "M3UA et MAP : aucun hub connu (lab arrete ?)"
else
    essai "m3ua-audit-stp" "JOIGNABLE|DAVA" "$CONSOLE" audit 1.11.2
    essai "m3ua-audit-msc" "JOIGNABLE|DAVA" "$CONSOLE" audit 1.11.1
    essai "m3ua-audit-inconnu" "INACCESSIBLE|DUNA|aucune reponse" "$CONSOLE" audit 7.255.7
    essai "map-sans-destination" "point code|global title" "$CONSOLE" map sri-sm msisdn=600101
    essai "map-operation-inconnue" "operation inconnue" "$CONSOLE" map pas-une-op --pc 1.11.2
    # Aller-retour complet : le repondeur repond a l'emetteur a travers le hub.
    essai "map-aller-retour" "IMSI" "$DIAG" --no-probe
fi

titre "5. DIAGNOSTIC COMPLET"
essai "diag-rapide" "RESUME" "$DIAG" --rapide --no-probe --log "$LOGDIR/diag-rapide.txt"
if [ "$RAPIDE" != "1" ] && [ -n "$HUB" ]; then
    essai "diag-complet" "RESUME" "$DIAG" --log "$LOGDIR/diag-complet.txt"
else
    ignore "diagnostic complet : lab a l'arret ou mode rapide"
fi

titre "6. LE SCHEMA (interface aux fleches)"
essai "schema-rendu" "INTER-STP" python3 -c "
import sys; sys.path.insert(0, '$REPO')
from navigation import topo, diagram
t = topo.discover(probe=False)
lignes, boites = diagram.build(t)
print('\n'.join(lignes))
print('%d boites' % len(boites))
assert boites, 'aucune boite'
i = 0
for direction in ((0, 1), (1, 0), (0, -1), (-1, 0)):
    i = diagram.navigate(boites, i, direction)
print('navigation ok')"
essai "schema-navigation" "navigation ok" python3 -c "
import sys; sys.path.insert(0, '$REPO')
from navigation import topo, diagram
t = topo.discover(probe=False)
_, boites = diagram.build(t)
vus = set()
i = 0
for _ in range(40):
    for d in ((0,1),(1,0),(0,-1),(-1,0)):
        i = diagram.navigate(boites, i, d); vus.add(i)
assert len(vus) >= min(3, len(boites))
print('navigation ok')"

titre "RESUME"
dire "  ${G}${PASS} reussi(s)${N}   ${R}${FAIL} echec(s)${N}   ${Y}${SKIP} ignore(s)${N}"
dire "  journaux : ${C}${LOGDIR}${N}  (un fichier par commande)"
dire "  rapport  : ${C}${RAPPORT}${N}"
dire ""
exit "$FAIL"

network/firewall-wan.sh

.sh 76 lignes - 3,1KB

network/firewall-wan.sh
#!/bin/bash
# ══════════════════════════════════════════════════════════════════════════════
# network/firewall-wan.sh - Ouvre les ports necessaires pour l'interop WAN
#
# Usage : sudo ./network/firewall-wan.sh <remote_ip> [n_operators]
# ══════════════════════════════════════════════════════════════════════════════

set -euo pipefail

REMOTE_IP="${1:?Usage: $0 <remote_ip> [n_operators]}"
N_OPS="${2:-2}"
SIP_WAN_BASE=5080
RTP_WAN_BASE=20000
RTP_PER_OP=500
SMS_RELAY_PORT=7890

echo "=== Firewall WAN Interop - Autorisation ${REMOTE_IP} ==="
echo ""

# Detection du firewall
if command -v ufw &>/dev/null && ufw status | grep -q "active"; then
    FW="ufw"
elif command -v firewall-cmd &>/dev/null; then
    FW="firewalld"
else
    FW="iptables"
fi

echo "Firewall detecte : ${FW}"
echo ""

for i in $(seq 1 "$N_OPS"); do
    sip_port=$(( SIP_WAN_BASE + (i - 1) * 2 ))
    rtp_start=$(( RTP_WAN_BASE + (i - 1) * RTP_PER_OP ))
    rtp_end=$(( RTP_WAN_BASE + i * RTP_PER_OP - 1 ))

    echo "Op${i}: SIP ${sip_port}/udp  RTP ${rtp_start}:${rtp_end}/udp"

    case "$FW" in
        ufw)
            ufw allow from "$REMOTE_IP" to any port "$sip_port" proto udp
            ufw allow from "$REMOTE_IP" to any port "${rtp_start}:${rtp_end}" proto udp
            ;;
        firewalld)
            firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=${REMOTE_IP} port port=${sip_port} protocol=udp accept"
            firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=${REMOTE_IP} port port=${rtp_start}-${rtp_end} protocol=udp accept"
            ;;
        iptables)
            # -C ... || -A : idempotent, pas de doublon si relance a chaque WAN.
            iptables -C INPUT -s "$REMOTE_IP" -p udp --dport "$sip_port" -j ACCEPT 2>/dev/null \
                || iptables -A INPUT -s "$REMOTE_IP" -p udp --dport "$sip_port" -j ACCEPT
            iptables -C INPUT -s "$REMOTE_IP" -p udp --dport "${rtp_start}:${rtp_end}" -j ACCEPT 2>/dev/null \
                || iptables -A INPUT -s "$REMOTE_IP" -p udp --dport "${rtp_start}:${rtp_end}" -j ACCEPT
            ;;
    esac
done

# SMS relay port
echo ""
echo "SMS Relay: ${SMS_RELAY_PORT}/tcp"
case "$FW" in
    ufw)
        ufw allow from "$REMOTE_IP" to any port "$SMS_RELAY_PORT" proto tcp
        ;;
    firewalld)
        firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=${REMOTE_IP} port port=${SMS_RELAY_PORT} protocol=tcp accept"
        firewall-cmd --reload
        ;;
    iptables)
        iptables -C INPUT -s "$REMOTE_IP" -p tcp --dport "$SMS_RELAY_PORT" -j ACCEPT 2>/dev/null \
            || iptables -A INPUT -s "$REMOTE_IP" -p tcp --dport "$SMS_RELAY_PORT" -j ACCEPT
        ;;
esac

echo ""
echo "✓ Ports ouverts pour ${REMOTE_IP}"

network/loopback.sh

.sh 87 lignes - 1,9KB

network/loopback.sh
#!/usr/bin/env bash
set -euo pipefail

SOURCE="gsm_audio.monitor"
ACTION="${1:-status}"
SINK="${2:-$(pactl get-default-sink)}"

find_loopback_ids() {
    pactl list short modules | \
        grep -F "module-loopback" | \
        grep -F "source=${SOURCE}" | \
        grep -F "sink=${SINK}" | \
        awk '{print $1}'
}

check_source() {
    pactl list short sources | grep -F "${SOURCE}" >/dev/null || {
        echo "[FAIL] source ${SOURCE} introuvable"
        exit 1
    }
}

enable_loopback() {
    check_source
    local ids
    ids="$(find_loopback_ids || true)"

    echo "[*] Source : $SOURCE"
    echo "[*] Sink   : $SINK"

    if [[ -n "${ids}" ]]; then
        echo "[OK] loopback deja present : ${ids}"
    else
        local mid
        mid="$(pactl load-module module-loopback source="${SOURCE}" sink="${SINK}" latency_msec=20)"
        echo "[OK] loopback charge, module id=${mid}"
    fi
}

disable_loopback() {
    local ids
    ids="$(find_loopback_ids || true)"

    echo "[*] Source : $SOURCE"
    echo "[*] Sink   : $SINK"

    if [[ -z "${ids}" ]]; then
        echo "[OK] aucun loopback a retirer"
        return 0
    fi

    while read -r id; do
        [[ -n "${id}" ]] || continue
        pactl unload-module "${id}"
        echo "[OK] loopback retire, module id=${id}"
    done <<< "${ids}"
}

status_loopback() {
    local ids
    ids="$(find_loopback_ids || true)"

    echo "[*] Source : $SOURCE"
    echo "[*] Sink   : $SINK"

    if [[ -n "${ids}" ]]; then
        echo "[OK] loopback actif : ${ids}"
    else
        echo "[INFO] aucun loopback actif"
    fi
}

case "${ACTION}" in
    enable)
        enable_loopback
        ;;
    disable)
        disable_loopback
        ;;
    status)
        status_loopback
        ;;
    *)
        echo "Usage: $0 {enable|disable|status} [sink]"
        exit 1
        ;;
esac

network/osmo-ip-plan.sh

.sh 198 lignes - 11KB

network/osmo-ip-plan.sh
#!/usr/bin/env bash
# =============================================================================
# network/osmo-ip-plan.sh - QUI PORTE LES ADRESSES PRIVEES DU NOEUD, ET OU
# =============================================================================
#
# CE QUI NE MARCHAIT PAS
# ----------------------
# Les adresses privees du noeud (192.168.<noeud+1>.1 et .10) etaient posees par
# systemd-networkd, dans 20-dhcp.network, sous un [Match] Name=en* eth*. Deux
# consequences, et la seconde est la vraie :
#
#   1. TOUTES les cartes qui repondent au motif recoivent les deux adresses.
#      Une machine a deux cartes se retrouve avec la meme /32 des deux cotes.
#   2. Surtout, le motif ne dit RIEN de la carte qui porte reellement le
#      reseau. Sur une VM a plusieurs interfaces, "en*" designe aussi bien le
#      NAT que le pont ; l'adresse se posait donc regulierement sur une carte
#      qui ne mene nulle part, pendant que celle qui a la route par defaut ne
#      l'avait pas. Un pair qui visait 192.168.2.10 ne trouvait personne, et
#      "ip addr" montrait pourtant l'adresse presente - sur l'autre carte.
#
# systemd-networkd ne sait pas exprimer "la carte qui a la route par defaut" :
# c'est une propriete d'EXECUTION, pas de configuration. D'ou ce script, appele
# au demarrage et a chaque changement de lien.
#
# LE REPLI, ET POURQUOI IL EXISTE
# -------------------------------
# Quand AUCUNE carte ne fournit Internet - VM sans reseau, cable debranche,
# DHCP muet - il n'y a aucun endroit sensé ou poser une adresse de segment. On
# ne la pose alors pas dans le vide : on met 127.0.0.66/32 sur la boucle
# locale. Les configurations qui nomment une adresse privee trouvent ainsi
# TOUJOURS quelque chose de local et de joignable, les demons demarrent, et le
# banc tourne en autarcie au lieu d'echouer au bind sur une adresse absente.
#
# DOCKER OU NATIF
# ---------------
# Le plan d'adressage n'est pas le meme des deux cotes, et prendre l'un pour
# l'autre donne des demons qui se lient a des adresses qui n'existent pas ici :
#   docker  le conteneur a son adresse sur le bridge avant que les demons ne
#           demarrent, et le backbone est 172.20.0.x. On ne touche a rien :
#           c'est docker qui adresse.
#   natif   l'adresse vient du DHCP, elle peut manquer au lancement, et le
#           segment prive du noeud est 192.168.<noeud+1>.x.
# La detection reprend celle de start-direct.sh, au mot pres : /.dockerenv ET
# /etc/docker-entrypoint-cmd (le couple qui identifie un conteneur DE CE DEPOT),
# puis le cgroup en repli.
#
# Usage :   osmo-ip-plan.sh [--apply|--show] [--node N]
# -----------------------------------------------------------------------------
set -uo pipefail

ACTION=apply
NODE=""
while [ $# -gt 0 ]; do
    case "$1" in
        --apply)  ACTION=apply ;;
        --show)   ACTION=show ;;
        --node)   NODE="${2:-}"; shift ;;
        --node=*) NODE="${1#*=}" ;;
        -h|--help) sed -n '2,45p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
        *) echo "option inconnue : $1" >&2; exit 2 ;;
    esac
    shift
done

LOOPBACK_FALLBACK="${OSMO_PRIV_FALLBACK:-127.0.0.66}"

# ── docker ou natif ─────────────────────────────────────────────────────────
detect_runtime_env() {
    if [ -f /.dockerenv ] && [ -f /etc/docker-entrypoint-cmd ]; then echo docker; return; fi
    if [ -f /.dockerenv ] || grep -qa 'docker\|containerd' /proc/1/cgroup 2>/dev/null; then
        echo docker; return
    fi
    echo native
}

# ── le noeud ────────────────────────────────────────────────────────────────
resolve_node() {
    local n="${NODE:-${OSMO_WAN_NODE:-${WAN_NODE_ID:-}}}"
    [ -n "$n" ] || n="$(awk -F= '/^OSMO_WAN_NODE=/{gsub(/[ \r\t]/,"",$2);v=$2} END{print v}' \
                        /etc/osmo-role 2>/dev/null)"
    [ -n "$n" ] || n="$(sed -n 's/^PLAN_NODE=//p' /etc/osmocom/radio-plan.env 2>/dev/null | tail -1)"
    case "$n" in [1-9]) ;; *) n=1 ;; esac
    printf '%s' "$n"
}

# ── LA carte qui fournit Internet ───────────────────────────────────────────
# "ip route get" repond ce que le NOYAU ferait vraiment : c'est la seule
# reponse qui ne devine pas. On essaie une adresse publique, puis la passerelle
# par defaut - un banc coupe d'Internet garde en general sa route par defaut.
uplink_dev() {
    local d
    d="$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.*dev \([^ ]*\).*/\1/p' | head -1)"
    [ -n "$d" ] || d="$(ip route show default 2>/dev/null | sed -n 's/.*dev \([^ ]*\).*/\1/p' | head -1)"
    # lo ne fournit rien : si la route par defaut y mene, c'est qu'il n'y en a pas.
    [ "$d" = "lo" ] && d=""
    printf '%s' "$d"
}

# ── CE QUE LES CONFIGS EXIGENT, PAS CE QU'ON SUPPOSE ────────────────────────
# Le plan prive (192.168.<noeud+1>.x) est une convention ; ce qui compte, c'est
# l'adresse que chaque demon va REELLEMENT essayer de lier. Elle est ecrite dans
# /etc/osmocom, et elle peut venir d'un autre generateur que le notre :
# qosmo-grgsm/run_modules/08-gabarits.sh passe op_private_ip() a
# apply_config_templates, donc 192.168.2.10, quand la recette native de l'ISO
# passe 127.0.0.2. Deux verites, et le demon suit la sienne :
#     osmo-ggsn : « gtp bind-ip 192.168.2.10 » -> bind sur une adresse absente
#     osmo-sgsn : « listen 192.168.2.10 23000 » -> idem
# On EXTRAIT donc les adresses des fichiers au lieu de les recalculer : la
# machine finit par porter ce que ses configs demandent, quel que soit celui
# qui les a ecrites.
#
# ATTENTION A CE QU ON NE PREND PAS : « ip dns 0/1 » (serveurs annonces a
# l'abonne), « remote-ip », « ggsn 0 remote-ip » designent des adresses
# DISTANTES. Les poser localement ferait repondre la machine a la place du pair.
addrs_des_configs() {
    local d="${OSMOCOM_CFG:-/etc/osmocom}"
    [ -d "$d" ] || return 0
    {
        sed -n 's/^[[:space:]]*gtp[[:space:]]\+bind-ip[[:space:]]\+\([0-9.]*\).*/\1/p'  "$d"/osmo-ggsn.cfg 2>/dev/null
        sed -n 's/^[[:space:]]*gtp[[:space:]]\+local-ip[[:space:]]\+\([0-9.]*\).*/\1/p' "$d"/osmo-sgsn.cfg 2>/dev/null
        sed -n 's/^[[:space:]]*listen[[:space:]]\+\([0-9.]*\)[[:space:]]\+[0-9]*.*/\1/p' "$d"/osmo-sgsn.cfg 2>/dev/null
        sed -n 's/^[[:space:]]*local-addr[[:space:]]\+\([0-9.]*\).*/\1/p'                 "$d"/osmo-upf.cfg  2>/dev/null
    } | sort -u | while read -r a; do
        case "$a" in
            ''|0.0.0.0|127.*) continue ;;      # deja la, ou « toutes les cartes »
        esac
        printf '%s\n' "$a"
    done
}

RUNTIME="$(detect_runtime_env)"
NODE="$(resolve_node)"
PRIV_BASE=$(( NODE + 1 ))
PRIV_GW="192.168.${PRIV_BASE}.1"
PRIV_IP="192.168.${PRIV_BASE}.10"
DEV="$(uplink_dev)"

if [ "$ACTION" = show ] || [ "${OSMO_IP_PLAN_VERBOSE:-1}" = 1 ]; then
    printf 'osmo-ip-plan : runtime=%s noeud=%s prive=%s,%s carte=%s\n' \
        "$RUNTIME" "$NODE" "$PRIV_GW" "$PRIV_IP" "${DEV:-aucune}"
fi
[ "$ACTION" = show ] && exit 0

# ── DOCKER : on ne touche a rien ────────────────────────────────────────────
if [ "$RUNTIME" = docker ]; then
    echo "osmo-ip-plan : conteneur - l'adressage appartient a docker, rien a poser"
    exit 0
fi

# ── On retire d'abord les adresses de PARTOUT ───────────────────────────────
# Sans ce nettoyage, changer de carte (cable debranche, Wi-Fi qui prend le
# relais) laissait l'ancienne adresse en place sur l'ancienne carte : deux
# interfaces revendiquaient la meme /32 et le noyau choisissait la source par
# ordre d'apparition, pas par route.
for a in "$PRIV_GW" "$PRIV_IP" "$LOOPBACK_FALLBACK"; do
    while read -r ifn; do
        [ -n "$ifn" ] || continue
        ip addr del "${a}/32" dev "$ifn" 2>/dev/null || true
    done < <(ip -o -4 addr show 2>/dev/null | awk -v a="$a" '$4 ~ "^"a"/" {print $2}')
done

# L'union du plan du noeud et de ce que les configs reclament. Les doublons
# tombent au tri ; « ip addr add » d'une adresse deja posee est sans effet.
BESOINS="$(printf '%s\n%s\n%s\n' "$PRIV_GW" "$PRIV_IP" "$(addrs_des_configs)" | sed '/^$/d' | sort -u)"

if [ -n "$DEV" ]; then
    _pose=""
    for a in $BESOINS; do
        # Deja portee par une carte quelconque : on n'y touche pas. Reposer une
        # adresse ailleurs ferait deux interfaces sur la meme /32.
        if ip -o -4 addr show 2>/dev/null | awk '{print $4}' | grep -qx "${a}/32\|${a}/[0-9]*"; then
            continue
        fi
        ip -o -4 addr show 2>/dev/null | awk -F'[ /]+' '{print $4}' | grep -qx "$a" && continue
        ip addr add "${a}/32" dev "$DEV" 2>/dev/null && _pose="$_pose $a"
    done
    if [ -n "$_pose" ]; then
        echo "osmo-ip-plan :${_pose} pose(s) en /32 sur ${DEV} (carte qui fournit Internet)"
    else
        echo "osmo-ip-plan : rien a poser, tout est deja la (${DEV})"
    fi
else
    # ── REPLI : la boucle locale ────────────────────────────────────────────
    # Aucune carte ne mene nulle part. Poser 192.168.x sur une interface au
    # hasard donnerait une adresse presente et injoignable - le pire des deux
    # mondes, parce que tout a l'air en place. Une adresse de boucle, elle, est
    # honnete : locale, joignable, et elle ne pretend rien.
    # Sur lo, une adresse absente empeche un demon de demarrer tout autant que
    # sur une carte : on y pose donc AUSSI ce que les configs reclament, plutot
    # que le seul repli. Le banc tourne alors en autarcie, complet.
    ip addr add "${LOOPBACK_FALLBACK}/32" dev lo 2>/dev/null || true
    for a in $BESOINS; do
        ip -o -4 addr show 2>/dev/null | awk -F'[ /]+' '{print $4}' | grep -qx "$a" && continue
        ip addr add "${a}/32" dev lo 2>/dev/null || true
    done
    echo "osmo-ip-plan : aucune carte ne fournit Internet - ${LOOPBACK_FALLBACK}/32 + $(echo $BESOINS) sur lo"
fi
exit 0

network/set_ip_vm.sh

.sh 438 lignes - 23KB

network/set_ip_vm.sh
#!/bin/bash
# =============================================================================
# network/set_ip_vm.sh - rend une VM capable de joindre les conteneurs docker
#
# Le probleme, en une ligne : l'ISO pose sur le NIC des adresses du plan docker
# (172.20.0.1/24, 172.20.0.11/24, 172.20.1.10/24) heritees du temps ou tout
# tournait sur une seule machine. Sur un banc ou les conteneurs vivent sur un
# HOTE a part, ces adresses mentent :
#
#   1. le /24 fait croire que 172.20.0.0/24 est sur le lien local. La VM l'ARP
#      donc sur le LAN au lieu de le router, et
#          ip route add 172.20.0.0/24 via <hote>
#      est refuse d'un "RTNETLINK answers: File exists".
#   2. 172.20.0.11 est l'adresse du PREMIER CONTENEUR operateur. La VM se
#      repond a elle-meme : la panne la plus deroutante du lot, puisque tout
#      "fonctionne" en local et que rien n'arrive jamais au conteneur.
#
# Ce script retire ce qui ment, garde ce qui sert, et pose la route. Tout est
# deduit a l'execution - interface, adresses presentes, reseaux - rien n'est
# ecrit en dur : la meme commande vaut sur les trois VM du banc.
#
# CE QU'IL NE FAIT PAS : casser une pile qui tourne. Une adresse sur laquelle un
# demon ecoute est signalee et CONSERVEE, sauf --force. Mieux vaut un routage
# incomplet qu'un osmo-stp qui ne redemarre plus.
#
# Usage :
#   sudo network/set_ip_vm.sh --via 192.168.1.101      l'hote qui porte docker
#   sudo network/set_ip_vm.sh                          --via demande, ou relu
#   sudo network/set_ip_vm.sh --status                 etat, sans rien changer
#   sudo network/set_ip_vm.sh --undo                   retire la route posee
#
#   --via ADRESSE      hote docker (routeur vers le backbone)
#   --docker-net CIDR  backbone des conteneurs (defaut 172.20.0.0/24)
#   --iface NOM        interface (defaut : celle de la route par defaut)
#   --keep ADRESSE     adresse a NE PAS retirer (repetable)
#   --force            retire meme les adresses sur lesquelles un demon ecoute
#   --hub-ip ADRESSE   ecrit l'inter-STP a viser dans osmo-stp.cfg, leve le
#                      shutdown de l'ASP, puis aligne la source sur la route
#                      qui y mene. Sans elle, on ne fait qu'aligner la source.
#   --no-stp           ne touche pas a osmo-stp.cfg (source de l'ASP)
#   --defaut           applique la configuration du banc sans rien demander.
#                      L'hote docker n'est PAS suppose : il est cherche sur le
#                      reseau lu sur l'interface - la seule machine qui sache
#                      joindre le backbone des conteneurs. --via le fixe.
#   --persist          rejoue au boot (unite systemd)
#   --dry-run          montre, n'applique pas
# =============================================================================
set -uo pipefail

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'

DOCKER_NET="172.20.0.0/24"
VIA=""
IFACE=""
FORCE=0
NO_STP=0
# --hub-ip : l'inter-STP que cet ASP doit viser. Sans elle, on ne fait
# qu'ALIGNER la source sur le remote-ip deja ecrit - ce qui ne vaut rien quand
# ce remote-ip est reste le defaut du gabarit (127.0.0.1) : on validerait une
# configuration qui ne joindra jamais personne. Avec elle, on ECRIT la
# destination, puis la source qui y mene.
HUB_IP=""
USE_DEFAULTS=0
# ── La configuration du banc, en un mot ─────────────────────────────────────
# --defaut applique ce qui est vrai sur toutes nos VM sans rien demander :
# l'hote qui porte docker, le backbone des conteneurs, et la persistance au
# boot (le live n'en a aucune, la route serait perdue au redemarrage).
# Chaque valeur reste surchargeable : --defaut --via X gagne sur le defaut.
DEFAULT_VIA=""                  # decouvert : voir discover_docker_host()
DEFAULT_DOCKER_NET="172.20.0.0/24"
DRY=0
PERSIST=0
ACTION="apply"
KEEP=()
STATE=/etc/osmo-docker-host          # memoire du --via, pour ne le taper qu'une fois

while [ $# -gt 0 ]; do
    case "$1" in
        --via)          VIA="${2:-}"; shift ;;
        --via=*)        VIA="${1#*=}" ;;
        --docker-net)   DOCKER_NET="${2:-}"; shift ;;
        --docker-net=*) DOCKER_NET="${1#*=}" ;;
        --iface)        IFACE="${2:-}"; shift ;;
        --iface=*)      IFACE="${1#*=}" ;;
        --keep)         KEEP+=("${2:-}"); shift ;;
        --keep=*)       KEEP+=("${1#*=}") ;;
        --force)        FORCE=1 ;;
        --hub-ip)       HUB_IP="${2:-}"; shift ;;
        --hub-ip=*)     HUB_IP="${1#*=}" ;;
        --no-stp)       NO_STP=1 ;;
        --defaut|--default) USE_DEFAULTS=1 ;;
        --persist)      PERSIST=1 ;;
        --dry-run)      DRY=1 ;;
        --status)       ACTION="status" ;;
        --undo)         ACTION="undo" ;;
        -h|--help)      sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
        *) echo -e "${RED}option inconnue : $1${NC}" >&2; exit 2 ;;
    esac
    shift
done

run() { if [ "$DRY" = 1 ]; then echo "    [dry-run] $*"; else "$@"; fi; }

# ── Trouver l'hote docker, sans le nommer ───────────────────────────────────
# Une adresse en dur ne survit pas au banc : il a suffi que l'hote passe de
# l'ethernet au wifi pour qu'il change d'adresse, et toutes les VM se sont mises
# a router vers une machine qui n'existait plus - aller sans retour, "connect
# failed" partout, sans qu'aucune configuration n'ait bouge.
#
# On le cherche donc par ce qu'il FAIT, pas par ce qu'il s'appelle : c'est la
# seule machine du LAN qui sait joindre le backbone des conteneurs. Pour chaque
# machine vivante, on pose la route a l'essai et on tente d'atteindre le premier
# conteneur ; celle qui repond est l'hote. La route est retiree si l'essai
# echoue - rien ne subsiste d'une tentative infructueuse.
discover_docker_host() {
    local prefix probe live c saved
    # Cette fonction POSE des routes pour trouver la bonne : elle ne passe pas
    # par run(), donc --dry-run modifiait quand meme la table de routage de la
    # machine. Un mode qui annonce "montre, n'applique pas" doit tenir parole.
    if [ "$DRY" = 1 ]; then
        echo -e "    [dry-run] decouverte sautee : elle pose des routes a l'essai - donnez --via" >&2
        return 1
    fi
    # Et sans root, "ip route replace" echoue silencieusement a chaque candidat :
    # la boucle allait au bout, VIA ressortait vide, et --defaut sortait en
    # "--via manquant" - un message qui designe la mauvaise cause. Le controle
    # de root n'arrive que cinquante lignes plus bas, trop tard pour celui-ci.
    if [ "$(id -u)" -ne 0 ]; then
        echo -e "    decouverte impossible sans root (elle pose des routes a l'essai) - donnez --via" >&2
        return 1
    fi
    # Le reseau se lit sur l'interface, il n'est jamais suppose.
    [ -n "$LAN_IP" ] || return 1
    prefix="${LAN_IP%.*}"
    probe="$(echo "$DOCKER_NET" | cut -d. -f1-3).11"   # le premier conteneur

    echo -e "  ${CYAN}Recherche de l'hote docker sur ${prefix}.0/24...${NC}" >&2
    # 1. qui est vivant ? (en parallele, une seconde au total)
    live="$(for c in $(seq 1 254); do
                [ "${prefix}.${c}" = "$LAN_IP" ] && continue
                ping -c1 -W1 "${prefix}.${c}" >/dev/null 2>&1 && echo "${prefix}.${c}" &
            done; wait)"
    [ -n "$live" ] || return 1

    # 2. lequel route vers les conteneurs ?
    saved="$(ip -4 route show "$DOCKER_NET" 2>/dev/null | head -1)"
    for c in $live; do
        ip route replace "$DOCKER_NET" via "$c" 2>/dev/null || continue
        if ping -c1 -W1 "$probe" >/dev/null 2>&1; then
            echo -e "  ${GREEN}✓${NC} hote docker : ${CYAN}${c}${NC} (il joint ${probe})" >&2
            printf '%s' "$c"; return 0
        fi
    done
    # rien trouve : on remet ce qui etait la
    ip route del "$DOCKER_NET" 2>/dev/null || true
    [ -n "$saved" ] && ip route add $saved 2>/dev/null || true
    return 1
}

# ── L'interface : celle qui porte la route par defaut ───────────────────────
[ -n "$IFACE" ] || IFACE="$(ip route show default 2>/dev/null | awk '/^default/{print $5; exit}')"
[ -n "$IFACE" ] || { echo -e "${RED}Aucune interface par defaut - precisez --iface${NC}" >&2; exit 1; }

# ── L'adresse de la machine SUR LE LAN ──────────────────────────────────────
# Prise sur la route par defaut, pas dans la liste des adresses : depuis que
# l'ISO pose aussi le plan prive (192.168.<n>.1/32 et .10/32), un simple "la
# premiere qui n'est pas 172.20" tombait sur 192.168.2.1 - une adresse locale,
# sur aucun segment. La decouverte de l'hote balayait alors le mauvais reseau.
# La source de la route par defaut, elle, est par construction celle qui sort.
LAN_IP="$(ip route show default 2>/dev/null | awk '/^default/{print $9; exit}')"
[ -n "$LAN_IP" ] || LAN_IP="$(ip -4 -o addr show dev "$IFACE" scope global 2>/dev/null \
          | awk '$0 !~ /\/32/ {print $4}' | cut -d/ -f1 | grep -vE '^172\.20\.' | head -1)"

# ── --defaut : la table du banc, sans rien demander ─────────────────────────
# Ce bloc vient APRES la lecture de l'interface et de l'adresse LAN, et il doit
# y rester : discover_docker_host balaye le reseau lu sur l'interface. Tant
# qu'il etait place avant, LAN_IP n'existait pas encore et set -u tuait le
# sous-shell de la substitution - la recherche ne demarrait donc jamais, VIA
# restait vide, et --defaut ressortait sur "--via manquant" (code 2) alors que
# son role est justement de ne rien avoir a demander.
if [ "$USE_DEFAULTS" = "1" ]; then
    [ -n "$VIA" ] || VIA="$(awk -F= '/^OSMO_DOCKER_HOST=/{print $2}' "$STATE" 2>/dev/null | tr -d ' \r')"
    [ -n "$VIA" ] || VIA="$(discover_docker_host || true)"
    [ "$DOCKER_NET" = "172.20.0.0/24" ] && DOCKER_NET="$DEFAULT_DOCKER_NET"
    PERSIST=1
    echo -e "${CYAN}  --defaut : hote docker ${VIA}, backbone ${DOCKER_NET}, rejoue au boot${NC}"
fi

# Le prefixe du plan docker, pour reconnaitre les adresses a retirer :
# 172.20.0.0/24 -> "172.20." (on ratisse le /16, car l'ISO pose aussi 172.20.1.x)
DOCKER_PREFIX="$(echo "$DOCKER_NET" | cut -d. -f1-2)."

echo -e "${CYAN}${BOLD}== Adressage VM vers les conteneurs docker ==${NC}"
echo -e "  interface : ${CYAN}${IFACE}${NC}   adresse LAN : ${CYAN}${LAN_IP:-?}${NC}"
echo -e "  backbone  : ${CYAN}${DOCKER_NET}${NC}"

# ── Ce qui traine ───────────────────────────────────────────────────────────
mapfile -t STALE_ADDRS < <(ip -4 -o addr show dev "$IFACE" 2>/dev/null \
    | awk '{print $4}' | grep "^${DOCKER_PREFIX}" || true)
mapfile -t STALE_ROUTES < <(ip -4 route show dev "$IFACE" 2>/dev/null \
    | awk '$1 ~ /^'"${DOCKER_PREFIX//./\\.}"'/ && /scope link/ {print $1}' || true)

# ── --status ────────────────────────────────────────────────────────────────
if [ "$ACTION" = "status" ]; then
    echo ""
    if [ "${#STALE_ADDRS[@]}" -eq 0 ]; then
        echo -e "  ${GREEN}✓${NC} aucune adresse ${DOCKER_PREFIX}x sur ${IFACE}"
    else
        echo -e "  ${YELLOW}adresses du plan docker encore posees :${NC}"
        for a in "${STALE_ADDRS[@]}"; do echo "    $a"; done
    fi
    cur="$(ip -4 route show "$DOCKER_NET" 2>/dev/null | head -1)"
    if echo "$cur" | grep -q " via "; then
        echo -e "  ${GREEN}✓${NC} route : ${cur}"
    elif [ -n "$cur" ]; then
        echo -e "  ${RED}✗${NC} route CONNECTEE (pas de via) : ${cur}"
        echo -e "     tant qu'elle existe, les conteneurs sont injoignables."
    else
        echo -e "  ${YELLOW}–${NC} aucune route vers ${DOCKER_NET}"
    fi
    exit 0
fi

[ "$DRY" = 1 ] || [ "$(id -u)" -eq 0 ] \
    || { echo -e "${RED}Root requis.${NC}" >&2; exit 1; }

# ── --undo ──────────────────────────────────────────────────────────────────
if [ "$ACTION" = "undo" ]; then
    run ip route del "$DOCKER_NET" 2>/dev/null \
        && echo -e "  ${GREEN}✓${NC} route retiree" \
        || echo -e "  ${YELLOW}–${NC} pas de route a retirer"
    rm -f /etc/systemd/system/osmo-docker-route.service 2>/dev/null && \
        systemctl disable osmo-docker-route.service >/dev/null 2>&1
    exit 0
fi

# ── L'hote docker ───────────────────────────────────────────────────────────
# Memorise : on ne le tape qu'une fois par machine. Sans terminal et sans
# memoire, on s'arrete plutot que d'inventer une passerelle.
[ -n "$VIA" ] || VIA="$(awk -F= '/^OSMO_DOCKER_HOST=/{print $2}' "$STATE" 2>/dev/null | tr -d ' \r')"
if [ -z "$VIA" ] && [ -t 0 ]; then
    def=""
    [ -n "$LAN_IP" ] && def="${LAN_IP%.*}.101"
    printf '  Adresse de l hote qui porte docker [%s] : ' "${def:-?}"
    read -r VIA || VIA=""
    [ -n "$VIA" ] || VIA="$def"
fi
if [ -z "$VIA" ]; then
    echo -e "  ${RED}✗ --via manquant : j'ignore par ou joindre ${DOCKER_NET}${NC}" >&2
    exit 2
fi
if ! [[ "$VIA" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
    echo -e "  ${RED}✗ '${VIA}' n'est pas une IPv4${NC}" >&2; exit 2
fi
echo -e "  hote docker : ${CYAN}${VIA}${NC}"

# ── Ce script est pour les VM. Pas pour l'hote qui porte docker ─────────────
# Lance sur l'hote, il pose une route vers LUI-MEME et prend le pas sur la route
# connectee du bridge docker. Resultat immediat : "Destination Host Unreachable"
# vers ses propres conteneurs - alors qu'ils tournent tres bien.
#
# Deux signes le trahissent, et un seul suffit :
#   - --via est une adresse locale ;
#   - un bridge porte deja le reseau des conteneurs.
#
# Attention a ne pas confondre les deux facons de "porter" ce reseau :
#   un BRIDGE docker (br-xxxx, docker0)  -> c'est l'hote, on s'arrete ;
#   une carte ordinaire (enp0s3...)      -> ce sont justement les alias perimes
#                                           de l'ISO, et c'est notre travail.
# Un premier jet refusait les deux et bloquait donc sur la VM qu'il devait
# reparer.
_self=0
ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 \
    | grep -qx "$VIA" && _self=1
_dev="$(ip -4 -o route show "$DOCKER_NET" 2>/dev/null | awk '/proto kernel/{print $3; exit}')"
_bridge=""
case "$_dev" in br-*|docker*|virbr*) _bridge="$_dev" ;; esac
if [ "$_self" = 1 ] || [ -n "$_bridge" ]; then
    echo ""
    echo -e "  ${RED}✗ Cette machine EST l'hote docker - rien a faire ici.${NC}" >&2
    [ "$_self" = 1 ] && echo -e "     ${VIA} est une de ses propres adresses." >&2
    [ -n "$_bridge" ] && echo -e "     ${DOCKER_NET} est deja porte par ${_bridge}." >&2
    echo -e "  ${YELLOW}Ce script se lance SUR LES VM${NC}, pour leur apprendre le chemin" >&2
    echo -e "  vers les conteneurs. Cote hote, c'est l'autre script :" >&2
    echo -e "     ${CYAN}sudo network/setup-docker-lan-route.sh --push \"<ip des VM>\"${NC}" >&2
    exit 2
fi
echo ""

# ── 1. Retirer ce qui ment ──────────────────────────────────────────────────
# Une adresse sur laquelle un demon ECOUTE n'est pas retiree : la supprimer
# ferait tomber le service qui s'y est lie, et le probleme deviendrait pire que
# celui qu'on corrige.
for a in ${STALE_ADDRS[@]+"${STALE_ADDRS[@]}"}; do
    ip_only="${a%%/*}"
    skip=0
    for k in ${KEEP[@]+"${KEEP[@]}"}; do [ "$k" = "$ip_only" ] && skip=1; done
    if [ "$skip" = 1 ]; then
        echo -e "  ${CYAN}–${NC} ${a} conservee (--keep)"; continue
    fi
    if [ "$FORCE" != 1 ] && ss -Hlnp 2>/dev/null | grep -q "[^0-9]${ip_only}:"; then
        echo -e "  ${YELLOW}⚠${NC} ${a} CONSERVEE : un demon y ecoute"
        echo -e "     (relancez la pile puis --force, ou --keep pour l'assumer)"
        continue
    fi
    # Une adresse qu'une CONFIG nomme, meme si rien n'ecoute encore. C'est le
    # cas au repos : la pile est arretee, aucun socket ouvert, et pourtant
    # osmo-ggsn.cfg dit "gtp bind-ip 172.20.1.10". La retirer donne, au
    # demarrage suivant, un service qui refuse de partir sur une adresse
    # "introuvable localement" - et le lien avec ce script est loin.
    if [ "$FORCE" != 1 ]; then
        _ref="$(grep -rlF "$ip_only" /etc/osmocom /etc/asterisk 2>/dev/null | head -3)"
        if [ -n "$_ref" ]; then
            echo -e "  ${YELLOW}⚠${NC} ${a} CONSERVEE : nommee dans une configuration"
            printf '     %s\n' $_ref
            echo -e "     (corrigez la config - 127.0.0.1 convient en natif - puis --force)"
            continue
        fi
    fi
    run ip addr del "$a" dev "$IFACE" 2>/dev/null \
        && echo -e "  ${GREEN}✓${NC} ${a} retiree" \
        || echo -e "  ${YELLOW}–${NC} ${a} deja absente"
done

for r in ${STALE_ROUTES[@]+"${STALE_ROUTES[@]}"}; do
    run ip route del "$r" dev "$IFACE" 2>/dev/null \
        && echo -e "  ${GREEN}✓${NC} route connectee ${r} retiree"
done

# ── 2. Poser la route ───────────────────────────────────────────────────────
# 'replace' plutot que 'add' : idempotent, et il ne bute pas sur un reste.
if run ip route replace "$DOCKER_NET" via "$VIA" dev "$IFACE"; then
    echo -e "  ${GREEN}✓${NC} ${DOCKER_NET} via ${CYAN}${VIA}${NC}"
else
    echo -e "  ${RED}✗${NC} route refusee - une adresse du plan docker subsiste ?"
    echo -e "     ${CYAN}ip -4 addr show dev ${IFACE} | grep ${DOCKER_PREFIX}${NC}"
    exit 1
fi

# ── 2bis. L'ASP doit partir de l'adresse qui ROUTE ──────────────────────────
# Poser la route ne suffit pas si osmo-stp se lie a une autre adresse. Une VM du
# banc en porte plusieurs - le pont, et le plan prive de l'ISO (192.168.<n>.1 et
# .10) - et l'auto-detection choisissait volontiers une adresse privee. Le hub
# ne peut alors pas repondre : l'association n'aboutit jamais et le journal ne
# dit que "connect failed (-110)", un timeout, sans nommer la source fautive.
#
# On aligne donc la source de l'ASP sur ce que le noyau utilisera reellement
# pour joindre le hub - la meme regle que network/set-node-id.sh.
STP_CFG="${STP_CFG:-/etc/osmocom/osmo-stp.cfg}"
if [ "${NO_STP:-0}" != "1" ] && [ -w "$STP_CFG" ]; then
    # --hub-ip fait autorite ; sinon on lit ce que le fichier declare deja.
    if [ -n "$HUB_IP" ]; then
        _hub="$HUB_IP"
        _cur_hub="$(awk '/asp asp-to-inter/{f=1} f&&/remote-ip/{print $2; exit}' "$STP_CFG")"
        if [ "$_cur_hub" != "$_hub" ]; then
            if [ "$DRY" = 1 ]; then
                echo -e "  [dry-run] ASP remote-ip ${_cur_hub:-?} -> ${_hub}"
            else
                sed -i "/asp asp-to-inter/,/^ *as / s/^\( *remote-ip \).*/\1${_hub}/" "$STP_CFG"
                # Un ASP en shutdown ne s'attache pas, meme bien adresse : on le
                # leve, sinon la correction reste sans effet visible.
                sed -i "/asp asp-to-inter/,/^ *as / s/^\( *\)shutdown[[:space:]]*$/\1no shutdown/" "$STP_CFG"
                echo -e "  ${GREEN}✓${NC} ASP : hub ${CYAN}${_hub}${NC} (etait ${_cur_hub:-aucun})"
            fi
        fi
    else
        _hub="$(awk '/asp asp-to-inter/{f=1} f&&/remote-ip/{print $2; exit}' "$STP_CFG")"
    fi
    # Un remote-ip en boucle locale n'est pas un hub : c'est le defaut du
    # gabarit, laisse par une regeneration qui a efface l'identite du noeud.
    # Aligner la source dessus validerait une configuration morte - "source
    # deja correcte (127.0.0.1)" sur un ASP qui ne joindra jamais personne.
    case "$_hub" in
        127.*|0.0.0.0|"")
            echo -e "  ${YELLOW}⚠${NC} ASP : remote-ip=${_hub:-vide} - ce n'est pas un hub."
            echo -e "     L'identite SS7 n'a pas ete appliquee : ${CYAN}./start-direct.sh --node N --hub-ip <hub>${NC}"
            _hub="" ;;
    esac
    if [ -n "$_hub" ]; then
        _src="$(ip route get "$_hub" 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1)"
        _cur="$(awk '/asp asp-to-inter/{f=1} f&&/local-ip/{print $2; exit}' "$STP_CFG")"
        if [ -n "$_src" ] && [ "$_src" != "$_cur" ]; then
            if [ "$DRY" = 1 ]; then
                echo -e "  [dry-run] ASP local-ip ${_cur:-?} -> ${_src}"
            else
                sed -i "/asp asp-to-inter/,/^ *as / s/^\( *local-ip \).*/\1${_src}/" "$STP_CFG"
                echo -e "  ${GREEN}✓${NC} ASP : source ${CYAN}${_src}${NC} (etait ${_cur:-aucune}) vers le hub ${_hub}"
                if systemctl is-active osmo-stp >/dev/null 2>&1; then
                    systemctl restart osmo-stp \
                        && echo -e "  ${GREEN}✓${NC} osmo-stp relance pour prendre la nouvelle source"
                fi
            fi
        else
            echo -e "  ${GREEN}✓${NC} ASP : source deja correcte (${_cur:-aucune})"
        fi
    fi
fi

[ "$DRY" = 1 ] || printf 'OSMO_DOCKER_HOST=%s\n' "$VIA" > "$STATE" 2>/dev/null || true

# ── 3. Au boot, si demande ──────────────────────────────────────────────────
# Un live sans persistance repart de l'ISO a chaque demarrage : sans unite, tout
# est a refaire. Elle rejoue simplement ce script, qui est idempotent.
if [ "$PERSIST" = 1 ]; then
    here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    if [ "$DRY" = 1 ]; then
        echo "    [dry-run] unite osmo-docker-route.service"
    else
        cat > /etc/systemd/system/osmo-docker-route.service <<EOF
[Unit]
Description=osmo-operator - route vers les conteneurs docker (${DOCKER_NET})
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=${here}/set_ip_vm.sh --via ${VIA} --docker-net ${DOCKER_NET}
[Install]
WantedBy=multi-user.target
EOF
        systemctl enable osmo-docker-route.service >/dev/null 2>&1 \
            && echo -e "  ${GREEN}✓${NC} rejoue au boot (osmo-docker-route.service)"
    fi
fi

# ── 4. Le controle qui compte ───────────────────────────────────────────────
echo ""
first="$(echo "$DOCKER_NET" | cut -d. -f1-3).11"
echo -e "  ${BOLD}Verifier :${NC} ${CYAN}ip route get ${first}${NC}   doit indiquer via ${VIA}"
if [ "$DRY" != 1 ]; then
    got="$(ip route get "$first" 2>/dev/null | head -1)"
    if echo "$got" | grep -q "via ${VIA}"; then
        echo -e "  ${GREEN}✓${NC} ${got}"
    else
        echo -e "  ${YELLOW}⚠${NC} ${got:-aucune reponse}"
    fi
fi

network/set-node-id.sh

.sh 320 lignes - 17KB

network/set-node-id.sh
#!/bin/bash
# ══════════════════════════════════════════════════════════════════════════════
# network/set-node-id.sh - donne a CETTE machine son identite de noeud SS7
#
# Une seule ISO pour tous les noeuds : le numero se choisit au demarrage, pas a
# la construction. Ce script reecrit ce qui, dans les configs Osmocom, designe
# le noeud - c'est-a-dire ses POINT CODES et ses routing contexts.
#
#   plan local (une machine)   1.<op>.<role>          op×100 + {10,20,30,50}
#   plan WAN   (N machines)    1.<noeud><op>.<role>    noeud×1000 + op×100 + ...
#
# Pourquoi ca ne peut pas rester le plan local : la formule se recalcule a
# l'identique sur chaque machine. Trois noeuds attaches au meme inter-STP y
# presenteraient trois fois 1.11.2. Un point code est une ADRESSE - deux
# equipements qui partagent la leur, ce n'est pas un doublon de nom, c'est du
# routage faux, et le hub n'a aucun moyen de s'en apercevoir.
#
# Trois fichiers portent cette identite, et ils doivent rester d'accord :
#   osmo-stp.cfg   son propre PC, son routing-key, son ASP vers le hub
#   osmo-msc.cfg   son PC, son routing-key, et le PC du BSC qu'il appelle
#   osmo-bsc.cfg   son PC, son routing-key, et le PC du MSC qu'il appelle
# En rater un donne un coeur qui demarre et des appels qui n'aboutissent pas.
#
# Idempotent : il lit l'etat courant et le remplace. Passer --node 2 apres
# --node 1 fonctionne, il n'y a pas d'etat a nettoyer entre les deux.
#
# Usage :
#   sudo network/set-node-id.sh --node 3 [--op 1] [--hub-ip 192.168.1.49]
#   sudo network/set-node-id.sh --show          etat actuel, sans rien modifier
#   sudo network/set-node-id.sh --node 3 --local   plan local (annule le plan WAN)
# ══════════════════════════════════════════════════════════════════════════════
set -euo pipefail

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# wan_detect_local_ip vit ici : c'est la meme regle de choix d'adresse que celle
# qu'appliquent start.sh et start-direct.sh. La dupliquer, c'est se garantir
# qu'un jour les deux ne designeront plus la meme carte.
# shellcheck source=network/wan-nodes.sh
. "$SCRIPT_DIR/wan-nodes.sh"

NODE=""
OP=1
HUB_IP=""
MODE=""            # docker | native - l'adressage n'est pas le meme
LOCAL_IP=""        # adresse source de l'ASP ; "auto" = detectee
CONF_DIR="${CONF_DIR:-/etc/osmocom}"
SHOW=0
LOCAL_PLAN=0
DRY=0

while [ $# -gt 0 ]; do
    case "$1" in
        --node)     NODE="${2:-}"; shift ;;
        --node=*)   NODE="${1#*=}" ;;
        --op)       OP="${2:-1}"; shift ;;
        --op=*)     OP="${1#*=}" ;;
        --local-ip)   LOCAL_IP="${2:-}"; shift ;;
        --local-ip=*) LOCAL_IP="${1#*=}" ;;
        --hub-ip)   HUB_IP="${2:-}"; shift ;;
        --hub-ip=*) HUB_IP="${1#*=}" ;;
        --conf-dir)   CONF_DIR="${2:-}"; shift ;;
        --conf-dir=*) CONF_DIR="${1#*=}" ;;
        --mode)     MODE="${2:-}"; shift ;;
        --mode=*)   MODE="${1#*=}" ;;
        --docker)   MODE="docker" ;;
        --native)   MODE="native" ;;
        --show)     SHOW=1 ;;
        --local)    LOCAL_PLAN=1 ;;
        --dry-run)  DRY=1 ;;
        -h|--help)  sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
        *) echo -e "${RED}option inconnue : $1${NC}" >&2; exit 2 ;;
    esac
    shift
done

STP="$CONF_DIR/osmo-stp.cfg"
MSC="$CONF_DIR/osmo-msc.cfg"
BSC="$CONF_DIR/osmo-bsc.cfg"

# ── Lecture de l'identite en place ──────────────────────────────────────────
# Le premier point-code d'un fichier est CELUI DU DEMON ; ceux qui suivent, a
# l'interieur d'un bloc "sccp-address", designent ses pairs. C'est cette
# distinction qui structure toute la reecriture.
own_pc() { awk '/^cs7 instance/{c=1} c && /^ *point-code /{print $2; exit}' "$1" 2>/dev/null; }

show_state() {
    local f n
    printf '  %-16s %-10s %-12s %-16s %s\n' FICHIER "POINT CODE" "ROUTING-KEY" "ASP INTER-STP" "SOURCE"
    for f in "$STP" "$MSC" "$BSC"; do
        [ -r "$f" ] || { printf '  %-16s %s\n' "$(basename "$f")" "absent"; continue; }
        local pc rk asp
        pc="$(own_pc "$f")"
        rk="$(awk '/^ *routing-key /{print $2; exit}' "$f")"
        asp="$(awk '/asp asp-to-inter/{f=1} f&&/remote-ip/{print $2; exit}' "$f")"
        local src
        src="$(awk '/asp asp-to-inter/{f=1} f&&/local-ip/{print $2; exit}' "$f")"
        printf '  %-16s %-10s %-12s %-16s %s\n' "$(basename "$f")" "${pc:--}" "${rk:--}" "${asp:--}" "${src:--}"
    done
}

if [ "$SHOW" = "1" ]; then
    [ -n "$MODE" ] || { [ -f /.dockerenv ] && MODE=docker || MODE=native; }
    echo -e "${BOLD}Identite SS7 de cette machine${NC}  (mode ${CYAN}${MODE}${NC})"
    show_state
    exit 0
fi

[[ "$NODE" =~ ^[1-9]$ ]] || { echo -e "${RED}--node : un chiffre de 1 a 9${NC}" >&2; exit 2; }
[[ "$OP"   =~ ^[1-9]$ ]] || { echo -e "${RED}--op : un chiffre de 1 a 9${NC}" >&2; exit 2; }
[ "$(id -u)" -eq 0 ] || { echo -e "${RED}Root requis (ecriture dans ${CONF_DIR}).${NC}" >&2; exit 1; }

for f in "$STP" "$MSC" "$BSC"; do
    [ -w "$f" ] || { echo -e "${RED}$f introuvable ou non modifiable${NC}" >&2; exit 1; }
done

# ── Le nouveau plan ─────────────────────────────────────────────────────────
if [ "$LOCAL_PLAN" = "1" ]; then
    MID="$OP"                       # 1.<op>.<role> - plan d'une machine isolee
    RBASE=$(( OP * 100 ))
else
    MID="${NODE}${OP}"              # 1.<noeud><op>.<role> - plan WAN
    RBASE=$(( NODE * 1000 + OP * 100 ))
fi
PC_MSC="1.${MID}.1"; PC_STP="1.${MID}.2"; PC_BSC="1.${MID}.3"
RCTX_MSC=$(( RBASE + 10 )); RCTX_STP=$(( RBASE + 20 ))
RCTX_BSC=$(( RBASE + 30 )); RCTX_INTER=$(( RBASE + 50 ))

# ── DOCKER OU VM : deux plans d'adressage, et le defaut de l'un est faux dans
# l'autre ──────────────────────────────────────────────────────────────────
#   docker : le hub est le conteneur osmo-inter-stp, 172.20.0.10, sur le reseau
#            gsm-inter. Le noeud y a deja son IP quand osmo-stp demarre.
#   VM/ISO : le hub est une machine a part, sur le LAN en acces par pont.
#            L'adresse du noeud vient du DHCP et n'est pas forcement montee au
#            lancement.
# Se tromper de defaut donne un ASP qui ne s'attache jamais, et le journal ne
# dit que "connection refused" - sans indiquer que l'adresse visee n'existe
# nulle part sur ce reseau-la.
#
# Le host-only VirtualBox (192.168.56.1) a longtemps servi de repli ici. Il est
# FAUX des que les VM sont en acces par pont : l'adresse n'existe sur aucun
# segment, et l'ASP la vise sans jamais rien atteindre. On DEMANDE donc
# l'adresse au lieu de la deviner, avec le hub du banc comme defaut. Une
# reponse VIDE est acceptee quand on ne la connait pas encore : dans ce cas le
# remote-ip deja present dans le fichier n'est pas touche - mieux vaut ne rien
# ecrire qu'ecrire une adresse fausse.
if [ -z "$MODE" ]; then
    if [ -f /.dockerenv ]; then MODE="docker"; else MODE="native"; fi
fi

: "${HUB_IP_DEFAULT:=192.168.1.49}"   # le hub du banc, en acces par pont

if [ -z "$HUB_IP" ]; then
    if [ -r /etc/osmo-role ]; then
        HUB_IP="$(awk -F= '/^OSMO_HUB_IP=/{print $2}' /etc/osmo-role)"
    fi
fi
if [ -z "$HUB_IP" ]; then
    case "$MODE" in
        docker) HUB_IP="${INTER_STP_IP:-172.20.0.10}" ;;
        *)
            # Interactif : on demande. Sinon (ISO, service, CI) on prend le
            # defaut du banc - un script non interactif ne peut pas repondre,
            # et rester bloque sur une lecture ne ferait qu'echouer plus tard.
            if [ -t 0 ] && [ "$DRY" != "1" ] && command -v _wan_ask >/dev/null 2>&1; then
                HUB_IP="$(_wan_ask "Inter-STP" \
                    "IP de l'inter-STP (vide si inconnue) :" "$HUB_IP_DEFAULT")" || exit 1
            else
                HUB_IP="$HUB_IP_DEFAULT"
            fi ;;
    esac
fi

# Vide assume : on ne reecrira pas le remote-ip. Le reste (point codes,
# routing-keys, local-ip) garde tout son sens et s'applique quand meme.
SET_HUB_IP=1
if [ -z "$HUB_IP" ]; then
    SET_HUB_IP=0
    echo -e "  ${YELLOW}Inter-STP inconnu : remote-ip laisse tel quel dans les fichiers.${NC}"
    echo -e "  ${YELLOW}Renseignez-le plus tard : --hub-ip ADRESSE, ou set_stp_ip.sh${NC}"
fi

# ── Adresse SOURCE de l'ASP vers le hub ─────────────────────────────────────
# En docker on n'y touche pas : le conteneur a son adresse avant que le demon ne
# demarre, et une source explicite evite que SCTP annonce plusieurs adresses
# (multi-homing) a un pair qui n'en attend qu'une.
#
# En VM, on INJECTE l'adresse reellement detectee sur le segment VirtualBox
# plutot que 0.0.0.0. La difference n'est pas cosmetique : avec 0.0.0.0, SCTP
# annonce TOUTES les adresses locales dans son INIT - y compris le NAT 10.0.2.15
# et les alias 172.20.x que l'ISO pose. Le hub tente alors de joindre des
# adresses qui, vues de lui, ne menent nulle part. Une adresse unique et juste
# vaut mieux qu'un multi-homing dont trois branches sur quatre sont mortes.
#
# On retombe sur 0.0.0.0 si aucune adresse n'est detectable - s'y lier vaut
# mieux que de se lier a une adresse absente, qui empeche osmo-stp de demarrer.
SET_LOCAL_IP=0
ASP_LOCAL_IP=""
if [ "$MODE" != "docker" ]; then
    SET_LOCAL_IP=1
    case "$LOCAL_IP" in
        "" | auto)
            # ── L'adresse qui ROUTE vers le hub, avant toute heuristique ────
            # Le noyau sait exactement quelle source il utilisera pour joindre
            # le hub : c'est ce que "ip route get" repond. Toute autre methode
            # devine - et devine mal des que la machine porte plusieurs
            # adresses privees. Une VM du banc en a trois (192.168.1.2 le pont,
            # 192.168.2.1 et .2.10 le plan prive de l'ISO) : l'heuristique
            # choisissait 192.168.2.1, une adresse que le hub ne peut pas
            # joindre en retour. L'ASP partait alors dans le vide et le journal
            # ne disait que "connect failed (-110)" - un timeout, sans indice
            # sur la source fautive.
            if [ -n "$HUB_IP" ]; then
                ASP_LOCAL_IP="$(ip route get "$HUB_IP" 2>/dev/null \
                                | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1)"
                [ -n "$ASP_LOCAL_IP" ] && LOCAL_IP_SRC="route vers le hub"
            fi
            if [ -n "$ASP_LOCAL_IP" ]; then
                :
            elif ! command -v wan_detect_local_ip >/dev/null 2>&1; then
                echo -e "${RED}wan_detect_local_ip introuvable - network/wan-nodes.sh non charge${NC}" >&2
                exit 1
            else
                ASP_LOCAL_IP="$(wan_detect_local_ip || true)"
            fi
            if [ -n "$ASP_LOCAL_IP" ]; then
                [ -n "${LOCAL_IP_SRC:-}" ] || LOCAL_IP_SRC="detectee sur le segment"
            else
                ASP_LOCAL_IP="0.0.0.0"
                LOCAL_IP_SRC="aucune adresse detectee - repli"
            fi ;;
        *)  ASP_LOCAL_IP="$LOCAL_IP"; LOCAL_IP_SRC="imposee par --local-ip" ;;
    esac
fi

echo -e "${BOLD}Noeud ${NODE} - operateur ${OP}${NC}   (mode ${CYAN}${MODE}${NC})"
echo -e "  STP ${CYAN}${PC_STP}${NC}   MSC ${CYAN}${PC_MSC}${NC}   BSC ${CYAN}${PC_BSC}${NC}"
echo -e "  routing-key : stp ${RCTX_STP} · msc ${RCTX_MSC} · bsc ${RCTX_BSC} · inter ${RCTX_INTER}"
if [ "$LOCAL_PLAN" = "1" ] && [ -z "$HUB_IP" ]; then
    echo -e "  ${YELLOW}plan LOCAL sans hub : point codes 1.<op>.<role>, ASP inchange${NC}"
else
    [ "$LOCAL_PLAN" = "1" ] && echo -e "  ${CYAN}plan LOCAL${NC} (point codes 1.<op>.<role>) avec hub sur cette machine"
    echo -e "  ASP inter-STP → ${CYAN}${HUB_IP}${NC}$([ "$SET_LOCAL_IP" = "1" ] && echo "  depuis ${CYAN}${ASP_LOCAL_IP}${NC} (${LOCAL_IP_SRC})" || echo "  (source inchangee)")"
fi
echo ""

# ── Reecriture ──────────────────────────────────────────────────────────────
# `own` = le point code du demon (au niveau cs7 instance).
# `peer` = celui qu'il declare dans ses blocs sccp-address.
# La distinction se fait sur le bloc courant, pas sur le numero de ligne : un
# fichier qui gagne une directive ne decale pas la reecriture.
rewrite() {  # $1=fichier  $2=own  $3=peer  $4=rctx  $5=1 si bloc asp inter-STP
    local f="$1" own="$2" peer="$3" rctx="$4" do_asp="$5"
    local tmp; tmp="$(mktemp)"
    awk -v own="$own" -v peer="$peer" -v rctx="$rctx" -v do_asp="$do_asp" -v hub="$HUB_IP" \
        -v set_hub="$SET_HUB_IP" \
        -v set_local="$SET_LOCAL_IP" -v localip="${ASP_LOCAL_IP:-0.0.0.0}" '
        /^cs7 instance/            { in_cs7=1 }
        /^ *sccp-address /         { sccp=1 }
        /^ *(as|asp|listen|route-table|network-indicator|xua) / { sccp=0 }
        /^!/                       { sccp=0 }
        /^ *asp asp-to-inter/      { in_asp=1; print; next }
        in_asp && /^ *(as|listen|route-table|!)/ { in_asp=0 }

        in_asp && do_asp && set_hub && /^ *remote-ip /   { sub(/remote-ip .*/, "remote-ip " hub); print; next }
        # local-ip de l ASP : l adresse SOURCE de la SCTP vers le hub. Elle vaut
        # 127.0.0.1 dans l image (tout etait local) ; la laisser ainsi ferait
        # partir la connexion de la boucle locale vers une adresse de segment -
        # elle n aboutirait jamais, et le seul symptome serait un ASP DOWN.
        # 0.0.0.0 : le noyau choisit la source selon la route.
        in_asp && do_asp && set_local && /^ *local-ip / { sub(/local-ip.*/, "local-ip " localip); print; next }
        in_asp && do_asp && /^ *shutdown[ \t]*$/ { sub(/shutdown/, "no shutdown"); print; next }

        /^ *point-code / {
            if (sccp && peer != "") { sub(/point-code .*/, "point-code " peer) }
            else if (in_cs7)        { sub(/point-code .*/, "point-code " own) }
            print; next
        }
        /^ *routing-key / { sub(/routing-key .*/, "routing-key " rctx " " own); print; next }
        { print }
    ' "$f" > "$tmp"

    if [ "$DRY" = "1" ]; then
        echo "  [dry-run] $f"; diff -u "$f" "$tmp" | sed -n '4,30p' | sed 's/^/    /'; rm -f "$tmp"
    else
        # cat plutot que mv : sur l'ISO comme dans un conteneur, ces fichiers
        # peuvent etre des points de montage, et rename() y echoue.
        cat "$tmp" > "$f"; rm -f "$tmp"
        echo -e "  ${GREEN}✓${NC} $(basename "$f")"
    fi
}

# ── LE PLAN DE POINT CODES ET LE HUB SONT DEUX CHOSES ───────────────────────
# [2026-08-31] do_asp valait 0 des que LOCAL_PLAN=1 : le plan local coupait
# TOUT traitement du bloc asp-to-inter. C etait vrai tant que "plan local"
# voulait dire "machine isolee, aucun hub". Ce n est plus le cas : le banc
# multi-operateur porte son inter-STP dans un CONTENEUR, sur la meme machine,
# joignable en 127.0.0.1:2908 (port SCTP publie par start.sh). Le plan des
# point codes y est bien local - 1.<op>.<role> - ET il y a un hub.
# Consequence du couplage : `--local --hub-ip 127.0.0.1` affichait
# "plan LOCAL : cette machine ne peut pas partager un hub avec une autre" et
# laissait le local-ip du gabarit, 172.20.0.11 - l adresse du PREMIER
# CONTENEUR, inexistante sur l hote. L ASP restait ASP_DOWN sans qu une seule
# ligne ne parle d adresse : le journal ne montre qu un timeout.
# On traite donc l ASP des qu un hub est CONFIGURE, quel que soit le plan.
_DO_ASP=0
[ -n "$HUB_IP" ] && _DO_ASP=1
[ "$LOCAL_PLAN" = "1" ] || _DO_ASP=1
rewrite "$STP" "$PC_STP" ""        "$RCTX_INTER" "$_DO_ASP"
rewrite "$MSC" "$PC_MSC" "$PC_BSC" "$RCTX_MSC"   0
rewrite "$BSC" "$PC_BSC" "$PC_MSC" "$RCTX_BSC"   0

if [ "$DRY" != "1" ]; then
    echo ""
    show_state
    echo ""
    echo -e "  ${YELLOW}Les demons deja lances gardent l'ancienne identite :${NC} relancez la pile."
fi

network/setup-docker-lan-route.sh

.sh 195 lignes - 10KB

network/setup-docker-lan-route.sh
#!/bin/bash
# =============================================================================
# network/setup-docker-lan-route.sh - les conteneurs docker vus du LAN, ROUTES
#
# Les operateurs docker vivent sur le backbone 172.20.0.0/24 (osmo-operator-1 en
# .11, -2 en .12, -3 en .13 ; l'inter-STP en .10). Les VM VirtualBox, elles, sont
# en acces par pont sur 192.168.1.0/24 avec le hub SS7 en .49. Les deux mondes
# s'ignorent : rien ne dit au LAN ou trouver 172.20.0.0/24.
#
# CE SCRIPT EN FAIT UN SEUL RESEAU, PAR ROUTAGE
# L'hote porte les deux (192.168.1.101 cote LAN, 172.20.0.1 cote docker) : il lui
# suffit de router. Pas de macvlan, pas de renumerotation, pas de publication de
# ports - les conteneurs gardent leurs adresses, et les VM les joignent telles
# quelles.
#
# POURQUOI PAS SIMPLEMENT -p (publication de ports)
# Une publication NAT presente TOUS les conteneurs sous l'IP de l'hote. Or
# l'inter-STP identifie ses ASP par leur adresse source ("match=" dans les blocs
# type=identify) : trois operateurs derriere une seule IP sont indiscernables, et
# le hub rattache leurs associations au mauvais AS - ou les refuse. Le routage,
# lui, preserve l'adresse d'origine.
#
# LES TROIS CHOSES A FAIRE, ET POURQUOI CHACUNE
#   1. ip_forward       sans lui l'hote ne route rien, il ne fait que recevoir.
#   2. pas de masquage vers le LAN
#                       docker MASQUERADE tout ce qui sort du bridge vers
#                       l'exterieur. Le hub verrait alors 192.168.1.101 partout,
#                       au lieu de 172.20.0.11/12/13 - exactement ce que le
#                       paragraphe ci-dessus dit d'eviter. On insere donc un
#                       RETURN avant la regle de docker, pour la seule
#                       destination 192.168.1.0/24.
#   3. FORWARD autorise docker pose un FORWARD par defaut a DROP. La chaine
#                       DOCKER-USER est faite pour ca : elle est consultee AVANT
#                       les regles de docker, et il ne la reecrit pas.
#
# COTE VM, une route a ajouter (le script l'affiche, et la pose si --push) :
#     ip route add 172.20.0.0/24 via <IP de l hote>
#
# Usage :
#   sudo network/setup-docker-lan-route.sh              applique sur l'hote
#   sudo network/setup-docker-lan-route.sh --push "192.168.1.2 192.168.1.49"
#                                                       + pose la route sur ces VM
#   sudo network/setup-docker-lan-route.sh --status     etat, sans rien changer
#   sudo network/setup-docker-lan-route.sh --undo       retire ce que le script a pose
#
#   --docker-net CIDR   reseau des conteneurs (defaut 172.20.0.0/24)
#   --lan CIDR          reseau du LAN         (defaut : deduit de l'interface)
#   --iface NOM         interface LAN         (defaut : celle de la route par defaut)
# =============================================================================
set -uo pipefail

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'

DOCKER_NET="172.20.0.0/24"
LAN_NET=""
IFACE=""
PUSH=""
ACTION="apply"

while [ $# -gt 0 ]; do
    case "$1" in
        --docker-net)   DOCKER_NET="${2:-}"; shift ;;
        --docker-net=*) DOCKER_NET="${1#*=}" ;;
        --lan)          LAN_NET="${2:-}"; shift ;;
        --lan=*)        LAN_NET="${1#*=}" ;;
        --iface)        IFACE="${2:-}"; shift ;;
        --iface=*)      IFACE="${1#*=}" ;;
        --push)         PUSH="${2:-}"; shift ;;
        --push=*)       PUSH="${1#*=}" ;;
        --status)       ACTION="status" ;;
        --undo)         ACTION="undo" ;;
        -h|--help)      sed -n '2,46p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
        *) echo -e "${RED}option inconnue : $1${NC}" >&2; exit 2 ;;
    esac
    shift
done

# ── Ou sommes-nous ? ────────────────────────────────────────────────────────
[ -n "$IFACE" ] || IFACE="$(ip route show default 2>/dev/null | awk '/^default/{print $5; exit}')"
[ -n "$IFACE" ] || { echo -e "${RED}Aucune interface par defaut - precisez --iface${NC}" >&2; exit 1; }

HOST_IP="$(ip -4 -o addr show dev "$IFACE" scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)"
[ -n "$HOST_IP" ] || { echo -e "${RED}Pas d'adresse sur ${IFACE}${NC}" >&2; exit 1; }

if [ -z "$LAN_NET" ]; then
    CIDR="$(ip -4 -o addr show dev "$IFACE" scope global 2>/dev/null | awk '{print $4}' | head -1)"
    # 192.168.1.101/24 -> 192.168.1.0/24
    LAN_NET="$(python3 - "$CIDR" <<'PY' 2>/dev/null || true
import ipaddress, sys
print(ipaddress.ip_interface(sys.argv[1]).network)
PY
)"
    [ -n "$LAN_NET" ] || LAN_NET="${HOST_IP%.*}.0/24"
fi

echo -e "${CYAN}${BOLD}== Routage docker <-> LAN ==${NC}"
echo -e "  hote      : ${CYAN}${HOST_IP}${NC} sur ${CYAN}${IFACE}${NC}"
echo -e "  LAN       : ${CYAN}${LAN_NET}${NC}"
echo -e "  conteneurs: ${CYAN}${DOCKER_NET}${NC}"
echo ""

# ── --status ────────────────────────────────────────────────────────────────
if [ "$ACTION" = "status" ]; then
    fwd="$(sysctl -n net.ipv4.ip_forward 2>/dev/null)"
    printf '  %-34s %s\n' "ip_forward" "$([ "$fwd" = 1 ] && echo "1 (ok)" || echo "${fwd:-?} - le routage est inactif")"
    if iptables -t nat -C POSTROUTING -s "$DOCKER_NET" -d "$LAN_NET" -j RETURN 2>/dev/null; then
        printf '  %-34s %s\n' "pas de masquage vers le LAN" "pose"
    else
        printf '  %-34s %s\n' "pas de masquage vers le LAN" "ABSENT - le hub verrait ${HOST_IP} partout"
    fi
    for spec in "-s ${LAN_NET} -d ${DOCKER_NET}" "-s ${DOCKER_NET} -d ${LAN_NET}"; do
        # shellcheck disable=SC2086
        if iptables -C DOCKER-USER $spec -j ACCEPT 2>/dev/null; then
            printf '  %-34s %s\n' "FORWARD ${spec}" "autorise"
        else
            printf '  %-34s %s\n' "FORWARD ${spec}" "ABSENT"
        fi
    done
    echo ""
    echo -e "  ${BOLD}Sur chaque VM :${NC} ip route add ${DOCKER_NET} via ${HOST_IP}"
    exit 0
fi

[ "$(id -u)" -eq 0 ] || { echo -e "${RED}Root requis.${NC}" >&2; exit 1; }

# ── --undo ──────────────────────────────────────────────────────────────────
if [ "$ACTION" = "undo" ]; then
    iptables -t nat -D POSTROUTING -s "$DOCKER_NET" -d "$LAN_NET" -j RETURN 2>/dev/null \
        && echo -e "  ${GREEN}✓${NC} regle de non-masquage retiree"
    iptables -D DOCKER-USER -s "$LAN_NET" -d "$DOCKER_NET" -j ACCEPT 2>/dev/null \
        && echo -e "  ${GREEN}✓${NC} FORWARD LAN -> docker retire"
    iptables -D DOCKER-USER -s "$DOCKER_NET" -d "$LAN_NET" -j ACCEPT 2>/dev/null \
        && echo -e "  ${GREEN}✓${NC} FORWARD docker -> LAN retire"
    echo -e "  ${YELLOW}ip_forward laisse tel quel : d'autres services peuvent en dependre.${NC}"
    exit 0
fi

# ── 1. Le routage ───────────────────────────────────────────────────────────
if [ "$(sysctl -n net.ipv4.ip_forward 2>/dev/null)" != "1" ]; then
    sysctl -qw net.ipv4.ip_forward=1 && echo -e "  ${GREEN}✓${NC} ip_forward active"
else
    echo -e "  ${GREEN}✓${NC} ip_forward deja actif"
fi

# ── 2. Ne PAS masquer vers le LAN ───────────────────────────────────────────
# -I : en TETE de POSTROUTING, donc avant le MASQUERADE que docker y a mis. Un
# RETURN rend la main a la chaine appelante sans masquer : le paquet garde son
# adresse source 172.20.0.x, et le hub SS7 reconnait l'operateur qui l'emet.
if iptables -t nat -C POSTROUTING -s "$DOCKER_NET" -d "$LAN_NET" -j RETURN 2>/dev/null; then
    echo -e "  ${GREEN}✓${NC} non-masquage deja en place"
else
    iptables -t nat -I POSTROUTING 1 -s "$DOCKER_NET" -d "$LAN_NET" -j RETURN \
        && echo -e "  ${GREEN}✓${NC} ${DOCKER_NET} -> ${LAN_NET} : adresse source preservee"
fi

# ── 3. Autoriser le FORWARD dans les deux sens ──────────────────────────────
# DOCKER-USER est consultee avant les regles de docker, et docker ne la reecrit
# pas : c'est le seul endroit ou une regle survit a un redemarrage du daemon.
iptables -N DOCKER-USER 2>/dev/null || true
for spec in "-s ${LAN_NET} -d ${DOCKER_NET}" "-s ${DOCKER_NET} -d ${LAN_NET}"; do
    # shellcheck disable=SC2086
    if iptables -C DOCKER-USER $spec -j ACCEPT 2>/dev/null; then
        echo -e "  ${GREEN}✓${NC} FORWARD deja autorise (${spec})"
    else
        # shellcheck disable=SC2086
        iptables -I DOCKER-USER 1 $spec -j ACCEPT \
            && echo -e "  ${GREEN}✓${NC} FORWARD autorise (${spec})"
    fi
done

# ── 4. La route, cote VM ────────────────────────────────────────────────────
echo ""
echo -e "  ${BOLD}Il reste UNE chose, sur chaque VM :${NC}"
echo -e "    ${CYAN}ip route add ${DOCKER_NET} via ${HOST_IP}${NC}"
echo -e "  Sans elle, la VM ne sait pas ou envoyer 172.20.0.x et le trafic part"
echo -e "  vers sa passerelle par defaut, qui le jette."

if [ -n "$PUSH" ]; then
    echo ""
    echo -e "  ${BOLD}Pose de la route sur les machines indiquees :${NC}"
    for vm in $PUSH; do
        if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=6 -o BatchMode=yes \
               "root@${vm}" "ip route replace ${DOCKER_NET} via ${HOST_IP}" 2>/dev/null; then
            echo -e "    ${GREEN}✓${NC} ${vm}"
        else
            echo -e "    ${YELLOW}⚠${NC} ${vm} injoignable - a poser a la main"
        fi
    done
fi

echo ""
echo -e "  ${BOLD}Verifier, depuis une VM :${NC} ${CYAN}ping 172.20.0.11${NC}  puis"
echo -e "  ${CYAN}checks/diag-interstp.sh${NC} cote hub - les ASP doivent apparaitre"
echo -e "  avec leur VRAIE adresse (172.20.0.11/.12/.13), pas celle de l'hote."

network/setup-vbox-interco.sh

.sh 177 lignes - 9,4KB

network/setup-vbox-interco.sh
#!/bin/bash
# ══════════════════════════════════════════════════════════════════════════════
# network/setup-vbox-interco.sh - interconnexion WAN entre CETTE machine et des
# VM VirtualBox, sur un seul PC
#
# Le WAN d'osmo-operator relie des machines par leur IP publique. Pour l'eprouver
# sans louer N serveurs, on remplace les pairs distants par des VM et le lien
# public par un segment host-only : la maquette est la meme, les adresses sont
# locales.
#
#   ┌─ cette machine ────────────┐        ┌─ VM osmo-wan-2 ─┐  ┌─ VM osmo-wan-3 ─┐
#   │ lab docker ou natif        │        │ ISO osmo-operator  │  │ ISO osmo-operator  │
#   │ noeud 1 · indicatif 11     │        │ noeud 2 · 22    │  │ noeud 3 · 33    │
#   └──────────┬─────────────────┘        └────────┬────────┘  └────────┬────────┘
#              │ vboxnet0 192.168.56.11            │ .12                │ .13
#              └───────────────── segment host-only ───────────────────┘
#
# POURQUOI HOST-ONLY ET PAS "RESEAU INTERNE"
# Un reseau interne relie les VM ENTRE ELLES et laisse l'hote dehors. Or ici
# l'hote est lui-meme un noeud : sans patte sur le segment, son lab ne peut pas
# joindre ses pairs, et rien ne le signale - les trunks restent simplement
# "Unavailable". Le host-only est le seul mode ou l'hote participe.
#
# LA CONTRAINTE A CONNAITRE
# VirtualBox refuse toute plage host-only hors 192.168.56.0/21 tant que
# /etc/vbox/networks.conf ne l'autorise pas. D'ou ce prefixe par defaut.
#
# Usage :
#   sudo network/setup-vbox-interco.sh [--nodes N] [--host-node 1]
#                                      [--subnet 192.168.56] [--iso f.iso]
#                                      [--build-iso] [--no-vms] [--indicatifs "11 22"]
#
# Ecrit la table dans /etc/osmo-wan.conf, prete pour --wan.
# Appele automatiquement par : start.sh --virtualbox  /  start-direct.sh --virtualbox
# ══════════════════════════════════════════════════════════════════════════════
set -euo pipefail

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'

DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LAB="$DIR/tools/vbox-wan-lab.sh"
# shellcheck source=network/wan-nodes.sh
. "$DIR/network/wan-nodes.sh"

NODES=""
HOST_NODE=1
SUBNET="192.168.56"
ISO=""
BUILD_ISO=0
NO_VMS=0
INDS=""
PASSTHRU=()

while [ $# -gt 0 ]; do
    case "$1" in
        --nodes)       NODES="${2:-}"; shift ;;
        --nodes=*)     NODES="${1#*=}" ;;
        --host-node)   HOST_NODE="${2:-}"; shift ;;
        --host-node=*) HOST_NODE="${1#*=}" ;;
        --subnet)      SUBNET="${2:-}"; shift ;;
        --subnet=*)    SUBNET="${1#*=}" ;;
        --iso)         ISO="${2:-}"; shift ;;
        --iso=*)       ISO="${1#*=}" ;;
        --build-iso)   BUILD_ISO=1 ;;
        --no-vms)      NO_VMS=1 ;;
        --indicatifs)  INDS="${2:-}"; shift ;;
        --indicatifs=*) INDS="${1#*=}" ;;
        --conf)        WAN_CONF_FILE="${2:-}"; shift ;;
        --conf=*)      WAN_CONF_FILE="${1#*=}" ;;
        -h|--help)     sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
        *) PASSTHRU+=("$1") ;;   # transmis tel quel au banc (--ram, --usb-filter...)
    esac
    shift
done

echo -e "${CYAN}${BOLD}"
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║   Interconnexion WAN - cette machine + VM VirtualBox             ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo -e "${NC}"

# ── Garde-fous ───────────────────────────────────────────────────────────────
# Lance DANS une VM, ce script ne peut rien faire d'utile et ses messages
# d'erreur ne le diraient pas : VBoxManage y est simplement absent.
if command -v systemd-detect-virt >/dev/null 2>&1; then
    # `|| echo none` produirait deux lignes quand la commande sort en 1 (cas
    # "aucune virtualisation") : on ne garde que la premiere.
    _virt="$(systemd-detect-virt 2>/dev/null | head -1)" || true
    _virt="${_virt:-none}"
    case "$_virt" in
        oracle|kvm|qemu|vmware|microsoft)
            echo -e "${RED}Vous etes DANS une machine virtuelle (${_virt}).${NC}" >&2
            echo -e "  L'interconnexion VirtualBox se monte depuis l'HOTE." >&2
            echo -e "  Ici, le WAN se configure normalement : ${CYAN}./start-direct.sh --wan${NC}" >&2
            exit 1 ;;
    esac
fi

VBM="$(command -v VBoxManage || command -v vboxmanage || true)"
if [ -z "$VBM" ]; then
    echo -e "${RED}VBoxManage introuvable - VirtualBox n'est pas installe.${NC}" >&2
    exit 1
fi
if ! grep -q '^vboxdrv ' /proc/modules 2>/dev/null; then
    echo -e "${RED}Le module vboxdrv n'est pas charge : aucune VM ne demarrera.${NC}" >&2
    echo -e "  ${CYAN}sudo /sbin/vboxconfig${NC}" >&2
    if command -v mokutil >/dev/null 2>&1 && mokutil --sb-state 2>/dev/null | grep -i enabled >/dev/null; then
        echo -e "  ${YELLOW}Secure Boot actif : le module doit etre signe et sa cle enrolee (MOK).${NC}" >&2
    fi
    exit 1
fi
[ "$(id -u)" -eq 0 ] || { echo -e "${RED}Root requis (interface reseau + /etc/osmo-wan.conf).${NC}" >&2; exit 1; }

# ── Combien de noeuds ────────────────────────────────────────────────────────
if [ -z "$NODES" ]; then
    if [ -t 0 ]; then
        # Meme question que ./start.sh --wan, meme bornes.
        NODES=$(_wan_ask "WAN VirtualBox" "Nombre de noeuds au total, cette machine comprise (1-9) :" "3") || exit 1
    else
        NODES=3
    fi
fi
[[ "$NODES" =~ ^[1-9]$ ]] || { echo -e "${RED}--nodes doit valoir 1 a 9${NC}" >&2; exit 2; }
[[ "$HOST_NODE" =~ ^[1-9]$ ]] && [ "$HOST_NODE" -le "$NODES" ] \
    || { echo -e "${RED}--host-node doit designer un noeud de 1 a ${NODES}${NC}" >&2; exit 2; }

# ── Plage autorisee ? ────────────────────────────────────────────────────────
if [ "$SUBNET" != "192.168.56" ] && ! grep -q "${SUBNET}" /etc/vbox/networks.conf 2>/dev/null; then
    echo -e "${YELLOW}Attention : VirtualBox n'autorise que 192.168.56.0/21 en host-only.${NC}"
    echo -e "  Pour ouvrir ${SUBNET}.0/24 : ${CYAN}echo '* ${SUBNET}.0/24' | sudo tee -a /etc/vbox/networks.conf${NC}"
    echo ""
fi

# ── Table des noeuds ─────────────────────────────────────────────────────────
declare -a ind_arr=()
[ -n "$INDS" ] && read -r -a ind_arr <<< "$INDS"
WAN_IP=(); WAN_IND=(); WAN_NODE_LIST=()
for i in $(seq 1 "$NODES"); do
    # Noeud N → .1N, sans exception pour l'hote : la table se lit d'un coup
    # d'oeil et le numero de noeud se retrouve dans l'adresse.
    WAN_IP[$i]="${SUBNET}.$((10 + i))"
    WAN_IND[$i]="${ind_arr[$((i-1))]:-$(wan_default_ind "$i")}"
    WAN_NODE_LIST+=("$i")
done
WAN_NODE_COUNT="$NODES"
WAN_NODE_ID="$HOST_NODE"
WAN_OPS="${WAN_OPS:-1}"
wan_nodes_validate || exit 1

echo -e "  Cette machine porte le ${BOLD}noeud ${HOST_NODE}${NC} ; les autres sont des VM."
wan_nodes_summary
echo ""

# ── Le banc de VM fait le reste : reseau host-only, DHCP fixe, materiel ──────
if [ "$NO_VMS" -eq 1 ]; then
    echo -e "  ${YELLOW}--no-vms : ni reseau ni VM crees, seule la table est ecrite.${NC}"
else
    [ -x "$LAB" ] || { echo -e "${RED}tools/vbox-wan-lab.sh introuvable${NC}" >&2; exit 1; }
    lab_args=(create --nodes "$NODES" --host-node "$HOST_NODE" --subnet "$SUBNET"
              --indicatifs "$(for i in "${WAN_NODE_LIST[@]}"; do printf '%s ' "${WAN_IND[$i]}"; done)")
    [ -n "$ISO" ]          && lab_args+=(--iso "$ISO")
    [ "$BUILD_ISO" -eq 1 ] && lab_args+=(--build-iso)
    bash "$LAB" "${lab_args[@]}" ${PASSTHRU[@]+"${PASSTHRU[@]}"} || {
        echo -e "${RED}La creation du banc a echoue.${NC}" >&2; exit 1; }
fi

# ── Table persistee ──────────────────────────────────────────────────────────
# WAN_AUTO=0 cote hote : ici c'est start.sh / start-direct.sh qui decide quand
# monter le WAN. Les ISO des VM, elles, sont construites avec WAN_AUTO=1.
WAN_AUTO=0 wan_nodes_save "$WAN_CONF_FILE"
echo -e "  ${GREEN}✓${NC} table ecrite dans ${CYAN}${WAN_CONF_FILE}${NC}"
echo ""
echo -e "  ${BOLD}Ensuite, sur cette machine :${NC}"
echo -e "    ${CYAN}sudo ./start.sh --wan${NC}          (le lab docker devient le noeud ${HOST_NODE})"
echo -e "    ${CYAN}tools/vbox-wan-lab.sh start${NC}    (demarre les VM)"
echo ""

network/setup-wan-interop.sh

.sh 590 lignes - 28KB

network/setup-wan-interop.sh
#!/bin/bash
# ══════════════════════════════════════════════════════════════════════════════
# network/setup-wan-interop.sh - Interconnexion WAN entre deux instances osmo-operator
#
# Permet aux MS d'un serveur d'appeler les MS de l'autre serveur via le
# prefixe +66. Exemple : composer 6610001 depuis le serveur A appelle
# le MS 10001 sur le serveur B.
#
# Architecture :
#
#   Serveur A (82.66.231.141)              Serveur B (37.59.111.84)
#   ┌─────────────────────────┐            ┌─────────────────────────┐
#   │ Op1 Asterisk :5060      │            │ Op1 Asterisk :5060      │
#   │   172.20.0.11           │            │   172.20.0.11           │
#   │ Op2 Asterisk :5060      │            │ Op2 Asterisk :5060      │
#   │   172.20.0.12           │            │   172.20.0.12           │
#   └──────────┬──────────────┘            └──────────┬──────────────┘
#              │                                      │
#   Host iptables DNAT                     Host iptables DNAT
#   Op1: :5080 → 172.20.0.11:5060         Op1: :5080 → 172.20.0.11:5060
#   Op2: :5082 → 172.20.0.12:5060         Op2: :5082 → 172.20.0.12:5060
#              │                                      │
#              └──────── SIP/RTP over WAN ────────────┘
#                   82.66.231.141 ↔ 37.59.111.84
#
# Ports WAN par operateur :
#   OpN SIP  : 5080 + (N-1)*2
#   OpN RTP  : 20000 + (N-1)*500  →  20000 + N*500 - 1
#
# Prefixe d'appel :
#   66 + numero_normal  →  route vers le serveur distant
#   Ex: 6610001 = appeler MS 10001 sur le serveur distant
#   Ex: 6620001 = appeler MS 20001 (op2) sur le serveur distant
#
# Usage :
#   sudo ./network/setup-wan-interop.sh <local_public_ip> <remote_public_ip> [n_operators]
#
# Executer sur CHAQUE serveur avec les IPs inversees :
#   Serveur A : sudo ./network/setup-wan-interop.sh 82.66.231.141 37.59.111.84 2
#   Serveur B : sudo ./network/setup-wan-interop.sh 37.59.111.84 82.66.231.141 2
# ══════════════════════════════════════════════════════════════════════════════

set -euo pipefail

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'

# ── Parametres ────────────────────────────────────────────────────────────────
LOCAL_IP="${1:-}"
REMOTE_IP="${2:-}"
N_OPS="${3:-2}"
WAN_PREFIX="66"                    # Prefixe pour les appels inter-serveur
SIP_WAN_BASE=5080                  # Port SIP WAN de base
RTP_WAN_BASE=20000                 # Port RTP WAN de base
RTP_PER_OP=500                     # Ports RTP par operateur
CONTAINER_PREFIX="osmo-operator-"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"   # pour appeler network/firewall-wan.sh

if [ -z "$LOCAL_IP" ] || [ -z "$REMOTE_IP" ]; then
    echo -e "${RED}Usage: sudo $0 <local_public_ip> <remote_public_ip> [n_operators]${NC}"
    echo ""
    echo "  Serveur A : sudo $0 82.66.231.142 37.59.111.84 2"
    echo "  Serveur B : sudo $0 37.59.111.84 82.66.231.142 2"
    exit 1
fi

if [ "$(id -u)" -ne 0 ]; then
    echo -e "${RED}Doit etre lance en root (sudo).${NC}"; exit 1
fi

# ── Fonctions helper ──────────────────────────────────────────────────────────
op_backbone_ip() { echo "172.20.0.$((10 + $1))"; }
op_sip_port()    { echo $(( SIP_WAN_BASE + ($1 - 1) * 2 )); }
op_rtp_start()   { echo $(( RTP_WAN_BASE + ($1 - 1) * RTP_PER_OP )); }
op_rtp_end()     { echo $(( RTP_WAN_BASE + $1 * RTP_PER_OP - 1 )); }
op_container()   { echo "${CONTAINER_PREFIX}$1"; }

# NOTE : sed -i echoue sur les bind mounts Docker (rename() impossible).
# Toutes les editions sed utilisent le pattern : cp file /tmp → sed -i /tmp → cp back

# ══════════════════════════════════════════════════════════════════════════════
# Banner
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${CYAN}${BOLD}"
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║        WAN Interop - Interconnexion osmo-operator distante         ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
echo -e "  Local       : ${CYAN}${LOCAL_IP}${NC}"
echo -e "  Remote      : ${CYAN}${REMOTE_IP}${NC}"
echo -e "  Operateurs  : ${CYAN}${N_OPS}${NC}"
echo -e "  Prefixe WAN : ${CYAN}${WAN_PREFIX}${NC}"
echo ""

# ── Verification des containers ───────────────────────────────────────────────
echo -e "${GREEN}[1/5] Verification des containers...${NC}"
for i in $(seq 1 "$N_OPS"); do
    cname=$(op_container "$i")
    if ! docker ps --format '{{.Names}}' | grep -q "^${cname}$"; then
        echo -e "  ${RED}✗ ${cname} non trouve${NC}"
        echo -e "  ${YELLOW}Lancez start.sh d'abord.${NC}"
        exit 1
    fi
    echo -e "  ${GREEN}✓${NC} ${cname} actif"
done
echo ""

# ══════════════════════════════════════════════════════════════════════════════
# [2/5] iptables - DNAT pour le trafic entrant depuis le serveur distant
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${GREEN}[2/5] Configuration iptables (DNAT entrant)...${NC}"

# Activer le forwarding IP
sysctl -w net.ipv4.ip_forward=1 > /dev/null 2>&1

# Nettoyer les anciennes regles WAN interop (chaine personnalisee)
iptables -t nat -D PREROUTING -j OSMO_WAN_INTEROP 2>/dev/null || true
iptables -t nat -F OSMO_WAN_INTEROP 2>/dev/null || true
iptables -t nat -X OSMO_WAN_INTEROP 2>/dev/null || true

# Creer la chaine
iptables -t nat -N OSMO_WAN_INTEROP

for i in $(seq 1 "$N_OPS"); do
    bb_ip=$(op_backbone_ip "$i")
    sip_port=$(op_sip_port "$i")
    rtp_start=$(op_rtp_start "$i")
    rtp_end=$(op_rtp_end "$i")

    # SIP signaling : port externe → container:5060
    iptables -t nat -A OSMO_WAN_INTEROP \
        -s "$REMOTE_IP" -p udp --dport "$sip_port" \
        -j DNAT --to-destination "${bb_ip}:5060"

    # SIP TCP (au cas ou)
    iptables -t nat -A OSMO_WAN_INTEROP \
        -s "$REMOTE_IP" -p tcp --dport "$sip_port" \
        -j DNAT --to-destination "${bb_ip}:5060"

    # RTP : plage de ports → container (meme ports en interne)
    iptables -t nat -A OSMO_WAN_INTEROP \
        -s "$REMOTE_IP" -p udp --dport "${rtp_start}:${rtp_end}" \
        -j DNAT --to-destination "${bb_ip}"

    echo -e "  ${CYAN}Op${i}${NC} SIP :${sip_port} → ${bb_ip}:5060  RTP ${rtp_start}-${rtp_end} → ${bb_ip}"
done

# Inserer la chaine dans PREROUTING
iptables -t nat -I PREROUTING -j OSMO_WAN_INTEROP

# MASQUERADE pour le retour (le container voit le trafic comme venant du host)
# --ctstate DNAT : sans restriction, ce masquage prenait AUSSI les flux du LAN
# simplement routes vers un conteneur ; celui-ci repondait alors a l'hote et non
# au client, qui n'a jamais vu la reponse. On ne masque que ce que le DNAT
# ci-dessus a redirige. Restreindre plutot sur l'adresse de l'hote ne matcherait
# rien : apres DNAT la source est celle du serveur distant, pas la notre.
# L'ancienne regle large est retiree d'abord - le garde -C teste la spec exacte,
# sans cela les deux resteraient en place et la large gagnerait.
iptables -t nat -D POSTROUTING -d 172.20.0.0/24 -j MASQUERADE 2>/dev/null || true
if ! iptables -t nat -C POSTROUTING -d 172.20.0.0/24 -m conntrack --ctstate DNAT -j MASQUERADE 2>/dev/null; then
    # Repli si le module conntrack d'iptables manque : sans lui le -A echoue et
    # le script s'arretait la, la regle large ayant deja ete retiree - plus
    # aucun masquage, donc plus aucun retour vers les conteneurs.
    iptables -t nat -A POSTROUTING -d 172.20.0.0/24 -m conntrack --ctstate DNAT -j MASQUERADE 2>/dev/null || {
        echo -e "  ${YELLOW}! -m conntrack indisponible : masquage large 172.20.0.0/24 (moins precis)${NC}"
        iptables -t nat -C POSTROUTING -d 172.20.0.0/24 -j MASQUERADE 2>/dev/null \
            || iptables -t nat -A POSTROUTING -d 172.20.0.0/24 -j MASQUERADE || true
    }
fi

echo -e "  ${GREEN}✓ iptables configure${NC}"
echo ""

# ══════════════════════════════════════════════════════════════════════════════
# [2bis/5] Firewall - ouverture automatique des ports entrants depuis le distant
# Le DNAT ci-dessus redirige le trafic, mais si un firewall (ufw/firewalld/
# iptables INPUT) filtre l'INPUT, SIP/RTP WAN sont droppes -> pas d'audio WAN.
# On applique donc les regles a CHAQUE lancement du WAN (network/firewall-wan.sh est
# idempotent : pas de doublon si relance).
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${GREEN}[2bis/5] Firewall - ouverture des ports WAN (entrant ${REMOTE_IP})...${NC}"
if [ -x "$SCRIPT_DIR/firewall-wan.sh" ] || [ -f "$SCRIPT_DIR/firewall-wan.sh" ]; then
    if bash "$SCRIPT_DIR/firewall-wan.sh" "$REMOTE_IP" "$N_OPS"; then
        echo -e "  ${GREEN}✓ Firewall ouvert pour ${REMOTE_IP}${NC}"
    else
        echo -e "  ${YELLOW}⚠ network/firewall-wan.sh a echoue - ouvre les ports a la main (voir resume).${NC}"
    fi
else
    echo -e "  ${YELLOW}⚠ network/firewall-wan.sh introuvable dans ${SCRIPT_DIR} - ports a ouvrir a la main.${NC}"
fi
echo ""

# ══════════════════════════════════════════════════════════════════════════════
# [3/5] Configuration RTP - plage de ports WAN par operateur
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${GREEN}[3/5] Configuration RTP WAN dans chaque container...${NC}"

for i in $(seq 1 "$N_OPS"); do
    cname=$(op_container "$i")
    rtp_start=$(op_rtp_start "$i")
    rtp_end=$(op_rtp_end "$i")

    # Creer/modifier rtp.conf dans Asterisk pour utiliser la plage WAN
    # On garde la plage originale pour les appels locaux, mais on ajoute
    # la config dans le transport PJSIP (external_media_address)
    docker exec "$cname" bash -c "cat > /etc/asterisk/rtp.conf << 'RTPEOF'
[general]
rtpstart=${rtp_start}
rtpend=${rtp_end}
strictrtp=no
icesupport=no
RTPEOF"

    echo -e "  ${CYAN}Op${i}${NC} RTP range: ${rtp_start}-${rtp_end}"
done

echo -e "  ${GREEN}✓ RTP configure${NC}"
echo ""

# ══════════════════════════════════════════════════════════════════════════════
# [4/5] PJSIP + Dialplan - Trunks WAN et contexte interop distant
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${GREEN}[4/5] Injection PJSIP trunks + dialplan WAN...${NC}"

for i in $(seq 1 "$N_OPS"); do
    cname=$(op_container "$i")
    local_sip_port=$(op_sip_port "$i")
    rtp_start=$(op_rtp_start "$i")
    rtp_end=$(op_rtp_end "$i")

    # ── PJSIP : patch transport-udp + trunks vers chaque operateur distant ──

    # Generer les blocs PJSIP pour les trunks WAN
    pjsip_wan=""

    # Pas de transport-wan separe - on patche transport-udp existant
    # pour ajouter external_media_address (necessaire pour SDP corrects)
    docker exec "$cname" bash -c "
        if ! grep -q 'external_media_address' /etc/asterisk/pjsip.conf; then
            cp /etc/asterisk/pjsip.conf /tmp/_pj.work
            sed -i '/^\[transport-udp\]/,/^\$/ {
                /^\$/i\\
external_media_address=${LOCAL_IP}\\
external_signaling_address=${LOCAL_IP}\\
local_net=172.20.0.0/24\\
local_net=127.0.0.0/8
            }' /tmp/_pj.work
            cp /tmp/_pj.work /etc/asterisk/pjsip.conf
            rm -f /tmp/_pj.work
        fi
    "

    pjsip_wan+="
; ══════════════════════════════════════════════════════════════════════════════
; WAN INTEROP - Trunks vers serveur distant ${REMOTE_IP}
; Genere par network/setup-wan-interop.sh le $(date '+%Y-%m-%d %H:%M:%S')
; ══════════════════════════════════════════════════════════════════════════════
"

    # Un identify par ADRESSE, hors de la boucle : identify ne regarde que
    # l'adresse source, jamais le port. Un bloc par operateur distant en posait
    # autant pour la meme adresse, et l'appel entrant etait attribue a l'un
    # d'eux au hasard. Deux adresses seulement : la passerelle du bridge, par
    # laquelle arrive le trafic WAN masque par l'hote, et le serveur distant
    # lui-meme quand il n'est pas masque.
    pjsip_wan+="
; ── Identification des appels entrants du WAN ─────────────────────────────────
[wan-identify-remote]
type=identify
endpoint=wan_trunk_op1
match=${REMOTE_IP}

[wan-identify-gw]
type=identify
endpoint=wan_trunk_op1
match=172.20.0.1
"

    # Un trunk par operateur distant
    for j in $(seq 1 "$N_OPS"); do
        remote_sip_port=$(op_sip_port "$j")

        pjsip_wan+="
; ── Trunk WAN → Serveur distant Op${j} (${REMOTE_IP}:${remote_sip_port}) ─────
[wan_trunk_op${j}]
type=endpoint
transport=transport-udp
context=wan_in
disallow=all
allow=gsm
allow=ulaw
aors=wan_trunk_op${j}
media_encryption=no
direct_media=no
rtp_symmetric=yes
force_rport=yes
rewrite_contact=yes
ice_support=no

[wan_trunk_op${j}]
type=aor
contact=sip:${REMOTE_IP}:${remote_sip_port}
qualify_frequency=30
qualify_timeout=5.0
"
    done

    # Injecter dans pjsip.conf
    # D'abord retirer l'ancien bloc WAN s'il existe
    docker exec "$cname" bash -c "
        cp /etc/asterisk/pjsip.conf /tmp/_pj.work
        sed -i '/; ══.*WAN INTEROP/,\$d' /tmp/_pj.work 2>/dev/null || true
        cp /tmp/_pj.work /etc/asterisk/pjsip.conf
        rm -f /tmp/_pj.work
    "

    # Ajouter le nouveau bloc
    docker exec "$cname" bash -c "cat >> /etc/asterisk/pjsip.conf << 'PJSIPEOF'
${pjsip_wan}
PJSIPEOF"

    echo -e "  ${CYAN}Op${i}${NC} PJSIP: ${N_OPS} trunk(s) WAN ajoutes"

    # ── Dialplan : contextes [wan_out] et [wan_in] ────────────────────────

    # ── Softphones SIP : numero → endpoint, extraits DYNAMIQUEMENT de pjsip.conf ──
    # Tout endpoint portant un "callerid=... <NNN>" (ex: linphone_A <100>) devient
    # routable SIP→SIP over WAN dans les deux sens, sans hardcoder les numeros.
    softphones=$(docker exec "$cname" cat /etc/asterisk/pjsip.conf | awk '
        /^\[/                 { s=$0; gsub(/[][]/,"",s); ep=s }
        /callerid=.*<[0-9]+>/ { n=$0; sub(/.*</,"",n); sub(/>.*/,"",n); print n":"ep }
    ')
    echo -e "  ${CYAN}Op${i}${NC} Softphones SIP detectes: $(echo "$softphones" | tr '\n' ' ')"

    # Generer le contexte wan_out (appels sortants vers le distant)
    wan_dialplan="
; ══════════════════════════════════════════════════════════════════════════════
; WAN INTEROP - Routage appels inter-serveur (prefixe ${WAN_PREFIX})
; Genere par network/setup-wan-interop.sh le $(date '+%Y-%m-%d %H:%M:%S')
;
; Composer ${WAN_PREFIX}NXXXX = appeler NXXXX sur le serveur distant
; Composer ${WAN_PREFIX}NXXXXX = appeler NXXXXX sur le serveur distant
; Ex: ${WAN_PREFIX}10001 → MS 10001 op1 distant
;     ${WAN_PREFIX}20001 → MS 20001 op2 distant
; ══════════════════════════════════════════════════════════════════════════════

[wan_out]
"
    for j in $(seq 1 "$N_OPS"); do
        # Pattern pour prefixe 1 chiffre operateur (1-9)
        if [ "$j" -lt 10 ]; then
            wan_dialplan+="
; → Operateur distant ${j} (4 chiffres)
exten => _${j}XXXX,1,NoOp(=== WAN OUT Op${j}: \${EXTEN} → ${REMOTE_IP} ===)
 same => n,Dial(PJSIP/\${EXTEN}@wan_trunk_op${j},,rT)
 same => n,NoOp(WAN: \${DIALSTATUS})
 same => n,Congestion()
 same => n,Hangup()

; → Operateur distant ${j} (5 chiffres)
exten => _${j}XXXXX,1,NoOp(=== WAN OUT Op${j}: \${EXTEN} → ${REMOTE_IP} ===)
 same => n,Dial(PJSIP/\${EXTEN}@wan_trunk_op${j},,rT)
 same => n,NoOp(WAN: \${DIALSTATUS})
 same => n,Congestion()
 same => n,Hangup()
"
        else
            wan_dialplan+="
; → Operateur distant ${j} (prefixe 2 chiffres)
exten => _${j}XXXX,1,NoOp(=== WAN OUT Op${j}: \${EXTEN} → ${REMOTE_IP} ===)
 same => n,Dial(PJSIP/\${EXTEN}@wan_trunk_op${j},,rT)
 same => n,Congestion()
 same => n,Hangup()
"
        fi
    done

    # → Softphones SIP distants (SIP→SIP over WAN) - generes dynamiquement.
    #   Un Linphone local compose ${WAN_PREFIX}<num> ; le ${WAN_PREFIX} est strippe
    #   en amont (Goto wan_out,\${EXTEN:2}), on route donc <num> vers le softphone
    #   homologue du meme operateur sur le serveur distant.
    for sp in $softphones; do
        sp_num="${sp%%:*}"
        wan_dialplan+="
; → Softphone SIP distant ${sp_num} (Op${i})
exten => ${sp_num},1,NoOp(=== WAN OUT SIP Op${i}: ${sp_num} → ${REMOTE_IP} ===)
 same => n,Dial(PJSIP/${sp_num}@wan_trunk_op${i},,rT)
 same => n,NoOp(WAN: \${DIALSTATUS})
 same => n,Congestion()
 same => n,Hangup()
"
    done

    wan_dialplan+="
; Fallback WAN
exten => _X.,1,NoOp(=== WAN OUT: destination inconnue \${EXTEN} ===)
 same => n,Congestion()
 same => n,Hangup()

; ══════════════════════════════════════════════════════════════════════════════
; [wan_in] - Appels entrants DEPUIS le serveur distant
; ══════════════════════════════════════════════════════════════════════════════
[wan_in]

; Appel distant → MS local (meme operateur)
exten => _${i}XXXX,1,NoOp(=== WAN IN → GSM local Op${i}: \${EXTEN} ===)
 same => n,Set(CALLERID(all)=<\${CALLERID(num)}>)
 same => n,Gosub(sub-record,s,1(\${EXTEN}))
 same => n,Dial(PJSIP/\${EXTEN}@gsm_msc,,rT)
 same => n,Congestion()
 same => n,Hangup()

exten => _${i}XXXXX,1,NoOp(=== WAN IN → GSM local Op${i}: \${EXTEN} ===)
 same => n,Set(CALLERID(all)=<\${CALLERID(num)}>)
 same => n,Gosub(sub-record,s,1(\${EXTEN}))
 same => n,Dial(PJSIP/\${EXTEN}@gsm_msc,,rT)
 same => n,Congestion()
 same => n,Hangup()
"

    # Softphones SIP locaux (appels entrants depuis le serveur distant) - dynamiques
    for sp in $softphones; do
        sp_num="${sp%%:*}"
        sp_ep="${sp#*:}"
        wan_dialplan+="
; Softphone SIP local ${sp_num} → ${sp_ep}
exten => ${sp_num},1,NoOp(=== WAN IN → ${sp_ep} local (${sp_num}) ===)
 same => n,Gosub(sub-record,s,1(${sp_num}))
 same => n,Dial(PJSIP/${sp_ep},,rT)
 same => n,Congestion()
 same => n,Hangup()
"
    done

    wan_dialplan+="
; Appel distant → autre operateur local (re-route via interop_out)
exten => _X.,1,NoOp(=== WAN IN → routage local: \${EXTEN} ===)
 same => n,Gosub(sub-record,s,1(\${EXTEN}))
 same => n,Goto(interop_out,\${EXTEN},1)

; Echo test depuis le distant
exten => 600,1,NoOp(=== WAN ECHO TEST ===)
 same => n,Answer()
 same => n,Playback(demo-echotest)
 same => n,Echo()
 same => n,Playback(demo-echodone)
 same => n,Hangup()
"

    # Retirer l'ancien bloc WAN du dialplan
    docker exec "$cname" bash -c '
        cp /etc/asterisk/extensions.conf /tmp/_ext.work
        sed -i "/; ══.*WAN INTEROP.*Routage/,\$d" /tmp/_ext.work 2>/dev/null || true
        cp /tmp/_ext.work /etc/asterisk/extensions.conf
        rm -f /tmp/_ext.work
    '

    # Ajouter au dialplan
    docker exec "$cname" bash -c "cat >> /etc/asterisk/extensions.conf << 'EXTEOF'
${wan_dialplan}
EXTEOF"

    # ── Injecter le pattern _66. directement dans [gsm_in] et [internal] ──
    docker exec "$cname" bash -c '
        EXT="/etc/asterisk/extensions.conf"
        if ! grep -q "_'"${WAN_PREFIX}"'\." "$EXT"; then
            cp "$EXT" /tmp/_ext.work

            sed -i "/^\[gsm_in\]$/a\\
\\
; -- WAN : prefixe '"${WAN_PREFIX}"' -> serveur distant --\\
exten => _'"${WAN_PREFIX}"'.,1,NoOp(=== GSM -> WAN: strip '"${WAN_PREFIX}"' ===)\\
 same => n,Gosub(sub-record,s,1(\${EXTEN:2}))\\
 same => n,Goto(wan_out,\${EXTEN:2},1)" /tmp/_ext.work

            sed -i "/^\[internal\]$/a\\
\\
; -- WAN : prefixe '"${WAN_PREFIX}"' -> serveur distant --\\
exten => _'"${WAN_PREFIX}"'.,1,NoOp(=== SIP -> WAN: strip '"${WAN_PREFIX}"' ===)\\
 same => n,Gosub(sub-record,s,1(\${EXTEN:2}))\\
 same => n,Goto(wan_out,\${EXTEN:2},1)" /tmp/_ext.work

            cp /tmp/_ext.work "$EXT"
            rm -f /tmp/_ext.work
        fi
    '

    echo -e "  ${CYAN}Op${i}${NC} Dialplan: wan_out + wan_in + routage ${WAN_PREFIX}"
done

echo -e "  ${GREEN}✓ PJSIP + Dialplan configures${NC}"
echo ""

# ══════════════════════════════════════════════════════════════════════════════
# [5/5] Restart Asterisk (transport modifie → restart complet requis)
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${GREEN}[5/5] Restart Asterisk...${NC}"

for i in $(seq 1 "$N_OPS"); do
    cname=$(op_container "$i")

    docker exec "$cname" bash -c "
        asterisk -rx 'core stop now' 2>/dev/null || pkill asterisk 2>/dev/null || true
        sleep 2
        pkill -9 asterisk 2>/dev/null || true
        sleep 1
        rm -f /var/lib/asterisk/astdb.sqlite3
        asterisk -f &
        disown
    " 2>/dev/null || true

    echo -e "  ${CYAN}Op${i}${NC} Asterisk redemarre"
done

echo ""

# ══════════════════════════════════════════════════════════════════════════════
# Resume
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${GREEN}${BOLD}"
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║              WAN Interop configure avec succes !                ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo -e "${NC}"

echo -e "  ${BOLD}Serveur local  :${NC} ${CYAN}${LOCAL_IP}${NC}"
echo -e "  ${BOLD}Serveur distant:${NC} ${CYAN}${REMOTE_IP}${NC}"
echo ""

echo -e "  ${BOLD}Ports WAN par operateur :${NC}"
for i in $(seq 1 "$N_OPS"); do
    sip_port=$(op_sip_port "$i")
    rtp_start=$(op_rtp_start "$i")
    rtp_end=$(op_rtp_end "$i")
    echo -e "    Op${i}: SIP ${CYAN}:${sip_port}${NC}  RTP ${CYAN}${rtp_start}-${rtp_end}${NC}"
done

echo ""
echo -e "  ${BOLD}Comment appeler :${NC}"
echo -e "    Depuis un MS local, composer ${CYAN}${WAN_PREFIX}${NC} + numero distant"
echo -e "    Ex: ${CYAN}${WAN_PREFIX}10001${NC} = appeler MS 10001 op1 sur ${REMOTE_IP}"
echo -e "    Ex: ${CYAN}${WAN_PREFIX}20001${NC} = appeler MS 20001 op2 sur ${REMOTE_IP}"
echo ""

echo -e "  ${BOLD}Firewall :${NC} ${GREEN}deja applique automatiquement sur CE serveur${NC} (etape 2bis)."
echo -e "    Pense a lancer le WAN aussi sur ${CYAN}${REMOTE_IP}${NC} (il ouvrira son propre INPUT)."
echo -e "    Pour re-ouvrir manuellement : ${CYAN}sudo ${SCRIPT_DIR}/network/firewall-wan.sh ${REMOTE_IP} ${N_OPS}${NC}"
echo -e "    Equivalent ufw :"
echo ""
for i in $(seq 1 "$N_OPS"); do
    sip_port=$(op_sip_port "$i")
    rtp_start=$(op_rtp_start "$i")
    rtp_end=$(op_rtp_end "$i")
    echo -e "    ${YELLOW}# Op${i}${NC}"
    echo -e "    ufw allow from ${REMOTE_IP} to any port ${sip_port} proto udp"
    echo -e "    ufw allow from ${REMOTE_IP} to any port ${rtp_start}:${rtp_end} proto udp"
done

echo ""
echo -e "  ${BOLD}Test rapide :${NC}"
echo -e "    # Depuis le container op1 sur ce serveur :"
echo -e "    docker exec -it osmo-operator-1 bash"
echo -e "    # Dans le VTY mobile (Ctrl-b → fenetre ue_g1) :"
echo -e "    call 1 ${WAN_PREFIX}10001"
echo ""
echo -e "  ${BOLD}Diagnostic :${NC}"
echo -e "    # Verifier la connectivite SIP :"
echo -e "    docker exec osmo-operator-1 asterisk -rx 'pjsip show endpoints'"
echo -e "    docker exec osmo-operator-1 asterisk -rx 'pjsip show aors'"
echo -e "    # Logs Asterisk :"
echo -e "    docker exec osmo-operator-1 asterisk -rx 'core set verbose 5'"
echo ""

# ── Sauvegarder la config pour persistence ────────────────────────────────────
CONFIG_SAVE="/etc/osmo-wan-interop.conf"
cat > "$CONFIG_SAVE" << EOF
# osmo-wan-interop.conf - sauvegarde config WAN
# Genere le $(date)
LOCAL_IP=${LOCAL_IP}
REMOTE_IP=${REMOTE_IP}
N_OPS=${N_OPS}
WAN_PREFIX=${WAN_PREFIX}
SIP_WAN_BASE=${SIP_WAN_BASE}
RTP_WAN_BASE=${RTP_WAN_BASE}
RTP_PER_OP=${RTP_PER_OP}
EOF

echo -e "  Config sauvegardee dans ${CYAN}${CONFIG_SAVE}${NC}"
echo -e "  Relancer apres reboot : ${CYAN}sudo $0 ${LOCAL_IP} ${REMOTE_IP} ${N_OPS}${NC}"
echo ""

network/setup-wan-mesh.sh

.sh 835 lignes - 42KB

network/setup-wan-mesh.sh
#!/bin/bash
# ══════════════════════════════════════════════════════════════════════════════
# network/setup-wan-mesh.sh - WAN a N noeuds, route par INDICATIF
#
# Generalise network/setup-wan-interop.sh (qui ne connait que 2 serveurs) :
# ici la topologie est une TABLE de noeuds (network/wan-nodes.sh), chacun avec
# son indicatif, et TOUT est genere en un seul passage - donc relancable et
# valable pour 2 noeuds comme pour 12.
#
#   Composer <indicatif_cible><numero>  →  ce numero, sur le noeud cible.
#   L'indicatif est retire a la SORTIE : le noeud d'arrivee recoit le numero nu,
#   celui que son HLR et son dialplan connaissent deja.
#
# Ce que ce script configure, sur CE noeud :
#   • iptables : entree SIP/RTP/SMS depuis chaque pair (DNAT docker, REDIRECT natif)
#   • firewall : ouverture des memes ports (network/firewall-wan.sh, idempotent)
#   • Asterisk : rtp.conf, trunks PJSIP vers chaque (noeud, operateur) distant,
#                dialplan [wan_out]/[wan_in] + entrees par indicatif
#   • SMS      : sms-routing.conf - un operateur distant par (noeud, operateur),
#                avec strip de l'indicatif (cf. scripts/sms-interop-relay.py)
#
# Usage :
#   sudo network/setup-wan-mesh.sh --nodes "1:1.2.3.4:33 2:5.6.7.8:44" --id 1 [--ops 2]
#   sudo network/setup-wan-mesh.sh --config /etc/osmo-wan.conf
#   options : --native | --docker (defaut : auto)  --no-restart  --dry-run
#
# A lancer sur CHAQUE noeud, avec la MEME table et son propre --id.
# ══════════════════════════════════════════════════════════════════════════════
set -euo pipefail

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=network/wan-nodes.sh
. "$SCRIPT_DIR/wan-nodes.sh"

MODE=""
# --op-is-node : l'operateur local i EST le noeud i, et non un operateur du
# noeud --id. C'est le cas quand chaque conteneur porte sa propre adresse et
# son propre indicatif : sans cette notion, un seul jeu de pairs etait calcule
# pour toute la machine, et tous les conteneurs recevaient la meme vue - l'un
# d'eux se retrouvait meme a se router vers lui-meme.
OP_IS_NODE=0            # docker | native
NO_RESTART=0
CONFIG_ONLY=0
DRY=0
CONF=""
CHAIN="OSMO_WAN_MESH"

SIP_WAN_BASE=5080
RTP_WAN_BASE=20000
RTP_PER_OP=500
SMS_RELAY_BASE=7890
CONTAINER_PREFIX="osmo-operator-"
# Prefixe de racine, mode natif seulement : permet de viser un arbre de test
# (NATIVE_ROOT=/tmp/essai) au lieu du /etc de la machine. Vide en production.
NATIVE_ROOT="${NATIVE_ROOT:-}"

wan_sip_port()  { echo $(( SIP_WAN_BASE + ($1 - 1) * 2 )); }
wan_rtp_start() { echo $(( RTP_WAN_BASE + ($1 - 1) * RTP_PER_OP )); }
wan_rtp_end()   { echo $(( RTP_WAN_BASE + $1 * RTP_PER_OP - 1 )); }
wan_sms_port()  { echo $(( SMS_RELAY_BASE + $1 - 1 )); }
op_backbone_ip(){ echo "172.20.0.$((10 + $1))"; }
op_container()  { echo "${CONTAINER_PREFIX}$1"; }

# Un pair dont l'adresse est celle du backbone est joint DANS le bridge : le
# trafic n'y traverse pas la table nat de l'hote (br_netfilter n'est pas
# charge), donc les ports PUBLIES sur l'hote - 5080/5082 pour SIP, 7891 pour le
# relais SMS du second operateur - ne repondent a personne. Les contacts et les
# routes visaient ces ports-la : le qualify restait KO et les SMS partaient dans
# le vide. Vers un tel pair il faut viser ce que le conteneur ecoute vraiment,
# 5060 et le port de base du relais.
peer_is_backbone() { case "$1" in 172.20.0.*) return 0 ;; *) return 1 ;; esac; }

# Sur quel port joindre l'operateur j du noeud r.
#
# Le plan 5080/5082/... n'existe QUE pour multiplexer plusieurs operateurs
# derriere une seule adresse : c'est un hote docker qui republie les ports de
# ses conteneurs, ou un noeud natif qui pose des REDIRECT vers 5060. Un noeud
# qui ne porte QU'UN operateur n'a rien a multiplexer - son Asterisk ecoute
# directement sur 5060.
#
# Le discriminant etait "l'adresse est-elle sur le backbone docker". C'etait
# trop etroit : le noeud 3 du banc est une VM NATIVE, hors backbone, avec un
# seul operateur. On visait donc 192.168.1.2:5080, ou rien n'ecoute, et le
# trunk restait "Unavail" pendant que la VM repondait tranquillement sur 5060 :
#     192.168.1.2:5060  -> SIP/2.0 401 Unauthorized
#     192.168.1.2:5080  -> aucune reponse
# (Elle repondrait sur 5080 si le maillage y avait tourne - il pose les
#  REDIRECT - mais on ne peut pas en faire une hypothese depuis ici.)
peer_sip_port() {          # $1 = id du noeud distant, $2 = operateur
    if [ "$(node_nops "$1")" -eq 1 ]; then echo 5060; else wan_sip_port "$2"; fi
}
peer_sms_port() {          # $1 = id du noeud distant, $2 = operateur
    if [ "$(node_nops "$1")" -eq 1 ]; then echo "$SMS_RELAY_BASE"; else wan_sms_port "$2"; fi
}

# L'adresse que la TABLE annonce pour nous n'est pas toujours celle que porte le
# paquet a l'arrivee : derriere un routeur, la table donne l'adresse publique et
# l'hote ne voit jamais que sa privee. Un "-d" sur une adresse absente de la
# machine ne matche alors PLUS RIEN, et le WAN meurt en silence - le cas de
# l'en-tete de ce fichier, deux serveurs derriere leur box. On ne restreint donc
# la destination que si l'adresse est bien a nous.
addr_is_local() {
    ip -o addr show 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | grep -qx "$1"
}

# Nombre d'operateurs portes par un noeud DISTANT. Le compteur local ne le dit
# pas : avec un noeud par conteneur il vaut 2 et fabriquait un trunk vers un
# operateur inexistant, un pattern _<indicatif>2XXXX et une route SMS que
# personne ne sert. WAN_NOPS vient de la table.
#
# Aucun producteur n'ecrit encore le 4e champ : le repli est donc, aujourd'hui,
# le cas general. Un repli fixe a 1 aurait supprime l'operateur 2 de tout
# montage CLASSIQUE ("--wan-nodes 1:a:11 2:b:22 --ops 2", ou chaque noeud porte
# les deux operateurs) - trunk absent, appels inter-noeuds morts pour lui. On
# ne replie sur 1 que la ou c'est vrai par construction : --op-is-node, un
# operateur par noeud. Sinon on garde l'hypothese d'origine, les pairs sont
# montes comme nous.
node_nops() {
    local n="${WAN_NOPS[$1]:-}"
    if ! [[ "$n" =~ ^[1-9][0-9]*$ ]]; then
        if [ "${OP_IS_NODE:-0}" = "1" ]; then n=1; else n="${N_OPS:-1}"; fi
    fi
    echo "$n"
}

# ── Piege `pipefail` + `grep -q` ─────────────────────────────────────────────
# `grep -q` sort des la PREMIERE correspondance ; le producteur du pipeline
# recoit alors SIGPIPE et meurt en 141, et sous `set -o pipefail` c'est 141 que
# le pipeline renvoie. Resultat : "trouve" se lit "pas trouve", de facon
# intermittente - selon que la sortie tenait ou non dans le tampon du tube.
# C'est exactement ce qui faisait dire a `check` que vboxdrv n'etait pas charge
# alors qu'il l'etait. On lit donc toute l'entree, et on jette la sortie.
qgrep() { grep "$@" >/dev/null; }

usage() { sed -n '2,26p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }

while [ $# -gt 0 ]; do
    case "$1" in
        --nodes)   WAN_NODES="${2:-}"; shift ;;
        --nodes=*) WAN_NODES="${1#*=}" ;;
        --id)      WAN_NODE_ID="${2:-}"; shift ;;
        --id=*)    WAN_NODE_ID="${1#*=}" ;;
        --op-is-node) OP_IS_NODE=1 ;;
        --ops)     WAN_OPS="${2:-}"; shift ;;
        --ops=*)   WAN_OPS="${1#*=}" ;;
        --config)  CONF="${2:-}"; shift ;;
        --config=*) CONF="${1#*=}" ;;
        --native)  MODE="native" ;;
        --docker)  MODE="docker" ;;
        --no-restart) NO_RESTART=1 ;;
        --config-only) CONFIG_ONLY=1; NO_RESTART=1 ;;
        --dry-run) DRY=1 ;;
        -h|--help) usage; exit 0 ;;
        *) echo -e "${RED}option inconnue : $1${NC}" >&2; usage >&2; exit 2 ;;
    esac
    shift
done

# ── Table des noeuds ─────────────────────────────────────────────────────────
# --config sert a la fois de source et de destination : on ne le lit que s'il
# existe deja, et --nodes reste prioritaire (on decrit une nouvelle topologie).
if [ -n "${WAN_NODES:-}" ]; then
    wan_nodes_parse "$WAN_NODES" || exit 1
elif [ -n "$CONF" ]; then
    wan_nodes_load "$CONF" || { echo -e "${RED}config WAN illisible : $CONF${NC}" >&2; exit 1; }
elif [ -n "${WAN_NODES:-}" ]; then
    wan_nodes_parse "$WAN_NODES" || exit 1
elif wan_nodes_load "$WAN_CONF_FILE" 2>/dev/null; then
    :
else
    echo -e "${RED}Aucune table de noeuds : --nodes, --config ou $WAN_CONF_FILE${NC}" >&2
    exit 1
fi

# --id explicite > config > auto-detection par IP locale.
if [ "${WAN_NODE_ID:-0}" = "0" ] || [ -z "${WAN_NODE_ID:-}" ]; then
    wan_nodes_detect_self || {
        echo -e "${RED}Impossible de determiner le noeud local : passez --id N${NC}" >&2; exit 1; }
    echo -e "  ${CYAN}Noeud local detecte par son IP : ${WAN_NODE_ID}${NC}"
fi
wan_nodes_validate || exit 1

# ── Mode ─────────────────────────────────────────────────────────────────────
# L'ISO n'a pas de docker ; l'hote du lab en a un ET des containers osmo-operator-*.
# On ne se fie donc pas a la presence du binaire mais a celle des containers.
if [ -z "$MODE" ]; then
    if command -v docker >/dev/null 2>&1 && \
       docker ps --format '{{.Names}}' 2>/dev/null | qgrep "^${CONTAINER_PREFIX}1$"; then
        MODE="docker"
    else
        MODE="native"
    fi
fi

if [ "$MODE" = "native" ]; then
    OP_IDS=( "${NATIVE_OP_ID:-${OPERATOR_ID:-1}}" )
else
    [ "${WAN_OPS:-1}" -ge 1 ] || WAN_OPS=1
    OP_IDS=(); for i in $(seq 1 "$WAN_OPS"); do OP_IDS+=("$i"); done
fi
N_OPS="${#OP_IDS[@]}"

LOCAL_IP="$(wan_local_ip)"
LOCAL_IND="$(wan_local_ind)"

echo -e "${CYAN}${BOLD}"
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║   WAN mesh - routage par indicatif entre N noeuds osmo-operator     ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
echo -e "  Mode        : ${CYAN}${MODE}${NC}   Operateurs locaux : ${CYAN}${N_OPS}${NC} (${OP_IDS[*]})"
wan_nodes_summary
echo ""

[ "$(id -u)" -ne 0 ] && { echo -e "${RED}Root requis (iptables + configs).${NC}" >&2; exit 1; }

REMOTES=()
for id in "${WAN_NODE_LIST[@]}"; do [ "$id" = "$WAN_NODE_ID" ] || REMOTES+=("$id"); done

# Le contexte d'UN operateur : son noeud, son indicatif, son adresse et ses
# pairs. Sans --op-is-node il ne change pas d'un operateur a l'autre (ils
# partagent le noeud de la machine) ; avec, chacun a le sien.
mesh_set_context() {
    local op="$1" node id
    if [ "${OP_IS_NODE:-0}" = "1" ]; then node="$op"; else node="$WAN_NODE_ID"; fi
    MESH_NODE="$node"
    [ -n "${WAN_IND[$node]:-}" ] && LOCAL_IND="${WAN_IND[$node]}"
    [ -n "${WAN_IP[$node]:-}"  ] && LOCAL_IP="${WAN_IP[$node]}"
    REMOTES=()
    for id in "${WAN_NODE_LIST[@]}"; do [ "$id" = "$node" ] || REMOTES+=("$id"); done
}
mesh_set_context "${OP_IDS[0]}"
[ "${#REMOTES[@]}" -ge 1 ] || { echo -e "${RED}Aucun pair distant.${NC}" >&2; exit 1; }

# Union des pairs de TOUS les operateurs locaux. mesh_set_context recalcule
# REMOTES a chaque appel : ce qui ne depend pas d'un operateur - l'ouverture du
# pare-feu, les compteurs affiches - lisait la liste du DERNIER operateur et
# oubliait les pairs des autres.
ALL_REMOTES=(); _seen=""
for _i in "${OP_IDS[@]}"; do
    mesh_set_context "$_i"
    for _r in "${REMOTES[@]}"; do
        case " $_seen " in
            *" $_r "*) ;;
            *) _seen="$_seen $_r"; ALL_REMOTES+=("$_r") ;;
        esac
    done
done
mesh_set_context "${OP_IDS[0]}"

run() { if [ "$DRY" -eq 1 ]; then echo "  [dry-run] $*"; else "$@"; fi; }

# ── Acces aux fichiers de conf, docker ou natif ──────────────────────────────
# On tire le fichier sur l'hote, on le transforme avec awk/sed LOCALEMENT, on le
# repousse. C'est ce qui permet au meme code de servir les deux modes - et ca
# evite les sed multi-niveaux echappes dans un `docker exec bash -c` (l'endroit
# ou network/setup-wan-interop.sh est le plus fragile).
ast_pull() {  # $1=op  $2=chemin  → stdout
    if [ "$MODE" = "docker" ]; then docker exec "$(op_container "$1")" cat "$2" 2>/dev/null || true
    else cat "${NATIVE_ROOT}$2" 2>/dev/null || true; fi
}
ast_push() {  # $1=op  $2=chemin  ← stdin
    if [ "$DRY" -eq 1 ]; then cat >/dev/null; echo "  [dry-run] ecriture $2 (op $1)"; return 0; fi
    if [ "$MODE" = "docker" ]; then docker exec -i "$(op_container "$1")" bash -c "cat > '$2'"
    else mkdir -p "$(dirname "${NATIVE_ROOT}$2")"; cat > "${NATIVE_ROOT}$2"; fi
}
ast_cli() {   # $1=op  $2=commande CLI asterisk
    if [ "$MODE" = "docker" ]; then docker exec "$(op_container "$1")" asterisk -rx "$2" 2>/dev/null || true
    else asterisk -rx "$2" 2>/dev/null || true; fi
}

BEGIN_MARK="; ─── OSMO WAN MESH BEGIN ───"
END_MARK="; ─── OSMO WAN MESH END ───"
SMS_BEGIN="# ─── OSMO WAN MESH BEGIN ───"
SMS_END="# ─── OSMO WAN MESH END ───"

# Retire nos blocs ET l'ancien bloc "WAN INTEROP" (genere par
# setup-wan-interop.sh, toujours ajoute en fin de fichier) : les deux decrivent
# le meme trafic, les laisser cohabiter ferait dependre le routage de l'ordre
# des patterns.
strip_generated() { sed -e '/OSMO WAN MESH BEGIN/,/OSMO WAN MESH END/d' -e '/; ══.*WAN INTEROP/,$d'; }

# ══════════════════════════════════════════════════════════════════════════════
# [1/6] Verification des cibles
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${GREEN}[1/6] Verification des cibles Asterisk...${NC}"
for i in "${OP_IDS[@]}"; do
    mesh_set_context "$i"
    if [ "$MODE" = "docker" ]; then
        docker ps --format '{{.Names}}' | qgrep "^$(op_container "$i")$" \
            || { echo -e "  ${RED}✗ $(op_container "$i") absent - lancez start.sh d'abord${NC}"; exit 1; }
        echo -e "  ${GREEN}✓${NC} $(op_container "$i")"
    else
        [ -d "${NATIVE_ROOT}/etc/asterisk" ] || { echo -e "  ${RED}✗ ${NATIVE_ROOT}/etc/asterisk absent${NC}"; exit 1; }
        echo -e "  ${GREEN}✓${NC} /etc/asterisk (natif, operateur ${i})"
    fi
done
echo ""

# ══════════════════════════════════════════════════════════════════════════════
# [2/6] iptables - entree depuis chaque pair
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${GREEN}[2/6] iptables - ouverture entrante depuis ${#ALL_REMOTES[@]} pair(s)...${NC}"
if [ "$CONFIG_ONLY" -eq 1 ]; then
    echo -e "  ${YELLOW}--config-only : ni iptables ni firewall. Les ports WAN resteront fermes${NC}"
    echo -e "  ${YELLOW}tant que ce script n'aura pas ete relance sans --config-only.${NC}"
else
run sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true

if [ "$DRY" -eq 0 ]; then
    iptables -t nat -D PREROUTING -j "$CHAIN" 2>/dev/null || true
    iptables -t nat -F "$CHAIN" 2>/dev/null || true
    iptables -t nat -X "$CHAIN" 2>/dev/null || true
    iptables -t nat -N "$CHAIN"
fi

# L'operateur local est la boucle EXTERIEURE : mesh_set_context recalcule
# REMOTES, et prise dans l'autre sens la boucle des pairs lisait la liste laissee
# par le dernier appel - une seule vue servait tous les operateurs, si bien que
# les pairs des autres n'avaient aucune regle et que les seules sources presentes
# dans la chaine etaient celles d'un seul operateur.
for i in "${OP_IDS[@]}"; do
    mesh_set_context "$i"
    lip="$LOCAL_IP"
    sip=$(wan_sip_port "$i"); rs=$(wan_rtp_start "$i"); re=$(wan_rtp_end "$i")
    sms=$(wan_sms_port "$i")
    # Le filtre de destination, calcule une fois par operateur local.
    _dfil=()
    if addr_is_local "$lip"; then _dfil=(-d "$lip")
    else echo -e "  ${YELLOW}! ${lip} (table) n'est pas une adresse de cette machine : DNAT non restreint en destination${NC}"; fi
    # ── LE SENS SORTANT : un port source PAR CONTENEUR ──────────────────────
    # Tous les conteneurs font tourner Asterisk sur le meme port, 5060. Vers un
    # pair du LAN ils sont masques derriere l'unique adresse de l'hote, et le
    # masquage leur a donne LE MEME port traduit :
    #     192.168.1.31.48057 > 192.168.1.2.5080   (venant de 172.20.0.11)
    #     192.168.1.31.48057 > 192.168.1.2.5080   (venant de 172.20.0.12)
    # Deux flux distincts, un seul tuple : les reponses du pair ne reviennent
    # qu'a l'un des deux, et le trunk de l'autre reste "Unavail" pour toujours.
    # Constate sur le banc, et invisible autrement - les deux conteneurs
    # emettent bien, seul le retour manque.
    #
    # On epingle donc un port source stable et distinct par operateur. Stable
    # aussi pour le pair : son identify voit toujours le meme couple
    # adresse/port pour un noeud donne, au lieu d'un port tire au hasard a
    # chaque expiration de conntrack.
    if [ "$MODE" = "docker" ] && [ "${#REMOTES[@]}" -gt 0 ]; then
        _snat_port=$(( 5060 + i ))
        _lan_net="$(printf '%s' "$lip" | cut -d. -f1-3).0/24"
        for _pr in udp tcp; do
            run iptables -t nat -D POSTROUTING -s "$(op_backbone_ip "$i")" -p "$_pr" --sport 5060 \
                -d "$_lan_net" -j SNAT --to-source "${lip}:${_snat_port}" 2>/dev/null || true
            run iptables -t nat -I POSTROUTING 1 -s "$(op_backbone_ip "$i")" -p "$_pr" --sport 5060 \
                -d "$_lan_net" -j SNAT --to-source "${lip}:${_snat_port}"
        done
        echo -e "  ${CYAN}Op${i}${NC} sort en SIP depuis ${CYAN}${lip}:${_snat_port}${NC} (port source epingle)"
        # Les entrees conntrack deja etablies ignorent la regle neuve : Asterisk
        # rafraichit son qualify toutes les 30 s, elles n'expirent jamais toutes
        # seules. On les efface, sinon le correctif ne prend qu'au prochain
        # redemarrage d'Asterisk - et l'on croit qu'il ne marche pas.
        if [ "$DRY" -eq 0 ] && command -v conntrack >/dev/null 2>&1; then
            conntrack -D -s "$(op_backbone_ip "$i")" -p udp >/dev/null 2>&1 || true
        fi
    fi

    for r in "${REMOTES[@]}"; do
        rip="${WAN_IP[$r]}"
        if [ "$MODE" = "docker" ]; then
            bb=$(op_backbone_ip "$i")
            # -d "$lip" : filtrer la seule source attrapait aussi ce que CE noeud
            # EMET vers le pair (meme source, meme port de destination). Le paquet
            # ne quittait jamais l'hote, il repartait vers notre propre conteneur
            # qui se repondait a lui-meme - le trunk affichait "Avail" alors que le
            # pair n'avait rien recu.
            run iptables -t nat -A "$CHAIN" -s "$rip" ${_dfil[@]+"${_dfil[@]}"} -p udp --dport "$sip" -j DNAT --to-destination "${bb}:5060"
            run iptables -t nat -A "$CHAIN" -s "$rip" ${_dfil[@]+"${_dfil[@]}"} -p tcp --dport "$sip" -j DNAT --to-destination "${bb}:5060"
            run iptables -t nat -A "$CHAIN" -s "$rip" ${_dfil[@]+"${_dfil[@]}"} -p udp --dport "${rs}:${re}" -j DNAT --to-destination "$bb"
            run iptables -t nat -A "$CHAIN" -s "$rip" ${_dfil[@]+"${_dfil[@]}"} -p tcp --dport "$sms" -j DNAT --to-destination "${bb}:${SMS_RELAY_BASE}"
        else
            # Natif : Asterisk et le relais ecoutent deja sur l'hote. Seule la
            # translation de port WAN → port de service est necessaire ; le RTP
            # arrive directement dans la plage posee par rtp.conf.
            run iptables -t nat -A "$CHAIN" -s "$rip" -p udp --dport "$sip" -j REDIRECT --to-ports 5060
            run iptables -t nat -A "$CHAIN" -s "$rip" -p tcp --dport "$sip" -j REDIRECT --to-ports 5060
            [ "$sms" = "$SMS_RELAY_BASE" ] || \
                run iptables -t nat -A "$CHAIN" -s "$rip" -p tcp --dport "$sms" -j REDIRECT --to-ports "$SMS_RELAY_BASE"
        fi
        echo -e "  ${CYAN}node ${r}${NC} ${rip} → Op${i} SIP/RTP/SMS acceptes"
    done
done

if [ "$DRY" -eq 0 ]; then
    iptables -t nat -I PREROUTING -j "$CHAIN"
    if [ "$MODE" = "docker" ]; then
        # --ctstate DNAT : sans restriction, ce masquage prenait AUSSI les flux du
        # LAN simplement routes vers un conteneur ; celui-ci repondait alors a
        # l'hote et non au client, qui n'a jamais vu la reponse. On ne masque donc
        # que ce que notre propre DNAT a redirige. Restreindre plutot sur l'adresse
        # de l'hote ne matcherait rien : apres DNAT la source est celle du pair.
        # L'ancienne regle large est retiree d'abord - le garde -C teste la spec
        # exacte, sans cela les deux resteraient en place et la large gagnerait.
        iptables -t nat -D POSTROUTING -d 172.20.0.0/24 -j MASQUERADE 2>/dev/null || true
        if ! iptables -t nat -C POSTROUTING -d 172.20.0.0/24 -m conntrack --ctstate DNAT -j MASQUERADE 2>/dev/null; then
            # Sans le module conntrack d'iptables, le -A echoue et, sous set -e,
            # le script mourait ICI : chaine deja inseree dans PREROUTING et
            # regle large deja retiree, mais ni rtp.conf, ni pjsip, ni dialplan,
            # ni routes SMS. On repose alors la regle large en le disant - moins
            # precise, mais un maillage complet vaut mieux qu'un demi-maillage.
            iptables -t nat -A POSTROUTING -d 172.20.0.0/24 -m conntrack --ctstate DNAT -j MASQUERADE 2>/dev/null || {
                echo -e "  ${YELLOW}! -m conntrack indisponible : masquage large 172.20.0.0/24 (moins precis)${NC}"
                iptables -t nat -C POSTROUTING -d 172.20.0.0/24 -j MASQUERADE 2>/dev/null \
                    || iptables -t nat -A POSTROUTING -d 172.20.0.0/24 -j MASQUERADE || true
            }
        fi
    fi
fi

# Firewall INPUT - sans ca le DNAT redirige un trafic que l'INPUT jette.
for r in "${ALL_REMOTES[@]}"; do
    if [ -f "$SCRIPT_DIR/firewall-wan.sh" ]; then
        run bash "$SCRIPT_DIR/firewall-wan.sh" "${WAN_IP[$r]}" "$N_OPS" >/dev/null 2>&1 \
            && echo -e "  ${GREEN}✓${NC} firewall ouvert pour ${WAN_IP[$r]}" \
            || echo -e "  ${YELLOW}⚠${NC} firewall-wan.sh a echoue pour ${WAN_IP[$r]}"
    fi
done
fi
echo ""

# ══════════════════════════════════════════════════════════════════════════════
# [3/6] rtp.conf - plage RTP WAN par operateur
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${GREEN}[3/6] rtp.conf...${NC}"
for i in "${OP_IDS[@]}"; do
    mesh_set_context "$i"
    rs=$(wan_rtp_start "$i"); re=$(wan_rtp_end "$i")
    printf '[general]\nrtpstart=%s\nrtpend=%s\nstrictrtp=no\nicesupport=no\n' "$rs" "$re" \
        | ast_push "$i" /etc/asterisk/rtp.conf
    echo -e "  ${CYAN}Op${i}${NC} RTP ${rs}-${re}"
done
echo ""

# ══════════════════════════════════════════════════════════════════════════════
# [4/6] PJSIP - un trunk par (noeud distant, operateur distant)
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${GREEN}[4/6] PJSIP - trunks WAN...${NC}"

for i in "${OP_IDS[@]}"; do
    mesh_set_context "$i"
    pj="$(mktemp)"; ast_pull "$i" /etc/asterisk/pjsip.conf | strip_generated > "$pj"

    # external_media_address : l'IP que le SDP annonce a l'autre bout. Elle DOIT
    # etre l'IP publique de CE noeud. Le gabarit configs/pjsip.conf en pose deja
    # une (l'IP docker/hote) : on l'ECRASE, on ne se contente pas de l'ajouter
    # quand elle manque - sinon le WAN annonce une adresse privee et l'audio
    # part dans le vide alors que la signalisation, elle, passe. C'est ce que
    # fait network/setup-wan-interop.sh ("if ! qgrep external_media_address"),
    # et ca se voit seulement a l'oreille : appel decroche, silence.
    awk -v ip="$LOCAL_IP" '
        /^\[/ { if (intr && !done) { emit(); done=1 } ; intr = ($0 == "[transport-udp]") ; print ; next }
        intr && /^external_(media|signaling)_address=/ { next }
        intr && /^local_net=/ { seen_net=1 ; print ; next }
        intr && /^[[:space:]]*$/ && !done { emit(); done=1; intr=0; print; next }
        { print }
        END { if (intr && !done) emit() }
        function emit() {
            print "external_media_address=" ip
            print "external_signaling_address=" ip
            if (!seen_net) { print "local_net=172.20.0.0/16"; print "local_net=127.0.0.0/8" }
        }
    ' "$pj" > "${pj}.new" && mv "${pj}.new" "$pj"

    {
        echo "$BEGIN_MARK"
        echo "; Trunks WAN - generes par network/setup-wan-mesh.sh"
        echo "; Noeud local ${WAN_NODE_ID} (${LOCAL_IP}, indicatif ${LOCAL_IND})"
        for r in "${REMOTES[@]}"; do
            rip="${WAN_IP[$r]}"; rind="${WAN_IND[$r]}"; rnops="$(node_nops "$r")"
            # UN identify par adresse distante : identify ne regarde que l'adresse
            # source, jamais le port. Un bloc par operateur distant en posait
            # plusieurs pour la MEME adresse, et l'appel entrant etait attribue a
            # l'un d'eux au hasard - donc parfois au mauvais contexte d'arrivee.
            cat <<EOF

; ── node ${r} (indicatif ${rind}) · ${rip} · ${rnops} operateur(s) ──
[wan-id-n${r}]
type=identify
endpoint=wan_n${r}_op1
match=${rip}
EOF
            for j in $(seq 1 "$rnops"); do
                rsip=$(peer_sip_port "$r" "$j")
                # Pair sur le backbone : le port publie sur l'hote n'est pas
                # traverse dans le bridge, c'est 5060 que le conteneur ecoute.

                cat <<EOF

; ── node ${r} · operateur ${j} · ${rip}:${rsip} ──
[wan_n${r}_op${j}]
type=endpoint
transport=transport-udp
context=wan_in
disallow=all
allow=gsm
allow=ulaw
aors=wan_n${r}_op${j}
media_encryption=no
direct_media=no
rtp_symmetric=yes
force_rport=yes
rewrite_contact=yes
ice_support=no

[wan_n${r}_op${j}]
type=aor
contact=sip:${rip}:${rsip}
qualify_frequency=30
qualify_timeout=5.0
EOF
            done
        done
        # La passerelle du bridge une seule fois, hors de la boucle : le trafic
        # WAN entrant est masque par l'hote et arrive donc avec 172.20.0.1 pour
        # source. Repetee dans chaque bloc, elle donnait autant d'identify que de
        # couples (noeud, operateur) pour cette unique adresse.
        if [ "$MODE" = "docker" ]; then
            # Le trafic qui arrive masque en 172.20.0.1 est celui que l'hote a
            # route depuis le LAN : il ne peut donc venir QUE d'un pair hors du
            # backbone. Prendre REMOTES[0] attribuait ce trafic au premier pair
            # de la liste - souvent un conteneur voisin, qui n'arrive jamais
            # masque : on passait d'une attribution au hasard a une attribution
            # systematiquement fausse.
            _gwr=""
            for _g in "${REMOTES[@]}"; do
                peer_is_backbone "${WAN_IP[$_g]}" || { _gwr="$_g"; break; }
            done
            if [ -n "$_gwr" ]; then
                cat <<EOF

; ── passerelle du bridge - trafic WAN masque par l'hote (noeud ${_gwr}) ──
[wan-id-gw]
type=identify
endpoint=wan_n${_gwr}_op1
match=172.20.0.1
EOF
            fi
        fi
        echo "$END_MARK"
    } >> "$pj"

    ast_push "$i" /etc/asterisk/pjsip.conf < "$pj"
    rm -f "$pj"
    ntrunk=0
    for r in "${REMOTES[@]}"; do ntrunk=$(( ntrunk + $(node_nops "$r") )); done
    echo -e "  ${CYAN}Op${i}${NC} ${ntrunk} trunk(s) WAN"
done
echo ""

# ══════════════════════════════════════════════════════════════════════════════
# [5/6] Dialplan - [wan_out] par indicatif, [wan_in], entrees gsm_in/internal
# ══════════════════════════════════════════════════════════════════════════════
echo -e "${GREEN}[5/6] Dialplan - routage par indicatif...${NC}"

for i in "${OP_IDS[@]}"; do
    mesh_set_context "$i"
    ext="$(mktemp)"; ast_pull "$i" /etc/asterisk/extensions.conf | strip_generated > "$ext"

    # Softphones : tout endpoint avec "callerid=... <NNN>" devient joignable
    # par le WAN, sans coder aucun numero en dur.
    softphones=$(ast_pull "$i" /etc/asterisk/pjsip.conf | awk '
        /^\[/                 { s=$0; gsub(/[][]/,"",s); ep=s }
        /callerid=.*<[0-9]+>/ { n=$0; sub(/.*</,"",n); sub(/>.*/,"",n); print n":"ep }')

    body="$(mktemp)"
    {
        echo "$BEGIN_MARK"
        echo "; Dialplan WAN - network/setup-wan-mesh.sh"
        echo "; Composer <indicatif><numero> ; l'indicatif est retire a la sortie."
        for r in "${REMOTES[@]}"; do
            printf ';   %-4s → node %s (%s)\n' "${WAN_IND[$r]}" "$r" "${WAN_IP[$r]}"
        done
        echo ""
        echo "[wan_out]"
        for r in "${REMOTES[@]}"; do
            rind="${WAN_IND[$r]}"; off="${#rind}"
            echo ""
            echo "; ── node ${r} - indicatif ${rind} (strip ${off}) ───────────────────"
            # Les operateurs du noeud DISTANT, pas les notres : compter les notres
            # produisait un _<indicatif>2XXXX vers un operateur que ce noeud-la
            # n'a pas - l'appel sortait et se perdait au lieu d'etre refuse ici.
            # Le numero compose : <indicatif du noeud> + le MSISDN a SIX chiffres.
            # Pour joindre l'operateur 1 du noeud 1 depuis ici : 11 100101.
            # Le MSISDN commence par le numero du noeud DISTANT (<noeud>00<op>
            # <rang>) : c'est "${r}00${j}" et non un "600" commun. Fige a 600,
            # le motif n'accrochait plus aucun numero du pair - l'appel tombait
            # dans le fourre-tout, Asterisk repondait "successful" a l'envoi SMS
            # et le message n'allait nulle part.
            # Un seul motif suffit, la longueur etant fixe.
            for j in $(seq 1 "$(node_nops "$r")"); do
                cat <<EOF
exten => _${rind}${r}00${j}XX,1,NoOp(=== WAN OUT node${r} op${j}: \${EXTEN} → ${WAN_IP[$r]} ===)
 same => n,Dial(PJSIP/\${EXTEN:${off}}@wan_n${r}_op${j},,rT)
 same => n,NoOp(WAN \${DIALSTATUS})
 same => n,Congestion()
 same => n,Hangup()
EOF
            done
            # L'operateur vise chez le pair, borne a ce qu'il porte reellement :
            # cette ligne prenait l'indice de l'operateur LOCAL. Vers un noeud
            # qui n'a qu'un operateur, elle appelait wan_n<r>_op2 - un endpoint
            # qui n'est plus declare depuis que les trunks suivent node_nops :
            # Asterisk journalise "endpoint not found" au lieu d'essayer.
            _spop="$i"; [ "$_spop" -le "$(node_nops "$r")" ] || _spop=1
            for sp in $softphones; do
                sp_num="${sp%%:*}"
                cat <<EOF
exten => ${rind}${sp_num},1,NoOp(=== WAN OUT node${r} softphone ${sp_num} ===)
 same => n,Dial(PJSIP/${sp_num}@wan_n${r}_op${_spop},,rT)
 same => n,Congestion()
 same => n,Hangup()
EOF
            done
        done
        cat <<EOF

; Indicatif local (${LOCAL_IND}) compose depuis ce noeud : c'est chez nous.
; On retire l'indicatif et on repart dans le routage local plutot que de
; sortir sur le WAN pour revenir - un aller-retour qui, lui, ne raccroche pas.
exten => _${LOCAL_IND}X.,1,NoOp(=== WAN: indicatif local, routage interne \${EXTEN} ===)
 same => n,Goto(gsm_in,\${EXTEN:${#LOCAL_IND}},1)

exten => _X.,1,NoOp(=== WAN OUT: indicatif inconnu \${EXTEN} ===)
 same => n,Congestion()
 same => n,Hangup()

; ── [wan_in] - ce qui ARRIVE des autres noeuds ────────────────────────────
; Le noeud emetteur a deja retire l'indicatif : on recoit le numero nu.
[wan_in]
EOF
        # L'indicatif a ete retire par le noeud appelant : ce qui arrive ici est
        # le MSISDN nu, six chiffres. Ce sont NOS numeros, donc ils commencent
        # par NOTRE numero de noeud : <WAN_NODE_ID>00<operateur><rang>.
        for j in "${OP_IDS[@]}"; do
                cat <<EOF
exten => _${WAN_NODE_ID}00${j}XX,1,NoOp(=== WAN IN → GSM Op${j}: \${EXTEN} ===)
 same => n,Set(CALLERID(all)=<\${CALLERID(num)}>)
 same => n,Gosub(sub-annuaire,\${CALLERID(num)},1)
 same => n,Gosub(sub-record,s,1(\${EXTEN}))
 same => n,Dial(PJSIP/\${EXTEN}@gsm_msc,,rT)
 same => n,Congestion()
 same => n,Hangup()
EOF
        done
        for sp in $softphones; do
            sp_num="${sp%%:*}"; sp_ep="${sp#*:}"
            cat <<EOF
exten => ${sp_num},1,NoOp(=== WAN IN → ${sp_ep} (${sp_num}) ===)
 same => n,Gosub(sub-record,s,1(${sp_num}))
 same => n,Dial(PJSIP/${sp_ep},,rT)
 same => n,Congestion()
 same => n,Hangup()
EOF
        done
        cat <<EOF

; Un pair qui n'aurait pas retire notre indicatif : on le retire ici.
exten => _${LOCAL_IND}X.,1,NoOp(=== WAN IN: indicatif local encore present ===)
 same => n,Goto(wan_in,\${EXTEN:${#LOCAL_IND}},1)

; Autre operateur de CE noeud : on repasse par le routage inter-operateur local.
exten => _X.,1,NoOp(=== WAN IN → routage local \${EXTEN} ===)
 same => n,Gosub(sub-record,s,1(\${EXTEN}))
 same => n,Goto(interop_out,\${EXTEN},1)

exten => 600,1,NoOp(=== WAN ECHO TEST ===)
 same => n,Answer()
 same => n,Playback(demo-echotest)
 same => n,Echo()
 same => n,Playback(demo-echodone)
 same => n,Hangup()
EOF
        echo "$END_MARK"
    } > "$body"

    # Entrees : un prefixe par indicatif distant, injecte dans [gsm_in] (appels
    # venus du GSM) et [internal] (softphones SIP).
    entry="$(mktemp)"
    {
        echo "$BEGIN_MARK"
        for r in "${REMOTES[@]}"; do
            printf 'exten => _%s.,1,NoOp(=== indicatif %s → node %s ===)\n' "${WAN_IND[$r]}" "${WAN_IND[$r]}" "$r"
            printf ' same => n,Goto(wan_out,${EXTEN},1)\n'
        done
        # Notre PROPRE indicatif compose en entier : il doit aboutir ici, pas
        # tomber dans le vide. wan_out le retire et rend la main au routage local.
        printf 'exten => _%s.,1,NoOp(=== indicatif local %s ===)\n' "$LOCAL_IND" "$LOCAL_IND"
        printf ' same => n,Goto(wan_out,${EXTEN},1)\n'
        echo "$END_MARK"
    } > "$entry"

    awk -v bf="$entry" '
        { print }
        $0 == "[gsm_in]" || $0 == "[internal]" {
            while ((getline l < bf) > 0) print l
            close(bf)
        }
    ' "$ext" > "${ext}.new" && mv "${ext}.new" "$ext"

    cat "$body" >> "$ext"
    ast_push "$i" /etc/asterisk/extensions.conf < "$ext"
    rm -f "$ext" "$body" "$entry"
    _inds=""; for r in "${REMOTES[@]}"; do _inds="${_inds}${_inds:+ }${WAN_IND[$r]}→node${r}"; done
    echo -e "  ${CYAN}Op${i}${NC} wan_out/wan_in · ${_inds}"
done
echo ""

# ══════════════════════════════════════════════════════════════════════════════
# [6/6] SMS - sms-routing.conf : un operateur distant par (noeud, operateur)
# ══════════════════════════════════════════════════════════════════════════════
# Le relais (scripts/sms-interop-relay.py) route par LONGEST-PREFIX sur le
# MSISDN compose. On lui donne donc les prefixes <indicatif><op>, la cible
# ip:port du relais distant, et le nombre de chiffres d'indicatif a RETIRER
# avant l'envoi : le HLR distant ne connait que le numero nu.
echo -e "${GREEN}[6/6] Routage SMS WAN...${NC}"
for i in "${OP_IDS[@]}"; do
    mesh_set_context "$i"
    conf="$(mktemp)"
    ast_pull "$i" /etc/osmocom/sms-routing.conf \
        | sed '/OSMO WAN MESH BEGIN/,/OSMO WAN MESH END/d' \
        | sed '/# ── WAN ROUTES/,/# ── FIN WAN/d' > "$conf"
    if [ ! -s "$conf" ]; then
        echo -e "  ${YELLOW}⚠${NC} Op${i} : sms-routing.conf absent - SMS WAN non configure"
        rm -f "$conf"; continue
    fi
    {
        echo ""
        echo "$SMS_BEGIN"
        echo "# Noeuds WAN distants. Cle = <w><node><op>, valeur = ip:port du relais."
        echo "[operators]"
        for r in "${REMOTES[@]}"; do
            rip="${WAN_IP[$r]}"
            for j in $(seq 1 "$(node_nops "$r")"); do
                # Vers un pair du backbone, le relais distant est joint dans le
                # bridge : son port publie sur l'hote (7891 pour l'operateur 2)
                # n'y mene pas, il ecoute le port de base.
                rsms="$(peer_sms_port "$r" "$j")"

                printf 'w%s%s = %s:%s\n' "$r" "$j" "$rip" "$rsms"
            done
        done
        echo ""
        # La cle de routage est le PREFIXE compose :
        # <indicatif><noeud distant>00<operateur>. Le "600" fixe qu'elle portait
        # ne correspondait plus a aucun numero depuis que le MSISDN commence par
        # le noeud : un SMS vers 11100101 ne matchait aucune route, le relais
        # rendait un succes et le message n'allait nulle part.
        # strip retire l'indicatif seul, ce qui laisse le MSISDN entier.
        echo "# <indicatif><noeud>00<op> → noeud distant ; strip = chiffres d'indicatif a retirer."
        echo "[routes]"
        for r in "${REMOTES[@]}"; do
            for j in $(seq 1 "$(node_nops "$r")"); do
                printf '%s%s00%s = w%s%s strip=%s\n' "${WAN_IND[$r]}" "$r" "$j" "$r" "$j" "${#WAN_IND[$r]}"
            done
        done
        echo "$SMS_END"
    } >> "$conf"
    ast_push "$i" /etc/osmocom/sms-routing.conf < "$conf"
    rm -f "$conf"
    nsms=0
    for r in "${REMOTES[@]}"; do nsms=$(( nsms + $(node_nops "$r") )); done
    echo -e "  ${CYAN}Op${i}${NC} ${nsms} route(s) SMS WAN"
done
echo ""

# ── Redemarrage Asterisk ─────────────────────────────────────────────────────
# Le transport PJSIP a change : un "reload" ne suffit pas. En natif au moment
# du bootstrap Asterisk n'est pas encore lance - on ne redemarre alors rien, la
# conf sera lue a son demarrage.
if [ "$NO_RESTART" -eq 1 ]; then
    echo -e "${YELLOW}--no-restart : Asterisk gardera l'ancienne conf jusqu'a son prochain demarrage.${NC}"
elif [ "$DRY" -eq 1 ]; then
    echo "  [dry-run] redemarrage Asterisk"
else
    for i in "${OP_IDS[@]}"; do
        mesh_set_context "$i"
        if [ "$MODE" = "docker" ]; then
            docker exec "$(op_container "$i")" bash -c "
                asterisk -rx 'core stop now' 2>/dev/null || pkill asterisk 2>/dev/null || true
                sleep 2; pkill -9 asterisk 2>/dev/null || true; sleep 1
                asterisk -f & disown" 2>/dev/null || true
            echo -e "  ${CYAN}Op${i}${NC} Asterisk redemarre"
        else
            if asterisk -rx 'core show uptime' 2>/dev/null | qgrep -i uptime; then
                asterisk -rx 'core restart now' >/dev/null 2>&1 || true
                echo -e "  ${CYAN}Op${i}${NC} Asterisk redemarre"
            else
                echo -e "  ${CYAN}Op${i}${NC} Asterisk non lance - conf prete pour son demarrage"
            fi
        fi
    done
fi

# ── Sauvegarde + resume ──────────────────────────────────────────────────────
if [ "$DRY" -eq 0 ]; then
    WAN_AUTO="${WAN_AUTO:-1}" wan_nodes_save "${CONF:-$WAN_CONF_FILE}"
fi

echo ""
echo -e "${GREEN}${BOLD}WAN mesh applique.${NC}"
echo ""
# Il faut composer l'indicatif du noeud SUIVI DU MSISDN entier, six chiffres :
# <noeud>00<operateur><rang>. Un exemple faux dans le resume final vaut une
# heure perdue a essayer un numero qui ne matche aucun motif - le resume a deja
# annonce "<indicatif>10001" puis "<indicatif>600101", deux plans revolus.
# LOCAL_IND et REMOTES gardent le contexte du DERNIER operateur boucle plus
# haut ; on repose donc le notre avant d'afficher, sinon la ligne annonce un
# indicatif qui n'est pas celui de ce noeud.
mesh_set_context "${OP_IDS[0]}"
echo -e "  ${BOLD}Depuis ce noeud (${WAN_NODE_ID}, indicatif ${LOCAL_IND}) :${NC}"
for r in "${REMOTES[@]}"; do
    for j in $(seq 1 "$(node_nops "$r")"); do
        echo -e "    ${CYAN}${WAN_IND[$r]}${NC}${r}00${j}01  → MS ${r}00${j}01 (op${j}) du noeud ${r} (${WAN_IP[$r]})"
    done
done
echo ""
echo -e "  ${BOLD}Ports WAN (identiques sur chaque noeud) :${NC}"
for i in "${OP_IDS[@]}"; do
    mesh_set_context "$i"
    echo -e "    Op${i}: SIP ${CYAN}$(wan_sip_port "$i")${NC}/udp  RTP ${CYAN}$(wan_rtp_start "$i")-$(wan_rtp_end "$i")${NC}/udp  SMS ${CYAN}$(wan_sms_port "$i")${NC}/tcp"
done
echo ""
echo -e "  Table sauvegardee : ${CYAN}${CONF:-$WAN_CONF_FILE}${NC}"
echo -e "  Diagnostic SS7/WAN : ${CYAN}checks/wan_ss7_check.sh${NC}"
echo ""

network/setup-wan-sms.sh

.sh 98 lignes - 4,1KB

network/setup-wan-sms.sh
#!/bin/bash
# ══════════════════════════════════════════════════════════════════════════════
# network/setup-wan-sms.sh - Routage SMS inter-serveur
#
# Ajoute le routage SMS pour le prefixe 66 dans la table sms-routing.conf
# de chaque operateur. Les SMS a destination de 66XXXXX sont relayes
# via TCP vers le sms-interop-relay.py du serveur distant.
#
# Usage :
#   sudo ./network/setup-wan-sms.sh <remote_public_ip> [n_operators]
#
# Executer sur CHAQUE serveur.
# ══════════════════════════════════════════════════════════════════════════════

set -euo pipefail

GREEN='\033[0;32m'; CYAN='\033[0;36m'; NC='\033[0m'

REMOTE_IP="${1:-}"
N_OPS="${2:-2}"
WAN_PREFIX="66"
RELAY_PORT=7890
CONTAINER_PREFIX="osmo-operator-"

if [ -z "$REMOTE_IP" ]; then
    echo "Usage: sudo $0 <remote_public_ip> [n_operators]"
    exit 1
fi

echo -e "${GREEN}Configuration SMS WAN → ${REMOTE_IP}${NC}"
echo ""

for i in $(seq 1 "$N_OPS"); do
    cname="${CONTAINER_PREFIX}${i}"

    echo -e "  ${CYAN}Op${i}${NC} - Mise a jour sms-routing.conf..."

    # Ajouter les routes WAN dans sms-routing.conf
    docker exec "$cname" bash -c "
        CONF='/etc/osmocom/sms-routing.conf'
        
        # Retirer l'ancien bloc WAN s'il existe
        sed -i '/# ── WAN ROUTES/,/# ── FIN WAN/d' \"\$CONF\" 2>/dev/null || true
        
        # Ajouter les operateurs distants dans [operators]
        # On utilise des IDs 100+N pour les operateurs distants
        cat >> \"\$CONF\" << SMSEOF

# ── WAN ROUTES - Serveur distant ${REMOTE_IP} ─────────────────────────────
# Les IDs 100+ designent les operateurs sur le serveur distant.
# Le relay utilise l'IP du serveur distant pour la connexion TCP.

SMSEOF

        # Ajouter dans la section [operators] si pas deja present
        for j in \$(seq 1 ${N_OPS}); do
            wan_op_id=\$((100 + j))
            if ! grep -q \"^\${wan_op_id} =\" \"\$CONF\"; then
                # Inserer apres la derniere ligne de [operators]
                sed -i \"/^\\[operators\\]/,/^\\[/ {
                    /^\\[routes\\]/i \${wan_op_id} = ${REMOTE_IP}
                }\" \"\$CONF\" 2>/dev/null || true
            fi
        done

        # Ajouter les routes pour le prefixe WAN
        cat >> \"\$CONF\" << SMSEOF2
# Routes WAN : prefixe ${WAN_PREFIX}NXXXX → operateur distant N
SMSEOF2

        for j in \$(seq 1 ${N_OPS}); do
            wan_op_id=\$((100 + j))
            # Route prefixe : 66 + operateur + numero
            echo \"${WAN_PREFIX}\${j}0000 = \${wan_op_id}\" >> \"\$CONF\"
            for ms in \$(seq 1 8); do
                # <noeud>00<op><ms> : ici j numerote le pair distant, donc il
                # est a la fois son noeud et son operateur (un operateur par
                # noeud sur ce chemin). Fige a 600, le numero construit ne
                # correspondait a aucun abonne du pair.
                msisdn=\$(( j * 100000 + j * 100 + ms ))
                echo \"${WAN_PREFIX}\${msisdn} = \${wan_op_id}\" >> \"\$CONF\"
            done
        done
        
        echo '# ── FIN WAN ─────────────────────────────────────────────────────' >> \"\$CONF\"
    "

    echo -e "  ${GREEN}✓${NC} Op${i} SMS WAN routes ajoutees"
done

echo ""
echo -e "${GREEN}SMS WAN routing configure.${NC}"
echo -e "  SMS vers ${CYAN}${WAN_PREFIX}10001${NC} → serveur distant Op1 MS1"
echo -e "  SMS vers ${CYAN}${WAN_PREFIX}20001${NC} → serveur distant Op2 MS1"
echo ""
echo -e "  ${CYAN}Note :${NC} Le sms-interop-relay.py doit etre accessible"
echo -e "  depuis l'exterieur sur le port TCP ${RELAY_PORT}."
echo -e "  Firewall : ufw allow from ${REMOTE_IP} to any port ${RELAY_PORT} proto tcp"

network/wan-nodes.sh

.sh 368 lignes - 19KB

network/wan-nodes.sh
#!/bin/bash
# =============================================================================
# network/wan-nodes.sh - la TABLE DES NOEUDS WAN, partagee par les 3 lanceurs
# =============================================================================
#
# Ce fichier ne definit que des fonctions et des variables : il est SOURCE par
#   start.sh          (hote docker, N operateurs par noeud)
#   start-direct.sh   (natif / ISO, 1 operateur par noeud)
#   build-iso.sh      (fige la table dans l'ISO)
#   network/setup-wan-mesh.sh  (l'applique a Asterisk / iptables / SMS)
#
# ── MODELE ───────────────────────────────────────────────────────────────────
# Un WAN = N noeuds. Un noeud = (id, ip publique, indicatif).
#
# L'INDICATIF est le prefixe telephonique du noeud, exactement comme un
# indicatif pays : depuis n'importe quel noeud, composer
#       <indicatif_cible><numero>
# joint <numero> sur le noeud qui porte cet indicatif. Le noeud emetteur
# retire l'indicatif avant de sortir sur le WAN ; le noeud d'arrivee recoit
# donc le numero nu, celui que son HLR et son dialplan connaissent deja.
#
# WAN_NODE_ID dit QUI est le noeud local. C'est la seule chose qui distingue
# "ce numero est chez moi" de "ce numero part sur le WAN" : sans lui, un
# noeud se renvoie ses propres appels a l'infini.
#
# ── POURQUOI UNE TABLE ET PAS UN COUPLE local/remote ─────────────────────────
# network/setup-wan-interop.sh (l'existant) ne connait QUE deux serveurs :
# LOCAL_IP et REMOTE_IP. Il reecrit a chaque appel le bloc "WAN INTEROP" de
# pjsip.conf/extensions.conf et remet a zero sa chaine iptables - le lancer en
# boucle sur trois pairs ne laisse donc que le DERNIER. D'ou cette table : on
# genere UN bloc qui contient tous les pairs, en un seul passage.
# -----------------------------------------------------------------------------

# Table : WAN_IP[id] / WAN_IND[id] / WAN_NOPS[id], plus la liste ordonnee des ids.
declare -A WAN_IP  2>/dev/null || true
declare -A WAN_IND 2>/dev/null || true
# WAN_NOPS[id] : combien d'operateurs porte le noeud id. WAN_OPS ne parle que du
# noeud LOCAL, donc tout ce qui genere le maillage devait SUPPOSER les autres
# identiques a soi. L'hote docker en porte deux, une VM un seul : depuis l'hote,
# on fabriquait donc vers la VM un trunk, un pattern _<indicatif>2XXXX et une
# route SMS que personne ne sert - de quoi laisser partir des appels qui
# n'arrivent nulle part. D'ou ce nombre par noeud, quatrieme champ de la table.
declare -A WAN_NOPS 2>/dev/null || true
WAN_NODE_LIST=()
: "${WAN_NODE_COUNT:=0}"
: "${WAN_NODE_ID:=0}"
: "${WAN_OPS:=1}"          # operateurs (PLMN) portes par CE noeud
: "${WAN_CONF_FILE:=/etc/osmo-wan.conf}"
# WAN_DOCKER_HOST : l'adresse LAN de l'hote qui porte docker, celle par laquelle
# une VM route vers le backbone des conteneurs. Elle est en DHCP et a deja
# change sous nos pieds (.101 puis .31) : elle se DECOUVRE a l'execution
# (network/set_ip_vm.sh) et ne se fige jamais ici - la table ne fait que la
# transporter d'une machine a l'autre.
: "${WAN_DOCKER_HOST:=}"

_wanc() { [ -t 1 ] && printf '\033[0;36m%s\033[0m' "$1" || printf '%s' "$1"; }
_wan_warn() { printf '  \033[1;33m⚠\033[0m %s\n' "$*" >&2; }
_wan_err()  { printf '  \033[0;31m✗\033[0m %s\n' "$*" >&2; }

# ── Indicatifs par defaut ────────────────────────────────────────────────────
# 11/22/.../99 : un doublet par noeud, aucun n'est prefixe d'un autre (condition
# dure, cf. wan_nodes_validate) et il y en a exactement neuf - la borne des
# noeuds. Le doublet se lit aussi comme le numero du noeud, ce qui rend un
# journal d'appel dechiffrable sans la table sous les yeux.
_WAN_DEFAULT_IND=(11 22 33 44 55 66 77 88 99)
wan_default_ind() {
    local i=$1
    if [ "$i" -ge 1 ] && [ "$i" -le "${#_WAN_DEFAULT_IND[@]}" ]; then
        echo "${_WAN_DEFAULT_IND[$((i-1))]}"
    else
        echo $(( 100 + i ))
    fi
}

# ── Serialisation "id:ip:indicatif[:operateurs] ..." ─────────────────────────
# Le nombre d'operateurs doit repasser par ici, sinon il ne survit pas au
# premier aller-retour : wan_nodes_save serialise avec cette fonction, et une
# table relue annoncait alors 1 operateur partout, y compris pour le noeud qui
# venait d'en declarer deux. Il n'est ecrit que lorsqu'il dit autre chose que 1,
# pour que tout ce qui lit deja WAN_NODES a trois champs (build-iso.sh,
# set_stp_ip.sh, les diagnostics) continue de voir la table qu'il connait.
wan_nodes_spec() {
    local id out="" nops
    for id in "${WAN_NODE_LIST[@]}"; do
        out="${out}${out:+ }${id}:${WAN_IP[$id]}:${WAN_IND[$id]}"
        nops="${WAN_NOPS[$id]:-1}"
        [ "$nops" = "1" ] || out="${out}:${nops}"
    done
    printf '%s' "$out"
}

wan_nodes_parse() {
    local spec="${1:-}" entry id rest ip ind nops
    WAN_IP=(); WAN_IND=(); WAN_NOPS=(); WAN_NODE_LIST=()
    spec="${spec//,/ }"
    for entry in $spec; do
        [ -n "$entry" ] || continue
        case "$entry" in
            *:*:*) ;;
            *) _wan_err "entree noeud invalide : '$entry' (attendu id:ip:indicatif[:operateurs])"; return 1 ;;
        esac
        id="${entry%%:*}"; rest="${entry#*:}"
        ip="${rest%%:*}";  ind="${rest#*:}"
        ind="${ind%%:*}"
        # Quatrieme champ optionnel. Toutes les tables ecrites avant lui - celle
        # de l'ISO, celle deja posee dans /etc/osmo-wan.conf - n'ont que trois
        # champs : elles doivent continuer de se lire, un noeud sans nombre
        # portant un operateur, ce qui est le cas d'une VM.
        nops=1
        case "$entry" in
            *:*:*:*) nops="${rest#*:}"; nops="${nops#*:}"; nops="${nops%%:*}" ;;
        esac
        [ -n "$nops" ] || nops=1
        # Un champ present mais illisible n'est pas rabattu en silence sur 1 :
        # ce serait un maillage prive des operateurs 2..n sans un mot dans le
        # journal - le genre de panne qu'on cherche longtemps.
        if ! [[ "$nops" =~ ^[1-9][0-9]*$ ]]; then
            _wan_err "noeud $id : '$nops' operateurs - attendu un entier >= 1"; return 1
        fi
        WAN_IP[$id]="$ip"; WAN_IND[$id]="$ind"; WAN_NOPS[$id]="$nops"
        WAN_NODE_LIST+=("$id")
    done
    WAN_NODE_COUNT=${#WAN_NODE_LIST[@]}
    [ "$WAN_NODE_COUNT" -gt 0 ] || { _wan_err "table de noeuds vide"; return 1; }
    return 0
}

# ── Validation ───────────────────────────────────────────────────────────────
# Refuse ce qui produirait un routage silencieusement faux, pas seulement ce
# qui est mal ecrit.
wan_nodes_validate() {
    local id other rc=0
    [ "${#WAN_NODE_LIST[@]}" -ge 1 ] || { _wan_err "table de noeuds vide"; return 1; }
    for id in "${WAN_NODE_LIST[@]}"; do
        # 1..9 : un chiffre. C'est ce qui garde <indicatif><operateur><MSISDN>
        # lisible et les patterns Asterisk sans ambiguite de longueur.
        [[ "$id" =~ ^[1-9]$ ]] \
            || { _wan_err "numero de noeud invalide : '$id' (1 a 9)"; rc=1; }
        [[ "${WAN_IP[$id]}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] \
            || { _wan_err "noeud $id : '${WAN_IP[$id]}' n'est pas une IPv4"; rc=1; }
        # ── L'INDICATIF NE DOIT PAS MORDRE SUR LE PLAN DE NUMEROTATION LOCAL ──
        # Les MSISDN du depot valent <noeud>00<op><ms> (100101, 200101...) :
        # six chiffres dont les deux premiers sont "<noeud>0". Un indicatif
        # "10" produirait donc "exten => _10." dans [gsm_in], qui avalerait
        # 100101 - l'abonne local deviendrait injoignable et l'appel partirait
        # sur le WAN. Un indicatif d'UN chiffre a le meme defaut, en pire.
        # Les defauts 11/22/.../99 passent tous cette regle ; c'est la regle
        # "^[1-9]0" ci-dessous qui ecarte 10, 20, ... 90.
        if ! [[ "${WAN_IND[$id]}" =~ ^[0-9]{2,4}$ ]]; then
            _wan_err "noeud $id : indicatif '${WAN_IND[$id]}' - 2 a 4 chiffres (1 chiffre entrerait en conflit avec les MSISDN locaux)"; rc=1
        elif [[ "${WAN_IND[$id]}" =~ ^[1-9]0 ]]; then
            _wan_err "noeud $id : indicatif '${WAN_IND[$id]}' commence comme un MSISDN local (<noeud>00<op><ms> → 100101, 200101...) - il capterait les appels locaux ; prenez ${WAN_IND[$id]:0:1}${WAN_IND[$id]:0:1} par exemple"; rc=1
        fi
    done
    # Indicatifs : uniques ET aucun prefixe d'un autre. Un indicatif 3 qui
    # prefixe un indicatif 33 rend les patterns Asterisk (_3. vs _33.) et le
    # longest-prefix du relais SMS dependants de l'ordre - donc imprevisibles.
    for id in "${WAN_NODE_LIST[@]}"; do
        for other in "${WAN_NODE_LIST[@]}"; do
            [ "$id" = "$other" ] && continue
            if [ "${WAN_IND[$id]}" = "${WAN_IND[$other]}" ]; then
                _wan_err "noeuds $id et $other partagent l'indicatif ${WAN_IND[$id]}"; rc=1
            elif [ "${WAN_IND[$other]#${WAN_IND[$id]}}" != "${WAN_IND[$other]}" ]; then
                _wan_err "indicatif ${WAN_IND[$id]} (noeud $id) est un prefixe de ${WAN_IND[$other]} (noeud $other)"; rc=1
            fi
            if [ "${WAN_IP[$id]}" = "${WAN_IP[$other]}" ]; then
                _wan_err "noeuds $id et $other partagent l'IP ${WAN_IP[$id]}"; rc=1
            fi
        done
    done
    if [ -n "${WAN_NODE_ID:-}" ] && [ "${WAN_NODE_ID:-0}" != "0" ]; then
        [ -n "${WAN_IP[$WAN_NODE_ID]:-}" ] \
            || { _wan_err "le noeud local ($WAN_NODE_ID) n'est pas dans la table"; rc=1; }
    fi
    return $rc
}

# ── Detection du noeud local par ses adresses ────────────────────────────────
# Une SEULE ISO peut alors servir les N machines : chacune se reconnait a son
# IP. Sans ca il faudrait fabriquer une image par noeud.
wan_nodes_detect_self() {
    local id addrs
    addrs="$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)"
    [ -n "$addrs" ] || return 1
    for id in "${WAN_NODE_LIST[@]}"; do
        if printf '%s\n' $addrs | grep -qx "${WAN_IP[$id]}"; then
            WAN_NODE_ID="$id"; return 0
        fi
    done
    return 1
}

# ── L'ADRESSE DE CE NOEUD SUR LE SEGMENT WAN ─────────────────────────────────
# "La premiere IP globale" est un mauvais critere sur une VM du banc : il y en
# a au moins trois, et deux sont des pieges.
#   • le NAT VirtualBox - toujours 10.0.2.x - sort vers Internet mais ne voit
#     aucun pair : un ASP lie dessus n'atteint jamais le hub ;
#   • les alias 172.20.0.1 / 172.20.0.11 / 172.20.1.10 que l'ISO pose elle-meme
#     sur le NIC (build-iso.sh, 20-dhcp.network) - ils appartiennent au plan
#     docker et n'existent sur aucun segment.
# La bonne adresse est celle de la carte "pont" VirtualBox. On la cherche dans
# cet ordre, du plus sur au plus deduit.
wan_detect_local_ip() {
    local addrs a

    addrs="$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)"
    [ -n "$addrs" ] || return 1

    # 1. La table fait foi - a condition que l'adresse soit reellement montee.
    #    Une table qui annonce une adresse absente est une table perimee, pas
    #    une source d'autorite.
    if [ "${WAN_NODE_ID:-0}" != "0" ] && [ -n "${WAN_IP[${WAN_NODE_ID}]:-}" ]; then
        for a in $addrs; do
            [ "$a" = "${WAN_IP[$WAN_NODE_ID]}" ] && { printf '%s' "$a"; return 0; }
        done
    fi

    # 2. La plage host-only de VirtualBox (192.168.56.0/21), la seule qu'il
    #    autorise sans /etc/vbox/networks.conf. C'est le segment du banc.
    for a in $addrs; do
        case "$a" in
            192.168.5[6-9].*|192.168.6[0-3].*) printf '%s' "$a"; return 0 ;;
        esac
    done

    # 3. Toute autre adresse en 192.168.x : c'est la carte "pont" (acces par
    #    pont / bridged adapter), celle qui met la VM sur le meme reseau que
    #    l'hote. C'est l'adresse par laquelle les pairs la joignent.
    for a in $addrs; do
        case "$a" in 192.168.*) printf '%s' "$a"; return 0 ;; esac
    done

    # 4. A defaut, la premiere adresse qui ne soit ni le NAT VirtualBox, ni un
    #    alias du plan docker, ni la boucle locale.
    for a in $addrs; do
        case "$a" in
            10.0.2.*|172.20.*|127.*|169.254.*) ;;
            *) printf '%s' "$a"; return 0 ;;
        esac
    done

    return 1
}

wan_local_ip()  { printf '%s' "${WAN_IP[${WAN_NODE_ID}]:-}"; }
wan_local_ind() { printf '%s' "${WAN_IND[${WAN_NODE_ID}]:-}"; }

# ── Saisie ───────────────────────────────────────────────────────────────────
# whiptail quand on en a un ET un terminal ; sinon `read`. Le mode "ni l'un ni
# l'autre" (cron, CI, docker exec -d) n'invente rien : il echoue en nommant
# les variables a poser.
_wan_ask() {   # titre  question  defaut
    local title="$1" q="$2" def="${3:-}" ans
    if [ -t 0 ] && [ -t 2 ] && command -v whiptail >/dev/null 2>&1 && [ -z "${WAN_NO_WHIPTAIL:-}" ]; then
        ans=$(whiptail --backtitle "osmo-operator - WAN" --title "$title" \
                --inputbox "$q" 10 72 "$def" 3>&1 1>&2 2>&3) || return 1
    elif [ -t 0 ]; then
        printf '  %s [%s] : ' "$q" "$def" >&2
        read -r ans || return 1
    else
        _wan_err "pas de terminal : renseignez WAN_NODES / WAN_NODE_ID / WAN_OPS"
        return 1
    fi
    printf '%s' "${ans:-$def}"
}

# wan_nodes_prompt [nb_ops_local]
#   1. combien de noeuds
#   2. pour CHAQUE noeud : son IP publique, puis son indicatif
#   3. quel numero porte le noeud construit par ce lancement
wan_nodes_prompt() {
    local n i ip ind me auto_ip def_n
    def_n="${WAN_NODE_COUNT:-0}"; [ "$def_n" -lt 1 ] && def_n=3

    n=$(_wan_ask "WAN - noeuds" "Nombre de noeuds du WAN (1-9) :" "$def_n") || return 1
    [[ "$n" =~ ^[1-9]$ ]] \
        || { _wan_err "nombre de noeuds invalide : '$n' (1 a 9)"; return 1; }

    auto_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -1)
    [ -z "$auto_ip" ] && auto_ip=$(hostname -I 2>/dev/null | awk '{print $1}')

    # ${x[@]+"${x[@]}"} : sous `set -u`, un tableau VIDE developpe sans cette
    # garde est une erreur fatale sur bash < 4.4 - et la premiere saisie part
    # justement d'une table vide.
    local -a old_list=(${WAN_NODE_LIST[@]+"${WAN_NODE_LIST[@]}"})
    local -A old_ip old_ind
    for i in "${old_list[@]}"; do old_ip[$i]="${WAN_IP[$i]}"; old_ind[$i]="${WAN_IND[$i]}"; done
    WAN_IP=(); WAN_IND=(); WAN_NODE_LIST=()

    for i in $(seq 1 "$n"); do
        ip=$(_wan_ask "WAN - noeud $i/$n" "IP publique du noeud $i :" "${old_ip[$i]:-}") || return 1
        ind=$(_wan_ask "WAN - noeud $i/$n" "Indicatif du noeud $i (prefixe d'appel) :" \
                       "${old_ind[$i]:-$(wan_default_ind "$i")}") || return 1
        WAN_IP[$i]="$ip"; WAN_IND[$i]="$ind"; WAN_NODE_LIST+=("$i")
    done
    WAN_NODE_COUNT="$n"

    # Numero du noeud LOCAL. On propose celui dont l'IP est la notre.
    me="${WAN_NODE_ID:-0}"
    if [ "$me" = "0" ] && [ -n "$auto_ip" ]; then
        for i in "${WAN_NODE_LIST[@]}"; do [ "${WAN_IP[$i]}" = "$auto_ip" ] && me="$i"; done
    fi
    [ "$me" = "0" ] && me=1
    me=$(_wan_ask "WAN - ce noeud" "Numero du noeud construit par ce lancement (1-$n) :" "$me") || return 1
    [[ "$me" =~ ^[0-9]+$ ]] && [ -n "${WAN_IP[$me]:-}" ] \
        || { _wan_err "noeud local '$me' hors table"; return 1; }
    WAN_NODE_ID="$me"

    wan_nodes_validate || return 1
    return 0
}

# ── Persistance ──────────────────────────────────────────────────────────────
wan_nodes_save() {
    local f="${1:-$WAN_CONF_FILE}" host="${WAN_DOCKER_HOST:-}"
    mkdir -p "$(dirname "$f")"
    # L'hote docker n'est connu que de qui vient de le decouvrir. Comme ce
    # fichier est reecrit ENTIER a chaque sauvegarde, une sauvegarde faite par un
    # script qui ne l'a pas dans son environnement effacait l'adresse : la VM
    # perdait, sans rien dire, la seule indication de par ou joindre le
    # backbone. On relit donc celle qui est deja dans le fichier avant d'ecrire.
    if [ -z "$host" ] && [ -r "$f" ]; then
        host="$(sed -n 's/^WAN_DOCKER_HOST=//p' "$f" | tr -d "\"' " | tail -1)"
    fi
    cat > "$f" <<EOF
# /etc/osmo-wan.conf - table des noeuds WAN (genere par network/wan-nodes.sh)
# Format : WAN_NODES="id:ip:indicatif[:operateurs] ..."  (operateurs : defaut 1)
WAN_NODES="$(wan_nodes_spec)"
WAN_NODE_COUNT=${WAN_NODE_COUNT}
WAN_NODE_ID=${WAN_NODE_ID}
WAN_OPS=${WAN_OPS}
# WAN_AUTO=1 : les lanceurs appliquent le WAN sans qu'on repasse --wan.
WAN_AUTO=${WAN_AUTO:-0}
EOF
    # Ecrite seulement si on la connait : une ligne vide serait lue comme une
    # passerelle, et une passerelle vide ne se distingue pas d'une passerelle
    # fausse au moment de poser la route.
    if [ -n "$host" ]; then
        {
            printf '# Hote qui porte docker, par ou une VM route vers le backbone des\n'
            printf '# conteneurs. Adresse DHCP : elle se redecouvre, elle ne se fige pas.\n'
            printf 'WAN_DOCKER_HOST="%s"\n' "$host"
        } >> "$f"
    fi
    return 0
}

wan_nodes_load() {
    local f="${1:-$WAN_CONF_FILE}"
    [ -r "$f" ] || return 1
    # Le sourcing ramene WAN_NODES, le noeud local, et WAN_DOCKER_HOST quand le
    # fichier la porte - c'est par elle qu'une VM sait vers qui router 172.20.
    # shellcheck disable=SC1090
    . "$f"
    wan_nodes_parse "${WAN_NODES:-}" || return 1
    return 0
}

wan_nodes_summary() {
    local id tag
    printf '  Noeuds WAN : %s   (ce noeud : %s)\n' "$WAN_NODE_COUNT" "$(_wanc "${WAN_NODE_ID}")"
    for id in "${WAN_NODE_LIST[@]}"; do
        tag="   "; [ "$id" = "${WAN_NODE_ID}" ] && tag="◀ ce noeud"
        printf '    node %-2s  %-15s  indicatif %-4s %s\n' "$id" "${WAN_IP[$id]}" "${WAN_IND[$id]}" "$tag"
    done
    printf '    Composer %s + numero pour joindre le noeud correspondant.\n' \
        "$(_wanc "<indicatif>")"
}

opt-gsm/si_bridge_loop.sh

.sh 12 lignes - 598B

opt-gsm/si_bridge_loop.sh
#!/bin/bash
# Lance si_bridge (2 grgsm : clair toujours-on + chiffre spawne sur Kc) en boucle.
# --line-buffered : sinon le grep bufferise par blocs (4KB) -> logs en retard,
# impossible de voir le respawn/Kc en temps reel. On laisse passer aussi les
# erreurs/tracebacks Python (sinon invisibles).
source /root/.env/bin/activate 2>/dev/null
echo "[si-bridge] 2 grgsm (clair iq_grgsm.fifo + chiffre iq_grgsm_ciph.fifo) -> feed_*"
while true; do
  python3 ${GSM_ROOT}/si_bridge.py /tmp/iq_grgsm.fifo 2>&1 \
    | grep --line-buffered -E "si-bridge|SI|Traceback|Error|error|Exception"
  sleep 2
done

scripts/audio-chain.sh

.sh 98 lignes - 4,9KB

scripts/audio-chain.sh
#!/usr/bin/env bash
# =============================================================================
#  scripts/audio-chain.sh - ferme la chaine audio locale, une fois pulse debout
# =============================================================================
#  [2026-08-14] POURQUOI CE SCRIPT EXISTE.
#  Trois maillons de la chaine audio n'etaient poses par AUCUN chemin execute :
#
#   1. /etc/asound.conf - absent de la VM au boot. Sans lui les PCM ALSA
#      `gsm_out` / `gsm_in` que `mobile` ouvre n'existent pas, donc la voix TCH
#      n'atteint jamais gsm_audio. Il est bien deploye par ensure_pulse() de
#      lib/audio.sh... mais lib/audio.sh n'est source par PERSONNE (son appelant
#      annonce, run_modules/25-audio.sh, n'existe pas).
#   2. le sink `gsm_mic` - build-iso.sh n'ecrivait que `gsm_audio` dans
#      system.pa. Or configs/asound.conf fait pointer `pcm.gsm_in` sur
#      `gsm_mic.monitor` : sans ce sink, gapk_io echoue a ouvrir la capture et
#      ABANDONNE LES DEUX SENS (gapk_io.c:468) → appel etabli, TOTALEMENT muet.
#   3. le `module-loopback` gsm_audio.monitor → carte. gsm_audio est un
#      null-sink : sans consommateur la voix descendante est jetee par
#      construction (sortie ALSA SUSPENDED, pcm0p/sub0/status = "closed").
#
#  Ce script pose les trois, dans l'ordre, de facon idempotente.
#
#  Usage : audio-chain.sh [timeout_s]   (defaut 30)
#  Neutralise par AUDIO=0 ; le seul loopback l'est par AUDIO_LOCAL_LOOPBACK=0.
#  Latence du loopback : LOOPBACK_LATENCY_MSEC (defaut 20).
#  Toujours exit 0 - l'audio ne doit jamais empecher la pile de monter.
# -----------------------------------------------------------------------------
set -uo pipefail

HERE="${HERE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
export HERE
: "${PULSE_SOCK:=/var/run/pulse/native}"
export PULSE_SERVER="unix:${PULSE_SOCK}"

# shellcheck source=../lib/audio.sh
. "${HERE}/lib/audio.sh"

[ "${AUDIO:-1}" = "1" ] || { echo "[audio] desactive (AUDIO=0)"; exit 0; }

# ── 1. Le mapping ALSA, AVANT tout le reste ──────────────────────────────────
# `mobile` ouvre gsm_out/gsm_in : si /etc/asound.conf manque, il tombe sur
# "Unknown PCM gsm_out" et rien ne remonte jamais dans gsm_audio.
if [ -f "${HERE}/configs/asound.conf" ] \
   && ! cmp -s "${HERE}/configs/asound.conf" /etc/asound.conf 2>/dev/null; then
    if cp -f "${HERE}/configs/asound.conf" /etc/asound.conf 2>/dev/null; then
        echo "[audio] /etc/asound.conf deploye (PCM gsm_out/gsm_in)"
    else
        echo "[audio] /etc/asound.conf NON deploye (droits ?)" >&2
    fi
fi

# ── 2. Attendre le demon ─────────────────────────────────────────────────────
deadline="${1:-30}"
while [ "$deadline" -gt 0 ] && ! pactl info >/dev/null 2>&1; do
    sleep 1
    deadline=$(( deadline - 1 ))
done
if ! pactl info >/dev/null 2>&1; then
    echo "[audio] PulseAudio injoignable @ ${PULSE_SOCK} - chaine non fermee" >&2
    exit 0
fi

# ── 2 bis. Attendre une SORTIE MATERIELLE, pas seulement le demon ────────────
# [2026-08-31] Au premier demarrage d'un disque fraichement installe, ce script
# concluait "aucune sortie materielle - loopback local ignore" et rendait la
# main : l'appel montait, et il etait MUET. Le demon repondait pourtant deja -
# c'est bien lui qu'attend l'etape 2 - mais les modules snd_hda_* finissaient
# de se charger, et module-udev-detect n'avait encore declare aucun sink ALSA.
# La cle live n'en souffrait pas : elle demarre plus lentement, la course se
# jouait dans l'autre sens, et le bug ne se voyait qu'apres installation.
#
# module-udev-detect rattrape les cartes qui arrivent apres coup, mais
# ensure_local_loopback, lui, ne passe qu'UNE fois : s'il passe trop tot, le
# loopback n'est jamais pose et plus rien ne le posera. On lui laisse donc la
# meme fenetre qu'au demon. Non bloquant au-dela : sans carte du tout (un banc
# sans son, une VM sans peripherique audio), on continue - les null-sinks
# suffisent a gapk_io, seule l'ecoute locale manque.
hw_deadline="${1:-30}"
while [ "$hw_deadline" -gt 0 ] \
      && ! pactl list short sinks 2>/dev/null | grep -q 'module-alsa-card'; do
    sleep 1
    hw_deadline=$(( hw_deadline - 1 ))
done

# ── 3. Les DEUX null-sinks, puis le loopback ─────────────────────────────────
# load_gsm_sinks() boucle sur GSM_SINKS = gsm_audio + gsm_mic : c'est ce qui
# rattrape un system.pa qui n'en declarerait qu'un.
load_gsm_sinks
for s in gsm_audio gsm_mic; do
    if pactl list short sinks 2>/dev/null | grep -qw "$s"; then
        echo "[audio] sink $s present"
    else
        echo "[audio] sink $s MANQUANT - gapk_io va abandonner les deux sens" >&2
    fi
done

ensure_local_loopback
exit 0

scripts/audio-diag.sh

.sh 177 lignes - 8,2KB

scripts/audio-diag.sh
#!/bin/bash
# audio-diag.sh - Diagnostic audio complet dans le container
# Usage : docker exec -ti osmo-operator-1 bash /etc/osmocom/audio-diag.sh
#
# Verifie toute la chaine : /dev/snd → ALSA → PulseAudio → mobile l1phy

set -euo pipefail

GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; NC='\033[0m'; BOLD='\033[1m'

ok()   { echo -e "  ${GREEN}✓${NC} $*"; }
fail() { echo -e "  ${RED}✗${NC} $*"; }
warn() { echo -e "  ${YELLOW}⚠${NC} $*"; }

echo -e "${CYAN}${BOLD}════════════════════════════════════════════════${NC}"
echo -e "${CYAN}${BOLD}  Diagnostic audio - container $(hostname)${NC}"
echo -e "${CYAN}${BOLD}════════════════════════════════════════════════${NC}"
echo ""

# ── 1. /dev/snd ──────────────────────────────────────────────────────────────
echo -e "${BOLD}[1/7] /dev/snd${NC}"
if [ -d /dev/snd ]; then
    ok "/dev/snd present"
    ls -la /dev/snd/ 2>/dev/null | sed 's/^/       /'
else
    fail "/dev/snd ABSENT - le container n'a pas --device /dev/snd"
    echo "       Fix: ajouter --device /dev/snd au docker run"
fi
echo ""

# ── 2. ALSA ──────────────────────────────────────────────────────────────────
echo -e "${BOLD}[2/7] ALSA${NC}"
if command -v aplay >/dev/null 2>&1; then
    ok "aplay disponible"
    echo "       Cartes:"
    cat /proc/asound/cards 2>/dev/null | sed 's/^/       /' || fail "pas de /proc/asound/cards"
    echo ""
    echo "       Devices playback:"
    aplay -l 2>/dev/null | grep "^card" | sed 's/^/       /' || warn "aucun device playback"
    echo ""
    echo "       Devices capture:"
    arecord -l 2>/dev/null | grep "^card" | sed 's/^/       /' || warn "aucun device capture"
else
    fail "aplay non installe"
fi
echo ""

# ── 3. PulseAudio ────────────────────────────────────────────────────────────
echo -e "${BOLD}[3/7] PulseAudio${NC}"
if [ -n "${PULSE_SERVER:-}" ]; then
    ok "PULSE_SERVER=${PULSE_SERVER}"
    # Tester la connexion
    if command -v pactl >/dev/null 2>&1; then
        if pactl info >/dev/null 2>&1; then
            ok "PulseAudio accessible"
            pactl info 2>/dev/null | grep -E "Server|Default Sink|Default Source" | sed 's/^/       /'
        else
            fail "PulseAudio inaccessible (socket monte mais serveur non joignable)"
        fi
    else
        warn "pactl non installe - impossible de verifier PulseAudio"
        echo "       Le socket est monte mais on ne peut pas tester sans pactl"
        if [ -S "${PULSE_SERVER#unix:}" ] 2>/dev/null; then
            ok "Socket PulseAudio existe : ${PULSE_SERVER#unix:}"
        else
            fail "Socket PulseAudio introuvable"
        fi
    fi
else
    warn "PULSE_SERVER non defini"
    echo "       Sans PulseAudio, ALSA utilise directement hw:X,Y"
    echo "       Sur un desktop Linux moderne, le son passe par PulseAudio/PipeWire"
fi
echo ""

# ── 4. Variables ALSA ────────────────────────────────────────────────────────
echo -e "${BOLD}[4/7] Variables d'environnement${NC}"
echo "       ALSA_CARD     = ${ALSA_CARD:-<non defini>}"
echo "       GAPK_ALSA_DEV = ${GAPK_ALSA_DEV:-<non defini>}"
echo "       ALSA_OUTPUT   = ${ALSA_OUTPUT:-<non defini>}"
echo "       ALSA_INPUT    = ${ALSA_INPUT:-<non defini>}"
echo "       PULSE_SERVER  = ${PULSE_SERVER:-<non defini>}"
echo ""

# ── 5. mobile.cfg tch-voice ──────────────────────────────────────────────────
echo -e "${BOLD}[5/7] mobile.cfg tch-voice${NC}"
for cfg in /root/.osmocom/bb/mobile_group*.cfg /root/.osmocom/bb/mobile.cfg; do
    [ -f "$cfg" ] || continue
    echo "       ${cfg}:"
    if grep -q "io-handler l1phy" "$cfg" 2>/dev/null; then
        ok "io-handler l1phy"
        grep -E "alsa-(output|input)-dev" "$cfg" 2>/dev/null | sed 's/^/       /'
    elif grep -q "io-handler gapk" "$cfg" 2>/dev/null; then
        warn "io-handler gapk (audio via osmo-gapk, pas ALSA direct)"
    else
        warn "pas de bloc tch-voice - pas d'audio MS"
    fi
    break
done
echo ""

# ── 6. Test ALSA rapide ─────────────────────────────────────────────────────
echo -e "${BOLD}[6/7] Test ALSA (bip 440Hz 0.2s)${NC}"
if [ -d /dev/snd ]; then
    if command -v speaker-test >/dev/null 2>&1; then
        echo "       Tentative speaker-test (2 secondes max)..."
        timeout 2 speaker-test -t sine -f 440 -l 1 -P 1 2>&1 | head -5 | sed 's/^/       /' || warn "speaker-test echoue"
    elif command -v aplay >/dev/null 2>&1; then
        # Generer un bip court
        python3 -c "
import struct, sys
rate=8000; dur=0.2; freq=440
samples = [int(32767 * __import__('math').sin(2*3.14159*freq*t/rate)) for t in range(int(rate*dur))]
header = struct.pack('<4sI4s4sIHHIIHH4sI', b'RIFF', 36+len(samples)*2, b'WAVE', b'fmt ', 16, 1, 1, rate, rate*2, 2, 16, b'data', len(samples)*2)
sys.stdout.buffer.write(header + struct.pack('<%dh'%len(samples), *samples))
" 2>/dev/null | aplay -q 2>/dev/null && ok "Bip joue sur ALSA" || warn "aplay a echoue"
    fi
else
    fail "Pas de /dev/snd - test impossible"
fi
echo ""

# ── 7. Processus audio ──────────────────────────────────────────────────────
echo -e "${BOLD}[7/7] Processus audio actifs${NC}"
if pgrep -fa "mobile.*mobile" >/dev/null 2>&1; then
    ok "Processus mobile actif"
    pgrep -fa "mobile" 2>/dev/null | head -3 | sed 's/^/       /'
else
    warn "Processus mobile non detecte (pas encore demarre ?)"
fi
if pgrep -fa "osmo-gapk" >/dev/null 2>&1; then
    ok "osmo-gapk actif"
    pgrep -fa "osmo-gapk" 2>/dev/null | head -3 | sed 's/^/       /'
else
    echo "       osmo-gapk non actif (normal si l1phy utilise)"
fi
echo ""

# ── Resume ───────────────────────────────────────────────────────────────────
echo -e "${BOLD}═══ Resume ═══${NC}"
ISSUES=0

if [ ! -d /dev/snd ]; then
    fail "BLOQUANT: /dev/snd absent → pas d'audio possible"
    echo "       Fix: dans start.sh, verifier que --device /dev/snd est dans le docker run"
    ISSUES=$((ISSUES+1))
fi

if [ -z "${PULSE_SERVER:-}" ] && [ -d /dev/snd ]; then
    warn "PulseAudio non configure"
    echo "       Sur un desktop moderne, le device 'default' ALSA necessite PulseAudio"
    echo "       Fix 1: monter le socket PulseAudio dans le container :"
    echo "         -v /run/user/\$(id -u)/pulse/native:/run/user/1000/pulse/native"
    echo "         -e PULSE_SERVER=unix:/run/user/1000/pulse/native"
    echo "       Fix 2: utiliser un device ALSA direct (bypass PulseAudio) :"
    echo "         Trouver le device: aplay -l"
    echo "         Puis: ALSA_OUTPUT=hw:0,0 ALSA_INPUT=hw:0,0 dans start.sh"
    ISSUES=$((ISSUES+1))
fi

if [ -n "${PULSE_SERVER:-}" ]; then
    pulse_sock="${PULSE_SERVER#unix:}"
    if [ ! -S "$pulse_sock" ] 2>/dev/null; then
        fail "Socket PulseAudio monte mais fichier inexistant: $pulse_sock"
        echo "       Le socket PulseAudio de l'hote n'est pas accessible dans le container"
        echo "       Verifier le -v dans le docker run"
        ISSUES=$((ISSUES+1))
    fi
fi

if [ $ISSUES -eq 0 ]; then
    echo -e "  ${GREEN}${BOLD}Audio devrait fonctionner${NC}"
    echo "       Tester: depuis le VTY mobile → call 1 600"
    echo "       Le son sort sur les HP de l'hote via /dev/snd"
fi
echo ""

scripts/banc-repro.sh

.sh 171 lignes - 8,1KB

scripts/banc-repro.sh
#!/bin/bash
# =============================================================================
#  banc-repro.sh — UNE EXPERIENCE REPRODUCTIBLE, DU DEMARRAGE AU VERDICT
# =============================================================================
#  POURQUOI CE SCRIPT EXISTE. Le defaut qu'on chasse est INTERMITTENT : sur une
#  serie d'appels, certains aboutissent et d'autres non. Diagnostiquer ca demande
#  de rejouer la meme sequence apres chaque changement, et de comparer des
#  journaux comparables. Fait a la main, chaque iteration coute plusieurs minutes
#  et produit des runs qui ne se comparent pas (nombre d'appels different, delais
#  differents, journaux qui se melangent avec les precedents).
#
#  Ce script fige la sequence : demarrage detache, attente du camp, N appels
#  identiques, collecte horodatee, verdict par appel.
#
#  IL NE CORRIGE RIEN ET NE DEVINE RIEN. Il produit une mesure.
#
#  Usage :
#     scripts/banc-repro.sh                      3 appels, avec redemarrage
#     scripts/banc-repro.sh --calls 5            5 appels
#     scripts/banc-repro.sh --no-restart         reutilise la pile en cours
#     scripts/banc-repro.sh --dest 600           numero appele (defaut 600)
#
#  Sortie : un repertoire /root/repro-<horodatage>/ contenant tous les journaux
#  du run et un resume.txt. Rien n'est ecrase d'un run a l'autre.
# -----------------------------------------------------------------------------
set -uo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CALLS=3
DEST=600
RESTART=1
VTY=4247                 # MS#1 ; MS#2 est sur 4248
CALL_S=20                # duree d'un appel
GAP_S=10                 # silence entre deux appels (le canal doit se liberer)
BOOT_MAX=180             # attente maximale du camp

while [ $# -gt 0 ]; do
    case "$1" in
        --calls)      CALLS="${2:?}"; shift ;;
        --dest)       DEST="${2:?}"; shift ;;
        --vty)        VTY="${2:?}"; shift ;;
        --call-s)     CALL_S="${2:?}"; shift ;;
        --gap-s)      GAP_S="${2:?}"; shift ;;
        --no-restart) RESTART=0 ;;
        -h|--help)    sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
        *) echo "option inconnue : $1" >&2; exit 2 ;;
    esac
    shift
done

STAMP="$(date +%Y%m%d-%H%M%S)"
OUT="/root/repro-$STAMP"
mkdir -p "$OUT"
LOGDIR=/run/user/0/osmo-nitb/logs

say() { printf '  %s\n' "$*"; }

# ── VTY : une commande, une reponse ─────────────────────────────────────────
# `enable` puis la commande. On laisse une seconde au demon pour repondre : nc
# ferme des que stdin se termine, et sans cette pause la commande part mais la
# reponse n'est jamais lue (symptome : "aucune sortie" sur une commande qui a
# pourtant bien ete executee).
vty() {
    { printf 'enable\n%s\n' "$1"; sleep 1; } | timeout 6 nc 127.0.0.1 "$VTY" 2>/dev/null
}

# ── 1. Demarrage ────────────────────────────────────────────────────────────
# CALYPSO_NO_ATTACH=1 : run.sh ne s'attache pas a tmux a la fin (run.sh:262).
# Sans ca le script resterait bloque sur une session interactive.
if [ "$RESTART" = 1 ]; then
    say "arret de la pile en cours"
    ( cd "$HERE" && CALYPSO_NO_ATTACH=1 ./start-direct.sh --stop ) >"$OUT/stop.log" 2>&1
    sleep 3
    say "demarrage detache (CALYPSO_NO_ATTACH=1)"
    ( cd "$HERE" && CALYPSO_NO_ATTACH=1 setsid ./start-direct.sh ) >"$OUT/start.log" 2>&1 &
else
    say "pile en cours reutilisee (--no-restart)"
fi

# ── 2. Attendre le camp, ne pas le supposer ─────────────────────────────────
# On interroge le VTY plutot que d'attendre un delai fixe : selon la machine, le
# camp arrive en 40 s ou en 2 min, et un sleep fige donne soit du gaspillage soit
# des appels lances avant que le mobile ne soit pret — donc des "echecs" qui ne
# mesurent que notre impatience.
say "attente du camp (C3), maximum ${BOOT_MAX}s"
camp=0
for i in $(seq 1 "$BOOT_MAX"); do
    if vty "show ms" 2>/dev/null | grep -q "C3 camped normally"; then camp=1; break; fi
    sleep 1
done
if [ "$camp" != 1 ]; then
    say "PAS DE CAMP apres ${BOOT_MAX}s — on s'arrete, les appels ne mesureraient rien"
    vty "show ms" > "$OUT/show-ms-echec.txt" 2>&1
    cp "$LOGDIR"/*.log "$OUT/" 2>/dev/null
    exit 1
fi
say "campe apres ${i}s"

# Marqueur : tout ce qui suit dans pont.log appartient a l'experience.
PONT_DEBUT=$(wc -l < /dev/shm/pont.log 2>/dev/null || echo 0)

# ── 3. Les appels ───────────────────────────────────────────────────────────
: > "$OUT/appels.txt"
for n in $(seq 1 "$CALLS"); do
    say "appel $n/$CALLS  -> $DEST"
    avant=$(wc -l < /dev/shm/pont.log 2>/dev/null || echo 0)
    vty "call 1 $DEST" > "$OUT/appel-$n-vty.txt" 2>&1
    sleep "$CALL_S"
    vty "call 1 hangup" >> "$OUT/appel-$n-vty.txt" 2>&1
    sleep 2

    # Verdict : dans la tranche de pont.log produite par CET appel, un
    # "DESARME (CHANNEL RELEASE" signe un TCH qui a vecu ; un "SANS ASSIGNMENT
    # COMPLETE" signe un mobile qui n'a pas bascule. On ne conclut pas d'apres
    # les compteurs cumulatifs, qui melangeraient les appels precedents.
    tranche=$(tail -n +$((avant + 1)) /dev/shm/pont.log 2>/dev/null)
    if printf '%s' "$tranche" | grep -q "SANS ASSIGNMENT COMPLETE"; then
        verdict=ECHEC
    elif printf '%s' "$tranche" | grep -q "DESARME (CHANNEL RELEASE"; then
        verdict=OK
    else
        verdict=INDETERMINE
    fi
    printf 'appel %d : %s\n' "$n" "$verdict" | tee -a "$OUT/appels.txt"
    printf '%s' "$tranche" > "$OUT/appel-$n-pont.log"
    sleep "$GAP_S"
done

# ── 4. Collecte ─────────────────────────────────────────────────────────────
say "collecte des journaux"
cp /dev/shm/pont.log            "$OUT/pont.log"            2>/dev/null
tail -n +$((PONT_DEBUT + 1)) /dev/shm/pont.log > "$OUT/pont-experience.log" 2>/dev/null
cp "$LOGDIR"/qemu.log           "$OUT/qemu.log"            2>/dev/null
cp "$LOGDIR"/qemu-tete.log      "$OUT/qemu-tete.log"       2>/dev/null
cp "$LOGDIR"/mobile.log         "$OUT/mobile.log"          2>/dev/null
journalctl -u osmo-bsc --since "-30min" --no-pager > "$OUT/osmo-bsc.log" 2>/dev/null
journalctl -u osmo-msc --since "-30min" --no-pager > "$OUT/osmo-msc.log" 2>/dev/null
for f in calypso_dcch_cfg calypso_kc_l1 calypso_tch_cfg; do
    [ -e "/dev/shm/$f" ] && od -A d -t x1 "/dev/shm/$f" > "$OUT/$f.hex" 2>/dev/null
done

# ── 5. Resume ───────────────────────────────────────────────────────────────
# Les compteurs de pont.py (STATS, DETAIL blocs) sont de VRAIS cumuls. Ceux du
# shunt sont des SHUNT_LOG limites en debit (ex. `if (++n <= 3 || n % 2000 == 0)`)
# : les compter donnerait un chiffre qui ne mesure rien. On ne met donc ici que
# ce qui est fiable, et on le dit.
{
    echo "=== experience $STAMP : $CALLS appels vers $DEST ==="
    cat "$OUT/appels.txt"
    echo
    echo "--- canaux dedies appris par le shunt (compteur reel, non limite) ---"
    grep -ac "DCCH #" "$OUT/qemu.log" 2>/dev/null
    grep -ah "DCCH #" "$OUT/qemu.log" 2>/dev/null | tail -5
    echo
    echo "--- pont : compteurs cumulatifs (FIABLES) ---"
    grep -a "STATS" "$OUT/pont.log" 2>/dev/null | tail -1
    grep -a "DETAIL blocs" "$OUT/pont.log" 2>/dev/null | tail -1
    grep -a "SOUS-VOIE ACTIVE" "$OUT/pont.log" 2>/dev/null | tail -1
    echo
    echo "--- reseau : echecs d'assignation ---"
    grep -ac "Assignment failed" "$OUT/osmo-bsc.log" 2>/dev/null
    echo
    echo "--- mobile : SAPI invalides ---"
    grep -ac "unsupported SAPI" "$OUT/mobile.log" 2>/dev/null
    echo
    echo "ATTENTION : les SHUNT_LOG du shunt sont limites en debit."
    echo "Ne pas utiliser 'grep -c' sur eux comme un compteur."
} > "$OUT/resume.txt" 2>&1

cat "$OUT/resume.txt"
say "tout est dans $OUT"

scripts/call-diag.sh

.sh 183 lignes - 7,3KB

scripts/call-diag.sh
#!/bin/bash
# call-diag.sh - Diagnostic chemin d'appel MS → Asterisk echo test
# Usage: sudo docker exec -ti osmo-operator-1 bash /scripts/call-diag.sh
#
# Verifie chaque maillon : MSC → MNCC → osmo-sip-connector → Asterisk

GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; NC='\033[0m'; BOLD='\033[1m'

ok()   { echo -e "  ${GREEN}✓${NC} $*"; }
fail() { echo -e "  ${RED}✗${NC} $*"; }
warn() { echo -e "  ${YELLOW}⚠${NC} $*"; }

echo -e "${CYAN}${BOLD}═══ Diagnostic appel MS → Echo Test ═══${NC}"
echo ""

# ── 1. Services core ─────────────────────────────────────────────────────────
echo -e "${BOLD}[1/8] Services Osmocom${NC}"
for svc in osmo-msc osmo-bsc osmo-mgw osmo-stp osmo-hlr; do
    if systemctl is-active --quiet "$svc" 2>/dev/null; then
        ok "$svc actif"
    elif pgrep -f "$svc" >/dev/null 2>&1; then
        ok "$svc (process)"
    else
        fail "$svc INACTIF"
    fi
done
echo ""

# ── 2. MNCC socket ──────────────────────────────────────────────────────────
echo -e "${BOLD}[2/8] MNCC socket (/tmp/msc_mncc)${NC}"
if [ -S /tmp/msc_mncc ]; then
    ok "Socket MNCC existe"
    ls -la /tmp/msc_mncc | sed 's/^/       /'
else
    fail "Socket MNCC ABSENT - MSC ne cree pas le socket MNCC external"
    echo "       Verifier que osmo-msc.cfg contient : mncc external /tmp/msc_mncc"
fi
echo ""

# ── 3. osmo-sip-connector ───────────────────────────────────────────────────
echo -e "${BOLD}[3/8] osmo-sip-connector${NC}"
if pgrep -f "osmo-sip-connector" >/dev/null 2>&1; then
    ok "Processus actif"
    pgrep -fa "osmo-sip-connector" | head -1 | sed 's/^/       /'
    
    # Verifier qu'il ecoute bien sur 5061
    if ss -tlnp 2>/dev/null | grep -q ":5061 "; then
        ok "Ecoute sur :5061 (cote SIP)"
    else
        warn "Pas en ecoute sur :5061"
    fi
else
    fail "osmo-sip-connector NON LANCE"
    echo ""
    echo "       C'est le pont MNCC ↔ SIP. Sans lui, aucun appel ne passe."
    echo "       Fix: systemctl start osmo-sip-connector"
    echo "       Ou:  osmo-sip-connector -c /etc/osmocom/osmo-sip-connector.cfg &"
fi
echo ""

# ── 4. Asterisk ──────────────────────────────────────────────────────────────
echo -e "${BOLD}[4/8] Asterisk${NC}"
if pgrep -f "asterisk" >/dev/null 2>&1; then
    ok "Processus actif"
    
    if ss -ulnp 2>/dev/null | grep -q ":5060 "; then
        ok "Ecoute SIP sur :5060/udp"
    else
        fail "Pas en ecoute sur :5060/udp"
    fi
    
    # Verifier les endpoints PJSIP
    echo "       Endpoints PJSIP:"
    asterisk -rx "pjsip show endpoints" 2>/dev/null | grep -E "Endpoint:|Contact:" | head -10 | sed 's/^/       /' || warn "Impossible de lister les endpoints"
    
    # Verifier que le dialplan a le 600
    echo ""
    echo "       Dialplan 600:"
    asterisk -rx "dialplan show 600@gsm_in" 2>/dev/null | head -10 | sed 's/^/       /' || warn "Extension 600 introuvable dans gsm_in"
else
    fail "Asterisk NON LANCE"
    echo "       Fix: asterisk -cvvv &"
fi
echo ""

# ── 5. osmo-sip-connector config ────────────────────────────────────────────
echo -e "${BOLD}[5/8] Config osmo-sip-connector${NC}"
SIP_CFG="/etc/osmocom/osmo-sip-connector.cfg"
if [ -f "$SIP_CFG" ]; then
    echo "       Contenu:"
    cat "$SIP_CFG" | sed 's/^/       /'
    
    remote_line=$(grep "remote" "$SIP_CFG" | head -1)
    echo ""
    if echo "$remote_line" | grep -q "5060"; then
        ok "Remote SIP pointe vers :5060"
    else
        warn "Remote SIP: $remote_line"
    fi
else
    fail "Config osmo-sip-connector introuvable"
fi
echo ""

# ── 6. MGW ───────────────────────────────────────────────────────────────────
echo -e "${BOLD}[6/8] OsmoMGW (RTP)${NC}"
if bash -c "echo >/dev/tcp/127.0.0.1/4243" 2>/dev/null; then
    ok "VTY 4243 accessible"
    echo "       Endpoints:"
    (echo "show mgcp"; sleep 1) | telnet 127.0.0.1 4243 2>/dev/null \
        | grep -vE "^(Trying|Connected|Escape|Welcome|OsmoMGW)" \
        | head -15 | sed 's/^/       /' || true
else
    fail "VTY MGW 4243 inaccessible"
fi
echo ""

# ── 7. BTS-TRX ──────────────────────────────────────────────────────────────
echo -e "${BOLD}[7/8] osmo-bts-trx${NC}"
if pgrep -f "osmo-bts-trx" >/dev/null 2>&1; then
    ok "Processus actif"
else
    if systemctl is-active --quiet osmo-bts-trx 2>/dev/null; then
        ok "Service actif"
    else
        fail "osmo-bts-trx INACTIF"
        echo "       Sans BTS, pas de canal radio → pas d'appel"
        echo "       Fix: systemctl start osmo-bts-trx"
    fi
fi
echo ""

# ── 8. Mobile registration ──────────────────────────────────────────────────
echo -e "${BOLD}[8/8] Enregistrement mobile (MSC VTY)${NC}"
MSC_OUT=$( (echo "enable"; echo "show subscriber all"; sleep 1; echo "exit") \
    | telnet 127.0.0.1 4254 2>/dev/null \
    | grep -vE "^(Trying|Connected|Escape|Welcome|OsmoMSC)" || true)

if echo "$MSC_OUT" | grep -qiE "IMSI|subscriber"; then
    ok "Abonnes enregistres sur le MSC:"
    echo "$MSC_OUT" | grep -iE "IMSI|MSISDN|LAC" | head -10 | sed 's/^/       /'
else
    warn "Aucun abonne enregistre sur le MSC"
    echo "       Les MS ne sont pas encore attaches au reseau"
    echo "       Verifier : dans le VTY mobile → show ms"
    echo "       L'etat doit etre 'NORMAL service'"
fi
echo ""

# ── Resume ───────────────────────────────────────────────────────────────────
echo -e "${BOLD}═══ Chaine d'appel echo test ═══${NC}"
echo ""
echo "  mobile (call 1 600)"
echo "    ↓ TCH via fake_trx"
echo "  osmo-bts-trx → osmo-bsc → osmo-msc"
echo "    ↓ MNCC socket /tmp/msc_mncc"

if [ -S /tmp/msc_mncc ]; then
    echo -e "    ${GREEN}✓${NC} MNCC socket"
else
    echo -e "    ${RED}✗ MNCC socket MANQUANT${NC}"
fi

echo "  osmo-sip-connector (5061 → 5060)"
if pgrep -f "osmo-sip-connector" >/dev/null 2>&1; then
    echo -e "    ${GREEN}✓${NC} sip-connector"
else
    echo -e "    ${RED}✗ sip-connector MANQUANT ← PROBLEME PROBABLE${NC}"
fi

echo "  Asterisk (gsm_in context, exten 600 → Echo())"
if pgrep -f "asterisk" >/dev/null 2>&1; then
    echo -e "    ${GREEN}✓${NC} Asterisk"
else
    echo -e "    ${RED}✗ Asterisk MANQUANT${NC}"
fi

echo "    ↓ RTP via OsmoMGW"
echo "  osmo-bts-trx → fake_trx → trxcon → mobile"
echo "    ↓ l1phy decode TCH-FR"
echo "  ALSA → PulseAudio → HP"
echo ""

scripts/check.sh

.sh 53 lignes - 2,8KB

scripts/check.sh
# ── INTER-STP ────────────────────────────────────────────────────────────────
sudo docker exec -it osmo-inter-stp telnet 127.0.0.1 4239
# show cs7 instance 0 asp
# show cs7 instance 0 as
# show cs7 instance 0 route

# ── OPERATEUR 1 - STP intra ──────────────────────────────────────────────────
sudo docker exec -it osmo-operator-1 telnet 127.0.0.1 4239
# show cs7 instance 0 asp
# show cs7 instance 0 as
# show cs7 instance 0 sccp users
# show cs7 instance 0 sccp connections

# ── OPERATEUR 1 - MSC ────────────────────────────────────────────────────────
sudo docker exec -it osmo-operator-1 telnet 127.0.0.1 4254
# en
# show cs7 instance 0 asp
# show cs7 instance 0 sccp users
# show cs7 instance 0 sccp connections
# show subscriber all
# show connection all

# ── OPERATEUR 1 - BSC ────────────────────────────────────────────────────────
sudo docker exec -it osmo-operator-1 telnet 127.0.0.1 4242
# en
# show cs7 instance 0 asp
# show cs7 instance 0 sccp users
# show cs7 instance 0 sccp connections
# show bts 0
# show trx 0 0

# ── OPERATEUR 2 - meme chose ──────────────────────────────────────────────────
sudo docker exec -it osmo-operator-2 telnet 127.0.0.1 4239
sudo docker exec -it osmo-operator-2 telnet 127.0.0.1 4254
sudo docker exec -it osmo-operator-2 telnet 127.0.0.1 4242

# ── ONE-LINER : dump tout sans interaction ────────────────────────────────────
for container in osmo-operator-1 osmo-operator-2; do
    echo "=== $container STP ==="
    sudo docker exec $container sh -c \
        'echo -e "show cs7 instance 0 asp\nshow cs7 instance 0 sccp users\nshow cs7 instance 0 sccp connections\n" | telnet 127.0.0.1 4239 2>/dev/null' \
        | grep -v "^Trying\|^Connected\|^Escape"

    echo "=== $container MSC ==="
    sudo docker exec $container sh -c \
        'echo -e "enable\nshow cs7 instance 0 sccp users\nshow cs7 instance 0 sccp connections\nshow subscriber all\n" | telnet 127.0.0.1 4254 2>/dev/null' \
        | grep -v "^Trying\|^Connected\|^Escape"
done

echo "=== inter-STP ASP ==="
sudo docker exec osmo-inter-stp sh -c \
    'echo -e "show cs7 instance 0 asp\nshow cs7 instance 0 as\n" | telnet 127.0.0.1 4239 2>/dev/null' \
    | grep -v "^Trying\|^Connected\|^Escape"

scripts/entrypoint.sh

.sh 64 lignes - 2,0KB

scripts/entrypoint.sh
#!/bin/bash
set -e

# --- AJOUT DE LA PARTIE TUN ---
echo "[*] Initialisation du peripherique TUN pour osmo-ggsn..."
mkdir -p /dev/net
if [ ! -c /dev/net/tun ]; then
    # Creation du noeud de peripherique (c = caractere, 10 = majeur, 200 = mineur)
    mknod /dev/net/tun c 10 200
    chmod 666 /dev/net/tun
fi

# --- CONFIGURATION ENVIRONNEMENT ---
container=docker
export container

# Verification de la presence d'une commande
if [ $# -eq 0 ]; then
  echo >&2 'ERROR: No command specified. You probably want to run bash or a script.'
  exit 1
fi

# Export des variables pour les sessions systemd
env > /etc/docker-entrypoint-env

# Creation du service de demarrage
quoted_args="$(printf " %q" "${@}")"
echo "${quoted_args}" > /etc/docker-entrypoint-cmd

cat >/etc/systemd/system/docker-entrypoint.service <<EOT
[Unit]
Description=Lancement de la stack Osmocom Simulee
After=network.target

[Service]
ExecStart=/bin/bash -exc "source /etc/docker-entrypoint-cmd"
StandardInput=tty-force
StandardOutput=inherit
StandardError=inherit
WorkingDirectory=/etc/osmocom
EnvironmentFile=/etc/docker-entrypoint-env

[Install]
WantedBy=multi-user.target
EOT

# Desactivation des services systemd conflictuels
systemctl mask systemd-firstboot.service systemd-udevd.service systemd-modules-load.service \
               systemd-udevd-kernel systemd-udevd-control 2>/dev/null || true
systemctl enable docker-entrypoint.service

# Localisation et execution de systemd
if [ -x /lib/systemd/systemd ]; then
  exec /lib/systemd/systemd --show-status=false --unit=multi-user.target
elif [ -x /usr/lib/systemd/systemd ]; then
  exec /usr/lib/systemd/systemd --show-status=false --unit=multi-user.target
else
  echo >&2 'ERROR: systemd is not installed'
  exit 1
fi

# NOTE : Les directives RUN/COPY/EXPOSE qui etaient ici sont des instructions
# Dockerfile et doivent etre dans le Dockerfile, pas dans ce script.
# exec systemd remplace le process - rien apres cette ligne n'est execute.

scripts/gapk-start.sh

.sh 687 lignes - 35KB

scripts/gapk-start.sh
#!/bin/bash
# gapk-start.sh - Gestionnaire audio GSM (osmo-gapk ↔ ALSA/RTP)
#
# ══════════════════════════════════════════════════════════════════════════════
# ARCHITECTURE AUDIO DOUBLE CHEMIN (osmo-nitb-for-calypso v2)
# ══════════════════════════════════════════════════════════════════════════════
#
# CHEMIN 1 - Baseband (mobile l1phy) :
#   mobile process ← TCH frames → trxcon → fake_trx → osmo-bts-trx
#                  ↓ io-handler l1phy
#                  ALSA device (decodage GSM-FR direct, zero latence reseau)
#
#   → Active par tch-voice { io-handler l1phy } dans mobile.cfg
#   → Pas besoin de gapk pour ce chemin
#   → Simule un combine GSM physique
#
# CHEMIN 2 - Reseau (MGW RTP) :
#   BTS → BSC → MSC → MNCC → Asterisk → SIP → Linphone
#                           → MGCP → OsmoMGW → RTP
#                                                ↓
#                                           osmo-gapk (monitor/record/bridge)
#
#   → osmo-gapk se greffe sur les ports RTP du MGW
#   → Utile pour : monitoring, enregistrement, injection audio, tests
#   → Linphone recoit l'audio directement via SIP (pas besoin de gapk)
#
# RESUME :
#   • Appel MS → MS (meme operateur) : audio via l1phy ALSA (chemin 1)
#   • Appel MS → Linphone            : audio via Asterisk SIP (chemin 2)
#   • Monitoring reseau              : gapk mode auto/monitor (chemin 2)
#   • Enregistrement appel           : gapk mode record (chemin 2)
#
# ══════════════════════════════════════════════════════════════════════════════

# ══════════════════════════════════════════════════════════════════════════════
# CONTRAT CLI D'osmo-gapk - RELIRE AVANT DE TOUCHER UNE INVOCATION
# ══════════════════════════════════════════════════════════════════════════════
# [2026-08-10] Tout ce fichier parlait a un osmo-gapk QUI N'EXISTE PAS : un
# format nomme "gsmfr" et une syntaxe d'URL "-i rtp://host:port/20".
# Consequence mesuree : gapk-rx.log et gapk-tx.log ne contenaient QUE
# "Unsupported format: gsmfr", en boucle depuis le demarrage - les deux
# process mouraient a l'analyse des arguments, le superviseur les relancait,
# et le pont RTP<->ALSA n'a JAMAIS transporte un seul echantillon. Le
# superviseur, lui, restait vivant : la sonde `_audio_gapk_vivant` le voyait
# et declarait le pont "en place". Sonde mensongere de bout en bout.
#
# Le contrat REEL (osmo-gapk --help, et src/app_osmo_gapk.c l.206-295) :
#
#   -i FICHIER        entree FICHIER          -o FICHIER   sortie FICHIER
#   -I HOTE/PORT      entree RTP              -O HOTE/PORT sortie RTP
#   -a PERIPH         entree ALSA             -A PERIPH    sortie ALSA
#   -f FORMAT         format d'ENTREE         -g FORMAT    format de SORTIE
#   -p PT             payload type RTP in     -P PT        payload type RTP out
#   -t                throttle (une trame codec / 20 ms)
#
# Pieges :
#   • `-i` n'est PAS `-I`. Une URL passee a `-i` devient un NOM DE FICHIER.
#   • `-f` seul ne suffit pas : sans `-g`, aucun format de sortie n'est defini.
#   • Le nom du format FR est "gsm" (fichier .gsm ET payload RTP RFC3551),
#     PAS "gsmfr". `osmo-gapk --help` fait foi ; la liste complete est
#     gsm | rawpcm-s16le | rtp-efr | rtp-hr-etsi | rtp-hr-ietf | amr-* | ti-* |
#     racal-* | hr-ref-*.
#   • Un seul codec est enc+dec dans ce build : `fr`. hr/efr/amr/pcm sont
#     format-only (colonne "enc dec" vide dans --help). Le reseau est en
#     payload type 3 = GSM FR (verifie : osmo-mgw "payload-types:3=GSM").
#   • `-t` UNIQUEMENT sur une entree FICHIER. Sur RTP c'est le reseau qui
#     cadence, sur ALSA c'est la carte : throttler en plus desynchronise.
# ══════════════════════════════════════════════════════════════════════════════

set -euo pipefail

GAPK_DEFAULT_FORMAT="${GAPK_FORMAT:-gsm}"
GAPK_DEFAULT_DEVICE="${GAPK_ALSA_DEV:-${ALSA_CARD:-default}}"
# [2026-08-10] Peripherique de CAPTURE distinct de celui de LECTURE. Les deux
# pattes utilisaient `$dev` (gsm_out) : cote TX cela demandait a ALSA de
# CAPTURER sur un peripherique de lecture. Et surtout, capturer la ou les
# mobiles ecrivent, c'est exactement la boucle fermee que le commentaire
# d'/etc/asound.conf documente pour le 08/08 (RMS x11 en 10 s). La capture doit
# viser gsm_in (= moniteur du null-sink gsm_mic, que personne n'alimente).
# Ne JAMAIS faire retomber ce defaut sur $GAPK_DEFAULT_DEVICE.
GAPK_DEFAULT_DEVICE_IN="${GAPK_ALSA_DEV_IN:-gsm_in}"
# Payload type RTP du GSM FR (RFC3551). OsmoMGW negocie "3=GSM".
GAPK_RTP_PT="${GAPK_RTP_PT:-3}"
GAPK_LOG_DIR="${GAPK_LOG_DIR:-/var/log/osmocom}"
GAPK_REC_DIR="${GAPK_REC_DIR:-/var/lib/gapk}"
# Periode de trame GSM. osmo-gapk la porte en dur (option -t) : cette variable
# ne sert plus qu'a documenter la cadence dans les messages.
GAPK_FRAME_MS=20
MGW_VTY_HOST="127.0.0.1"
MGW_VTY_PORT="4243"
AUTO_POLL_INTERVAL="${GAPK_POLL_INTERVAL:-2}"

GAPK_PID_RX="/var/run/gapk-rx.pid"
GAPK_PID_TX="/var/run/gapk-tx.pid"
GAPK_AUTO_LOCK="/var/run/gapk-auto.lock"

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; NC='\033[0m'; BOLD='\033[1m'

mkdir -p "$GAPK_LOG_DIR" "$GAPK_REC_DIR"

log_info()  { echo -e "${GREEN}[gapk]${NC} $*"; }
log_warn()  { echo -e "${YELLOW}[gapk]${NC} $*"; }
log_error() { echo -e "${RED}[gapk ERROR]${NC} $*" >&2; }
log_auto()  { echo -e "${CYAN}[gapk-auto]${NC} $(date '+%H:%M:%S') $*"; }

# ── Verifications ──────────────────────────────────────────────────────────────
check_gapk() {
    command -v osmo-gapk >/dev/null 2>&1 || {
        log_error "osmo-gapk introuvable."
        log_error "Verifier que le Dockerfile inclut l'etape osmo-gapk --enable-alsa."
        exit 1
    }
}

# [2026-08-12] Cette fonction jugeait sur la PRESENCE DE /dev/snd. Or aucun PCM
# de cette chaine n'est une carte : gsm_out et gsm_in sont `type pulse`
# (/etc/asound.conf) et sortent dans des null-sinks. Sur une machine sans carte
# son - un serveur, exactement la cible du projet - start.sh ne passe pas
# `--device /dev/snd` (start.sh:191), check_alsa echouait, et mode_loopback
# faisait `exit 1 : ALSA requis` alors que toute la chaine pulse etait
# fonctionnelle. On teste donc CE QU'ON VA UTILISER : le PCM s'ouvre-t-il ?
# Meme geste que assert_audio_devices() de lib/audio.sh - une seconde de
# silence, et un verdict qui porte sur le peripherique reel.
check_alsa() {
    local dev="${1:-default}"
    command -v aplay >/dev/null 2>&1 || {
        log_warn "aplay absent - impossible de verifier le PCM '${dev}'"
        return 1
    }
    if timeout 5 aplay -D "$dev" -f S16_LE -r 8000 -c 1 -d 1 /dev/zero >/dev/null 2>&1; then
        return 0
    fi
    log_warn "PCM ALSA '${dev}' ne s'ouvre pas."
    [ -d /dev/snd ] || log_warn "  (/dev/snd absent - sans importance si le PCM est 'type pulse')"
    pactl info >/dev/null 2>&1 \
        || log_warn "  PulseAudio injoignable (PULSE_SERVER=${PULSE_SERVER:-non defini})"
    log_warn "  verifier /etc/asound.conf et 'pactl list short sinks' (gsm_audio + gsm_mic)"
    return 1
}

# ── Detection du mode audio mobile ────────────────────────────────────────────
detect_audio_mode() {
    # Verifie si le mobile.cfg utilise l1phy
    local cfg="/root/.osmocom/bb/mobile.cfg"
    if [ -f "$cfg" ] && grep -q "io-handler l1phy" "$cfg" 2>/dev/null; then
        echo "l1phy"
    else
        echo "gapk"
    fi
}

# ── Adressage RTP ─────────────────────────────────────────────────────────────
# osmo-gapk decoupe HOTE/PORT au SLASH (app_osmo_gapk.c:190, strtok(dup, "/")).
# Un "127.0.0.1:16386" lui donne host="127.0.0.1:16386" puis strtok NULL ->
# -EINVAL -> "Invalid port". Or tout ce script, sa CLI publique et la sortie
# de mode_mgw_ports parlent en IP:PORT. On normalise ICI, une seule fois, pour
# ne pas avoir a changer le contrat visible du script.
rtp_hostport() {
    local hp="$1"
    # deja en HOTE/PORT : on ne touche pas. Sinon dernier ':' -> '/'.
    case "$hp" in
        */*) echo "$hp" ;;
        *)   echo "${hp%:*}/${hp##*:}" ;;
    esac
}

# ── Cycle de vie background ────────────────────────────────────────────────────
run_bg() {
    local pid_file="$1"; shift
    local log_file="$1"; shift
    osmo-gapk "$@" >> "$log_file" 2>&1 &
    echo $! > "$pid_file"
    log_info "PID $(cat "$pid_file") → $(basename "$log_file")"
}

stop_pid() {
    local pid_file="$1"
    [ -f "$pid_file" ] || return 0
    local pid; pid=$(cat "$pid_file")
    if kill -0 "$pid" 2>/dev/null; then
        kill "$pid" 2>/dev/null && log_info "Arrete PID $pid" || true
    fi
    rm -f "$pid_file"
}

is_running() {
    local pid_file="$1"
    [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null
}

# ══════════════════════════════════════════════════════════════════════════════
# info - affiche l'architecture audio actuelle
# ══════════════════════════════════════════════════════════════════════════════
mode_info() {
    local audio_mode
    audio_mode=$(detect_audio_mode)

    echo -e "${CYAN}${BOLD}"
    echo "╔══════════════════════════════════════════════════════╗"
    echo "║        Architecture audio osmo-nitb-for-calypso                 ║"
    echo "╚══════════════════════════════════════════════════════╝"
    echo -e "${NC}"

    echo -e "  Mode mobile.cfg : ${BOLD}${audio_mode}${NC}"
    echo ""

    if [ "$audio_mode" = "l1phy" ]; then
        echo -e "  ${YELLOW}● Chemin 1 - Baseband (l1phy)${NC}"
        echo -e "    mobile.cfg a tch-voice l1phy mais fake_trx ne transporte"
        echo -e "    PAS le payload audio TCH (bursts downlink = zeros)."
        echo -e "    → Pas de son direct sur les MS virtuels."
        echo ""
        echo -e "  ${GREEN}● Chemin 2 - Reseau (SIP/RTP) - SEUL CHEMIN AUDIO${NC}"
        echo -e "    MS appelle → Asterisk → SIP → Linphone (audio natif)"
        echo -e "    Linphone appelle → Asterisk → MNCC → MSC → MS (signalisation)"
        echo -e "    → L'audio du lab passe par Linphone."
    else
        echo -e "  ${GREEN}● Mode gapk classique${NC}"
        echo -e "    osmo-gapk bridge RTP ↔ ALSA"
        echo -e "    → Requis pour l'audio des appels MS"
    fi

    echo ""
    echo -e "  ${BOLD}Linphone :${NC}"
    local inter_ip
    inter_ip=$(ip -4 addr show | grep '172\.20\.0\.' | awk '{print $2}' | cut -d/ -f1 | head -1 || echo "172.20.0.X")
    echo -e "    SIP server : ${CYAN}${inter_ip}:5060${NC}"
    echo -e "    linphone_A : user=${CYAN}linphone_A${NC}  pass=${CYAN}tester${NC}   → 100"
    echo -e "    linphone_B : user=${CYAN}linphone_B${NC}  pass=${CYAN}testerB${NC}  → 200"
    echo ""

    # MGW ports
    mode_mgw_ports "" 2>/dev/null || true
}

# ══════════════════════════════════════════════════════════════════════════════
# mgw-ports
# ══════════════════════════════════════════════════════════════════════════════
mode_mgw_ports() {
    local quiet="${1:-}"
    local mgw_out

    mgw_out=$( (echo "show mgcp"; sleep 1; echo "exit") | telnet "$MGW_VTY_HOST" "$MGW_VTY_PORT" 2>/dev/null \
        | grep -v "^Trying\|^Connected\|^Escape\|Welcome\|OsmoMGW[>#]\|Copyright\|License\|Contributions\|free software\|NO WARRANTY\|Based on") || true

    if [ -z "$mgw_out" ]; then
        [ -z "$quiet" ] && echo "(MGW non disponible ou aucun endpoint)"
        return 1
    fi

    # Format reel OsmoMGW :
    #   Virtual trunk 0 endpoint rtpbridge/1@mgw:
    #      CONN: (9/rtp C:51E1AF8F r=127.0.0.1:4072<->l=127.0.0.1:4068)
    #      CONN: (9/rtp C:96FA1465 r=127.0.0.1:30004<->l=127.0.0.1:4066)

    if [ -z "$quiet" ]; then
        echo -e "${CYAN}── Endpoints OsmoMGW actifs ──${NC}"
        local has_conn=false
        echo "$mgw_out" | while IFS= read -r line; do
            if echo "$line" | grep -q "endpoint.*@mgw"; then
                local ep; ep=$(echo "$line" | grep -oP 'rtpbridge/\S+')
                # Peek if next lines have CONN
                continue
            fi
            if echo "$line" | grep -q "CONN:.*r="; then
                has_conn=true
                local remote local_p
                remote=$(echo "$line" | grep -oP 'r=\K[0-9.]+:[0-9]+')
                local_p=$(echo "$line" | grep -oP 'l=\K[0-9.]+:[0-9]+')
                echo -e "  ${GREEN}●${NC} local=${CYAN}${local_p}${NC}  remote=${CYAN}${remote}${NC}"
            fi
        done
        # [2026-08-10] has_conn etait affecte DANS le sous-shell du pipeline
            # (`echo | while`) : perdu a la sortie -> "aucune connexion active"
            # s'imprimait en listant 4 connexions. Sonde mensongere.
            if ! echo "$mgw_out" | grep -q "CONN:.*r="; then
            echo "  (aucune connexion active)"
        fi
    else
        # Mode machine : "LOCAL_PORT REMOTE_IP:REMOTE_PORT" par connexion active
        echo "$mgw_out" | grep "CONN:.*r=" | while IFS= read -r line; do
            local remote local_p
            remote=$(echo "$line" | grep -oP 'r=\K[0-9.]+:[0-9]+')
            # [2026-08-10] LE MOTIF EXIGEAIT 'l=PORT', OsmoMGW ecrit 'l=IP:PORT'. Sans le
            # point dans la classe, grep bute sur le premier '.' et rend VIDE -> le test
            # ci-dessous echoue -> le mode machine ne sort AUCUNE ligne -> mode_auto ne
            # lance JAMAIS gapk, en silence, endpoints actifs ou non. La branche
            # d'AFFICHAGE portait deja [0-9.]+ : seule celle qui pilote l'automate etait
            # restee en arriere. ATTENTION : la SOURCE est ici (scripts/), /etc/osmocom en
            # est une COPIE regeneree au demarrage - patcher la copie seule ne survit pas.
            local_p=$(echo "$line" | grep -oP 'l=\K[0-9.]+:[0-9]+' | cut -d: -f2)
            [ -n "$local_p" ] && [ -n "$remote" ] && echo "$local_p $remote"
        done
    fi
}

# ══════════════════════════════════════════════════════════════════════════════
# call - appel bidirectionnel ALSA ↔ RTP (chemin 2 uniquement)
# ══════════════════════════════════════════════════════════════════════════════
mode_call() {
    local rx_port="${1:?RTP_RX_PORT requis (port MGW downlink)}"
    local tx_dest="${2:?RTP_TX_DEST:PORT requis (dest MGW uplink)}"
    local fmt="${3:-$GAPK_DEFAULT_FORMAT}"
    local dev="${4:-$GAPK_DEFAULT_DEVICE}"
    local dev_in="${5:-$GAPK_DEFAULT_DEVICE_IN}"

    check_gapk
    check_alsa "$dev" || true

    stop_pid "$GAPK_PID_RX"
    stop_pid "$GAPK_PID_TX"

    local audio_mode; audio_mode=$(detect_audio_mode)
    if [ "$audio_mode" = "l1phy" ]; then
        echo -e "${YELLOW}${BOLD}NOTE: mobile.cfg utilise io-handler l1phy${NC}"
        echo -e "${YELLOW}L'audio MS passe directement via ALSA (pas via gapk).${NC}"
        echo -e "${YELLOW}Ce mode gapk bridge le RTP reseau (MGW) ↔ ALSA.${NC}"
        echo -e "${YELLOW}Pour les appels MS↔MS, l'audio est deja gere par mobile.${NC}"
        echo ""
    fi

    echo -e "${CYAN}${BOLD}"
    echo "╔══════════════════════════════════════════╗"
    echo "║   gapk - Bridge RTP reseau ↔ ALSA       ║"
    echo "╚══════════════════════════════════════════╝"
    echo -e "${NC}"
    printf "  Codec     : ${CYAN}%s${NC}\n" "$fmt"
    printf "  Periph HP : ${CYAN}%s${NC}\n" "$dev"
    printf "  Periph mic: ${CYAN}%s${NC}\n" "$dev_in"
    printf "  RX ecoute : ${CYAN}0.0.0.0:%s${NC}  (MGW downlink → HP)\n" "$rx_port"
    printf "  TX envoi  : ${CYAN}%s${NC}  (micro → MGW uplink)\n" "$tx_dest"
    echo ""

    local log_rx="${GAPK_LOG_DIR}/gapk-rx.log"
    local log_tx="${GAPK_LOG_DIR}/gapk-tx.log"

    # RX : RTP (FR) → decodage codec fr → PCM → ALSA lecture
    log_info "RX : rtp/${rx_port} → alsa:${dev}  [${fmt} → rawpcm-s16le]"
    run_bg "$GAPK_PID_RX" "$log_rx" \
        -I "0.0.0.0/${rx_port}" -f "$fmt" -p "$GAPK_RTP_PT" \
        -A "$dev"               -g rawpcm-s16le

    sleep 0.3

    # TX : ALSA capture → PCM → encodage codec fr → RTP (FR)
    log_info "TX : alsa:${dev_in} → rtp/${tx_dest}  [rawpcm-s16le → ${fmt}]"
    run_bg "$GAPK_PID_TX" "$log_tx" \
        -a "$dev_in"                     -f rawpcm-s16le \
        -O "$(rtp_hostport "$tx_dest")"  -g "$fmt" -P "$GAPK_RTP_PT"

    echo ""
    log_info "Bridge actif. Arreter : gapk-start.sh stop"
}

# ══════════════════════════════════════════════════════════════════════════════
# auto - surveille MGW, bridge gapk auto
# ══════════════════════════════════════════════════════════════════════════════
mode_auto() {
    local fmt="${1:-$GAPK_DEFAULT_FORMAT}"
    local dev="${2:-$GAPK_DEFAULT_DEVICE}"
    local dev_in="${3:-$GAPK_DEFAULT_DEVICE_IN}"

    check_gapk
    check_alsa "$dev" || log_warn "ALSA absent - mode RTP-only"

    local audio_mode; audio_mode=$(detect_audio_mode)

    echo -e "${CYAN}${BOLD}"
    echo "╔══════════════════════════════════════════════════════╗"
    echo "║   gapk-auto - Integration OsmoMGW (chemin reseau)   ║"
    echo "╚══════════════════════════════════════════════════════╝"
    echo -e "${NC}"

    if [ "$audio_mode" = "l1phy" ]; then
        log_auto "Mode l1phy detecte → audio MS via mobile process"
        log_auto "gapk surveille le chemin reseau (MGW RTP) uniquement"
        log_auto "Pour les appels MS↔MS, l'audio est gere nativement"
        echo ""
    fi

    log_auto "Codec=${fmt}  HP=${dev}  Mic=${dev_in}  Poll=${AUTO_POLL_INTERVAL}s"
    log_auto "Ctrl+C pour arreter"
    echo ""

    touch "$GAPK_AUTO_LOCK"
    local prev_rx_port=""

    cleanup_auto() {
        log_auto "Arret..."
        stop_pid "$GAPK_PID_RX"
        stop_pid "$GAPK_PID_TX"
        rm -f "$GAPK_AUTO_LOCK"
        exit 0
    }
    trap cleanup_auto SIGINT SIGTERM

    while [ -f "$GAPK_AUTO_LOCK" ]; do
        local first_active
        first_active=$(mode_mgw_ports "quiet" 2>/dev/null | head -1) || first_active=""

        if [ -n "$first_active" ]; then
            local rx_port tx_dest
            rx_port=$(echo "$first_active" | awk '{print $1}')
            tx_dest=$(echo "$first_active" | awk '{print $2}')

            if [ "$rx_port" != "$prev_rx_port" ]; then
                if [ -n "$prev_rx_port" ]; then
                    log_auto "Changement endpoint (${prev_rx_port} → ${rx_port})"
                    stop_pid "$GAPK_PID_RX"
                    stop_pid "$GAPK_PID_TX"
                fi

                log_auto "Endpoint actif : RX=${rx_port}  TX=${tx_dest}"

                local log_rx="${GAPK_LOG_DIR}/gapk-rx.log"
                local log_tx="${GAPK_LOG_DIR}/gapk-tx.log"

                # ── PATTE RX : ARCHITECTURALEMENT IMPOSSIBLE, ON NE LA LANCE PLUS ──
                # [2026-08-10] `-I 0.0.0.0/$rx_port` echoue en
                # "unable to bind socket:0.0.0.0:4006: Address already in use".
                # Ce n'est pas un reglage a trouver : $rx_port est le port LOCAL
                # d'OsmoMGW, il le detient deja. Deux process ne peuvent pas
                # ecouter le meme port UDP, et surtout le MGW n'ENVOIE pas vers
                # ce port - il envoie vers son pair distant. Recevoir la copie
                # d'un flux MGW demande que le MGW nous l'adresse, donc une
                # connexion MGCP supplementaire sur l'endpoint : ca se regle
                # cote MGCP, jamais cote gapk.
                # Avant ce correctif, la patte RX mourait a chaque tour et le
                # superviseur la relancait indefiniment - une boucle muette qui
                # donnait l'illusion d'un pont en place.
                # POUR ECOUTER l'audio reseau : Asterisk enregistre deja chaque
                # appel (MixMonitor, cf. sub-record dans extensions.conf), avec
                # depuis le 10/08 un fichier par sens : *-ul.wav / *-dl.wav.
                log_auto "RX non lancee : ${rx_port} appartient a OsmoMGW (voir le commentaire)"
                log_auto "  ecoute reseau -> enregistrements MixMonitor (*-ul.wav / *-dl.wav)"
                # TX : ALSA capture → PCM → codec fr (encode) → RTP (FR)
                run_bg "$GAPK_PID_TX" "$log_tx" \
                    -a "$dev_in"                     -f rawpcm-s16le \
                    -O "$(rtp_hostport "$tx_dest")"  -g "$fmt" -P "$GAPK_RTP_PT"

                prev_rx_port="$rx_port"
                log_auto "Audio actif (PID RX=$(cat $GAPK_PID_RX 2>/dev/null) TX=$(cat $GAPK_PID_TX 2>/dev/null))"
            fi

            # On ne surveille QUE la patte TX : la RX n'est plus lancee (cf.
            # ci-dessus). L'exiger relancait le couple sans fin.
            if ! is_running "$GAPK_PID_TX"; then
                log_warn "Processus gapk mort - restart"
                stop_pid "$GAPK_PID_RX"; stop_pid "$GAPK_PID_TX"
                prev_rx_port=""
            fi
        else
            if [ -n "$prev_rx_port" ]; then
                log_auto "Endpoint disparu - arret gapk"
                stop_pid "$GAPK_PID_RX"; stop_pid "$GAPK_PID_TX"
                prev_rx_port=""
            fi
        fi
        sleep "$AUTO_POLL_INTERVAL"
    done
}

# ══════════════════════════════════════════════════════════════════════════════
# monitor - ecoute passive RTP → ALSA
# ══════════════════════════════════════════════════════════════════════════════
mode_monitor() {
    local rx_port="${1:?RTP_RX_PORT requis}"
    local fmt="${2:-$GAPK_DEFAULT_FORMAT}"
    local dev="${3:-$GAPK_DEFAULT_DEVICE}"

    check_gapk; check_alsa "$dev" || true
    stop_pid "$GAPK_PID_RX"

    log_info "Monitor : rtp/${rx_port} → alsa:${dev}  [${fmt} → rawpcm-s16le]"
    exec osmo-gapk \
        -I "0.0.0.0/${rx_port}" -f "$fmt" -p "$GAPK_RTP_PT" \
        -A "$dev"               -g rawpcm-s16le
}

# ══════════════════════════════════════════════════════════════════════════════
# record - RTP → fichier .gsm
# ══════════════════════════════════════════════════════════════════════════════
mode_record() {
    local rx_port="${1:?RTP_RX_PORT requis}"
    local outfile="${2:-${GAPK_REC_DIR}/record_$(date '+%Y%m%d_%H%M%S').gsm}"
    local fmt="${3:-$GAPK_DEFAULT_FORMAT}"

    check_gapk; stop_pid "$GAPK_PID_RX"

    # Sortie fichier au MEME format que l'entree : on archive les trames FR
    # telles quelles (.gsm lisible par gapk playback et par grgsm_decode -d FR).
    log_info "Record : rtp/${rx_port} → ${outfile}  [${fmt}]"
    exec osmo-gapk \
        -I "0.0.0.0/${rx_port}" -f "$fmt" -p "$GAPK_RTP_PT" \
        -o "$outfile"           -g "$fmt"
}

# ══════════════════════════════════════════════════════════════════════════════
# playback - fichier .gsm → RTP
# ══════════════════════════════════════════════════════════════════════════════
mode_playback() {
    local infile="${1:?INPUT_FILE requis}"
    local tx_dest="${2:?RTP_TX_DEST:PORT requis}"
    local fmt="${3:-$GAPK_DEFAULT_FORMAT}"

    check_gapk
    [ -f "$infile" ] || { log_error "Fichier introuvable : $infile"; exit 1; }

    # -t OBLIGATOIRE ici : l'entree est un FICHIER, rien ne cadence la lecture.
    # Sans throttle gapk vide le fichier a pleine vitesse et noie le jitter
    # buffer d'en face (des minutes d'audio expediees en quelques secondes).
    log_info "Playback : ${infile} → rtp/${tx_dest}  [${fmt}]"
    exec osmo-gapk -t \
        -i "$infile"                     -f "$fmt" \
        -O "$(rtp_hostport "$tx_dest")"  -g "$fmt" -P "$GAPK_RTP_PT"
}

# ══════════════════════════════════════════════════════════════════════════════
# loopback - ALSA micro → codec → ALSA HP
# ══════════════════════════════════════════════════════════════════════════════
mode_loopback() {
    local fmt="${1:-$GAPK_DEFAULT_FORMAT}"
    local dev="${2:-$GAPK_DEFAULT_DEVICE}"
    local dev_in="${3:-$GAPK_DEFAULT_DEVICE_IN}"

    check_gapk
    check_alsa "$dev" || { log_error "ALSA requis."; exit 1; }

    echo -e "${YELLOW}${BOLD}⚠  UTILISEZ DES ECOUTEURS - risque de larsen !${NC}"
    # CE QUE CE MODE PROUVE, ET CE QU'IL NE PROUVE PAS.
    # osmo-gapk construit UNE chaine "format d'entree -> canon -> format de
    # sortie" : le codec n'apparait que du cote ou le format EST un format
    # codec. PCM -> fr -> PCM n'est donc PAS exprimable en une invocation, et
    # $fmt n'est volontairement pas utilise ici. Ce loopback valide le chemin
    # ALSA (capture, cadence, peripheriques), PAS le vocodeur FR.
    # Pour eprouver le vocodeur : `record` puis `playback` du .gsm obtenu.
    log_info "Loopback ALSA (sans vocodeur) : alsa:${dev_in} → alsa:${dev}"
    exec osmo-gapk \
        -a "$dev_in" -f rawpcm-s16le \
        -A "$dev"    -g rawpcm-s16le
}

# ══════════════════════════════════════════════════════════════════════════════
# stop / status
# ══════════════════════════════════════════════════════════════════════════════
mode_stop() {
    stop_pid "$GAPK_PID_RX"
    stop_pid "$GAPK_PID_TX"
    rm -f "$GAPK_AUTO_LOCK"
    pkill -x osmo-gapk 2>/dev/null && log_info "Toutes instances arretees" \
        || log_info "Aucune instance active"
}

mode_status() {
    local audio_mode; audio_mode=$(detect_audio_mode)
    echo -e "${CYAN}── Etat audio ──${NC}"
    echo -e "  Mode mobile.cfg : ${BOLD}${audio_mode}${NC}"
    echo ""

    local any=0
    for f in "$GAPK_PID_RX" "$GAPK_PID_TX"; do
        [ -f "$f" ] || continue
        local pid; pid=$(cat "$f")
        if kill -0 "$pid" 2>/dev/null; then
            local cmdline; cmdline=$(cat "/proc/${pid}/cmdline" 2>/dev/null \
                | tr '\0' ' ' | cut -c1-80 || echo "?")
            echo -e "  ${GREEN}●${NC} PID ${pid}  [$(basename "$f")]  ${cmdline}"
            any=1
        else
            echo -e "  ${RED}✗${NC} PID ${pid} mort"; rm -f "$f"
        fi
    done
    [ -f "$GAPK_AUTO_LOCK" ] && echo -e "  ${CYAN}●${NC} mode AUTO actif" && any=1
    [ $any -eq 0 ] && echo -e "  ${YELLOW}Aucune instance gapk active${NC}"

    if [ "$audio_mode" = "l1phy" ]; then
        echo ""
        echo -e "  ${GREEN}●${NC} Audio MS via l1phy (natif dans mobile process)"
        if pgrep -f "mobile.*mobile" >/dev/null 2>&1; then
            echo -e "  ${GREEN}●${NC} Processus mobile actif"
        else
            echo -e "  ${YELLOW}○${NC} Processus mobile non detecte"
        fi
    fi

    if pgrep -x osmo-gapk >/dev/null 2>&1; then
        echo ""; echo -e "${CYAN}Processus gapk :${NC}"
        ps -o pid,args --no-headers -C osmo-gapk 2>/dev/null | sed 's/^/  /' || true
    fi
}

mode_list_codecs() {
    check_gapk
    # `--list-codecs` n'existe pas dans osmo-gapk : l'option echouait, et seul
    # le `||` de repli produisait la liste - en imprimant d'abord une erreur
    # d'usage qui donnait l'air casse un mode qui marchait. On interroge
    # directement --help, qui est la source de verite des noms.
    echo -e "${CYAN}Codecs et formats supportes (osmo-gapk --help) :${NC}"
    osmo-gapk --help 2>&1 | sed -n '/Supported codecs:/,$p'
}

mode_list_devices() {
    echo -e "${CYAN}── Peripheriques ALSA ──${NC}"
    echo "Lecture (HP) :"
    aplay -L 2>/dev/null | head -20 || echo "  (aplay indisponible)"
    echo ""; echo "Capture (micro) :"
    arecord -L 2>/dev/null | head -20 || echo "  (arecord indisponible)"
    echo ""; echo "Cartes son :"
    cat /proc/asound/cards 2>/dev/null || echo "  (relancer avec --device /dev/snd)"

    local audio_mode; audio_mode=$(detect_audio_mode)
    if [ "$audio_mode" = "l1phy" ]; then
        echo ""
        echo -e "${CYAN}── Config l1phy (mobile.cfg) ──${NC}"
        grep -A3 "tch-voice" /root/.osmocom/bb/mobile.cfg 2>/dev/null | sed 's/^/  /' || true
    fi
}

# ══════════════════════════════════════════════════════════════════════════════
# Usage
# ══════════════════════════════════════════════════════════════════════════════
usage() {
    local audio_mode; audio_mode=$(detect_audio_mode)
    cat <<EOF
Usage: gapk-start.sh <mode> [options]

  Mode audio mobile.cfg : ${audio_mode}
  $([ "$audio_mode" = "l1phy" ] && echo "  → Audio MS natif via l1phy. gapk = monitoring reseau uniquement." || echo "  → Audio MS via gapk (mode classique).")

  info                    Affiche l'architecture audio et les IPs Linphone

  auto     [FORMAT] [ALSA_HP] [ALSA_MIC]
               Surveille MGW, bridge RTP↔ALSA auto (chemin reseau).

  call     <RX_PORT> <TX_DEST:PORT> [FORMAT] [ALSA_HP] [ALSA_MIC]
               Bridge bidirectionnel RTP reseau ↔ ALSA.

  monitor  <RX_PORT> [FORMAT] [ALSA_HP]
               Ecoute passive RTP → ALSA.

  record   <RX_PORT> [FICHIER] [FORMAT]
               Enregistre flux RTP → fichier .gsm.

  playback <FICHIER> <TX_DEST:PORT> [FORMAT]
               Injecte fichier .gsm → RTP.

  loopback [FORMAT] [ALSA_HP] [ALSA_MIC]
               Loopback ALSA mic→HP (ECOUTEURS OBLIGATOIRES).
               Ne traverse PAS le vocodeur - voir mode_loopback.

  mgw-ports     Endpoints RTP OsmoMGW actifs.
  stop          Arrete les instances gapk.
  status        Etat audio complet (l1phy + gapk).
  list-codecs   Codecs disponibles.
  list-devices  Peripheriques ALSA + config l1phy.

Formats : gsm (defaut, = GSM FR / RFC3551) | rawpcm-s16le | rtp-efr
          | rtp-hr-etsi | rtp-hr-ietf | amr-* | ti-* | racal-*
          "gsmfr", "gsmefr", "gsmhr", "pcm8", "pcm16" N'EXISTENT PAS :
          ces noms ont fait mourir gapk au demarrage jusqu'au 10/08/2026.
          Liste faisant foi : gapk-start.sh list-codecs
Codecs  : seul "fr" est encodeur ET decodeur dans ce build.

Variables : GAPK_FORMAT, GAPK_ALSA_DEV (HP), GAPK_ALSA_DEV_IN (micro),
            GAPK_RTP_PT, ALSA_CARD, GAPK_POLL_INTERVAL
EOF
}

# ── Dispatch ──────────────────────────────────────────────────────────────────
MODE="${1:-help}"; shift 2>/dev/null || true
case "$MODE" in
    info)          mode_info ;;
    auto)          mode_auto "$@" ;;
    call)          mode_call "$@" ;;
    monitor)       mode_monitor "$@" ;;
    record)        mode_record "$@" ;;
    playback)      mode_playback "$@" ;;
    loopback)      mode_loopback "$@" ;;
    mgw-ports)     mode_mgw_ports "" ;;
    stop)          mode_stop ;;
    status)        mode_status ;;
    list-codecs)   mode_list_codecs ;;
    list-devices)  mode_list_devices ;;
    help|-h|--help) usage ;;
    *) log_error "Mode inconnu : $MODE"; echo ""; usage; exit 1 ;;
esac

scripts/osmo-disclaimer.sh

.sh 18 lignes - 1,3KB

scripts/osmo-disclaimer.sh
# osmo-disclaimer.sh — rappel affiche a l'ouverture d'un shell dans le conteneur.
#
# SOURCE, pas execute : /root/.bashrc fait `. /etc/osmo-disclaimer.sh` (symlink
# pose par Dockerfile.run vers ce fichier). Le pendant ISO est
# /etc/profile.d/01-osmo-disclaimer.sh, ecrit par build-iso.sh.
#
# Ce texte vivait dans un heredoc de Dockerfile.run. Le builder docker classique
# (Step N/M, sans BuildKit) ne connait pas les heredocs : il joignait les lignes
# de continuation et lancait le RUN sans le corps, d'ou
#   « here-document at line 1 delimited by end-of-file (wanted `DISC') »
# et un /etc/osmo-disclaimer.sh vide. Un fichier du depot copie par
# `COPY scripts/* /etc/osmocom/` marche avec les deux builders.
export OSMO_DISCLAIMER_SHOWN=1
printf "\n  \033[1;33mDisclaimer\033[0m - banc d'essai GSM/SS7 Osmocom (conteneur %s).\n" "$(hostname)"
printf "  A n'utiliser que sur un reseau radio \033[1mISOLE\033[0m (cage/attenuateur) ou\n"
printf "  sur une bande sous licence : emettre sur le spectre public est illegal.\n"
printf "  \033[1;33mDemarrer la pile radio :\033[0m cd /opt/GSM/osmo-operator && ./start-direct.sh --force\n"
printf "  \033[0;36mSuivre :\033[0m tmux attach -t calypso   \033[0;36m(Ctrl-b puis d pour se detacher)\033[0m\n\n"

scripts/osmo-start.sh

.sh 159 lignes - 7,4KB

scripts/osmo-start.sh
#!/bin/bash
# osmo-start.sh - Demarrage sequence du Core Osmocom
#
# Ordre de dependance :
#   STP (4239) → HLR (4258) → MGW (4243)
#                    ↓
#               MSC (4254) → BSC (4242)
#                    ↓
#          GGSN (4260) → SGSN (4245) → PCU (4239 bts)
#
# Chaque service attend le VTY du precedent avant de demarrer.
# BTS-TRX et SIP-connector sont geres par run.sh (dependent de fake_trx / Asterisk).
#
# Appele par : run.sh (etape 3)

set -e

GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; NC='\033[0m'

OPERATOR_ID="${OPERATOR_ID:-1}"

# ── Helper : attendre qu'un port TCP soit ouvert ──────────────────────────────
wait_port() {
    local host="$1" port="$2" label="$3" timeout="${4:-30}"
    local elapsed=0
    echo -ne "  Attente ${label} (${host}:${port})"
    while ! bash -c "echo >/dev/tcp/${host}/${port}" 2>/dev/null; do
        sleep 1; elapsed=$((elapsed + 1))
        echo -n "."
        if [ "$elapsed" -ge "$timeout" ]; then
            echo -e " ${RED}TIMEOUT${NC}"
            return 1
        fi
    done
    echo -e " ${GREEN}OK${NC} (${elapsed}s)"
}

# ── Helper : demarrer un service et verifier ──────────────────────────────────
start_svc() {
    local svc="$1" vty_port="$2" label="$3" timeout="${4:-30}"

    echo -e "  ${CYAN}${label}${NC}"
    # ── reset-failed AVANT start ────────────────────────────────────────────
    # systemd abandonne un service apres StartLimitBurst redemarrages (compteur
    # a 10 sur osmo-bsc). Une config refusee suffit a l'y amener en dix
    # secondes. Ensuite - et c'est le piege - REPARER LA CONFIG NE SUFFIT PLUS :
    # "systemctl start" rend "start request repeated too quickly" et le service
    # reste mort, sans que rien ne le relie a l'erreur d'origine. C'est ce qui
    # a laisse le BSC de l'operateur 1 eteint alors que son fichier etait
    # redevenu valide. On efface donc le compteur a chaque lancement : ce script
    # EST le geste de demarrage, il ne doit pas heriter des echecs d'hier.
    systemctl reset-failed "$svc" 2>/dev/null || true
    systemctl start "$svc" || {
        echo -e "    ${RED}✗ systemctl start ${svc} echoue${NC}"
        journalctl -u "$svc" -n 20 --no-pager >&2
        return 1
    }

    if [ -n "$vty_port" ]; then
        wait_port 127.0.0.1 "$vty_port" "$label VTY" "$timeout" || {
            echo -e "    ${YELLOW}[WARN] VTY :${vty_port} non accessible apres ${timeout}s${NC}" >&2
            journalctl -u "$svc" -n 10 --no-pager >&2
            return 1
        }
    fi
}

# ══════════════════════════════════════════════════════════════════════════════

echo -e "${GREEN}=== Core Osmocom - Op${OPERATOR_ID} ===${NC}"
echo ""

# ── 1. Reseau TUN (APN0 pour GGSN) ───────────────────────────────────────────
echo -e "${CYAN}[1/4] Interface TUN${NC}"
if ip link show apn0 > /dev/null 2>&1; then
    ip link del dev apn0
fi
ip tuntap add dev apn0 mode tun
ip addr add 176.16.32.0/24 dev apn0
ip link set dev apn0 up
echo -e "  ${GREEN}✓${NC} apn0 up"

# ── 2. Signalisation (STP → HLR → MGW) ───────────────────────────────────────
#
# STP doit etre pret en premier : tout le SS7 passe par lui.
# HLR doit etre pret avant MSC : MSC se connecte au HLR au demarrage.
# MGW doit etre pret avant MSC : MSC ouvre un MGCP vers MGW.
# ══════════════════════════════════════════════════════════════════════════════
echo ""
echo -e "${CYAN}[2/4] Signalisation (STP → HLR → MGW)${NC}"

start_svc osmo-stp  4239 "OsmoSTP"  30 || true
start_svc osmo-hlr  4258 "OsmoHLR"  30 || {
    echo -e "  ${RED}[ERR] HLR indispensable pour MSC - abandon${NC}"
    exit 1
}
start_svc osmo-mgw  4243 "OsmoMGW"  20 || true

# ── 3. Core Network (MSC → BSC) ──────────────────────────────────────────────
#
# MSC doit etre pret avant BSC : BSC se connecte au MSC via A-interface.
# ══════════════════════════════════════════════════════════════════════════════
echo ""
echo -e "${CYAN}[3/4] Core Network (MSC → BSC)${NC}"

start_svc osmo-msc  4254 "OsmoMSC"  30 || true
start_svc osmo-bsc  4242 "OsmoBSC"  30 || true

# ── 4. Data (GGSN → SGSN → PCU) ──────────────────────────────────────────────
echo ""
echo -e "${CYAN}[4/4] Data (GGSN → SGSN → PCU)${NC}"

start_svc osmo-ggsn 4260 "OsmoGGSN" 20 || true
start_svc osmo-sgsn 4245 "OsmoSGSN" 20 || true
start_svc osmo-pcu  ""   "OsmoPCU"  0  || true
start_svc osmo-sip-connector  ""   "OsmoSIPConnector"  0  || true
sleep 0.5
chmod 777 /tmp/pcu_bts 2>/dev/null || true

# NOTE : osmo-bts-trx et osmo-sip-connector sont intentionnellement
# absents ici. run.sh les demarre dans l'ordre correct :
#   fake_trx → wait_udp 5700 → osmo-bts-trx  (evite la race condition TRX)
#   Asterisk → osmo-sip-connector             (evite le MNCC connect avant SIP UP)

# ── Resume ────────────────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}=== Verification ===${NC}"
SERVICES="osmo-stp osmo-hlr osmo-mgw osmo-msc osmo-bsc osmo-ggsn osmo-sgsn osmo-pcu"
MORTS=""
for svc in $SERVICES; do
    if systemctl is-active --quiet "$svc"; then
        echo -e "  ${GREEN}✓${NC} ${svc}"
    else
        echo -e "  ${RED}✗${NC} ${svc}"
        MORTS="${MORTS} ${svc}"
    fi
done

echo ""
# Chaque start_svc porte un "|| true" : c'est voulu, un GGSN absent ne doit pas
# emporter le reste. Mais la ligne finale disait "Core Osmocom pret" meme avec
# osmo-msc et osmo-bsc en croix juste au-dessus - et c'est cette ligne-la qu'on
# lit. Un coeur sans MSC ne fait pas d'attachement : autant le dire ici plutot
# que de le decouvrir une heure plus tard dans "show bts".
if [ -n "$MORTS" ]; then
    echo -e "${RED}Coeur INCOMPLET -${NC}${MORTS}"
    for svc in $MORTS; do
        echo -e "  ${CYAN}journalctl -u ${svc} -n 30${NC}   ou   ${CYAN}tail -30 /var/log/osmocom/${svc}.log${NC}"
    done
    case "$MORTS" in
        *osmo-msc*|*osmo-bsc*)
            echo -e "  ${YELLOW}Sans MSC ni BSC, aucun mobile ne peut s'attacher.${NC}"
            echo -e "  ${YELLOW}Cause frequente : la config est refusee a la lecture. La ligne fautive${NC}"
            echo -e "  ${YELLOW}est nommee par :${NC} ${CYAN}osmo-msc -c /etc/osmocom/osmo-msc.cfg 2>&1 | grep -A2 'Error occurred'${NC}" ;;
    esac
else
    echo -e "${GREEN}Core Osmocom pret. BTS et SIP connector geres par run.sh.${NC}"
fi

scripts/pulse-gsm-setup.sh

.sh 106 lignes - 4,1KB

scripts/pulse-gsm-setup.sh
#!/bin/bash
# pulse-gsm-setup.sh - Configure PulseAudio for osmo-gapk audio
#
# Chaine audio :
#   osmo-gapk → ALSA "gsm_out" → asound.conf(type pulse, device gsm_audio)
#              → PulseAudio null-sink "gsm_audio"
#              → gsm_audio.monitor → parec → WebSocket → navigateur
#
# Appele par run.sh avant le lancement d'OsmocomBB/gapk.
# Requiert : PULSE_SERVER defini + socket monte par start.sh

set -u

TAG="[pulse-gsm]"
TIMEOUT="${PULSE_TIMEOUT:-30}"
SINK_NAME="gsm_audio"
# [2026-08-08] Sink silencieux servant de MICRO aux mobiles. Sans lui, asound.conf
# fait retomber la capture sur la source par defaut (= gsm_audio.monitor), donc
# sur la sortie que les mobiles viennent d'ecrire : boucle audio fermee.
MIC_NAME="gsm_mic"
SINK_RATE=8000
SINK_CHANNELS=1
SINK_FORMAT="s16le"

log()  { echo "$TAG $*"; }
err()  { echo "$TAG ERROR: $*" >&2; }

# ── 1. Attente PulseAudio ────────────────────────────────────────────────────
wait_pulse() {
    local i=0
    while [ $i -lt "$TIMEOUT" ]; do
        if pactl info >/dev/null 2>&1; then
            return 0
        fi
        sleep 1
        i=$((i + 1))
    done
    return 1
}

log "Attente connexion PulseAudio (${PULSE_SERVER:-non defini})..."

if ! wait_pulse; then
    err "PulseAudio injoignable apres ${TIMEOUT}s"
    err "Verifier que PULSE_SERVER est defini et le socket monte"
    exit 1
fi

log "PulseAudio connecte"

# ── 2. Creation du null-sink gsm_audio ───────────────────────────────────────
if pactl list short sinks 2>/dev/null | grep -q "$SINK_NAME"; then
    log "Sink ${SINK_NAME} deja present"
else
    if pactl load-module module-null-sink \
        sink_name="$SINK_NAME" \
        format="$SINK_FORMAT" \
        rate="$SINK_RATE" \
        channels="$SINK_CHANNELS" \
        sink_properties=device.description=GSM_Audio >/dev/null 2>&1; then
        log "Sink ${SINK_NAME} cree (${SINK_FORMAT}/${SINK_RATE}Hz/${SINK_CHANNELS}ch)"
    else
        err "Echec creation sink ${SINK_NAME}"
        exit 1
    fi
fi

# ── 2bis. Sink silencieux "gsm_mic" (micro des mobiles, cf. asound.conf) ─────
if pactl list short sinks 2>/dev/null | grep -q "$MIC_NAME"; then
    log "Sink ${MIC_NAME} deja present"
else
    pactl load-module module-null-sink sink_name="$MIC_NAME" \
        format="$SINK_FORMAT" rate="$SINK_RATE" channels="$SINK_CHANNELS" \
        sink_properties=device.description=GSM_Mic >/dev/null \
        && log "Sink ${MIC_NAME} cree (micro silencieux)" \
        || err "Echec creation sink ${MIC_NAME}"
fi

# ── 3. Verification monitor ──────────────────────────────────────────────────
if pactl list short sources 2>/dev/null | grep -q "${SINK_NAME}.monitor"; then
    log "Source ${SINK_NAME}.monitor disponible"
else
    err "Source ${SINK_NAME}.monitor introuvable"
    exit 1
fi

# ── 4. Empecher la suspension automatique ────────────────────────────────────
# PulseAudio suspend les sinks inactifs → osmo-gapk ecrit dans le vide
# et il faut toggle les haut-parleurs sur l'hote pour reveiller le flux

pactl suspend-sink "$SINK_NAME" 0 2>/dev/null || true
pactl suspend-source "${SINK_NAME}.monitor" 0 2>/dev/null || true

# Desactiver module-suspend-on-idle s'il est charge
SUSPEND_IDX=$(pactl list short modules 2>/dev/null | awk '/module-suspend-on-idle/ {print $1; exit}')
if [ -n "$SUSPEND_IDX" ]; then
    pactl unload-module "$SUSPEND_IDX" 2>/dev/null && \
        log "module-suspend-on-idle desactive" || true
fi

log "Sink ${SINK_NAME} force actif (pas de suspend)"

log "Configuration audio prete"
# NOTE : pas de loopback gsm_audio → haut-parleurs.
# Ecoute via parec → WebSocket → navigateur uniquement.
# Sink maintenu actif par suspend-sink 0 + suppression module-suspend-on-idle (etape 4).

scripts/run.sh

.sh 543 lignes - 29KB

scripts/run.sh
#!/bin/bash
# run.sh - Orchestrateur intra-container Osmocom
#
# PHY_MODE=faketrx (defaut) : fake_trx → trxcon → mobile
# PHY_MODE=virtphy           : osmo-bts-virtual → virtphy → mobile
#
set -euo pipefail

LOG_FILE="/var/log/osmocom/run.sh.log"
mkdir -p "$(dirname "$LOG_FILE")"
exec > >(tee -a "$LOG_FILE") 2>&1

[[ -n "${DEBUG:-}" ]] && set -x

GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'

# Ce fichier est DEPLOYE HORS DEPOT (/etc/osmocom/run.sh, /root/run.sh) et lance
# par des scripts qui n'exportent pas forcement le manifeste : sous `set -u`, un
# ${GSM_ROOT} nu tuerait le script ici. D'ou les valeurs de repli explicites.
FAKETRX_PY="${FAKETRX_PY:-${FAKE_TRX:-${OSMOCOM_BB:-${GSM_ROOT:-/opt/GSM}/osmocom-bb}/src/target/trx_toolkit/fake_trx.py}}"
OPERATOR_ID="${OPERATOR_ID:-1}"; N_MS="${N_MS:-1}"; MOBILE_MODE="${MOBILE_MODE:-combined}"
PHY_MODE="${PHY_MODE:-faketrx}"   # faketrx | virtphy
# RUN_NO_PROCESS=1 : prepare/genere uniquement les configs (MS, TRX, handover)
# et sort SANS lancer aucun process (ni osmo-start, ni fake_trx/trxcon, ni
# mobile/asterisk/smsc). Utilise par le mode QEMU de start.sh qui veut juste
# les configs en place avant de lancer ${OQC_ROOT}/run.sh.
RUN_NO_PROCESS="${RUN_NO_PROCESS:-0}"
MAX_MS=64; MAX_MS_PER_MOBILE=8; MS_PER_TRX=16
BB_PORT_BASE=6700; BB_PORT_STEP=3; BTS_PORT_BASE=5700
L2_SOCK_BASE="/tmp/osmocom_l2"; SAP_SOCK_BASE="/tmp/osmocom_sap"

[[ ! "$N_MS" =~ ^[0-9]+$ ]] || [ "$N_MS" -lt 1 ] || [ "$N_MS" -gt $MAX_MS ] && N_MS=1
[[ ! "$OPERATOR_ID" =~ ^[0-9]+$ ]] || [ "$OPERATOR_ID" -lt 1 ] || [ "$OPERATOR_ID" -gt 24 ] && exit 1
[[ "$MOBILE_MODE" != "combined" && "$MOBILE_MODE" != "split" ]] && MOBILE_MODE="combined"
[[ "$PHY_MODE" != "faketrx" && "$PHY_MODE" != "virtphy" ]] && PHY_MODE="faketrx"

N_TRX=$(( (N_MS + MS_PER_TRX - 1) / MS_PER_TRX ))
[ "$MOBILE_MODE" = "combined" ] && N_GROUPS=$(( (N_MS + MAX_MS_PER_MOBILE - 1) / MAX_MS_PER_MOBILE )) || N_GROUPS=$N_MS

echo -e "  ${CYAN}Op${OPERATOR_ID}${NC}  N_MS=${N_MS}  mode=${MOBILE_MODE}  phy=${PHY_MODE}  TRX=${N_TRX}  groups=${N_GROUPS}"

# ── Helpers ───────────────────────────────────────────────────────────────────
bb_port()  { echo $(( BB_PORT_BASE + ($1 - 1) * BB_PORT_STEP )); }
l2_sock()  { echo "${L2_SOCK_BASE}_${1}"; }
sap_sock() { echo "${SAP_SOCK_BASE}_${1}"; }
ms_imsi()  { printf '%s%s%04d%06d' "$_MCC" "$_MNC" "$OPERATOR_ID" "$1"; }
ms_imei()  { printf '3589250059%02d%02d0' "$OPERATOR_ID" "$1"; }
ms_ki()    { printf '00 11 22 33 44 55 66 77 88 99 aa bb cc dd %02x %02x' "$1" "$OPERATOR_ID"; }

wait_port() {
    local host=$1 port=$2 label=$3 timeout=${4:-60} elapsed=0
    echo -ne "  Attente ${label} (${host}:${port})"
    while ! bash -c "echo >/dev/tcp/${host}/${port}" 2>/dev/null; do
        sleep 1; elapsed=$((elapsed+1)); echo -n "."
        [ "$elapsed" -ge "$timeout" ] && echo -e " ${RED}TIMEOUT${NC}" && return 1
    done
    echo -e " ${GREEN}OK${NC} (${elapsed}s)"
}

wait_udp() {
    local port=$1 label=$2 timeout=${3:-30} elapsed=0
    echo -ne "  Attente ${label} (UDP ${port})"
    while ! ss -unlp | grep -q ":${port} "; do
        sleep 1; elapsed=$((elapsed+1)); echo -n "."
        [ "$elapsed" -ge "$timeout" ] && echo -e " ${RED}TIMEOUT${NC}" && return 1
    done
    echo -e " ${GREEN}OK${NC} (${elapsed}s)"
}

# ── TMUX ──────────────────────────────────────────────────────────────────────
SESSION="osmocom"; TMUX_SOCKET="/tmp/osmocom_tmux"; TMUX_OK=false

init_tmux() {
    echo -e "  ${CYAN}Init tmux...${NC}"
    rm -f "$TMUX_SOCKET"

    tmux -S "$TMUX_SOCKET" start-server 2>/dev/null || true
    sleep 0.5
    tmux -S "$TMUX_SOCKET" new-session -d -s "$SESSION" -n main 2>/dev/null || true
    sleep 0.5

    local i=0
    while [ ! -S "$TMUX_SOCKET" ] && [ $i -lt 30 ]; do sleep 0.3; i=$((i+1)); done

    if [ -S "$TMUX_SOCKET" ] && tmux -S "$TMUX_SOCKET" has-session -t "$SESSION" 2>/dev/null; then
        TMUX_OK=true
        echo -e "  ${GREEN}✓ tmux OK${NC}"
    else
        TMUX_OK=false
        echo -e "  ${RED}✗ tmux echec - mode background${NC}"
    fi
}

run_in_tmux() {
    local win=$1; shift; local cmd="$*"
    if [ "$TMUX_OK" = true ]; then
        tmux -S "$TMUX_SOCKET" new-window -t "$SESSION" -n "$win" 2>/dev/null || true
        sleep 0.2
        tmux -S "$TMUX_SOCKET" send-keys -t "${SESSION}:${win}" "$cmd" C-m 2>/dev/null || true
    else
        echo -e "  ${YELLOW}[bg]${NC} ${win}"
        bash -c "$cmd" >"/var/log/osmocom/${win}.log" 2>&1 &
    fi
}

# ── Config MS ─────────────────────────────────────────────────────────────────
generate_ms_configs() {
    local base_cfg="/root/.osmocom/bb/mobile.cfg"
    [ -f "$base_cfg" ] || { echo -e "${RED}mobile.cfg introuvable${NC}"; return 1; }

    local ref_imsi ref_imei ref_ki ref_l2 ref_sap
    ref_imsi=$(grep -oP '^\s+imsi\s+\K[0-9]+' "$base_cfg" 2>/dev/null | head -1 || true)
    ref_imei=$(grep -oP '^\s+imei \K[0-9]+' "$base_cfg" 2>/dev/null | head -1 || true)
    ref_ki=$(grep -oP '^\s+ki comp128 \K[0-9a-f ]+' "$base_cfg" 2>/dev/null | head -1 | sed 's/[[:space:]]*$//' || true)
    ref_l2=$(grep -oP 'layer2-socket \K\S+' "$base_cfg" 2>/dev/null | head -1 || echo "/tmp/osmocom_l2")
    ref_sap=$(grep -oP 'sap-socket \K\S+' "$base_cfg" 2>/dev/null | head -1 || true)

    if [ -z "$ref_imsi" ]; then
        ref_imsi=$(grep -E 'imsi [0-9]+' "$base_cfg" | head -1 | awk '{print $NF}')
        ref_imei=$(grep -E 'imei [0-9]+' "$base_cfg" | head -1 | awk '{print $2}')
        ref_ki=$(grep -E 'ki comp128' "$base_cfg" | head -1 | sed 's/.*ki comp128 //' | sed 's/[[:space:]]*$//')
    fi
    [ -z "$ref_imsi" ] || [ -z "$ref_imei" ] || [ -z "$ref_ki" ] && return 1

    _MCC=$(grep -E '^\s+rplmn ' "$base_cfg" | awk '{print $2}' | head -1); _MCC=${_MCC:-001}
    _MNC=$(grep -E '^\s+rplmn ' "$base_cfg" | awk '{print $3}' | head -1); _MNC=${_MNC:-01}
    echo -e "  ${CYAN}[ref]${NC} IMSI=${ref_imsi} MCC/MNC=${_MCC}/${_MNC}"

    local global_line; global_line=$(grep -n '^ms [0-9]' "$base_cfg" | head -1 | cut -d: -f1)
    [ -z "$global_line" ] && return 1
    local global_header; global_header=$(head -n $((global_line - 1)) "$base_cfg")
    local ms_template; ms_template=$(awk '/^ms [0-9]/{if(f)exit;f=1;p=1} p{print} /^(exit|end)$/&&p&&f{p=0;exit}' "$base_cfg")
    [ -z "$ms_template" ] && return 1

    _write_ms_block() {
        local outfile=$1 lnum=$2 gidx=$3
        local ni=$(ms_imsi "$gidx") ne=$(ms_imei "$gidx") nk=$(ms_ki "$gidx") nl=$(l2_sock "$gidx") ns=$(sap_sock "$gidx")
        local block; block=$(echo "$ms_template" | sed \
            -e "s|^ms [0-9]\+|ms ${lnum}|" \
            -e "s|layer2-socket ${ref_l2}|layer2-socket ${nl}|" \
            -e "s|imsi ${ref_imsi}|imsi ${ni}|" \
            -e "s|imei ${ref_imei}[[:space:]]*[0-9]*|imei ${ne}|" \
            -e "s|ki comp128 ${ref_ki}|ki comp128 ${nk}|")
        [ -n "$ref_sap" ] && block=$(echo "$block" | sed "s|sap-socket ${ref_sap}|sap-socket ${ns}|") \
            || block=$(echo "$block" | sed "/layer2-socket/a\\ sap-socket ${ns}")
        echo "$block" >> "$outfile"; echo "" >> "$outfile"
    }

    # Si le fichier est bind-monte (docker -v file:file), rm renvoie EBUSY.
    # Dans ce cas on ne peut pas le supprimer mais on peut le truncate puis
    # re-ecrire dedans, ce qui est equivalent pour notre besoin.
    for f in /root/.osmocom/bb/mobile_ms*.cfg /root/.osmocom/bb/mobile_group*.cfg; do
        [ -e "$f" ] || continue
        if ! rm -f "$f" 2>/dev/null; then
            : > "$f" 2>/dev/null || \
                echo -e "  ${YELLOW}[warn] $f ni rm ni truncate (bind-mount RO ?)${NC}"
        fi
    done

    if [ "$MOBILE_MODE" = "split" ]; then
        for ms in $(seq 1 "$N_MS"); do
            echo "$global_header" > "/root/.osmocom/bb/mobile_ms${ms}.cfg"
            _write_ms_block "/root/.osmocom/bb/mobile_ms${ms}.cfg" 1 "$ms"
        done
    else
        local grp=1 gs=1
        while [ "$gs" -le "$N_MS" ]; do
            local ge=$((gs + MAX_MS_PER_MOBILE - 1)); [ "$ge" -gt "$N_MS" ] && ge=$N_MS
            local cfg="/root/.osmocom/bb/mobile_group${grp}.cfg"
            echo "$global_header" | sed "s|127\.0\.0\.1|127.0.0.${grp}|g" > "$cfg"
            local li=1; for ms in $(seq "$gs" "$ge"); do _write_ms_block "$cfg" "$li" "$ms"; li=$((li+1)); done
            echo -e "  ${CYAN}[Grp${grp}]${NC} IP=127.0.0.${grp} MS ${gs}-${ge}"
            grp=$((grp+1)); gs=$((ge+1))
        done
    fi
    echo -e "  ${GREEN}✓ Configs MS OK${NC}"
}

# ── TRX injection (fake_trx mode) ────────────────────────────────────────────
inject_extra_trx() {
    [ "$1" -le 1 ] && return 0
    local bts="/etc/osmocom/osmo-bts-trx.cfg" bsc="/etc/osmocom/osmo-bsc.cfg"
    [ -f "$bts" ] || return 0
    local arfcn0; arfcn0=$(grep -E '^\s+arfcn [0-9]+' "$bsc" | head -1 | awk '{print $2}')
    local tmp; tmp=$(mktemp)
    awk -v n="$1" '/^ osmotrx ip local/&&!d{for(i=1;i<n;i++)printf" instance %d\n  osmotrx rx-gain 40\n  osmotrx tx-attenuation 50\n",i;d=1}{print}' "$bts" > "$tmp"; cp "$tmp" "$bts"; rm -f "$tmp"
    for t in $(seq 1 $(($1-1))); do
        local a=$((arfcn0 + t*2))
        printf ' trx %d\n  power-ramp max-initial 23000 mdBm\n  power-ramp step-size 2000 mdB\n  power-ramp step-interval 1\n  ms-power-control osmo\n  phy 0 instance %d\n' "$t" "$t" >> "$bts"
    done
    echo -e "  ${GREEN}✓ ${1} TRX injectes (osmo-bts-trx)${NC}"
}

# ── TRX injection (virtphy mode) ─────────────────────────────────────────────
inject_extra_trx_virtual() {
    [ "$1" -le 1 ] && return 0
    local bts="/etc/osmocom/osmo-bts-virtual.cfg"
    [ -f "$bts" ] || return 0

    # Ajouter des instances phy supplementaires apres "instance 0"
    local tmp; tmp=$(mktemp)
    local insert_done=0
    while IFS= read -r line; do
        echo "$line" >> "$tmp"
        if [ $insert_done -eq 0 ] && echo "$line" | grep -q '^ instance 0'; then
            for t in $(seq 1 $(($1-1))); do
                echo " instance ${t}" >> "$tmp"
            done
            insert_done=1
        fi
    done < "$bts"
    cp "$tmp" "$bts"; rm -f "$tmp"

    # Ajouter les TRX supplementaires en fin de fichier
    for t in $(seq 1 $(($1-1))); do
        printf ' trx %d\n  power-ramp max-initial 23000 mdBm\n  power-ramp step-size 2000 mdB\n  power-ramp step-interval 1\n  ms-power-control osmo\n  phy 0 instance %d\n' "$t" "$t" >> "$bts"
    done
    echo -e "  ${GREEN}✓ ${1} TRX injectes (osmo-bts-virtual)${NC}"
}

# ── Handover ──────────────────────────────────────────────────────────────────
inject_handover() {
    local cfg="/etc/osmocom/osmo-bsc.cfg"; [ -f "$cfg" ] || return 0
    local tmp; tmp=$(mktemp)
    sed -e 's/^ handover 0/ handover 1/' -e 's/^ handover algorithm 1/ handover algorithm 2/' "$cfg" > "$tmp"
    awk '/^ handover algorithm 2/&&!d{print;print" handover2 window rxlev averaging 10\n handover2 window rxqual averaging 1\n handover2 window rxlev neighbor averaging 10\n handover2 power budget interval 6\n handover2 power budget hysteresis 3\n handover2 maximum distance 9999\n handover2 assignment 1\n handover2 tdma-measurement full";d=1;next}/^ handover1 /{next}{print}' "$tmp" > "${tmp}.2"
    cp "${tmp}.2" "$cfg"; rm -f "$tmp" "${tmp}.2"
    echo -e "  ${GREEN}✓ Handover algo 2${NC}"
}

# ── Sinks PulseAudio (gsm_audio + gsm_mic) ───────────────────────────────────
# Docker : /scripts/  |  natif : /etc/osmocom/  (meme script, chemin different)
# Idempotent et NON FATAL : sans PulseAudio l'audio gapk est indispo, mais on ne
# doit jamais tuer run.sh (set -e) pour ca.
# $1 = timeout d'attente de PulseAudio (defaut 30 s). Le demon n'est pas demarre
# par ce script mais par lib/audio.sh (pipeline qemu) : sur l'appel PRECOCE [2d]
# il peut ne pas etre encore la, d'ou un timeout court pour ne pas staller.
setup_pulse_sinks() {
    local PULSE_SETUP=/scripts/pulse-gsm-setup.sh
    [ -f "$PULSE_SETUP" ] || PULSE_SETUP=/etc/osmocom/pulse-gsm-setup.sh
    [ -f "$PULSE_SETUP" ] || { echo -e "  ${YELLOW}pulse-gsm-setup.sh introuvable - audio indispo${NC}"; return 0; }
    PULSE_TIMEOUT="${1:-30}" "$PULSE_SETUP" \
        || echo -e "  ${YELLOW}[warn] pulse-gsm-setup.sh echoue (audio indispo) - on continue${NC}"
}

# ── Bridge audio UNIVERSEL ───────────────────────────────────────────────────
# gapk (RTP reseau → sink gsm_audio) + sortie son. Appele en mode faketrx/virtphy
# ET en mode no-process (qemu) - c'est CE bridge qui manquait en qemu (run.sh
# sortait avant [6c] → gsm_audio muet → "pas d'audio en qemu").
#
# Sortie : si le pulse de l'HOTE est joignable en TCP (relai ouvert par start.sh,
# = WSLg→Windows en WSL, = pulse de session en Linux natif), on fait un pont
# direct parec|paplay (1 seule horloge, pas de derive → pas de son "pete").
# Sinon, fallback loopback local vers le sink par defaut du conteneur (carte).
audio_bridge() {
    pactl info >/dev/null 2>&1 || { echo -e "  ${YELLOW}PulseAudio indisponible - bridge audio non lance${NC}"; return 0; }

    # gapk : RTP reseau → sink gsm_audio
    local GAPK=/scripts/gapk-start.sh; [ -f "$GAPK" ] || GAPK=/etc/osmocom/gapk-start.sh
    if [ -f "$GAPK" ]; then
        run_in_tmux "gapk" "GAPK_ALSA_DEV=gsm_out bash '$GAPK' auto gsm gsm_out"
        echo -e "  ${GREEN}✓ gapk auto (RTP reseau → gsm_audio)${NC}"
    else
        echo -e "  ${YELLOW}gapk-start.sh absent - pas de bridge RTP${NC}"
    fi

    # Sortie vers le pulse de l'hote si le relai TCP est ouvert (HOST_AUDIO_RELAY
    # est positionne par start.sh quand le relai a ete active : tcp:<gw>:4713).
    local relay="${HOST_AUDIO_RELAY:-}"
    if [ -n "$relay" ] && pactl --server="$relay" info >/dev/null 2>&1; then
        # ── UN SEUL PONT POUR TOUT LE BANC ──────────────────────────────
        # [2026-08-31] Le pkill ci-dessous ne suffit pas des qu il y a
        # PLUSIEURS conteneurs : il s execute DANS le conteneur, qui a son
        # propre espace de PID, et ne voit donc jamais le pont du voisin.
        # Chaque operateur demarrait le sien, et comme ils partagent tous le
        # meme PulseAudio d hote, ils relisaient le MEME sink gsm_audio pour
        # le rejouer sur la MEME sortie : le son etait entendu deux fois a
        # deux operateurs, trois fois a trois. Constate le 31/08.
        #
        # On interroge donc le SERVEUR partage, pas la table des processus
        # locale : un client deja connecte sous ce nom signifie qu un pont
        # tourne quelque part, peu importe dans quel conteneur.
        local _bridge_name="osmo-gsm-bridge"
        if pactl --server="$relay" list clients 2>/dev/null \
             | grep -q "application.name = \"${_bridge_name}\""; then
            echo -e "  ${GREEN}✓ pont audio deja actif sur l hote (${relay}) - on n en ajoute pas un second${NC}"
            return 0
        fi
        # ── NI DOUBLON AVEC LE LOOPBACK DE L HOTE ───────────────────────────
        # L hote charge deja, de son cote, un module-loopback
        # gsm_audio.monitor -> carte son (lib/audio.sh, ensure_local_loopback).
        # C est le MEME travail que ce pont : meme source, meme sortie. Les
        # deux ensemble, on entend tout DEUX FOIS - et le symptome survit a
        # l arret d un conteneur, ce qui fait chercher le doublon du mauvais
        # cote. Constate le 31/08 : sink-input #0 "Loopback from Monitor of
        # GSM_Audio" et #399 "paplay" sur la meme sortie.
        # On garde le loopback : il est cote hote et bien plus court
        # (20 ms contre 250 ms pour le pont TCP).
        if pactl --server="$relay" list short modules 2>/dev/null \
             | grep -q 'source=gsm_audio.monitor'; then
            echo -e "  ${GREEN}✓ loopback gsm_audio deja en place sur l hote - pont TCP inutile${NC}"
            return 0
        fi
        pkill -f "paplay --server=${relay}" 2>/dev/null || true
        [ -f /run/host-audio.pid ] && kill -- "-$(cat /run/host-audio.pid)" 2>/dev/null || true
        setsid sh -c '
          while true; do
            parec -d gsm_audio.monitor --latency-msec=30 --format=s16le --rate=8000 --channels=1 \
              | paplay --server='"${relay}"' --client-name='"${_bridge_name}"' --latency-msec=250 --raw --format=s16le --rate=8000 --channels=1
            sleep 1
          done' >/var/log/osmocom/host-audio.log 2>&1 &
        echo $! > /run/host-audio.pid
        echo -e "  ${GREEN}✓ pont audio parec|paplay → hote (${relay})${NC}"
    else
        # Fallback natif sans relai : loopback vers le sink par defaut (carte).
        if ! pactl list short modules 2>/dev/null | grep -q 'source=gsm_audio.monitor'; then
            pactl load-module module-loopback source=gsm_audio.monitor latency_msec=20 >/dev/null 2>&1 \
                && echo -e "  ${GREEN}✓ loopback gsm_audio → sink par defaut (carte)${NC}" \
                || echo -e "  ${YELLOW}loopback non charge (pas de sortie son ?)${NC}"
        else
            echo -e "  ${GREEN}✓ loopback gsm_audio deja actif${NC}"
        fi
    fi
}

# ══════════════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════════════

# RUN_NO_PROCESS=1 : on prepare tmux + configs + core Osmocom (osmo-start :
# STP/HLR/MGW/MSC/BSC... → le HLR est up et peut etre alimente par start.sh),
# mais on NE lance PAS les process radio/mobile/asterisk/smsc dans le tmux.
[ "$RUN_NO_PROCESS" = "1" ] && echo -e "${YELLOW}=== RUN_NO_PROCESS=1 : core seul, PHY/mobile/asterisk/smsc NON lances ===${NC}"

echo -e "${GREEN}=== [1/10] tmux ===${NC}"
init_tmux
echo ""

echo -e "${GREEN}=== [2/10] Config MS ===${NC}"
generate_ms_configs
echo ""

echo -e "${GREEN}=== [2b] TRX ===${NC}"
if [ "$PHY_MODE" = "virtphy" ]; then
    inject_extra_trx_virtual "$N_TRX"
else
    inject_extra_trx "$N_TRX"
fi

echo -e "${GREEN}=== [2c] Handover ===${NC}"
inject_handover
echo ""

# ── [2d] Audio PulseAudio - AVANT le coeur, volontairement ────────────────────
# [2026-08-12] Ce bloc n'existait qu'en [6b], donc APRES osmo-start.sh. Or
# osmo-start.sh fait `exit 1` si le HLR ne monte pas, et avec `set -euo pipefail`
# (ligne 7) run.sh meurt la : pulse-gsm-setup.sh - le seul endroit qui cree le
# null-sink gsm_mic - n'est alors JAMAIS execute. La capture gsm_in
# (= gsm_mic.monitor) ne s'ouvre plus, gapk_io abandonne les DEUX sens, et tout
# appel est muet, echo-test 600 compris. C'est exactement le "ca marche sur un
# PC et pas sur l'autre" : ca depend de si le HLR est monte du premier coup.
# L'audio n'a AUCUNE dependance sur le coeur - on le monte donc en premier.
# L'appel en [6b] reste en place (idempotent) pour les relances partielles.
echo -e "${GREEN}=== [2d] Audio PulseAudio (sinks) ===${NC}"
setup_pulse_sinks 3
echo ""

echo -e "${GREEN}=== [3/10] Core Osmocom ===${NC}"
/etc/osmocom/osmo-start.sh

# Mode no-process : le core tourne (HLR alimentable par start.sh), on
# s'arrete ici sans lancer PHY/mobile/asterisk/smsc dans le tmux. On
# configure tout de meme PulseAudio (sink gsm_audio) car le pipeline lance
# ensuite (ex. ${OQC_ROOT}/run.sh) en a besoin pour l'audio gapk.
if [ "$RUN_NO_PROCESS" = "1" ]; then
    echo ""
    echo -e "${GREEN}=== Audio PulseAudio (no-process) ===${NC}"
    setup_pulse_sinks
    # Bridge audio AUSSI en no-process : sans ca, gapk n'est jamais lance et le
    # pipeline qemu n'a aucun audio (bug "pas de son en qemu").
    echo -e "${GREEN}=== Bridge audio (no-process / qemu) ===${NC}"
    audio_bridge
    echo ""
    echo -e "${GREEN}Core Osmocom pret (no-process). PHY/mobile/asterisk/smsc NON lances.${NC}"
    echo -e "  tmux : ${CYAN}tmux -S ${TMUX_SOCKET} attach -t ${SESSION}${NC}"
    exit 0
fi

# ══════════════════════════════════════════════════════════════════════════════
# PHY : fake_trx (TRXD) OU virtphy (multicast)
# ══════════════════════════════════════════════════════════════════════════════

if [ "$PHY_MODE" = "virtphy" ]; then
    # ─────────────────────────────────────────────────────────────────────────
    # MODE VIRTPHY : osmo-bts-virtual → virtphy → mobile
    #
    #   mobile ↔ virtphy ↔ (multicast UDP) ↔ osmo-bts-virtual ↔ BSC
    #
    # Pas de fake_trx, pas de trxcon.
    # ─────────────────────────────────────────────────────────────────────────

    echo -e "${GREEN}=== [4/10] BTS Virtual ===${NC}"
    # Arreter osmo-bts-trx s'il a ete demarre par systemd
    systemctl stop osmo-bts-trx 2>/dev/null || true
    systemctl disable osmo-bts-trx 2>/dev/null || true

    run_in_tmux "bts" "osmo-bts-virtual -c /etc/osmocom/osmo-bts-virtual.cfg"
    wait_port 127.0.0.1 4241 "BTS-virtual" 30 || true

    echo -e "${GREEN}=== [5/10] virtphy (${N_MS} instances) ===${NC}"
    for ms in $(seq 1 "$N_MS"); do
        local_l2=$(l2_sock "$ms")
        cmd="virtphy -s ${local_l2}"
        pw=$(( (ms-1)/8 )); pl=$(( (ms-1)%8 ))
        win="virtphy${pw}"
        if [ "$pl" -eq 0 ]; then
            run_in_tmux "$win" "$cmd"
        elif [ "$TMUX_OK" = true ]; then
            tmux -S "$TMUX_SOCKET" split-window -v -t "${SESSION}:${win}" 2>/dev/null || true
            tmux -S "$TMUX_SOCKET" select-layout -t "${SESSION}:${win}" even-vertical 2>/dev/null || true
            tmux -S "$TMUX_SOCKET" send-keys -t "${SESSION}:${win}.${pl}" "$cmd" C-m 2>/dev/null || true
        else
            bash -c "$cmd" >"/var/log/osmocom/virtphy_ms${ms}.log" 2>&1 &
        fi
        [ $((ms % 8)) -eq 1 ] || [ "$ms" -eq "$N_MS" ] && echo -e "  ${CYAN}[ue${ms}]${NC} l2=${local_l2}"
    done
    # Laisser virtphy creer les sockets avant de lancer mobile
    sleep 2

    # Pas de step 6 (trxcon) en mode virtphy

else
    # ─────────────────────────────────────────────────────────────────────────
    # MODE FAKETRX : fake_trx → trxcon → mobile
    #
    #   mobile ↔ trxcon ↔ (TRXD UDP) ↔ fake_trx ↔ osmo-bts-trx ↔ BSC
    # ─────────────────────────────────────────────────────────────────────────

    echo -e "${GREEN}=== [3b/10] osmo-bts-trx ===${NC}"
    # BTS lance AVANT fake_trx : il poll le transceiver (POWERON) et se connecte
    # des que fake_trx repond. On stoppe une eventuelle instance systemd pour
    # eviter un double-BTS.
    systemctl stop osmo-bts-trx 2>/dev/null || true
    run_in_tmux "bts" "osmo-bts-trx -c /etc/osmocom/osmo-bts-trx.cfg"

    echo -e "${GREEN}=== [4/10] FakeTRX ===${NC}"
    FAKETRX_CMD="python3 ${FAKETRX_PY} -b 127.0.0.1 -R 127.0.0.1 -r 127.0.0.1 -P ${BTS_PORT_BASE} -p ${BB_PORT_BASE}"
    for t in $(seq 1 $((N_TRX-1))); do
        FAKETRX_CMD+=" --trx bts${t}@127.0.0.1:${BTS_PORT_BASE}/${t}"
    done
    for ms in $(seq 2 "$N_MS"); do
        local_g=$(( (ms-1)/8 + 1 ))
        FAKETRX_CMD+=" --trx ue${ms}@127.0.0.${local_g}:$(bb_port $ms)"
    done
    run_in_tmux "faketrx" "$FAKETRX_CMD"
    wait_udp "$BTS_PORT_BASE" "fake_trx" 30 || true

    echo -e "${GREEN}=== [6/10] trxcon ===${NC}"
    for ms in $(seq 1 "$N_MS"); do
        local_l2=$(l2_sock "$ms"); local_port=$(bb_port "$ms")
        pw=$(( (ms-1)/8 )); pl=$(( (ms-1)%8 )); ug=$((pw+1)); gip="127.0.0.${ug}"
        cmd="trxcon ue${ms} -C 1 -i 127.0.0.1 -b ${gip} -p ${local_port} -s ${local_l2} -F 100"
        win="trxcon${pw}"
        if [ "$pl" -eq 0 ]; then
            run_in_tmux "$win" "$cmd"
        elif [ "$TMUX_OK" = true ]; then
            tmux -S "$TMUX_SOCKET" split-window -v -t "${SESSION}:${win}" 2>/dev/null || true
            tmux -S "$TMUX_SOCKET" select-layout -t "${SESSION}:${win}" even-vertical 2>/dev/null || true
            tmux -S "$TMUX_SOCKET" send-keys -t "${SESSION}:${win}.${pl}" "$cmd" C-m 2>/dev/null || true
        else
            bash -c "$cmd" >"/var/log/osmocom/trxcon_ue${ms}.log" 2>&1 &
        fi
        [ $((ms % 8)) -eq 1 ] || [ "$ms" -eq "$N_MS" ] && echo -e "  ${CYAN}[ue${ms}]${NC} ${gip}:${local_port}"
    done
fi

echo -e "${GREEN}=== [6b/10] Audio PulseAudio ===${NC}"
setup_pulse_sinks   # deja fait en [2d] - idempotent, filet pour relance partielle
echo ""

# ── [6c] Bridge audio : gapk (RTP → gsm_audio) + sortie vers l'hote ───────────
echo -e "${GREEN}=== [6c/10] Bridge audio (gapk + sortie hote) ===${NC}"
audio_bridge
echo ""

echo -e "${GREEN}=== [7/10] Mobile ===${NC}"
if [ "$MOBILE_MODE" = "split" ]; then
    for ms in $(seq 1 "$N_MS"); do
        run_in_tmux "ue${ms}" "mobile -c /root/.osmocom/bb/mobile_ms${ms}.cfg"
    done
else
    for g in $(seq 1 "$N_GROUPS"); do
        if [ "$g" -gt 1 ]; then
            prev_ip="127.0.0.$((g-1))"
            echo -ne "  ${CYAN}Attente Grp$((g-1)) (${prev_ip}:4247)${NC}"
            r=0
            while ! bash -c "echo >/dev/tcp/${prev_ip}/4247" 2>/dev/null; do
                sleep 2; echo -n "."; r=$((r+1)); [ $r -ge 30 ] && break
            done
            echo -e " ${GREEN}OK${NC}"; sleep 3
        fi
        run_in_tmux "ue_g${g}" "mobile -c /root/.osmocom/bb/mobile_group${g}.cfg"
        echo -e "  ${GREEN}Groupe ${g} demarre${NC}"
    done
fi

echo -e "${GREEN}=== [9/10] Asterisk ===${NC}"
run_in_tmux "asterisk" "pkill asterisk 2>/dev/null; sleep 2; pkill -9 asterisk 2>/dev/null; sleep 1; rm -f /var/lib/asterisk/astdb.sqlite3; asterisk -cvvv"

echo -e "${GREEN}=== [10/10] SMSC ===${NC}"
if [ -f /etc/osmocom/smsc-start.sh ]; then
    run_in_tmux "smsc" "/etc/osmocom/smsc-start.sh"
else
    run_in_tmux "smsc" "echo 'SMSC non disponible'"
fi

# ── Nettoyage : supprimer la fenetre main inutile ─────────────────────────────
if [ "$TMUX_OK" = true ]; then
    sleep 1
    tmux -S "$TMUX_SOCKET" kill-window -t "${SESSION}:main" 2>/dev/null || true
fi

# ── Resume ────────────────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║  Op${OPERATOR_ID} - ${N_MS} MS - ${MOBILE_MODE} - ${PHY_MODE} - ${N_TRX} TRX     ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
echo ""
if [ "$PHY_MODE" = "virtphy" ]; then
    echo -e "  ${CYAN}PHY       :${NC} virtphy (osmo-bts-virtual, multicast)"
    echo -e "  ${CYAN}tmux      :${NC} bts  virtphy*  ue*  asterisk  smsc"
else
    echo -e "  ${CYAN}PHY       :${NC} faketrx (osmo-bts-trx, TRXD)"
    echo -e "  ${CYAN}BB ports  :${NC} $(bb_port 1) .. $(bb_port $N_MS)"
    echo -e "  ${CYAN}tmux      :${NC} faketrx  trxcon*  ue*  asterisk  smsc"
fi
if [ "$TMUX_OK" = true ]; then
    echo -e "  ${CYAN}Attach    :${NC} tmux -S ${TMUX_SOCKET} attach -t ${SESSION}"
    tmux -S "$TMUX_SOCKET" select-window -t "${SESSION}:ue_g1" 2>/dev/null \
        || tmux -S "$TMUX_SOCKET" select-window -t "${SESSION}:ue1" 2>/dev/null || true
fi
echo ""
echo -e "${GREEN}run.sh termine${NC}"

scripts/send-mt-sms.sh

.sh 24 lignes - 707B

scripts/send-mt-sms.sh
#!/bin/bash
# send-mt-sms.sh - Envoi de SMS MT local via proto-smsc-sendmt
# Usage : ./send-mt-sms.sh <imsi> <message> [from_number]
set -euo pipefail

OPERATOR_ID="${OPERATOR_ID:-1}"
SC_ADDRESS="1999001${OPERATOR_ID}444"
SENDMT_SOCKET="/tmp/sendmt_socket"

if [ $# -lt 2 ]; then
    echo "Usage: $0 <imsi> <message> [from_number]"
    echo "  Ex: $0 001010000000001 'Bonjour!'"
    exit 1
fi

DEST_IMSI="$1"; MESSAGE="$2"; FROM="${3:-${SC_ADDRESS}}"

[ ! -S "$SENDMT_SOCKET" ] && { echo "ERREUR: $SENDMT_SOCKET absent"; exit 1; }

sms-encode-text "$MESSAGE" \
    | gen-sms-deliver-pdu "$FROM" \
    | proto-smsc-sendmt "$SC_ADDRESS" "$DEST_IMSI" "$SENDMT_SOCKET"

echo "MT SMS envoye → IMSI=$DEST_IMSI"

scripts/smsc-start.sh

.sh 144 lignes - 5,6KB

scripts/smsc-start.sh
#!/bin/bash
# smsc-start.sh - Lance le proto-smsc-daemon + le relay interop
# Fix: attente HLR GSUP robuste + verification socket sendmt avec retry

set -euo pipefail

OPERATOR_ID="${OPERATOR_ID:-1}"
HLR_IP="${HLR_IP:-127.0.0.2}"
SMSC_IPA_NAME="SMSC-OP${OPERATOR_ID}"
MO_LOG="/var/log/osmocom/mo-sms-op${OPERATOR_ID}.log"
SENDMT_SOCKET="/tmp/sendmt_socket"
ROUTING_CONF="/etc/osmocom/sms-routing.conf"
RELAY_PORT="${RELAY_PORT:-7890}"

GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[0;33m'; RED='\033[0;31m'; NC='\033[0m'

echo -e "${GREEN}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║  SMS Gateway - Operateur ${OPERATOR_ID}                       ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════╝${NC}"
echo -e "  HLR        : ${CYAN}${HLR_IP}${NC}"
echo -e "  IPA name   : ${CYAN}${SMSC_IPA_NAME}${NC}"
echo -e "  MO log     : ${CYAN}${MO_LOG}${NC}"
echo -e "  MT socket  : ${CYAN}${SENDMT_SOCKET}${NC}"
echo -e "  Relay port : ${CYAN}${RELAY_PORT}${NC}"
echo -e "  Routing    : ${CYAN}${ROUTING_CONF}${NC}"

rm -f "$SENDMT_SOCKET"
mkdir -p /var/log/osmocom

# ── Attente HLR GSUP (port 4222) - robuste ──────────────────────────────────
echo -ne "${YELLOW}[SMSC] Attente OsmoHLR GSUP (4222)"
retries=60
while [ $retries -gt 0 ]; do
    if bash -c "echo >/dev/tcp/${HLR_IP}/4222" 2>/dev/null; then
        echo -e " ${GREEN}✓${NC}"
        break
    fi
    # Fallback: verifier aussi via ss
    if ss -tln 2>/dev/null | grep -q ":4222 "; then
        echo -e " ${GREEN}✓${NC}"
        break
    fi
    echo -n "."
    sleep 1
    retries=$(( retries - 1 ))
done
if [ $retries -eq 0 ]; then
    echo -e " ${RED}TIMEOUT - HLR non disponible${NC}"
    echo -e "${YELLOW}[SMSC] Tentative de demarrage quand meme...${NC}"
fi

sleep 3

# ── 1. proto-smsc-daemon (background) ────────────────────────────────────────
echo -e "${GREEN}[1/2] proto-smsc-daemon${NC}"

# Verifier que le binaire existe
if ! command -v proto-smsc-daemon >/dev/null 2>&1; then
    echo -e "  ${RED}proto-smsc-daemon introuvable dans PATH${NC}"
    echo -e "  ${YELLOW}SMS local uniquement via MSC VTY${NC}"
    # Lancer quand meme le relay pour les SMS entrants via TCP
else
    proto-smsc-daemon "$HLR_IP" "$SMSC_IPA_NAME" "$MO_LOG" "$SENDMT_SOCKET" &
    DAEMON_PID=$!
    echo -e "  PID: ${CYAN}${DAEMON_PID}${NC}"

    # Attente du socket sendmt avec timeout
    echo -ne "  Socket sendmt"
    for i in $(seq 1 30); do
        if [ -S "$SENDMT_SOCKET" ]; then
            echo -e " ${GREEN}✓${NC}"
            break
        fi
        echo -n "."
        sleep 1
    done
    if [ ! -S "$SENDMT_SOCKET" ]; then
        echo -e " ${YELLOW}non cree (les MT SMS ne fonctionneront pas)${NC}"
    fi
fi

# ── 2. Relay interop (background) ────────────────────────────────────────────
echo -e "${GREEN}[2/2] sms-interop-relay${NC}"

# Verifier que le script existe
RELAY_SCRIPT="/etc/osmocom/sms-interop-relay.py"
if [ ! -f "$RELAY_SCRIPT" ]; then
    echo -e "  ${RED}${RELAY_SCRIPT} introuvable${NC}"
    echo -e "  ${YELLOW}SMS inter-operateur desactive${NC}"
else
    # --node-id : la cle de routage de sms-routing.conf est le NOEUD, pas
    # l'operateur (MSISDN = <noeud>00<operateur><rang>). Sans elle, le relais
    # tient pour locaux les numeros des autres noeuds et n'emet jamais.
    python3 "$RELAY_SCRIPT" \
        --config "$ROUTING_CONF" \
        --port "$RELAY_PORT" \
        --mo-log "$MO_LOG" \
        --operator-id "$OPERATOR_ID" \
        --node-id "${OSMO_WAN_NODE:-${WAN_NODE_ID:-1}}" &
    RELAY_PID=$!
    echo -e "  PID: ${CYAN}${RELAY_PID}${NC}"

    # Verifier que le port TCP est en ecoute
    echo -ne "  Relay TCP ${RELAY_PORT}"
    for i in $(seq 1 15); do
        if ss -tlnp 2>/dev/null | grep -q ":${RELAY_PORT} "; then
            echo -e " ${GREEN}✓${NC}"
            break
        fi
        echo -n "."
        sleep 1
    done
fi

echo ""
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo -e "${GREEN}  SMS Gateway actif - Op${OPERATOR_ID}${NC}"
echo -e "${GREEN}  MT local : send-mt-sms.sh <imsi> 'msg'${NC}"
echo -e "${GREEN}  MO log   : tail -f ${MO_LOG}${NC}"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"

cleanup() {
    echo -e "\n${YELLOW}[SMSC] Arret...${NC}"
    [ -n "${DAEMON_PID:-}" ] && kill $DAEMON_PID 2>/dev/null || true
    [ -n "${RELAY_PID:-}" ] && kill $RELAY_PID 2>/dev/null || true
    rm -f "$SENDMT_SOCKET"
    exit 0
}
trap cleanup SIGINT SIGTERM

# Attendre fin d'un processus
if [ -n "${DAEMON_PID:-}" ] && [ -n "${RELAY_PID:-}" ]; then
    wait -n $DAEMON_PID $RELAY_PID 2>/dev/null || true
elif [ -n "${DAEMON_PID:-}" ]; then
    wait $DAEMON_PID 2>/dev/null || true
elif [ -n "${RELAY_PID:-}" ]; then
    wait $RELAY_PID 2>/dev/null || true
else
    # Aucun processus a attendre - juste dormir
    while true; do sleep 60; done
fi

echo -e "${YELLOW}[SMSC] Processus termine, arret...${NC}"
cleanup

scripts/sms-routing-setup.sh

.sh 581 lignes - 27KB

scripts/sms-routing-setup.sh
#!/bin/bash
# sms-routing-setup.sh - Gestion complete du routage SMS inter-operateur
#
# Fonctions exportables appelees depuis start.sh :
#   sms_routing_generate  <op_id> <n_ops> <destdir> [op1_nms op2_nms ...]
#   sms_routing_validate  <conf_file>
#   sms_routing_summary   <n_ops>         [op1_nms op2_nms ...]
#
# Formules COMMUNES (identiques a run.sh, hlr-feed-subscribers.sh) :
#   MSISDN  = <noeud>00<op_id><ms_idx>       (100101, 100102, 100201...)
#   IMSI    = MCC(3) + MNC(2) + printf('%04d%06d', op_id, ms_idx)
#   KI      = 00 11 22 33 44 55 66 77 88 99 aa bb cc dd <ms_hex> <op_hex>
#
# Architecture de routage :
#   ┌─────────────────────────────────────────────────────────────────────┐
#   │  MS (op1, ms1)  MSISDN=10001  envoie a  MSISDN=20002 (op2, ms2)   │
#   │    → proto-smsc-daemon (op1) → MO log                              │
#   │    → sms-interop-relay.py (op1) : lookup prefix 20002 → op2       │
#   │    → TCP 172.20.0.12:7890                                           │
#   │    → sms-interop-relay.py (op2) : MSISDN→IMSI via HLR VTY         │
#   │    → proto-smsc-sendmt (op2) → HLR (op2) → MS (op2, ms2)          │
#   └─────────────────────────────────────────────────────────────────────┘
#
# Usage autonome (test/debug) :
#   bash sms-routing-setup.sh generate 1 3 /tmp/sms-cfg 2 2 3
#   bash sms-routing-setup.sh validate /tmp/sms-cfg/sms-routing-op1.conf
#   bash sms-routing-setup.sh summary  3 2 2 3

set -euo pipefail

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'

# ═══════════════════════════════════════════════════════════════════════════════
# Helpers internes
# ═══════════════════════════════════════════════════════════════════════════════

_sms_op_backbone_ip() { echo "172.20.0.$((10 + $1))"; }
# MSISDN = <noeud>00<operateur><rang sur 2 chiffres> : le premier chiffre est
# l'adresse du noeud, c'est lui qui route. Le noeud vient du plan radio ecrit
# par generate_configs.sh, de l'environnement, ou vaut 1.
_sms_node_id() {
    local n="${OSMO_WAN_NODE:-${WAN_NODE_ID:-}}"
    [ -n "$n" ] || n="$(sed -n 's/^PLAN_NODE=//p' /etc/osmocom/radio-plan.env 2>/dev/null | tail -1)"
    case "$n" in [1-9]) ;; *) n=1 ;; esac
    printf '%s' "$n"
}
_sms_op_msisdn()      { echo $(( $(_sms_node_id) * 100000 + $1 * 100 + $2 )); }   # op_id ms_idx
_sms_op_ms_imsi()     {                                   # mcc mnc op_id ms_idx
    local mcc=$1 mnc=$2 op=$3 ms=$4
    printf '%s%s%04d%06d' "$mcc" "$mnc" "$op" "$ms"
}
_sms_sc_address()     { printf '1999001%s444' "$1"; }    # op_id

_log_ok()   { echo -e "  ${GREEN}✓${NC}  $*"; }
_log_warn() { echo -e "  ${YELLOW}⚠${NC}  $*"; }
_log_err()  { echo -e "  ${RED}✗${NC}  $*" >&2; }

# ═══════════════════════════════════════════════════════════════════════════════
# sms_routing_generate
#
# Genere configs/sms-routing-op<N>.conf pour chaque operateur.
# Le fichier de chaque operateur contient TOUTES les routes (locales + distantes)
# et est monte dans /etc/osmocom/sms-routing.conf du container.
#
# Usage :
#   sms_routing_generate <op_id> <n_ops> <destdir> [ms_counts...]
#   ms_counts : nombre de MS par operateur (1 valeur par operateur)
#               ex: "2 3 1" → op1=2MS, op2=3MS, op3=1MS
#
# Sortie :
#   <destdir>/sms-routing-op<op_id>.conf
#
# ═══════════════════════════════════════════════════════════════════════════════
sms_routing_generate() {
    local op_id="$1"
    local n_ops="$2"
    local destdir="$3"
    shift 3
    local ms_counts=("$@")   # tableau : ms_counts[0]=nMS_op1, [1]=nMS_op2, ...

    mkdir -p "$destdir"
    local outfile="${destdir}/sms-routing-op${op_id}.conf"

    # ── LES NUMEROS D OPERATEUR NE COMMENCENT PAS FORCEMENT A 1 ─────────────
    # [2026-08-31] Tout ce fichier enumerait 1..n_ops. Sur le banc
    # multi-operateur, les CONTENEURS portent les rangs 2 et 3 - le 1 est au
    # natif (OP_ID_BASE=2). La table sortait donc "Op1 172.20.0.11 / Op2
    # 172.20.0.12" pour ce qui est en realite op2 et op3 : de mauvais MSISDN
    # (100101 au lieu de 100201), de mauvaises IMSI, et un relais annonce a une
    # adresse qui n est celle de personne (172.20.0.11 est le natif, qui n est
    # pas un conteneur).
    #
    # ⚠️ ms_counts reste une liste POSITIONNELLE 0-based : son premier element
    # decrit le premier operateur enumere, quel que soit son rang. On decale
    # donc la NUMEROTATION, pas l indexation - d ou le (i - _b).
    local _b="${OP_ID_BASE:-1}"
    local _last=$(( _b + n_ops - 1 ))
    local -a nms
    for i in $(seq "$_b" "$_last"); do
        nms[$i]=${ms_counts[$((i - _b))]:-1}
    done

    local mcc="${MCC:-001}"

    cat > "$outfile" << HEADER
# ═══════════════════════════════════════════════════════════════════════════════
# sms-routing-op${op_id}.conf
# Genere par sms-routing-setup.sh - $(date '+%Y-%m-%d %H:%M:%S')
#
# Operateur local  : ${op_id}
# Nombre d'operat. : ${n_ops}
# MS par operateur : $(for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_ops - 1 ))"); do printf 'Op%s=%s ' "$i" "${nms[$i]}"; done)
#
# Routage MSISDN :
#   MSISDN = <noeud> × 100000 + op_id × 100 + ms_idx
#   Regle longest-prefix match (prefixe le plus long l'emporte)
#
# ═══════════════════════════════════════════════════════════════════════════════

[local]
operator_id = ${op_id}
sc_address  = $(_sms_sc_address "$op_id")
hlr_vty_ip  = 127.0.0.1
hlr_vty_port = 4258
sendmt_socket = /tmp/sendmt_socket
mo_log = /var/log/osmocom/mo-sms-op${op_id}.log

[operators]
# operator_id = container_ip  (reseau backbone 172.20.0.0/24)
HEADER

    for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_ops - 1 ))"); do
        printf '%s = %s\n' "$i" "$(_sms_op_backbone_ip "$i")" >> "$outfile"
    done

    cat >> "$outfile" << ROUTES_HEADER

[routes]
# Format : prefix = operator_id
# Longest-prefix match : le prefixe le plus long l'emporte.
# Strategie :
#   1. MSISDN exact de chaque MS          → routage precis (priorite max)
#   2. Prefixe court par operateur        → fallback pour MSISDN inconnus
#   3. Prefixes E.164 (+336XX...)           → routage international
#
ROUTES_HEADER

    # ── Routes exactes par MS (priorite maximale) ─────────────────────────────
    for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_ops - 1 ))"); do
        local mnc; mnc=$(printf '%02d' "$i")
        printf '\n# ── Operateur %s (SC=%s) ──────────────────────────────────────\n' \
               "$i" "$(_sms_sc_address "$i")" >> "$outfile"
        printf '# %s MS declares\n' "${nms[$i]}" >> "$outfile"

        for ms in $(seq 1 "${nms[$i]}"); do
            local msisdn; msisdn=$(_sms_op_msisdn "$i" "$ms")
            local imsi; imsi=$(_sms_op_ms_imsi "$mcc" "$mnc" "$i" "$ms")
            # Commentaire IMSI pour tracabilite
            printf '%-8s = %s   # IMSI=%s\n' "$msisdn" "$i" "$imsi" >> "$outfile"
        done

        # Prefixe court de l'operateur (fallback MSISDN hors liste)
        local op_prefix="${i}0000"
        printf '# Fallback operateur %s (MSISDN inconnu)\n' "$i" >> "$outfile"
        printf '%-8s = %s\n' "$op_prefix" "$i" >> "$outfile"

        # Prefixe E.164 fictif : +336<op>X... (format francais simule)
        local e164_prefix; e164_prefix=$(printf '336%02d' "$i")
        printf '%-8s = %s   # E.164 +33 6%02d...\n' "$e164_prefix" "$i" "$i" >> "$outfile"
    done

    cat >> "$outfile" << ROUTES_FOOTER

[relay]
# Port TCP sur lequel ce relay ecoute les MT entrants d'autres operateurs.
port = 7890
# Timeout connexion vers un relay distant (secondes)
connect_timeout = 10
# Tentatives de reemission si le relay distant est indisponible
retry_count = 3
retry_delay = 5
ROUTES_FOOTER

    _log_ok "sms-routing-op${op_id}.conf → ${outfile}"
}


# ═══════════════════════════════════════════════════════════════════════════════
# sms_routing_generate_all
#
# Genere les configs pour TOUS les operateurs en une seule passe.
# Appele depuis start.sh en mode bridge.
#
# Usage :
#   sms_routing_generate_all <n_ops> <destdir> [ms_counts...]
#
# ═══════════════════════════════════════════════════════════════════════════════
sms_routing_generate_all() {
    local n_ops="$1"
    local destdir="$2"
    shift 2
    local ms_counts=("$@")

    echo -e "${CYAN}${BOLD}── SMS Routing - generation configs (${n_ops} operateurs) ──${NC}"

    for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_ops - 1 ))"); do
        sms_routing_generate "$i" "$n_ops" "$destdir" "${ms_counts[@]}"
    done

    echo -e "  ${GREEN}✓ Configs generees dans ${destdir}${NC}"
}


# ═══════════════════════════════════════════════════════════════════════════════
# sms_routing_validate
#
# Verifie la coherence d'un fichier sms-routing.conf :
#   • Section [local] presente
#   • Section [operators] non vide
#   • Section [routes] non vide
#   • Aucune collision de prefixe exact entre deux operateurs differents
#   • MSISDN locaux routes vers l'operateur local
#
# Retourne 0 si OK, 1 si erreurs.
#
# Usage :
#   sms_routing_validate <conf_file>
#
# ═══════════════════════════════════════════════════════════════════════════════
sms_routing_validate() {
    local conf="$1"
    local errors=0

    echo -e "${CYAN}Validation : ${conf}${NC}"

    if [ ! -f "$conf" ]; then
        _log_err "Fichier introuvable : $conf"
        return 1
    fi

    # ── Sections obligatoires ─────────────────────────────────────────────────
    for section in local operators routes; do
        if ! grep -q "^\[${section}\]" "$conf"; then
            _log_err "Section manquante : [${section}]"
            errors=$(( errors + 1 ))
        fi
    done

    # ── operator_id dans [local] ──────────────────────────────────────────────
    local local_op
    local_op=$(awk '/^\[local\]/,/^\[/' "$conf" \
               | grep -E '^\s*operator_id\s*=' \
               | head -1 | cut -d= -f2 | tr -d ' ')
    if [ -z "$local_op" ]; then
        _log_err "[local] : operator_id manquant"
        errors=$(( errors + 1 ))
    else
        _log_ok "[local] operator_id = ${local_op}"
    fi

    # ── Au moins 1 operateur declare ─────────────────────────────────────────
    local op_count
    op_count=$(awk '/^\[operators\]/,/^\[/' "$conf" \
               | grep -cE '^\s*[0-9]+\s*=' || echo 0)
    if [ "$op_count" -eq 0 ]; then
        _log_err "[operators] : aucun operateur declare"
        errors=$(( errors + 1 ))
    else
        _log_ok "[operators] : ${op_count} operateur(s)"
    fi

    # ── Routes non vides ──────────────────────────────────────────────────────
    local route_count
    route_count=$(awk '/^\[routes\]/,/^\[/' "$conf" \
                  | grep -cE '^\s*[0-9]+\s*=' || echo 0)
    if [ "$route_count" -eq 0 ]; then
        _log_err "[routes] : aucune route definie"
        errors=$(( errors + 1 ))
    else
        _log_ok "[routes] : ${route_count} entree(s)"
    fi

    # ── Collision de prefixes : meme MSISDN → deux operateurs differents ─────
    local collision_count=0
    while IFS= read -r line; do
        [[ "$line" =~ ^[[:space:]]*# ]] && continue   # commentaire
        [[ "$line" =~ ^[[:space:]]*$ ]] && continue   # vide
        [[ "$line" =~ ^\[ ]]           && continue   # section

        local prefix op
        prefix=$(echo "$line" | cut -d= -f1 | tr -d ' ')
        op=$(echo "$line" | cut -d= -f2 | cut -d'#' -f1 | tr -d ' ')

        # Chercher si ce meme prefixe apparait avec un operateur different
        local other_op
        other_op=$(awk -v p="$prefix" -v o="$op" '
            /^\[routes\]/,/^\[/ {
                if ($0 ~ "^[[:space:]]*" p "[[:space:]]*=") {
                    split($0, a, "=")
                    gsub(/[[:space:]]/, "", a[2])
                    sub(/#.*/, "", a[2])
                    if (a[2] != o) { print a[2]; exit }
                }
            }' "$conf")

        if [ -n "$other_op" ]; then
            _log_err "Collision : prefixe ${prefix} → op${op} ET op${other_op}"
            collision_count=$(( collision_count + 1 ))
            errors=$(( errors + 1 ))
        fi
    done < <(awk '/^\[routes\]/,/^\[/' "$conf")

    [ "$collision_count" -eq 0 ] && _log_ok "Pas de collision de prefixes"

    # ── Routes MSISDN locaux → operateur local ────────────────────────────────
    if [ -n "$local_op" ]; then
        local wrong_local=0
        while IFS= read -r line; do
            [[ "$line" =~ ^[[:space:]]*# ]] && continue
            [[ "$line" =~ ^[[:space:]]*$ ]] && continue
            [[ "$line" =~ ^\[ ]]           && continue

            local prefix op
            prefix=$(echo "$line" | cut -d= -f1 | tr -d ' ')
            op=$(echo "$line" | cut -d= -f2 | cut -d'#' -f1 | tr -d ' ')

            # Un MSISDN commencant par op_id devrait router vers op_id
            if [[ "$prefix" =~ ^${local_op}[0-9]* ]] && [ "$op" != "$local_op" ]; then
                _log_warn "MSISDN local ${prefix} route vers op${op} ≠ op${local_op}"
                wrong_local=$(( wrong_local + 1 ))
            fi
        done < <(awk '/^\[routes\]/,/^\[/' "$conf")
        [ "$wrong_local" -eq 0 ] && _log_ok "MSISDN locaux correctement routes"
    fi

    # ── Resultat ──────────────────────────────────────────────────────────────
    echo ""
    if [ "$errors" -eq 0 ]; then
        echo -e "  ${GREEN}${BOLD}✓ Validation OK${NC}"
        return 0
    else
        echo -e "  ${RED}${BOLD}✗ ${errors} erreur(s)${NC}"
        return 1
    fi
}


# ═══════════════════════════════════════════════════════════════════════════════
# sms_routing_summary
#
# Affiche la table de routage complete en format lisible (debug / audit).
# Montre tous les MSISDN enregistres et leur operateur cible.
#
# Usage :
#   sms_routing_summary <n_ops> [ms_counts...]
#
# ═══════════════════════════════════════════════════════════════════════════════
sms_routing_summary() {
    local n_ops="$1"
    shift
    local ms_counts=("$@")

    local mcc="${MCC:-001}"

    echo -e "${CYAN}${BOLD}"
    echo "╔══════════════════════════════════════════════════════════════════╗"
    echo "║                  SMS Routing - Table complete                   ║"
    printf "║  %d operateur(s)                                                 ║\n" "$n_ops"
    echo "╚══════════════════════════════════════════════════════════════════╝"
    echo -e "${NC}"

    # En-tete tableau
    printf "${BOLD}%-12s %-8s %-20s %-18s %-15s${NC}\n" \
           "MSISDN" "Op→" "IMSI" "Container IP" "SC-address"
    printf "%-12s %-8s %-20s %-18s %-15s\n" \
           "────────────" "────────" "────────────────────" "──────────────────" "───────────────"

    local total_ms=0
    for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_ops - 1 ))"); do
        # ms_counts est POSITIONNELLE (0-based) alors que i est un RANG
        # d operateur, qui ne commence plus forcement a 1. Le (i-1)
        # d origine lisait donc l element du VOISIN : avec les rangs 2 et
        # 3, op2 heritait du compte d op3 et op3 tombait sur un element
        # absent, donc sur le defaut de 1 MS. La table affichait "3 MS"
        # pour quatre abonnes reellement provisionnes.
        local n_ms=${ms_counts[$((i - ${OP_ID_BASE:-1}))]:-1}
        local mnc; mnc=$(printf '%02d' "$i")
        local container_ip; container_ip=$(_sms_op_backbone_ip "$i")
        local sc; sc=$(_sms_sc_address "$i")

        for ms in $(seq 1 "$n_ms"); do
            local msisdn; msisdn=$(_sms_op_msisdn "$i" "$ms")
            local imsi; imsi=$(_sms_op_ms_imsi "$mcc" "$mnc" "$i" "$ms")
            printf "%-12s ${CYAN}Op%-6s${NC} %-20s %-18s %-15s\n" \
                   "$msisdn" "$i" "$imsi" "$container_ip" "$sc"
            total_ms=$(( total_ms + 1 ))
        done

        # Ligne de separation entre operateurs
        [ "$i" -lt "$n_ops" ] && \
            printf "${YELLOW}%-12s %-8s %-20s %-18s %-15s${NC}\n" \
                   "──────────" "↓ Op$((i+1))" "" "" ""
    done

    echo ""
    printf "${BOLD}Total : %d MS  |  %d operateur(s)${NC}\n" "$total_ms" "$n_ops"

    echo ""
    echo -e "${CYAN}── Reseau inter-operateurs ──${NC}"
    for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_ops - 1 ))"); do
        # ms_counts est POSITIONNELLE (0-based) alors que i est un RANG
        # d operateur, qui ne commence plus forcement a 1. Le (i-1)
        # d origine lisait donc l element du VOISIN : avec les rangs 2 et
        # 3, op2 heritait du compte d op3 et op3 tombait sur un element
        # absent, donc sur le defaut de 1 MS. La table affichait "3 MS"
        # pour quatre abonnes reellement provisionnes.
        local n_ms=${ms_counts[$((i - ${OP_ID_BASE:-1}))]:-1}
        printf "  Op%-2s  %s  ← TCP 7890  (relay)  %d MS\n" \
               "$i" "$(_sms_op_backbone_ip "$i")" "$n_ms"
    done

    echo ""
    echo -e "${CYAN}── Exemple de flux SMS inter-op ──${NC}"
    if [ "$n_ops" -ge 2 ]; then
        local src_msisdn; src_msisdn=$(_sms_op_msisdn 1 1)
        local dst_msisdn; dst_msisdn=$(_sms_op_msisdn 2 1)
        printf "  %s (Op1) ──GSUP──► HLR(Op1) ──► proto-smsc-daemon\n" "$src_msisdn"
        printf "    ──► sms-interop-relay(Op1) [lookup %s → Op2]\n" "$dst_msisdn"
        printf "    ──TCP──► sms-interop-relay(Op2) @ %s:7890\n" "$(_sms_op_backbone_ip 2)"
        printf "    ──► HLR(Op2) VTY → IMSI lookup\n"
        printf "    ──► proto-smsc-sendmt(Op2) → MS\n"
    fi
    echo ""
}


# ═══════════════════════════════════════════════════════════════════════════════
# sms_routing_mount_args
#
# Retourne les arguments -v Docker pour monter la config SMS du bon operateur.
# Utilise dans start_operator() de start.sh.
#
# Usage :
#   vol_args+=$(sms_routing_mount_args <op_id> <destdir>)
#
# ═══════════════════════════════════════════════════════════════════════════════
sms_routing_mount_args() {
    local op_id="$1"
    local destdir="$2"
    local conf="${destdir}/sms-routing-op${op_id}.conf"

    if [ -f "$conf" ]; then
        echo "-v ${conf}:/etc/osmocom/sms-routing.conf"
    else
        echo "" # pas de volume si le fichier n'existe pas encore
    fi
}


# ═══════════════════════════════════════════════════════════════════════════════
# sms_routing_wait_ready
#
# Attend que le relay SMS d'un operateur soit pret (port TCP 7890 en ecoute).
# Appele optionnellement apres start_operator() pour s'assurer que le relay
# est disponible avant d'injecter des SMS de test.
#
# Usage :
#   sms_routing_wait_ready <container_name> [timeout_sec]
#
# ═══════════════════════════════════════════════════════════════════════════════
sms_routing_wait_ready() {
    local container="$1"
    local timeout="${2:-60}"
    local elapsed=0

    echo -ne "  Attente relay SMS (${container}) "

    while [ "$elapsed" -lt "$timeout" ]; do
        if docker exec "$container" \
           bash -c "ss -tlnp | grep -q ':7890'" 2>/dev/null; then
            echo -e " ${GREEN}✓${NC}"
            return 0
        fi
        echo -n "."
        sleep 2
        elapsed=$(( elapsed + 2 ))
    done

    echo -e " ${YELLOW}timeout${NC}"
    return 1
}


# ═══════════════════════════════════════════════════════════════════════════════
# sms_routing_test_send
#
# Envoie un SMS de test entre deux MS de deux operateurs differents.
# Necessite que les containers soient demarres et les HLR alimentes.
#
# Usage :
#   sms_routing_test_send <src_container> <src_imsi> <dst_msisdn> <message>
#
# ═══════════════════════════════════════════════════════════════════════════════
sms_routing_test_send() {
    local src_container="$1"
    local src_imsi="$2"
    local dst_msisdn="$3"
    local message="$4"

    echo -e "${CYAN}Test SMS :${NC} ${src_container}  IMSI=${src_imsi} → MSISDN=${dst_msisdn}"

    if ! docker ps --format '{{.Names}}' | grep -q "^${src_container}$"; then
        _log_err "Container ${src_container} non demarre"
        return 1
    fi

    docker exec "$src_container" bash -c "
        OPERATOR_ID=\${OPERATOR_ID:-1}
        SC_ADDRESS=\"\$(_sms_sc_address \$OPERATOR_ID)\" 2>/dev/null \
            || SC_ADDRESS=\"1999001\${OPERATOR_ID}444\"
        sms-encode-text '${message}' \
            | gen-sms-deliver-pdu \"\$SC_ADDRESS\" \
            | proto-smsc-sendmt \"\$SC_ADDRESS\" '${src_imsi}' /tmp/sendmt_socket
    " && _log_ok "SMS envoye" || _log_err "Echec envoi SMS"
}


# ═══════════════════════════════════════════════════════════════════════════════
# Point d'entree CLI (appel direct en ligne de commande)
# ═══════════════════════════════════════════════════════════════════════════════
_usage() {
    cat << 'EOF'
Usage: sms-routing-setup.sh <commande> [args...]

Commandes :
  generate <op_id> <n_ops> <destdir> [ms_counts...]
      Genere sms-routing-op<op_id>.conf dans <destdir>.
      ms_counts : nombre de MS par operateur (ex: 2 3 1 pour 3 ops).

  generate-all <n_ops> <destdir> [ms_counts...]
      Genere les configs pour TOUS les operateurs.

  validate <conf_file>
      Valide un fichier sms-routing.conf et affiche les erreurs.

  summary <n_ops> [ms_counts...]
      Affiche la table de routage complete (audit / debug).

  wait-ready <container_name> [timeout_sec]
      Attend que le relay SMS soit actif dans un container.

  test-send <container> <src_imsi> <dst_msisdn> <message>
      Envoie un SMS de test via proto-smsc-sendmt.

Variables d'environnement :
  MCC   : Mobile Country Code (defaut: 001)

Exemples :
  bash sms-routing-setup.sh generate-all 3 /tmp/sms-cfg 2 2 3
  bash sms-routing-setup.sh validate /tmp/sms-cfg/sms-routing-op1.conf
  bash sms-routing-setup.sh summary 2 2 3
EOF
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    # Execute directement (pas source)
    CMD="${1:-help}"; shift 2>/dev/null || true
    case "$CMD" in
        generate)      sms_routing_generate    "$@" ;;
        generate-all)  sms_routing_generate_all "$@" ;;
        validate)      sms_routing_validate     "$@" ;;
        summary)       sms_routing_summary      "$@" ;;
        wait-ready)    sms_routing_wait_ready   "$@" ;;
        test-send)     sms_routing_test_send    "$@" ;;
        help|-h|--help) _usage ;;
        *)
            echo -e "${RED}Commande inconnue : ${CMD}${NC}" >&2
            _usage; exit 1 ;;
    esac
fi

scripts/status.sh

.sh 14 lignes - 338B

scripts/status.sh
#!/bin/bash
cmd="${1:-status}" 
set -ex
systemctl daemon-reload
systemctl $cmd osmo-hlr \
               osmo-msc \
               osmo-mgw \
               osmo-stp \
               osmo-bsc \
               osmo-ggsn \
               osmo-sgsn \
               osmo-sip-connector \
               osmo-bts-trx \
               osmo-pcu

scripts/wslg-audio-bridge.sh

.sh 320 lignes - 16KB

scripts/wslg-audio-bridge.sh
#!/bin/bash
# wslg-audio-bridge.sh - Fait sortir l'audio des conteneurs Osmocom sur les
# haut-parleurs de l'HOTE, depuis l'INTERIEUR du Docker. Marche dans deux cas :
#
#   • WSL2 + WSLg  : l'hote audio = PulseServer WSLg (/mnt/wslg/PulseServer),
#                    qui relaie lui-meme vers Windows.
#   • Linux natif  : l'hote audio = le PulseAudio/PipeWire de la session
#                    graphique (/run/user/<uid>/pulse/native).
#
# Principe (verifie) :
#   host-relay  → expose le pulse de l'hote en TCP (module-native-protocol-tcp)
#   container   → pont AUDIO direct dans le conteneur :
#                     parec(gsm_audio.monitor) | paplay(--server=tcp:<gw>:4713)
#
# On N'utilise PAS module-tunnel-sink : sur TCP/WSL son horloge derive (la
# latence grimpe a >1 s → son hache "tout pete"). parec|paplay = un seul
# serveur d'horloge (celui de l'hote) + simple pipe d'octets → audio propre.
#
# Le conteneur garde son PulseAudio autonome ; on ne touche pas a gsm_audio
# (le dashboard continue de lire gsm_audio.monitor).
#
# Usage :
#   wslg-audio-bridge.sh host-relay              # hote : ouvre le pulse en TCP
#   wslg-audio-bridge.sh container <name> [gw]   # conteneur : lance le pont
#   wslg-audio-bridge.sh test <name>             # bip 880Hz via gsm_audio
#   wslg-audio-bridge.sh down <name>             # stoppe le pont
#   wslg-audio-bridge.sh all <name> [gw]         # host-relay + container + test
set -euo pipefail

TCP_PORT="${WSLG_TCP_PORT:-4713}"
# ACL : tout le bloc prive 172.16/12 (couvre les subnets docker gsm-inter/op)
TCP_ACL="${WSLG_TCP_ACL:-127.0.0.1;172.16.0.0/12}"

log()  { echo -e "  [host-audio] $*"; }
die()  { echo -e "  [host-audio][FAIL] $*" >&2; exit 1; }

# IP de l'hote vue depuis le conteneur = gateway de sa route par defaut.
detect_gw() {
    docker exec "$1" sh -c "ip route 2>/dev/null | awk '/default/{print \$3; exit}'" 2>/dev/null || true
}

# Utilisateur de la session graphique (proprietaire du pulse natif).
# [2026-08-12] Le critere n'est pas le nom mais la PRESENCE du socket pulse :
# sous `sudo -i` SUDO_USER est perdu et `id -un` rend root, dont le
# /run/user/0/pulse/native n'existe quasiment jamais - on partait alors chercher
# un pulse inexistant au lieu de celui, bien vivant, de la session graphique.
# On balaye donc /run/user/* en dernier recours. Meme logique que
# session_pulse_user() de start.sh.
session_user() {
    local u uid sock
    for u in "${HOST_PULSE_USER:-}" "${SUDO_USER:-}" "$(logname 2>/dev/null || true)" "$(id -un)"; do
        [ -n "$u" ] || continue
        uid="$(id -u "$u" 2>/dev/null)" || continue
        [ -S "/run/user/${uid}/pulse/native" ] && { echo "$u"; return 0; }
    done
    for sock in /run/user/*/pulse/native; do
        [ -S "$sock" ] || continue
        uid="${sock#/run/user/}"; uid="${uid%%/*}"
        u="$(getent passwd "$uid" 2>/dev/null | cut -d: -f1)"
        [ -n "$u" ] && { echo "$u"; return 0; }
    done
    echo "${HOST_PULSE_USER:-${SUDO_USER:-$(id -un)}}"   # rien trouve : ancien defaut
}

# Execute `pactl ...` sur le pulse de l'hote natif, en tant qu'utilisateur session.
pactl_native() {
    local u uid rt
    u="$(session_user)"; uid="$(id -u "$u")"; rt="/run/user/${uid}"
    if [ "$(id -un)" = "$u" ]; then
        XDG_RUNTIME_DIR="$rt" PULSE_SERVER="unix:${rt}/pulse/native" pactl "$@"
    else
        sudo -u "$u" XDG_RUNTIME_DIR="$rt" PULSE_SERVER="unix:${rt}/pulse/native" pactl "$@"
    fi
}

# Execute un outil pulse quelconque (pactl, parec, timeout parec...) contre le
# pulse de l'HOTE, en WSLg comme en natif. Generalise pactl_native.
host_pa() {
    local bin="$1"; shift
    if [ -S /mnt/wslg/PulseServer ]; then
        PULSE_SERVER="unix:/mnt/wslg/PulseServer" "$bin" "$@"
    else
        local u uid rt
        u="$(session_user)"; uid="$(id -u "$u")"; rt="/run/user/${uid}"
        if [ "$(id -un)" = "$u" ]; then
            XDG_RUNTIME_DIR="$rt" PULSE_SERVER="unix:${rt}/pulse/native" "$bin" "$@"
        else
            sudo -u "$u" XDG_RUNTIME_DIR="$rt" PULSE_SERVER="unix:${rt}/pulse/native" "$bin" "$@"
        fi
    fi
}

# ── HOTE : choisir une source micro qui a REELLEMENT du signal ───────────────
#
# [2026-08-12] Panne vecue : le micro du dashboard web (bouton -> WebSocket ->
# pacat -> sink gsm_mic) debitait au bon rythme (49400 o/3 s a 8 kHz s16le) mais
# ne transportait que du ZERO. Cause : le navigateur capture la source PAR
# DEFAUT du systeme, et sur cette machine le defaut pointait sur
# alsa_input...hw_Generic_1__source - presente, NON mutee, volume 93 %, et
# pourtant silence numerique integral. La vraie entree etait hw_acp__source.
# `pactl` ne permet pas de distinguer les deux : il faut ECOUTER.
#
# On ne code donc aucun nom de peripherique (ils changent d'une machine a
# l'autre) : on mesure. Critere volontairement binaire - un micro vivant a
# TOUJOURS un plancher de bruit, donc des octets non nuls ; une entree mal
# routee ne sort que des zeros. `tr -d '\0' | wc -c` suffit : pas de dependance
# python/sox, ca tourne partout ou parec existe.
# ATTENTION AU FAUX NEGATIF : une source SUSPENDUE met ~2 s a delivrer ses
# premiers octets (mesure : timeout 1s -> 0 octet, timeout 3s -> 32000 octets
# dont 31946 non nuls, sur la MEME source vivante). Juger sur une fenetre courte
# revient a declarer muet tout peripherique au repos. On distingue donc deux
# cas distincts, jamais confondus :
#   - RIEN capture            -> le peripherique n'a pas demarre (indetermine)
#   - capture mais TOUT A ZERO -> silence numerique (la vraie panne)
# On lit jusqu'a MIC_PROBE_BYTES puis on coupe (head ferme le tube, parec recoit
# SIGPIPE) : rapide sur un peripherique vivant, seul un mort coute le timeout.
MIC_PROBE_SECS="${MIC_PROBE_SECS:-4}"
# Seconde chance pour un peripherique QUI N'A RIEN RENDU (verdict 2). Le
# commentaire ci-dessus mesure ~2 s de reveil sur une source suspendue ; une
# carte USB derriere un hub, ou un bluetooth qui doit negocier son profil, en
# demande davantage. Ce parametre existe pour ne pas condamner ces machines-la.
MIC_PROBE_SECS_SLOW="${MIC_PROBE_SECS_SLOW:-12}"
MIC_PROBE_BYTES="${MIC_PROBE_BYTES:-16000}"   # 1 s @ 8 kHz s16le mono
MIC_MIN_BYTES="${MIC_MIN_BYTES:-4000}"        # en deca : pas de verdict possible
MIC_MIN_NONZERO="${MIC_MIN_NONZERO:-100}"
MIC_MAX_PROBES="${MIC_MAX_PROBES:-8}"
# 1 = ne JAMAIS toucher au defaut systeme de l'hote (on se contente de dire ce
# qu'on a mesure). Utile sur un poste de travail : la selection ci-dessous est
# permanente, elle survit a l'arret d'osmo-operator.
MIC_KEEP_DEFAULT="${MIC_KEEP_DEFAULT:-0}"

# Renvoie "<octets_captures> <octets_non_nuls>" sur stdout.
# $2 = duree de la fenetre (defaut MIC_PROBE_SECS).
probe_source() {
    local src="$1" secs="${2:-$MIC_PROBE_SECS}" tmp raw nz
    tmp="$(mktemp)"
    # `|| true` : timeout sort en 124, head provoque un SIGPIPE - sous
    # `set -o pipefail` l'un ou l'autre tuerait le script.
    { host_pa timeout "$secs" parec -d "$src" \
        --format=s16le --rate=8000 --channels=1 2>/dev/null || true; } \
      | head -c "$MIC_PROBE_BYTES" > "$tmp" 2>/dev/null || true
    raw="$(wc -c < "$tmp")"; nz="$(tr -d '\000' < "$tmp" | wc -c)"
    rm -f "$tmp"
    echo "${raw:-0} ${nz:-0}"
}

# 0 = signal, 1 = silence numerique, 2 = indetermine (rien capture)
source_verdict() {
    local r raw nz secs="${2:-$MIC_PROBE_SECS}"
    r="$(probe_source "$1" "$secs")"; raw="${r%% *}"; nz="${r##* }"
    [ "$raw" -ge "$MIC_MIN_BYTES" ] || return 2
    [ "$nz"  -ge "$MIC_MIN_NONZERO" ] || return 1
    return 0
}

# [2026-08-12] Le code distinguait soigneusement le verdict 1 (silence
# numerique = LA panne) du verdict 2 (rien capture = INDETERMINE)... puis
# traitait les deux pareil : dans les deux cas on partait chercher ailleurs et
# on reecrivait le defaut systeme. Un peripherique simplement lent a demarrer se
# faisait donc evincer au profit d'une entree qui, elle, n'a rien prouve de plus.
# "Indetermine" veut dire qu'il faut REGARDER PLUS LONGTEMPS, pas conclure.
verdict_retry() {
    local src="$1" rc=0
    source_verdict "$src" || rc=$?
    [ "$rc" = "2" ] || return "$rc"
    log "[mic]   ${src} : rien en ${MIC_PROBE_SECS}s - seconde chance sur ${MIC_PROBE_SECS_SLOW}s"
    rc=0; source_verdict "$src" "$MIC_PROBE_SECS_SLOW" || rc=$?
    return "$rc"
}

host_mic() {
    command -v parec >/dev/null 2>&1 \
        || { log "[mic] parec absent - selection micro ignoree"; return 0; }
    host_pa pactl info >/dev/null 2>&1 \
        || { log "[mic] pulse hote injoignable - selection micro ignoree"; return 0; }

    # RAPPEL DE PORTEE, sinon cette fonction se fait crediter d'un pouvoir
    # qu'elle n'a pas : elle regle le defaut du PULSE DE L'HOTE. Le micro du
    # tableau de bord, lui, est celui du POSTE QUI AFFICHE LA PAGE (getUserMedia
    # -> WebSocket -> pacat -> gsm_mic). Les deux ne coincident que si l'on
    # navigue depuis la machine hote. Depuis un autre poste, c'est le selecteur
    # de la page (web/index.html, #mic-dev) qui decide, et rien d'autre.
    log "[mic] selection de l'entree du PULSE DE L'HOTE (le navigateur, lui,"
    log "[mic]   utilise le micro de SON poste - selecteur dans le tableau de bord)."

    local cur rc; cur="$(host_pa pactl get-default-source 2>/dev/null || true)"
    if [ -n "$cur" ]; then
        # `|| rc=$?` OBLIGATOIRE : sous `set -e` un appel nu de source_verdict
        # renvoyant 1 (silence) ou 2 (rien capture) tuerait le script sur place,
        # sans une ligne de log - panne observee, tres deroutante.
        rc=0; verdict_retry "$cur" || rc=$?
        case "$rc" in
            0) log "[mic] source par defaut deja valide : ${cur}"; return 0 ;;
            1) log "[mic] defaut '${cur}' : SILENCE NUMERIQUE - recherche d'une autre entree..." ;;
            2) log "[mic] defaut '${cur}' : aucune donnee meme en ${MIC_PROBE_SECS_SLOW}s - recherche..." ;;
        esac
    fi

    if [ "$MIC_KEEP_DEFAULT" = "1" ]; then
        log "[mic] MIC_KEEP_DEFAULT=1 - defaut systeme laisse tel quel, pas de bascule."
        return 0
    fi

    local src i=0
    for src in $(host_pa pactl list short sources 2>/dev/null | awk '{print $2}' | grep -v '\.monitor$'); do
        [ "$src" = "$cur" ] && continue
        i=$((i + 1)); [ "$i" -gt "$MIC_MAX_PROBES" ] && break
        rc=0; verdict_retry "$src" || rc=$?        # cf. remarque set -e ci-dessus
        if [ "$rc" = "0" ]; then
            if host_pa pactl set-default-source "$src" >/dev/null 2>&1; then
                log "[mic] source par defaut -> ${src} (signal detecte)"
                # La bascule est PERMANENTE : pulse la garde apres l'arret
                # d'osmo-operator. On journalise l'ancienne valeur pour que le
                # retour en arriere soit une commande, pas une enquete.
                [ -n "$cur" ] && log "[mic]   ancien defaut : ${cur}"
                [ -n "$cur" ] && log "[mic]   restaurer : pactl set-default-source ${cur}"
                log "[mic]   (MIC_KEEP_DEFAULT=1 desactive cette bascule)"
                log "[mic]   un onglet qui tient deja le micro reste epingle sur"
                log "[mic]   l'ancien peripherique : recouper/relancer le bouton micro."
                return 0
            fi
        else
            log "[mic]   ${src} : $([ "$rc" = 1 ] && echo 'silence numerique' || echo 'aucune donnee')"
        fi
    done

    log "[mic][!] AUCUNE source micro ne produit de signal (${i} testee(s))."
    log "[mic][!]   -> micro coupe materiellement (touche/interrupteur) ?"
    log "[mic][!]   -> mauvais profil UCM : verifier 'pactl list cards' / alsamixer."
    log "[mic][!]   Sans ca l'echo-test rendra le HP mais jamais la voix."
    return 0
}

# ── HOTE : expose le pulse de l'hote en TCP (idempotent) ─────────────────────
host_relay() {
    if [ -S /mnt/wslg/PulseServer ]; then
        # WSL2 + WSLg : socket monde-accessible (root OK)
        export PULSE_SERVER="unix:/mnt/wslg/PulseServer"
        if pactl list short modules 2>/dev/null | grep -q "protocol-tcp.*port=${TCP_PORT}"; then
            log "relai TCP deja actif (WSLg) :${TCP_PORT}"; return 0
        fi
        pactl load-module module-native-protocol-tcp \
            "port=${TCP_PORT}" "auth-ip-acl=${TCP_ACL}" auth-anonymous=1 >/dev/null \
            || die "load module-native-protocol-tcp (WSLg)"
        log "relai TCP WSLg → Windows active :${TCP_PORT}"
    else
        # Linux natif : pulse de la session graphique
        local u uid; u="$(session_user)"; uid="$(id -u "$u")"
        [ -S "/run/user/${uid}/pulse/native" ] \
            || die "pulse de session introuvable (/run/user/${uid}/pulse/native) - session audio demarree ? user=$u"
        if pactl_native list short modules 2>/dev/null | grep -q "protocol-tcp.*port=${TCP_PORT}"; then
            log "relai TCP deja actif (natif, user=$u) :${TCP_PORT}"; return 0
        fi
        pactl_native load-module module-native-protocol-tcp \
            "port=${TCP_PORT}" "auth-ip-acl=${TCP_ACL}" auth-anonymous=1 >/dev/null \
            || die "load module-native-protocol-tcp (natif)"
        log "relai TCP natif active (user=$u) :${TCP_PORT}"
    fi
}

# ── CONTENEUR : pont parec|paplay supervise vers le pulse de l'hote ──────────
container_bridge() {
    local name="$1" gw="${2:-}"
    [ -n "$name" ] || die "nom de conteneur requis"
    [ -z "$gw" ] && gw="$(detect_gw "$name")"
    [ -n "$gw" ] || die "gateway introuvable pour $name"
    docker exec -i "$name" sh -s -- "tcp:${gw}:${TCP_PORT}" <<'EOSH'
set -eu
RELAY="$1"
pactl info >/dev/null 2>&1 || { echo "  [host-audio][FAIL] pas de daemon pulse dans le conteneur" >&2; exit 1; }
# Le relai hote doit etre joignable
if ! pactl --server="$RELAY" info >/dev/null 2>&1; then
    echo "  [host-audio][FAIL] relai hote injoignable ($RELAY) - lancer 'host-relay' d'abord" >&2; exit 1
fi
# Stoppe un pont precedent (idempotent)
pkill -f "paplay --server=${RELAY}" 2>/dev/null || true
[ -f /run/host-audio.pid ] && kill -- "-$(cat /run/host-audio.pid)" 2>/dev/null || true
mkdir -p /var/log/osmocom
# Pont supervise : redemarre si parec/paplay tombe. setsid = nouveau groupe → kill propre.
setsid sh -c '
  while true; do
    parec -d gsm_audio.monitor --latency-msec=30 --format=s16le --rate=8000 --channels=1 \
      | paplay --server='"${RELAY}"' --latency-msec=250 --raw --format=s16le --rate=8000 --channels=1
    sleep 1
  done' >/var/log/osmocom/host-audio.log 2>&1 &
echo $! > /run/host-audio.pid
echo "  [host-audio] pont parec|paplay gsm_audio.monitor -> ${RELAY} (pgid $!)"
EOSH
}

# ── CONTENEUR : bip de test via gsm_audio (preuve bout-en-bout) ──────────────
container_test() {
    docker exec -i "$1" sh -s <<'EOSH'
set -eu
ffmpeg -y -hide_banner -loglevel error -f lavfi -i "sine=frequency=880:duration=1" -ar 8000 -ac 1 /tmp/host_test.wav
paplay -d gsm_audio /tmp/host_test.wav
echo "  [host-audio] bip 880Hz injecte dans gsm_audio -> hote"
EOSH
}

container_down() {
    docker exec -i "$1" sh -s <<'EOSH'
set -eu
[ -f /run/host-audio.pid ] && kill -- "-$(cat /run/host-audio.pid)" 2>/dev/null || true
pkill -f 'parec -d gsm_audio.monitor' 2>/dev/null || true
rm -f /run/host-audio.pid
echo "  [host-audio] pont arrete"
EOSH
}

cmd="${1:-}"; shift || true
case "$cmd" in
    host-relay) host_relay ;;
    host-mic)   host_mic ;;
    container)  container_bridge "${1:-}" "${2:-}" ;;
    test)       container_test "${1:-}" ;;
    down)       container_down "${1:-}" ;;
    all)        host_relay; host_mic; container_bridge "${1:-}" "${2:-}"; container_test "${1:-}" ;;
    *) echo "usage: $0 {host-relay|host-mic|container <name> [gw]|test <name>|down <name>|all <name> [gw]}" >&2; exit 2 ;;
esac