pl4y.store

6 - Shell - partie 3

set_stp_ip.sh

.sh 537 lignes - 28KB

set_stp_ip.sh
#!/bin/bash
# ══════════════════════════════════════════════════════════════════════════════
# set_stp_ip.sh - donne a CETTE VM son adresse SS7 sur le reseau en pont
#
# Une seule ISO, deux roles. Le script demande d'abord LEQUEL, puis les
# adresses - parce que ce ne sont pas les memes des deux cotes :
#
#   inter-STP (hub)      son IP de pont, + l'IP de chaque osmo-operator-N
#   osmo-operator-N      son IP de pont, + l'IP de l'inter-STP
#
#   osmo-operator-1  192.168.1.11 ─┐
#   osmo-operator-2  192.168.1.12 ─┼─ M3UA/SCTP 2908 ─► inter-STP 192.168.1.10
#   osmo-operator-3  192.168.1.13 ─┘                     PC 0.0.0
#
# POURQUOI LE PONT ET PAS L'HOTE-SEUL
# Les configs de l'image visent 192.168.56.1 (host-only VirtualBox) ou
# 172.20.0.10 (reseau docker). Ni l'un ni l'autre n'existe quand les VM sont en
# "acces par pont" : elles sont sur le LAN, en 192.168.1.X. Un ASP qui vise
# une adresse absente du segment ne dit rien d'autre que "connection
# refused" cote noeud - le hub, lui, ne voit jamais personne arriver.
#
# CE QUE LE SCRIPT ECRIT
#   hub       /etc/osmocom/osmo-stp-interop.cfg   local-ip = son IP de pont
#             /etc/osmo-wan.conf                  la table des operateurs
#             /etc/osmo-role                      OSMO_ROLE=interstp
#   operateur /etc/osmocom/osmo-stp.cfg           local-ip = la sienne
#                                                 remote-ip = celle du hub
#             /etc/osmocom/osmo-{msc,bsc}.cfg     point codes du plan WAN
#             /etc/osmo-wan.conf, /etc/osmo-role
#
# Idempotent : relancer avec d'autres adresses les remplace, il n'y a pas
# d'etat a nettoyer entre deux passages.
#
# Usage :
#   sudo ./set_stp_ip.sh                        menu : role, puis adresses
#   sudo ./set_stp_ip.sh --inter --ip 192.168.1.10 --ips "192.168.1.11 192.168.1.12"
#   sudo ./set_stp_ip.sh --operator --node 1 --ip 192.168.1.11 --hub-ip 192.168.1.10
#   sudo ./set_stp_ip.sh --show                 etat actuel, sans rien modifier
#
#   --inter | --operator   role (sinon : demande)
#   --ip ADRESSE           IP de pont de CETTE machine
#   --ips "a b c"          hub : IP des operateurs, dans l'ordre
#   --hub-ip ADRESSE       operateur : IP de l'inter-STP
#   --node N               operateur : son numero de noeud (1-9)
#   --op K                 COMBIEN d'operateurs (PLMN) ce noeud porte (defaut 1)
#   --op-index K           LEQUEL de ces operateurs est celui de ce noeud, dans
#                          le point code 1.<noeud><op>.<role> (1 a 9, pas plus
#                          que --op ; defaut 1)
#   --iface NOM            interface ou poser l'adresse (defaut : detectee)
#   --port N               port M3UA (defaut 2908)
#   --no-ip                ne touche pas a la configuration reseau
#   --defaut               la configuration du banc, sans une question : role
#                          deduit, IP de pont deduite, hub 192.168.1.49, noeud
#                          lu dans /etc/osmo-role ou la table WAN.
#   --restart              relance la pile apres reecriture
#   --dry-run              montre ce qui serait ecrit
# ══════════════════════════════════════════════════════════════════════════════
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'

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF_DIR="${CONF_DIR:-/etc/osmocom}"
ROLE_FILE="${ROLE_FILE:-/etc/osmo-role}"
INTEROP_CFG="${CONF_DIR}/osmo-stp-interop.cfg"

# wan_detect_local_ip, _wan_ask, la table WAN et sa validation vivent ici. Les
# redefinir localement, c'est se garantir qu'un jour ce script et les lanceurs
# ne designeront plus la meme carte ni la meme table.
# shellcheck source=network/wan-nodes.sh
. "$HERE/network/wan-nodes.sh"

ROLE=""
SELF_IP=""
HUB_IP=""
IPS=""
NODE=""
OPS=1
# --op COMPTE les operateurs : il dimensionne le pare-feu, les blocs "as" du hub
# et la table WAN. Il ne dit pas LEQUEL est celui de ce noeud - et c'est pourtant
# lui qui partait en --op de set-node-id.sh, ou l'option est un INDICE. Avec
# --op 2 sur le noeud 3, l'ecran annoncait STP 1.31.2 pendant que les fichiers
# recevaient 1.32.2 et le routing context 3250 : l'ASP se presentait au hub sous
# une identite que sa table ne connait pas.
OP_INDEX=1
IFACE=""
PORT=2908
SET_IP=1
RESTART=0
# ── La configuration du banc, sans une question ─────────────────────────────
# --defaut repond a tout ce que ce script demande d'habitude, a partir de ce
# que la machine sait deja d'elle-meme :
#   role     interstp si son adresse EST celle du hub, operateur sinon ;
#   son IP   celle du pont, deduite ;
#   le hub   192.168.1.49 ;
#   le noeud /etc/osmo-role, puis la table WAN, puis 1.
# Chaque valeur reste surchargeable : --defaut --node 3 gagne sur la deduction.
USE_DEFAULTS=0
DEFAULT_HUB_IP="192.168.1.49"
DRY=0
SHOW=0

while [ $# -gt 0 ]; do
    case "$1" in
        --inter|--interstp|--hub)  ROLE="interstp" ;;
        --operator|--op-node)      ROLE="operator" ;;
        --role)     ROLE="${2:-}"; shift ;;
        --role=*)   ROLE="${1#*=}" ;;
        --ip)       SELF_IP="${2:-}"; shift ;;
        --ip=*)     SELF_IP="${1#*=}" ;;
        --hub-ip)   HUB_IP="${2:-}"; shift ;;
        --hub-ip=*) HUB_IP="${1#*=}" ;;
        --ips)      IPS="${2:-}"; shift ;;
        --ips=*)    IPS="${1#*=}" ;;
        --node)     NODE="${2:-}"; shift ;;
        --node=*)   NODE="${1#*=}" ;;
        --op)       OPS="${2:-1}"; shift ;;
        --op=*)     OPS="${1#*=}" ;;
        --ops)      OPS="${2:-1}"; shift ;;
        --ops=*)    OPS="${1#*=}" ;;
        --op-index)   OP_INDEX="${2:-1}"; shift ;;
        --op-index=*) OP_INDEX="${1#*=}" ;;
        --iface)    IFACE="${2:-}"; shift ;;
        --iface=*)  IFACE="${1#*=}" ;;
        --port)     PORT="${2:-2908}"; shift ;;
        --port=*)   PORT="${1#*=}" ;;
        --conf-dir)   CONF_DIR="${2:-}"; INTEROP_CFG="${CONF_DIR}/osmo-stp-interop.cfg"; shift ;;
        --conf-dir=*) CONF_DIR="${1#*=}"; INTEROP_CFG="${CONF_DIR}/osmo-stp-interop.cfg" ;;
        --defaut|--default) USE_DEFAULTS=1 ;;
        --no-ip)    SET_IP=0 ;;
        --restart)  RESTART=1 ;;
        --dry-run)  DRY=1 ;;
        --show)     SHOW=1 ;;
        -h|--help)  sed -n '2,53p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
        *) echo -e "${RED}option inconnue : $1${NC}" >&2; exit 2 ;;
    esac
    shift
done

banner() {
    echo -e "${CYAN}${BOLD}"
    echo "╔══════════════════════════════════════════════════════════════════╗"
    echo "║   set_stp_ip.sh - adressage SS7 sur le reseau en pont            ║"
    echo "╚══════════════════════════════════════════════════════════════════╝"
    echo -e "${NC}"
}

is_ipv4() {
    local ip="$1" o
    [[ "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]] || return 1
    for o in ${ip//./ }; do [ "$o" -le 255 ] || return 1; done
    return 0
}

need_ipv4() {  # $1=valeur  $2=ce que c'est
    is_ipv4 "$1" || { echo -e "${RED}$2 : '$1' n'est pas une IPv4${NC}" >&2; exit 2; }
}

# ── L'adresse de pont de cette VM ────────────────────────────────────────────
# wan_detect_local_ip privilegie le host-only VirtualBox (192.168.56-63), qui
# est le bon choix pour le banc mais pas ici : en acces par pont, la VM est sur
# le LAN de l'hote. On cherche donc d'abord une adresse de LAN privee qui ne
# soit ni le NAT VirtualBox (10.0.2.x), ni un alias du plan docker (172.20.x),
# ni le host-only - et on ne retombe sur wan_detect_local_ip qu'a defaut.
bridge_ip() {
    local addrs a
    addrs="$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)"
    for a in $addrs; do
        case "$a" in
            192.168.5[6-9].*|192.168.6[0-3].*) ;;   # host-only : pas le pont
            192.168.*) printf '%s' "$a"; return 0 ;;
        esac
    done
    for a in $addrs; do
        case "$a" in
            10.0.2.*|172.20.*|127.*|169.254.*) ;;
            10.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) printf '%s' "$a"; return 0 ;;
        esac
    done
    wan_detect_local_ip 2>/dev/null || return 1
}

# L'interface qui porte deja ce /24 : c'est celle du segment vise. A defaut, la
# premiere interface globale - poser l'adresse sur la mauvaise carte donne un
# ASP qui part par une route qui ne mene pas au pair.
iface_for() {  # $1=ip
    local ip="$1" f
    f="$(ip -4 -o addr show scope global 2>/dev/null \
         | awk -v pfx="${ip%.*}." '$4 ~ "^"pfx {print $2; exit}')"
    [ -z "$f" ] && f="$(ip -4 -o addr show scope global 2>/dev/null | awk 'NR==1{print $2}')"
    printf '%s' "$f"
}

ensure_ip() {  # $1=ip - la pose si elle manque
    local ip="$1" dev
    [ "$SET_IP" = "1" ] || { echo -e "  ${YELLOW}-${NC} --no-ip : reseau inchange"; return 0; }
    if ip -4 addr show 2>/dev/null | grep -q "inet ${ip}/"; then
        echo -e "  ${GREEN}✓${NC} ${CYAN}${ip}${NC} deja presente sur cette machine"
        return 0
    fi
    dev="${IFACE:-$(iface_for "$ip")}"
    if [ "$DRY" = "1" ]; then
        echo -e "  [dry-run] ip addr add ${ip}/24 dev ${dev:-?}"; return 0
    fi
    if [ -n "$dev" ] && ip addr add "${ip}/24" dev "$dev" 2>/dev/null; then
        echo -e "  ${GREEN}✓${NC} ${CYAN}${ip}/24${NC} posee sur ${dev}"
    else
        echo -e "  ${YELLOW}⚠${NC} impossible de poser ${ip} (interface ${dev:-?})"
        echo -e "     Tant qu'elle manque, aucun ASP ne peut s'attacher."
    fi
}

write_role() {  # $1=role  $2=hub-ip  $3=node (vide pour le hub)
    if [ "$DRY" = "1" ]; then
        echo -e "  [dry-run] ${ROLE_FILE} : OSMO_ROLE=$1 OSMO_HUB_IP=$2 ${3:+OSMO_WAN_NODE=$3}"
        return 0
    fi
    {
        printf '# %s - genere par set_stp_ip.sh\n' "$ROLE_FILE"
        printf 'OSMO_ROLE=%s\n' "$1"
        [ -n "${3:-}" ] && printf 'OSMO_WAN_NODE=%s\n' "$3"
        printf 'OSMO_HUB_IP=%s\n' "$2"
    } > "$ROLE_FILE"
    echo -e "  ${GREEN}✓${NC} ${CYAN}${ROLE_FILE}${NC}"
}

role_of_file() { awk -F= '/^OSMO_ROLE=/{gsub(/[ \r\t]/,"",$2); print $2}' "$ROLE_FILE" 2>/dev/null | tail -1; }
hub_of_file()  { awk -F= '/^OSMO_HUB_IP=/{gsub(/[ \r\t]/,"",$2); print $2}' "$ROLE_FILE" 2>/dev/null | tail -1; }
node_of_file() { awk -F= '/^OSMO_WAN_NODE=/{gsub(/[ \r\t]/,"",$2); print $2}' "$ROLE_FILE" 2>/dev/null | tail -1; }

# ══════════════════════════════════════════════════════════════════════════════
# --show : ce que cette machine croit etre, aujourd'hui
# ══════════════════════════════════════════════════════════════════════════════
if [ "$SHOW" = "1" ]; then
    banner
    r="$(role_of_file)"; h="$(hub_of_file)"; n="$(node_of_file)"
    echo -e "  ${BOLD}Role${NC}      : ${CYAN}${r:-inconnu}${NC}${n:+   noeud ${CYAN}${n}${NC}}"
    echo -e "  ${BOLD}IP de pont${NC}: ${CYAN}$(bridge_ip || echo '-')${NC}"
    echo -e "  ${BOLD}Inter-STP${NC} : ${CYAN}${h:--}${NC}"
    echo ""
    if [ -r "$INTEROP_CFG" ]; then
        echo -e "  ${BOLD}$(basename "$INTEROP_CFG")${NC}"
        awk '/^ *listen m3ua/{f=1} f&&/^ *(local-ip|listen)/{print "    "$0} f&&/^!/{f=0}' "$INTEROP_CFG"
    fi
    for f in "$CONF_DIR/osmo-stp.cfg" "$CONF_DIR/osmo-msc.cfg" "$CONF_DIR/osmo-bsc.cfg"; do
        [ -r "$f" ] || continue
        pc="$(awk '/^cs7 instance/{c=1} c && /^ *point-code /{print $2; exit}' "$f")"
        lo="$(awk '/asp asp-to-inter/{f=1} f&&/local-ip/{print $2; exit}' "$f")"
        re="$(awk '/asp asp-to-inter/{f=1} f&&/remote-ip/{print $2; exit}' "$f")"
        printf '  %-16s PC %-10s local-ip %-16s remote-ip %s\n' \
            "$(basename "$f")" "${pc:--}" "${lo:--}" "${re:--}"
    done
    echo ""
    [ -r "$WAN_CONF_FILE" ] && { echo -e "  ${BOLD}${WAN_CONF_FILE}${NC}"; sed 's/^/    /' "$WAN_CONF_FILE"; }
    exit 0
fi

if [ "$USE_DEFAULTS" = "1" ]; then
    [ -n "$HUB_IP" ]  || HUB_IP="$DEFAULT_HUB_IP"
    [ -n "$SELF_IP" ] || SELF_IP="$(bridge_ip || true)"
    if [ -z "$ROLE" ]; then
        if [ "$SELF_IP" = "$HUB_IP" ]; then ROLE="interstp"; else ROLE="operator"; fi
    fi
    if [ -z "$NODE" ] && [ "$ROLE" = "operator" ]; then
        NODE="$(node_of_file)"
        if [ -z "$NODE" ] && [ -r "$WAN_CONF_FILE" ]; then
            NODE="$(awk -F\" '/WAN_NODES=/{print $2}' "$WAN_CONF_FILE" 2>/dev/null \
                    | tr ' ' '\n' | awk -F: -v ip="$SELF_IP" '$2==ip{print $1; exit}')"
        fi
        [ -n "$NODE" ] || NODE=1
    fi
    if [ -z "$IPS" ] && [ "$ROLE" = "interstp" ] && [ -r "$WAN_CONF_FILE" ]; then
        IPS="$(awk -F\" '/WAN_NODES=/{print $2}' "$WAN_CONF_FILE" 2>/dev/null \
               | tr ' ' '\n' | awk -F: '{print $2}' | tr '\n' ' ')"
    fi
fi

banner
[ "$DRY" = "1" ] || [ "$(id -u)" -eq 0 ] \
    || { echo -e "${RED}Root requis (ecriture dans ${CONF_DIR} et sur l'interface).${NC}" >&2; exit 1; }

# ══════════════════════════════════════════════════════════════════════════════
# 1. QUI EST CETTE MACHINE ?
# ══════════════════════════════════════════════════════════════════════════════
# Le role decide de tout le reste : le hub ecoute et connait ses operateurs, un
# operateur s'attache et ne connait que le hub. Se tromper ici produit une
# machine qui ecoute sans que personne l'appelle, ou qui appelle une adresse
# qui n'ecoute pas.
if [ -z "$ROLE" ]; then
    def_role="$(role_of_file)"; [ -n "$def_role" ] || def_role="operator"
    if [ -t 0 ]; then
        echo -e "${BOLD}Role de cette machine${NC}"
        echo -e "    ${CYAN}1${NC}) inter-STP   - le hub SS7, PC 0.0.0, celui que tous appellent"
        echo -e "    ${CYAN}2${NC}) operateur   - osmo-operator-N, qui s'attache au hub"
        echo ""
        def_n=2; [ "$def_role" = "interstp" ] && def_n=1
        ans="$(_wan_ask "Role" "1 = inter-STP, 2 = operateur :" "$def_n")" || exit 1
        case "$ans" in
            1|inter|interstp|hub)   ROLE="interstp" ;;
            2|op|operator|operateur|operateur) ROLE="operator" ;;
            *) echo -e "${RED}reponse invalide : '$ans'${NC}" >&2; exit 2 ;;
        esac
    else
        ROLE="$def_role"
        echo -e "  ${YELLOW}Pas de terminal - role repris de ${ROLE_FILE} : ${ROLE}${NC}"
    fi
fi
case "$ROLE" in
    interstp|inter|hub) ROLE="interstp" ;;
    operator|op)        ROLE="operator" ;;
    *) echo -e "${RED}--role : interstp ou operator${NC}" >&2; exit 2 ;;
esac
[[ "$OPS" =~ ^[1-9]$ ]] || { echo -e "${RED}--op : un chiffre de 1 a 9${NC}" >&2; exit 2; }
[[ "$OP_INDEX" =~ ^[1-9]$ ]] || { echo -e "${RED}--op-index : un chiffre de 1 a 9${NC}" >&2; exit 2; }
# Un indice au-dela du compteur designe un operateur que ce noeud ne porte pas :
# le point code partirait dans une plage dont personne n'a la route.
[ "$OP_INDEX" -le "$OPS" ] \
    || { echo -e "${RED}--op-index ${OP_INDEX} : ce noeud n'annonce que ${OPS} operateur(s) (--op)${NC}" >&2; exit 2; }

DETECTED="$(bridge_ip || true)"
echo -e "${BOLD}Role :${NC} ${CYAN}${ROLE}${NC}${DETECTED:+    IP de pont detectee : ${CYAN}${DETECTED}${NC}}"
echo ""

# ══════════════════════════════════════════════════════════════════════════════
# 2A. INTER-STP - son IP de pont, et celles des osmo-operator-N
# ══════════════════════════════════════════════════════════════════════════════
if [ "$ROLE" = "interstp" ]; then

    if [ -z "$SELF_IP" ]; then
        def="${DETECTED:-192.168.1.10}"
        SELF_IP="$(_wan_ask "Inter-STP" "IP de pont de CE hub :" "$def")" || exit 1
    fi
    need_ipv4 "$SELF_IP" "IP du hub"

    # Les IP des operateurs. Le hub n'en a pas besoin pour ROUTER - il ecoute et
    # les ASP viennent a lui, c'est le point code qui decide du reste. Il en a
    # besoin pour n'ouvrir son pare-feu qu'a eux, pour ecrire la table que
    # liront les noeuds, et pour que --status puisse dire QUI manque : sans la
    # liste, un noeud absent est indiscernable d'un noeud qui n'existe pas.
    WAN_IP=(); WAN_IND=(); WAN_NODE_LIST=(); i=0
    if [ -n "$IPS" ]; then
        for ip in ${IPS//,/ }; do
            i=$((i+1)); need_ipv4 "$ip" "IP de l'operateur $i"
            WAN_IP[$i]="$ip"; WAN_IND[$i]="$(wan_default_ind "$i")"; WAN_NODE_LIST+=("$i")
        done
    else
        old_n=0
        [ -r "$WAN_CONF_FILE" ] && { wan_nodes_load "$WAN_CONF_FILE" >/dev/null 2>&1 && old_n="$WAN_NODE_COUNT"; }
        declare -A old_ip=()
        for k in ${WAN_NODE_LIST[@]+"${WAN_NODE_LIST[@]}"}; do old_ip[$k]="${WAN_IP[$k]}"; done
        WAN_IP=(); WAN_IND=(); WAN_NODE_LIST=()
        n="$(_wan_ask "Inter-STP" "Nombre d'osmo-operator (1-9) :" "$([ "$old_n" -ge 1 ] && echo "$old_n" || echo 3)")" || exit 1
        [[ "$n" =~ ^[1-9]$ ]] || { echo -e "${RED}nombre invalide : $n${NC}" >&2; exit 2; }
        for i in $(seq 1 "$n"); do
            # Defaut aligne sur le plan du banc : osmo-operator-N a .1N sur le
            # meme /24 que le hub.
            def="${old_ip[$i]:-${SELF_IP%.*}.$((10 + i))}"
            ip="$(_wan_ask "osmo-operator-$i" "IP de pont de osmo-operator-$i :" "$def")" || exit 1
            need_ipv4 "$ip" "IP de osmo-operator-$i"
            WAN_IP[$i]="$ip"; WAN_IND[$i]="$(wan_default_ind "$i")"; WAN_NODE_LIST+=("$i")
        done
    fi
    NODES="${#WAN_NODE_LIST[@]}"
    [ "$NODES" -ge 1 ] || { echo -e "${RED}aucun operateur saisi${NC}" >&2; exit 2; }
    # Le hub ne porte aucun operateur : il n'est pas un noeud de la table. On note
    # 0 - que wan_nodes_validate accepte - plutot qu'un numero qui le ferait
    # ressembler a un noeud et lui volerait un point code.
    WAN_NODE_ID=0; WAN_NODE_COUNT="$NODES"; WAN_OPS="$OPS"
    wan_nodes_validate || exit 2

    echo ""
    echo -e "  Hub ${CYAN}${SELF_IP}:${PORT}${NC}   PC ${CYAN}0.0.0${NC}"
    for i in "${WAN_NODE_LIST[@]}"; do
        printf '    osmo-operator-%s  %-15s  STP %s  MSC %s  BSC %s\n' \
            "$i" "${WAN_IP[$i]}" "1.${i}1.2" "1.${i}1.1" "1.${i}1.3"
    done
    echo ""

    ensure_ip "$SELF_IP"

    # ── La config du hub ────────────────────────────────────────────────────
    # local-ip 0.0.0.0 marche, mais annonce dans l'INIT SCTP toutes les adresses
    # de la machine - NAT 10.0.2.15 et alias 172.20.x compris. Les noeuds tentent
    # alors des chemins qui, vus d'eux, ne menent nulle part. Une adresse unique
    # et juste vaut mieux qu'un multi-homing dont trois branches sur quatre sont
    # mortes.
    if [ "$DRY" = "1" ]; then
        echo -e "  [dry-run] ${INTEROP_CFG} : listen m3ua ${PORT} / local-ip ${SELF_IP}"
    else
        mkdir -p "$CONF_DIR"
        # Regeneree a chaque passage, comme le fait start-interstp.sh : ce
        # fichier est entierement derive de (NODES, OPS): le garder tel quel
        # apres un changement de nombre d'operateurs laisserait des blocs "as"
        # pour des noeuds disparus et aucun pour le nouveau - un ASP qui arrive
        # sans "as" correspondant est refuse, sans que rien ne le dise.
        bash "$HERE/helpers/create_interop.sh" --wan "$NODES" "$OPS" "$INTEROP_CFG" || exit 1
        tmp="$(mktemp)"
        awk -v hub="$SELF_IP" -v port="$PORT" '
            /^ *listen m3ua/ { sub(/listen m3ua .*/, "listen m3ua " port); in_l=1; print; next }
            in_l && /^ *local-ip / { sub(/local-ip .*/, "local-ip " hub); print; next }
            in_l && /^(!|[a-z])/ { in_l=0 }
            { print }
        ' "$INTEROP_CFG" > "$tmp"
        # 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" > "$INTEROP_CFG"; rm -f "$tmp"
        echo -e "  ${GREEN}✓${NC} $(basename "$INTEROP_CFG") - ecoute ${CYAN}${SELF_IP}:${PORT}${NC}"
    fi

    if [ "$DRY" = "1" ]; then
        echo -e "  [dry-run] ${WAN_CONF_FILE} : $(wan_nodes_spec)"
    else
        WAN_AUTO=0 wan_nodes_save "$WAN_CONF_FILE" \
            && echo -e "  ${GREEN}✓${NC} table ecrite dans ${CYAN}${WAN_CONF_FILE}${NC}"
        for i in "${WAN_NODE_LIST[@]}"; do
            bash "$HERE/network/firewall-wan.sh" "${WAN_IP[$i]}" "$OPS" >/dev/null 2>&1 || true
        done
        echo -e "  ${GREEN}✓${NC} pare-feu ouvert pour ${NODES} operateur(s)"
    fi
    write_role interstp "$SELF_IP" ""

    echo ""
    echo -e "  ${BOLD}Cote operateurs :${NC} sur chaque VM osmo-operator-N, lancer"
    echo -e "    ${CYAN}sudo ./set_stp_ip.sh --operator --node N --ip <son IP> --hub-ip ${SELF_IP}${NC}"

    if [ "$RESTART" = "1" ] && [ "$DRY" != "1" ]; then
        echo ""
        if [ -x "$HERE/start-interstp.sh" ]; then
            "$HERE/start-interstp.sh" --ip "$SELF_IP" --nodes "$NODES" --ops "$OPS" --port "$PORT" --no-ip
        else
            systemctl restart osmo-interstp 2>/dev/null \
                && echo -e "  ${GREEN}✓${NC} osmo-interstp relance" \
                || echo -e "  ${YELLOW}⚠${NC} relancez le hub a la main : ${CYAN}./start-interstp.sh${NC}"
        fi
    else
        echo -e "  ${YELLOW}Le hub deja lance garde l'ancienne adresse :${NC} ${CYAN}./start-interstp.sh${NC}"
    fi
    exit 0
fi

# ══════════════════════════════════════════════════════════════════════════════
# 2B. OSMO-OPERATOR-N - son IP de pont, et celle de l'inter-STP
# ══════════════════════════════════════════════════════════════════════════════
if [ -z "$NODE" ]; then
    def="$(node_of_file)"; [ -n "$def" ] || def=1
    NODE="$(_wan_ask "osmo-operator" "Numero de CE noeud operateur (1-9) :" "$def")" || exit 1
fi
[[ "$NODE" =~ ^[1-9]$ ]] || { echo -e "${RED}--node : un chiffre de 1 a 9${NC}" >&2; exit 2; }

if [ -z "$SELF_IP" ]; then
    def="${DETECTED:-192.168.1.$((10 + NODE))}"
    SELF_IP="$(_wan_ask "osmo-operator-$NODE" "IP de pont de CE noeud :" "$def")" || exit 1
fi
need_ipv4 "$SELF_IP" "IP de ce noeud"

if [ -z "$HUB_IP" ]; then
    def="$(hub_of_file)"
    # Un hub en 172.20.x (plan docker) ou 192.168.56.x (host-only) herite de
    # l'image n'existe pas sur le pont : le proposer comme defaut ferait
    # reconduire l'adresse meme qu'on est venu corriger.
    case "$def" in ""|172.20.*|192.168.5[6-9].*|192.168.6[0-3].*) def="${SELF_IP%.*}.10" ;; esac
    HUB_IP="$(_wan_ask "Inter-STP" "IP de pont de l'inter-STP :" "$def")" || exit 1
fi
need_ipv4 "$HUB_IP" "IP de l'inter-STP"

if [ "$SELF_IP" = "$HUB_IP" ]; then
    echo -e "${RED}Ce noeud et l'inter-STP portent la meme adresse (${SELF_IP}).${NC}" >&2
    echo -e "${RED}L'ASP s'attacherait a lui-meme - corrigez l'une des deux.${NC}" >&2
    exit 2
fi
if [ "${SELF_IP%.*}" != "${HUB_IP%.*}" ]; then
    echo -e "  ${YELLOW}⚠${NC} ${SELF_IP} et ${HUB_IP} ne sont pas sur le meme /24 -"
    echo -e "     il faudra une route entre les deux pour que l'ASP monte."
fi

echo ""
echo -e "  ${BOLD}osmo-operator-${NODE}${NC}   ${CYAN}${SELF_IP}${NC}  ──M3UA──►  inter-STP ${CYAN}${HUB_IP}:${PORT}${NC}"
# Le <op> du plan est l'indice, pas le compteur. Ces trois point codes portaient
# un 1 en dur : ils annoncaient 1.<noeud>1.x quoi qu'on demande, pendant que
# set-node-id.sh en ecrivait un autre dans les fichiers.
echo -e "  STP ${CYAN}1.${NODE}${OP_INDEX}.2${NC}   MSC ${CYAN}1.${NODE}${OP_INDEX}.1${NC}   BSC ${CYAN}1.${NODE}${OP_INDEX}.3${NC}   (plan WAN 1.<noeud><op>.<role>)"
echo ""

ensure_ip "$SELF_IP"

# ── Point codes et adresses de l'ASP ────────────────────────────────────────
# set-node-id.sh est le seul endroit qui sait tenir d'accord les TROIS fichiers
# (osmo-stp, osmo-msc, osmo-bsc) : son PC, son routing-key, et le PC du pair que
# chacun appelle. En rater un donne un coeur qui demarre et des appels qui
# n'aboutissent pas - d'ou la delegation plutot qu'un second sed ici.
# Son --op est un INDICE d'operateur, pas un nombre : lui passer le compteur
# revenait a lui demander le point code du DERNIER operateur du noeud.
SNI_ARGS=(--node "$NODE" --op "$OP_INDEX" --native
          --local-ip "$SELF_IP" --hub-ip "$HUB_IP" --conf-dir "$CONF_DIR")
[ "$DRY" = "1" ] && SNI_ARGS+=(--dry-run)
bash "$HERE/network/set-node-id.sh" "${SNI_ARGS[@]}" || exit 1

# ── La table WAN ────────────────────────────────────────────────────────────
# On repart de celle qui existe pour ne pas perdre les autres noeuds : ce script
# ne connait que CE noeud et le hub, il n'a rien a dire sur les voisins.
WAN_IP=(); WAN_IND=(); WAN_NODE_LIST=()
[ -r "$WAN_CONF_FILE" ] && wan_nodes_load "$WAN_CONF_FILE" >/dev/null 2>&1 || true
if [ -z "${WAN_IP[$NODE]:-}" ]; then
    WAN_NODE_LIST+=("$NODE")
    WAN_IND[$NODE]="$(wan_default_ind "$NODE")"
fi
WAN_IP[$NODE]="$SELF_IP"
[ -n "${WAN_IND[$NODE]:-}" ] || WAN_IND[$NODE]="$(wan_default_ind "$NODE")"
WAN_NODE_ID="$NODE"; WAN_NODE_COUNT="${#WAN_NODE_LIST[@]}"; WAN_OPS="$OPS"

if [ "$DRY" = "1" ]; then
    echo -e "  [dry-run] ${WAN_CONF_FILE} : $(wan_nodes_spec)  (ce noeud : ${NODE})"
else
    if wan_nodes_validate; then
        wan_nodes_save "$WAN_CONF_FILE" \
            && echo -e "  ${GREEN}✓${NC} ${CYAN}${WAN_CONF_FILE}${NC} - ce noeud : ${NODE}"
    else
        echo -e "  ${YELLOW}⚠${NC} table WAN incoherente, non reecrite - ${CYAN}${WAN_CONF_FILE}${NC} inchange"
    fi
    bash "$HERE/network/firewall-wan.sh" "$HUB_IP" "$OPS" >/dev/null 2>&1 || true
fi
write_role operator "$HUB_IP" "$NODE"

echo ""
if [ "$RESTART" = "1" ] && [ "$DRY" != "1" ]; then
    if [ -x "$HERE/start-direct.sh" ]; then
        echo -e "  Relance de la pile..."
        "$HERE/start-direct.sh" --node "$NODE" || true
    else
        echo -e "  ${YELLOW}⚠${NC} start-direct.sh absent - relancez la pile a la main."
    fi
else
    echo -e "  ${YELLOW}Les demons deja lances gardent l'ancienne adresse :${NC} relancez la pile"
    echo -e "    ${CYAN}sudo ./start-direct.sh --node ${NODE}${NC}"
fi
echo -e "  ${BOLD}Verifier l'attachement, cote hub :${NC} ${CYAN}./start-interstp.sh --status${NC}"

start-direct.sh

.sh 1791 lignes - 94KB

start-direct.sh
#!/bin/bash
# =============================================================================
# start-direct.sh - Prepare l'environnement Calypso puis lance run.sh
# =============================================================================
#
# Ce script ne demarre aucun demon.
#
# Il :
#   1. charge l'environnement ;
#   2. detecte le materiel / les binaires ;
#   3. choisit le mode (profil) ;
#   4. genere les fichiers mobile_*.cfg ;
#   5. exporte les variables necessaires ;
#   6. execute run.sh.
#
# Toute la logique GSM vit ensuite dans run.sh (et run_modules/).
#
# CHAINE DE CONFIGURATION - NE PAS LA CASSER
#     VAR=x ./start-direct.sh   ← la ligne de commande gagne toujours
#       -> environment/load.env
#          -> paths / modes / domaines
#     puis exec run.sh avec le profil choisi.
# -----------------------------------------------------------------------------
set -uo pipefail

# ── [2026-08-14] GARDE "on doit etre dans Docker" RETIREE ───────────────────
# Elle exigeait /.dockerenv ET /etc/docker-entrypoint-cmd, et sortait en exit 1
# quand l'un manquait. Sur l'ISO il n'y a pas de Docker : les deux fichiers sont
# absents, la garde bloquait donc le seul lanceur utilisable la-bas. Retiree sur
# demande explicite - c'est le cas d'usage ISO qui prime.
#
# CE QU'ON PERD, et c'etait sa raison d'etre (constat du 12/08) : lance par
# erreur sur un HOTE qui a Docker, ce script repart sur un environnement a
# moitie construit - /opt/GSM/qosmo-grgsm n'y existe pas, mais /tmp/osmo-nitb/logs,
# lui, se cree tout seul - et meurt sur un "run.sh introuvable" qui ne designe
# pas la vraie cause. Si ce symptome reapparait sur une machine avec Docker,
# c'est ca : la-bas le lanceur est ./start.sh (ou ./start-nitb.sh), pas celui-ci.

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
# --- options ------------------------------------------------------------------
DRY=0 VERBOSE=0 ACTION=start PROFILE="${CALYPSO_PROFILE:-faketrx-qemu}" FORCE=0
# WAN : jamais par defaut. --wan (ou WAN_AUTO=1 dans /etc/osmo-wan.conf, ce que
# pose une ISO construite avec --wan) le monte avant de passer la main a run.sh.
WAN_MESH=0
# --virtualbox : monte le segment et les VM avant le WAN. Refuse si l'on tourne
# DANS une VM - c'est l'hote qui pilote VirtualBox (le script le verifie).
VBOX_INTERCO=0
VBOX_NODES=""
VBOX_HOST_NODE=1
# --node : le numero de CE noeud, choisi au lancement et pas a la construction.
# C'est ce qui permet une ISO unique pour les neuf noeuds.
NODE_ID=""
NODE_OP=1
HUB_IP=""
# Les gabarits ne sont PAS regeneres par defaut : run.sh le ferait au demarrage
# et effacerait l'identite SS7 posee juste avant. --regen retablit l'ancien
# comportement pour qui veut repartir des gabarits.
REGEN_GABARITS=0
# --menu : demande les choix au lieu de les deviner. Voir le bloc « Menu » plus bas.
MENU_MODE=0
usage() {
    cat <<'USAGE'
Usage : ./start-direct.sh [options] [mode]
  Modes (profils) :
    faketrx-qemu   coeur + BTS#0 QEMU + BTS#1 faketrx (defaut)
    faketrx        coeur + fake_trx + trxcon + mobile
    qemu           pipeline Calypso QEMU seul
    noproc         coeur seul (no-process)
    core           alias noproc
    hybrid         alias faketrx-qemu
  Options :
    --list              affiche le plan (delegue a run.sh --list)
    --dry-run           deroule sans effet de bord
    --profile <nom>     force le profil
    --stop              arrete la pile (delegue a run.sh --stop)
    --status            interroge l'etat (delegue a run.sh --status)
    --force             relance meme les modules deja demarres
    --verbose           montre la sortie des modules
    --check-paths       verifie les dependances declarees
    --wan               monte le WAN a N noeuds (1 a 9) AVANT run.sh et demande :
                          nombre de noeuds, IP de chaque noeud, indicatif de
                          chaque noeud, numero du noeud construit par ce lancement
    --wan-nodes "1:IP:IND ..."   meme chose sans question (scriptable)
    --wan-id N          numero du noeud local (sinon deduit des IP locales)
    --wan-conf FICHIER  table a lire/ecrire (defaut /etc/osmo-wan.conf)
    --node N            numero de CE noeud, 1 a 9. DEDUIT AUTOMATIQUEMENT s'il
    --menu              demande les choix au lieu de les deviner : profil,
                        noeud, operateur, inter-STP, WAN, regeneration des
                        configs. Les valeurs proposees sont
                        celles que le lanceur aurait prises tout seul, donc
                        valider sans rien changer equivaut a ne pas passer
                        l'option. Sans terminal, l'option est ignoree.
    --regen             regenere les configs depuis les gabarits (par defaut on
                        CONSERVE celles deja en place : run.sh les ecraserait)
                        est omis : environnement, puis /etc/osmo-role, puis la
                        table WAN comparee aux adresses locales. Cette option
                        ne sert qu'a forcer. Reecrit son identite SS7
                        (point codes 1.<noeud><op>.<role>, routing contexts) et
                        pointe son ASP sur l'inter-STP. Une seule ISO suffit
                        alors pour les neuf noeuds - le numero se choisit ici.
    --op N              operateur porte par ce noeud (defaut 1)
    --hub-ip ADRESSE    inter-STP a joindre (defaut : selon docker ou VM)
    --air-mesh[=PORT]   maillage de BURSTS entre noeuds : le milieu radio
                        devient commun et un mobile peut entendre - et choisir -
                        la BTS d'un autre noeud. Les pairs viennent de la table
                        WAN, la portee de data/air-mesh.txt. Declare deux
                        mobiles de plus pour s'y brancher (voir pont/airmesh.py).
    --virtualbox[=N]    WAN entre CETTE machine et N-1 VM VirtualBox (implique
                        --wan). A lancer depuis l'hote, pas depuis une VM.
    --vbox-node N       numero de noeud porte par cette machine (defaut 1)
    --mobile            client de couche 2 = mobile (defaut) -- pile L2/L3
                        complete derriere le firmware QEMU
    --ccch_scan         client de couche 2 = ccch_scan -- ecoute le CCCH
    --bcch_scan         client de couche 2 = bcch_scan -- releve les BCCH
    --cell_log          client de couche 2 = cell_log -- journal de cellules
                        Ces quatre-la sont EXCLUSIFS et ECRASENT le mobile de
                        QEMU : ils prennent sa socket L1CTL et sa VTY.
    -h, --help          cette aide

  Sans --wan : aucun WAN. Avec --wan le profil reste faketrx-qemu (hybride,
  1 faketrx + 1 QEMU Calypso), qui est deja le defaut de ce script.
  Composer <indicatif><numero> joint ce numero sur le noeud correspondant.
Toute variable CALYPSO_* passee en prefixe est transmise a run.sh / QEMU :
  CALYPSO_LANG=en ./start-direct.sh       CALYPSO_NO_ATTACH=1 ./start-direct.sh
USAGE
}
while [ $# -gt 0 ]; do
    case "$1" in
        --list)        ACTION=list ;;
        --dry-run)     DRY=1 ;;
        --profile)     if [ $# -lt 2 ] || [ -z "${2:-}" ]; then
                           printf '%s\n\n' "--profile attend un nom de profil" >&2
                           usage >&2; exit 2
                       fi
                       PROFILE="$2"; shift ;;
        --stop)        ACTION=stop ;;
        --status)      ACTION=status ;;
        --force)       FORCE=1 ;;
        --verbose)     VERBOSE=1 ;;
        --check-paths) ACTION=checkpaths ;;
        --wan)         WAN_MESH=1 ;;
        --wan=*)       WAN_MESH=1; WAN_NODES="${1#*=}" ;;
        --wan-nodes)   WAN_MESH=1; WAN_NODES="${2:-}"; shift ;;
        --wan-nodes=*) WAN_MESH=1; WAN_NODES="${1#*=}" ;;
        --wan-id)      WAN_MESH=1; WAN_NODE_ID="${2:-}"; shift ;;
        --wan-id=*)    WAN_MESH=1; WAN_NODE_ID="${1#*=}" ;;
        --wan-conf)    WAN_CONF_FILE="${2:-}"; shift ;;
        --wan-conf=*)  WAN_CONF_FILE="${1#*=}" ;;
        --virtualbox)   WAN_MESH=1; VBOX_INTERCO=1 ;;
        --virtualbox=*) WAN_MESH=1; VBOX_INTERCO=1; VBOX_NODES="${1#*=}" ;;
        --vbox-node)    VBOX_HOST_NODE="${2:-1}"; shift ;;
        --vbox-node=*)  VBOX_HOST_NODE="${1#*=}" ;;
        --node)         NODE_ID="${2:-}"; shift ;;
        --node=*)       NODE_ID="${1#*=}" ;;
        --air-mesh)     AIR_MESH=1 ;;
        --air-mesh=*)   AIR_MESH=1; AIRMESH_PORT="${1#*=}" ;;
        --regen)        REGEN_GABARITS=1 ;;
        --menu)         MENU_MODE=1 ;;
        --op)           NODE_OP="${2:-1}"; shift ;;
        --op=*)         NODE_OP="${1#*=}" ;;
        --hub-ip)       HUB_IP="${2:-}"; shift ;;
        --hub-ip=*)     HUB_IP="${1#*=}" ;;
        --mobile|--ccch_scan|--bcch_scan|--cell_log)
                       # run_modules/70-l2.sh gere deja les quatre clients via
                       # CALYPSO_L2_CLIENT ; il manquait de quoi les choisir.
                       # Un seul a la fois : ils se disputeraient la socket
                       # L1CTL (/tmp/osmocom_l2) et la VTY 4247, et le second
                       # lance resterait muet pour toujours.
                       _l2c="${1#--}"
                       if [ -n "${L2_CLIENT_CHOISI:-}" ] && \
                          [ "$L2_CLIENT_CHOISI" != "$_l2c" ]; then
                           printf '%s\n\n' \
                             "--$_l2c et --$L2_CLIENT_CHOISI sont exclusifs : un seul client de couche 2" >&2
                           usage >&2; exit 2
                       fi
                       L2_CLIENT_CHOISI="$_l2c" ;;
        -h|--help)     usage; exit 0 ;;
        faketrx-qemu|faketrx|qemu|noproc|core|hybrid)
            PROFILE="$1"
            [ "$PROFILE" = "hybrid" ] && PROFILE=faketrx-qemu
            [ "$PROFILE" = "core" ]   && PROFILE=noproc
            ;;
        *) printf 'option inconnue : %s\n\n' "$1" >&2; usage >&2; exit 2 ;;
    esac
    shift
done
# --- ou tourne-t-on ? ---------------------------------------------------------
# La question n'est pas cosmetique : le plan d'adressage EN DEPEND.
#   docker  → l'inter-STP est le conteneur osmo-inter-stp (172.20.0.10), le
#             noeud a deja son IP quand les demons demarrent.
#   VM/ISO  → l'inter-STP est une machine a part (192.168.1.49 sur le banc,
#             ou une host-only 192.168.56.x selon le montage), et l'adresse
#             du noeud vient du DHCP : elle peut manquer au lancement.
# Prendre l'un pour l'autre donne un ASP qui ne s'attache jamais, avec pour
# seule trace un "connection refused" vers une adresse inexistante ici.
#
# /.dockerenv seul ne suffit pas - il est vrai dans n'importe quel conteneur.
# On exige aussi /etc/docker-entrypoint-cmd, que scripts/entrypoint.sh depose :
# c'est ce couple qui identifie un conteneur DE CE DEPOT.
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
    # systemd-detect-virt SORT EN CODE 1 quand il ne trouve rien. Un
    # "$(cmd || echo none)" produit alors DEUX lignes - la sortie "none" du
    # programme, plus celle du repli - et aucun motif ne correspond : une
    # machine physique se retrouvait classee "vm". On ignore le code de
    # sortie et on ne garde que la premiere ligne.
    local v
    v="$(systemd-detect-virt 2>/dev/null | head -1)" || true
    v="${v:-none}"
    case "$v" in
        oracle|kvm|qemu|vmware|microsoft|xen|bochs|parallels) echo vm ;;
        none|"") echo bare ;;
        *) echo vm ;;
    esac
}
RUNTIME_ENV="${RUNTIME_ENV:-$(detect_runtime_env)}"

# --- quel noeud sommes-nous ? --------------------------------------------------
# Le numero de noeud decide des point codes, du routing context et de l'indicatif.
# Le taper a chaque lancement est une occasion de se tromper - et une erreur ici
# ne se voit pas : la pile demarre, elle presente simplement au hub l'adresse SS7
# d'un autre noeud. On le deduit donc, et on DIT d'ou vient la reponse.
#
# Ordre, du plus explicite au plus deduit. Le premier qui repond gagne :
#   1. --node passe en ligne de commande
#   2. l'environnement - c'est ainsi que start.sh le transmet au conteneur
#   3. /etc/osmo-role, fige dans l'image par build-iso.sh --node N
#   4. la table WAN : l'adresse de ce noeud y figure, et nous l'avons
#   5. rien - on ne touche alors PAS a l'identite SS7, plutot que d'en inventer une
# Renvoie "numero|origine". Pas deux variables : la fonction est appelee dans
# une substitution de commande, donc dans un SOUS-SHELL - toute variable qu'elle
# poserait y resterait, et l'appelant n'afficherait "?" qu'apres coup.
detect_node_id() {
    local n

    n="${WAN_NODE_ID:-${OSMO_WAN_NODE:-}}"
    if [[ "$n" =~ ^[1-9]$ ]]; then echo "${n}|environnement"; return 0; fi

    if [ -r /etc/osmo-role ]; then
        n="$(awk -F= '/^OSMO_WAN_NODE=/{print $2}' /etc/osmo-role 2>/dev/null | tr -d ' \r')"
        if [[ "$n" =~ ^[1-9]$ ]]; then echo "${n}|/etc/osmo-role"; return 0; fi
    fi

    # La table WAN et nos propres adresses : c'est la source la plus fiable en VM,
    # ou une seule ISO sert les neuf noeuds et ou seule l'IP les distingue.
    local conf="${WAN_CONF_FILE:-/etc/osmo-wan.conf}"
    if [ -r "$conf" ] && [ -r "$HERE/network/wan-nodes.sh" ]; then
        n="$(
            # Sous-shell : wan-nodes.sh pose des variables et des tableaux qu'on
            # ne veut pas voir deborder dans le lanceur.
            set +u
            . "$HERE/network/wan-nodes.sh" 2>/dev/null || exit 0
            WAN_NODE_ID=0
            wan_nodes_load "$conf" 2>/dev/null || exit 0
            wan_nodes_detect_self 2>/dev/null || exit 0
            printf '%s' "$WAN_NODE_ID"
        )"
        if [[ "$n" =~ ^[1-9]$ ]]; then echo "${n}|table WAN (IP locale)"; return 0; fi
    fi

    return 1
}

NODE_ID_SRC=""
if [ -n "$NODE_ID" ]; then
    NODE_ID_SRC="option --node"
else
    _detected="$(detect_node_id || true)"
    NODE_ID="${_detected%%|*}"
    [ -n "$NODE_ID" ] && NODE_ID_SRC="${_detected#*|}"
    unset _detected
fi
# L'adresse du hub arrive par le MEME chemin que le noeud : start.sh la pose
# dans l'environnement du conteneur (OSMO_HUB_IP, et INTER_STP_IP pour les
# scripts plus anciens). Sans ce repli, HUB_IP restait vide alors que la
# reponse etait deja la : un conteneur lance par docker exec -d n'a pas de
# terminal, la question du hub est donc sautee et l'adresse restait nulle
# jusqu'aux configs. Le repli est pose ici, apres la boucle d'options, pour
# que --hub-ip garde le dernier mot.
[ -n "$HUB_IP" ] || HUB_IP="${OSMO_HUB_IP:-${INTER_STP_IP:-}}"
# ── DERNIER REPLI : L ADRESSE QUE L ASP COMPOSE DEJA ─────────────────────────
# [2026-08-31] Sans HUB_IP, network/set-node-id.sh retombe sur le hub WAN par
# defaut, 192.168.1.49, calcule la source qui y route (192.168.1.31 ici) et
# l ecrit en local-ip de l ASP. Or sur un banc a une machine le hub vit dans un
# conteneur et l ASP compose 127.0.0.1:2908 - le port SCTP que le hub publie
# sur la boucle. La source calculee pour 192.168.1.49 ne mene donc pas au hub,
# et l ASP reste ASP_DOWN. Vu le 31/08 :
#     asp-to-inter  as-inter  ASP_DOWN  client
#                   Local 172.20.0.11:2910  Remote 127.0.0.1:2908
# (172.20.0.11 etant, lui, la valeur du gabarit jamais corrigee - l adresse du
# PREMIER CONTENEUR, qui n existe meme pas sur l hote.)
# Rien de tout cela ne se signale : le journal ne dit qu un timeout.
#
# La config SAIT ou est le hub - c est son remote-ip. On le lit plutot que de
# laisser deviner : la source et la destination viennent alors du meme
# endroit, et `ip route get` rend la bonne patte locale.
# --hub-ip et OSMO_HUB_IP gardent la priorite, ce repli ne sert qu au silence.
if [ -z "$HUB_IP" ] && [ -r "${OSMOCOM_CFG:-/etc/osmocom}/osmo-stp.cfg" ]; then
    HUB_IP="$(awk '/^[[:space:]]*asp[[:space:]]+asp-to-inter[[:space:]]/{a=1; next}
                   a && /^[[:space:]]*remote-ip[[:space:]]/{print $2; exit}' \
              "${OSMOCOM_CFG:-/etc/osmocom}/osmo-stp.cfg" 2>/dev/null)"
    [ -n "$HUB_IP" ] && echo "  hub        deduit du remote-ip de l ASP : $HUB_IP" >&2
fi
# Pour les scripts qui ne connaissent que deux mondes : docker, ou tout le reste.
case "$RUNTIME_ENV" in docker) NODE_MODE=docker ;; *) NODE_MODE=native ;; esac

# --- affichage (identique a run.sh) -------------------------------------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
    TTY=1
    C_OK=$'\033[32m'; C_KO=$'\033[31m'; C_SK=$'\033[33m'
    C_DIM=$'\033[2m'; C_CYAN=$'\033[36m'; C_BOLD=$'\033[1m'; C_Z=$'\033[0m'
else
    TTY=0
    C_OK=""; C_KO=""; C_SK=""; C_DIM=""; C_CYAN=""; C_BOLD=""; C_Z=""
fi
say_begin() {
    if [ $TTY -eq 1 ]; then
        printf '[ %s.. %s] %s' "$C_DIM" "$C_Z" "$1"
    else
        printf '[ .. ] %s\n' "$1"
    fi
}
say_end() { # $1=tag $2=couleur $3=libelle $4=detail
    if [ $TTY -eq 1 ]; then printf '\r\033[K'; fi
    printf '[%s%s%s] %s' "$2" "$1" "$C_Z" "$3"
    [ -n "${4:-}" ] && printf ' %s(%s)%s' "$C_DIM" "$4" "$C_Z"
    printf '\n'
}
banner() {
    echo -e "${C_CYAN}"
    echo "╔══════════════════════════════════════════════════════╗"
    echo "║   Calypso GSM - bootstrap → run.sh                   ║"
    echo "╚══════════════════════════════════════════════════════╝"
    echo -e "${C_Z}"
}
# --- 1. configuration ---------------------------------------------------------
# [2026-08-03] globals.conf - les reglages reseau (MCC/MNC/ARFCN/KI/IMSI/A5...).
# Genere par ./generate_configs.sh cote hote ; ici on se contente de le lire.
# L'idiome ":=" qu'il utilise laisse gagner toute variable deja posee, donc
#     ARFCN=520 ./start-direct.sh
# surcharge sans toucher au fichier.
[ -r "$HERE/globals.conf" ] && { set -a; . "$HERE/globals.conf"; set +a; }

say_begin "Chargement de l'environnement"
if [ -f "$HERE/environment/load.env" ]; then
    set -a; . "$HERE/environment/load.env"; set +a
    say_end " OK " "$C_OK" "Chargement de l'environnement" "environment/load.env"
elif [ -f "$HERE/env/load.env" ]; then
    set -a; . "$HERE/env/load.env"; set +a
    say_end " OK " "$C_OK" "Chargement de l'environnement" "env/load.env"
else
    # Fallback minimal (chemins typiques du depot)
    : "${GSM_ROOT:=/opt/GSM}"
    : "${NITB_TREE:=$HERE}"
    if [ -x "$HERE/../qosmo-grgsm/run.sh" ]; then
        : "${OQC_ROOT:=$(cd "$HERE/../qosmo-grgsm" && pwd)}"
    else
        : "${OQC_ROOT:=$GSM_ROOT/qosmo-grgsm}"
    fi
    say_end " OK " "$C_OK" "Chargement de l'environnement" "fallback minimal"
fi
: "${RUN_DIR:=/run/osmo-direct}"
# Repli si load.env est absent. Les journaux restent sous RUN_DIR (tmpfs) :
# le defilement tmux en depend - cf. environment/paths.env.
: "${LOG_DIR:=$RUN_DIR/logs}"
# A5/1 par defaut : c'est le chiffrement que la maquette valide de bout en bout
# (Calypso ↔ BTS), et ce que start.sh impose deja dans son hand-off vers ce
# script. Un defaut a "a5 0" faisait diverger le lancement direct du lancement
# par start.sh - meme stack, deux chiffrements, selon la porte d'entree.
#
# ATTENTION : globals.conf est lu AVANT et fait autorite sur cette variable (son
# en-tete le dit). Ce ":=" n'est donc qu'un repli quand globals.conf est absent
# - l'ISO, un conteneur nu. La valeur qui s'applique en pratique vient de la-bas,
# et c'est pourquoi elle y a ete changee aussi.
: "${ENCRYPTION:=a5 1}"
# Le pont TRX par defaut : un transceiver unique (pont/pont.py) qui se presente
# a osmo-bts-trx en TRX-UDP, decode le DL vers GSMTAP 4730/4731 et encode l'UL.
# CALYPSO_BRIDGE n'est pas declare dans globals.conf (les CALYPSO_* "passent au
# travers, intact" d'apres son en-tete) : l'environnement gagne donc vraiment.
#   CALYPSO_BRIDGE=none ./start-direct.sh   -> pas de pont (QEMU + BTS seuls)
#
# [2026-09-02] LE DSP EST ENTERRE SUR CE FORK. La chaine I/Q (osmo-trx-ipc,
# calypso-ipc-device, si_bridge, demod-bridge), le mode CALYPSO_BRIDGE=ipc,
# l'option --dsp (trace asm, mailbox) et les drapeaux CALYPSO_SHUNT_* /
# CALYPSO_CANNED / CALYPSO_SKIP_* / CALYPSO_PIPELINE ont ete retires d'ici :
# le modele QEMU de qosmo-grgsm ne lit plus qu'UNE variable d'environnement,
# CALYPSO_SIM_CFG (voir plus bas), et son lanceur run.sh aucune de celles-la.
# Le travail DSP continue dans le fork qosmo-dsp, pas ici.
: "${CALYPSO_BRIDGE:=pont}"

: "${MS_COUNT:=2}"
: "${HOST_IP:=127.0.0.1}"
mkdir -p "$RUN_DIR" "$LOG_DIR" /root/.osmocom/bb 2>/dev/null || true
# --- 2. detection des chemins / binaires --------------------------------------
say_begin "Resolution de run.sh"
# [2026-08-08] Le chemin etait cable en dur ICI, ce qui rendait MORTE toute la
# resolution de OQC_ROOT faite plus haut (trois candidats testes pour rien) et
# contredisait le contrat annonce en tete de fichier ("la ligne de commande
# gagne toujours") : RUN_SH etait la seule variable non surchargeable.
# Ordre : RUN_SH explicite > OQC_ROOT resolu > chemin historique.
: "${RUN_SH:=${OQC_ROOT:+$OQC_ROOT/run.sh}}"
: "${RUN_SH:=/opt/GSM/qosmo-grgsm/run.sh}"
if [ ! -x "$RUN_SH" ]; then
    say_end "FAIL" "$C_KO" "Resolution de run.sh" "$RUN_SH introuvable ou non executable"
    printf '       %s→ Verifiez que /opt/GSM/qosmo-grgsm/run.sh existe et est executable%s\n' "$C_DIM" "$C_Z"
    exit 1
fi
say_end " OK " "$C_OK" "Resolution de run.sh" "$RUN_SH"
RUN_ROOT="$(dirname "$RUN_SH")"
# ══ --menu : on DEMANDE, au lieu de deviner ════════════════════════════════
# Le lanceur resout tout seul le profil, le noeud, l'inter-STP, le WAN. C'est ce
# qu'il faut pour une ISO qui demarre sans personne devant. Mais devant un banc,
# la meme resolution silencieuse oblige a relire le script pour savoir ce qui a
# ete choisi - et a relancer avec les bonnes options quand ce n'etait pas ca.
#
# Ce menu ne CHANGE aucune regle : il PRE-REMPLIT chaque question avec la valeur
# que le lanceur aurait prise seul, et n'ecrit que ce que l'operateur modifie.
# Valider tout sans rien toucher donne donc exactement le lancement par defaut.
#
# Il s'efface de lui-meme quand personne ne peut repondre (pas de terminal,
# --dry-run, NO_MENU=1 que start.sh pose devant un conteneur) : une question
# sans lecteur, c'est un lancement qui se fige sans le dire.
menu_interactif() {
    [ "${MENU_MODE:-0}" -eq 1 ] || return 0
    [ "${NO_MENU:-0}" = "1" ] && return 0
    [ "$DRY" -eq 1 ] && return 0
    [ -t 0 ] || { printf "  %smenu%s       ignore : pas de terminal\n" "${C_DIM:-}" "${C_Z:-}"; return 0; }

    local has_wt=0
    command -v whiptail >/dev/null 2>&1 && has_wt=1

    # Une question, une valeur par defaut, une reponse. Sans whiptail on
    # retombe sur read : une ISO minimale n'a pas toujours newt.
    _ask() {   # $1 = titre  $2 = question  $3 = defaut
        local rep
        if [ "$has_wt" -eq 1 ]; then
            rep="$(whiptail --title "$1" --inputbox "$2" 11 70 "$3" 3>&1 1>&2 2>&3)" || rep="$3"
        else
            printf "  %s [%s] : " "$2" "$3" >&2
            read -r rep </dev/tty || rep=""
        fi
        printf "%s" "${rep:-$3}"
    }
    _yesno() { # $1 = titre  $2 = question  $3 = defaut (0=oui 1=non)
        if [ "$has_wt" -eq 1 ]; then
            if [ "$3" -eq 0 ]; then whiptail --title "$1" --yesno "$2" 10 70; else
                whiptail --title "$1" --defaultno --yesno "$2" 10 70; fi
            return $?
        fi
        local d="o" rep
        [ "$3" -eq 0 ] || d="n"
        printf "  %s [%s] : " "$2" "$d" >&2
        read -r rep </dev/tty || rep=""
        case "${rep:-$d}" in [oOyY]*) return 0 ;; *) return 1 ;; esac
    }

    printf "  %smenu%s       --menu : les defauts proposes sont ceux du lancement automatique\n" \
        "${C_DIM:-}" "${C_Z:-}"

    PROFILE="$(_ask "Profil" \
        "Profil de pile :\n  faketrx-qemu  coeur + BTS QEMU + BTS faketrx (defaut)\n  faketrx  qemu  noproc" \
        "$PROFILE")"

    # Le noeud est deja resolu plus haut (--node, environnement, /etc/osmo-role,
    # table WAN) : on propose CE resultat, pas un 1 arbitraire.
    NODE_ID="$(_ask "Noeud SS7" \
        "Numero de ce noeud (1-9). Il fixe le point code 1.<noeud><op>.<role>,\nle routing context et les MSISDN <noeud>00<op><rang>." \
        "${NODE_ID:-1}")"
    NODE_OP="$(_ask "Operateur" "Operateur porte par ce noeud (1-9) :" "${NODE_OP:-1}")"
    HUB_IP="$(_ask "Inter-STP" \
        "Adresse du hub SS7 (vide = ne pas toucher au remote-ip en place) :" \
        "${HUB_IP:-$(awk -F= '/^OSMO_HUB_IP=/{gsub(/[ \r\t]/,"",$2);v=$2} END{print v}' "${ROLE_FILE:-/etc/osmo-role}" 2>/dev/null)}")"

    _yesno "WAN" "Monter le maillage WAN entre noeuds ?" "$([ "${WAN_MESH:-0}" -eq 1 ] && echo 0 || echo 1)" \
        && WAN_MESH=1 || WAN_MESH=0

    _yesno "Configurations" \
        "Regenerer /etc/osmocom et /etc/asterisk depuis les gabarits ?\n\nLa pile sera ARRETEE, les fichiers sauvegardes, puis reecrits\navec l'identite du noeud ci-dessus." \
        "$([ "${REGEN_GABARITS:-0}" -eq 1 ] && echo 0 || echo 1)" \
        && REGEN_GABARITS=1 || REGEN_GABARITS=0

    printf "  %smenu%s       profil=%s  noeud=%s  op=%s  hub=%s  wan=%s  regen=%s\n" \
        "${C_DIM:-}" "${C_Z:-}" "$PROFILE" "${NODE_ID:-?}" "$NODE_OP" \
        "${HUB_IP:-inchange}" "$WAN_MESH" "$REGEN_GABARITS"
}
menu_interactif

# Profil → mapping vers les profils attendus par run.sh
case "$PROFILE" in
    faketrx-qemu|hybrid) CALYPSO_PROFILE=hybrid;   MODE=faketrx-qemu ;;
    faketrx)             CALYPSO_PROFILE=faketrx;  MODE=faketrx ;;
    qemu)                CALYPSO_PROFILE=calypso;  MODE=qemu ;;
    noproc|core)         CALYPSO_PROFILE=core;     MODE=noproc; RUN_NO_PROCESS=1 ;;
    *)                   CALYPSO_PROFILE=hybrid;   MODE=faketrx-qemu; PROFILE=faketrx-qemu ;;
esac
export CALYPSO_PROFILE MODE
export RUN_NO_PROCESS="${RUN_NO_PROCESS:-0}"
export ENCRYPTION MS_COUNT HOST_IP
# Client de couche 2 : l'option de ligne de commande ECRASE tout, et elle est
# EXPORTEE -- une valeur posee sans export ne traverse pas jusqu'a run.sh,
# defaut deja paye sur ENCRYPTION (cf. generate_configs.sh).
if [ -n "${L2_CLIENT_CHOISI:-}" ]; then
    CALYPSO_L2_CLIENT="$L2_CLIENT_CHOISI"
fi
export CALYPSO_L2_CLIENT="${CALYPSO_L2_CLIENT:-mobile}"
export LOG_DIR RUN_DIR
# --- 3. validation des chemins critiques --------------------------------------
say_begin "Validation des chemins"
path_ok=1
_check() {
    local name="$1" val="${!1:-}"
    if [ -z "$val" ]; then
        printf '\n       %s%s non defini%s\n' "$C_DIM" "$name" "$C_Z"
        path_ok=0
    elif [ -e "$val" ]; then
        :
    else
        printf '\n       %s%s introuvable : %s%s\n' "$C_DIM" "$name" "$val" "$C_Z"
        path_ok=0
    fi
}
# Variables optionnelles selon le profil ; on ne bloque que si presentes et cassees
# (DSP_PROM0 retire : ROM plus generee depuis le merge `sans-dsp` de
#  qosmo-grgsm — cf. run.sh --check-paths et run_modules/00-prereqs.sh.)
for v in QEMU_BIN FIRMWARE_ELF OSMOCON; do
    # `[ -n x ] && _check || true` avalait le verdict : _check pouvait poser
    # path_ok=0 sans que la boucle ne le laisse remonter. Forme explicite.
    if [ -n "${!v:-}" ]; then _check "$v"; fi
done
if [ $path_ok -eq 1 ]; then
    say_end " OK " "$C_OK" "Validation des chemins"
else
    say_end "WARN" "$C_SK" "Validation des chemins" "certains chemins manquent (run.sh verifiera)"
fi
# --- 4. generation des configs mobile -----------------------------------------
# MS#1 (QEMU / mobile principal) et MS#2 (faketrx side-car) pour le profil hybrid.
BB_DIR="/root/.osmocom/bb"
mkdir -p "$BB_DIR"
# Sources possibles pour le template mobile
MS_TEMPLATE=""
for t in \
    "${OQC_ROOT:-}/cfgs/mobile_group1.cfg" \
    "$RUN_ROOT/cfgs/mobile_group1.cfg" \
    "$HERE/configs/mobile.cfg.template" \
    "$HERE/configs/mobile.cfg" \
    "$BB_DIR/mobile_group1.cfg" \
    "$BB_DIR/mobile.cfg"
do
    if [ -f "$t" ]; then MS_TEMPLATE="$t"; break; fi
done
generate_mobile_cfg() {
    local dest="$1" vty_port="$2" l2sock="$3" sapsock="$4" arfcn="$5" imsi="$6" ki="$7"
    # [2026-08-08] Peripheriques ALSA PAR MOBILE. Le template porte
    # gsm_out/gsm_in pour tout le monde ; laisser ca remettrait les deux
    # mobiles sur le meme sink et la meme source, c'est-a-dire la boucle
    # audio qu'on vient d'ouvrir (cf /etc/asound.conf).
    local aout="${8:-gsm_out}" ain="${9:-gsm_in}"
    # [2026-08-08] GARDE-FOU : la liste de templates contient $BB_DIR/mobile.cfg,
    # qui est aussi une DESTINATION. Si c'est lui qui est retenu, `sed "$tpl" >
    # "$dest"` avec tpl == dest fait tronquer le fichier par le shell AVANT que
    # sed ne le lise : on produit un mobile.cfg VIDE et le mobile ne demarre plus.
    # On bascule alors sur le template de secours plutot que de se detruire.
    local tpl="$MS_TEMPLATE"
    if [ -n "$tpl" ] && [ "$tpl" -ef "$dest" ] 2>/dev/null; then
        printf '\n       %sMS_TEMPLATE == destination (%s) : template de secours%s\n' \
            "$C_DIM" "$dest" "$C_Z"
        tpl=""
    fi
    if [ -n "$tpl" ]; then
        sed \
            -e "s|bind 127.0.0.1 424[0-9]|bind 127.0.0.1 ${vty_port}|" \
            -e "s|layer2-socket /tmp/osmocom_l2[_0-9]*|layer2-socket ${l2sock}|" \
            -e "s|sap-socket /tmp/osmocom_sap[_0-9]*|sap-socket ${sapsock}|" \
            -e "s|stick [0-9]*|stick ${arfcn}|" \
            -e "s|^\([[:space:]]*\)imsi .*|\1imsi ${imsi}|" \
            -e "s|^\([[:space:]]*\)imei .*|\1imei $(rand_imei) 0|" \
            -e "s|^\([[:space:]]*\)ki .*|\1ki comp128 ${ki}|" \
            -e "s|alsa-output-dev .*|alsa-output-dev ${aout}|" \
            -e "s|alsa-input-dev .*|alsa-input-dev ${ain}|" \
            "$tpl" > "$dest"
    else
        # Template minimal de secours
        cat > "$dest" <<EOF
!
! mobile.cfg genere par start-direct.sh (secours)
!
log stderr
 logging color 1
 logging print category 1
 logging timestamp 0
!
line vty
 no login
 bind 127.0.0.1 ${vty_port}
!
ms 1
 layer2-socket ${l2sock}
 sap-socket ${sapsock}
 sim reader
 imsi ${imsi}
 ki comp128 ${ki}
 network-selection-mode auto
 stick ${arfcn}
!
EOF
    fi
}
# ⚠️ [2026-08-08, resolu le 26/08] LE RUN N'UTILISE PAS mobile.cfg POUR MS#1.
# Le mobile Calypso tourne avec
#     mobile -c /root/.osmocom/bb/mobile_group1.cfg
# alors qu'on ne generait ici que mobile.cfg. mobile_group1.cfg, lui, est COPIE
# tel quel par qosmo-grgsm (run_modules/20-mobile-cfg.sh, depuis $QEMU_TREE/cfgs) :
# un fichier statique, jamais derive de l'operateur. Il porte donc l'identite de
# l'operateur 1 - IMSI 001010001000001, ARFCN 514 - quel que soit l'operateur.
#
# Constate sur le banc, operateur 2 : le HLR attend 002010002000001, la BTS
# emet sur 516, et le mobile se presentait avec 001010001000001 sur 514.
#     Mobile Subscriber of MS '1':
#      IMSI: 001010001000001
#      Status: U2_NOT_UPDATED  IMSI detached  LAI: invalid
#      Registered PLMN: MCC-MNC 001-01
# Tout le reste etait juste - clefs, MSISDN, point codes, ARFCN des BTS - et
# l'abonne restait detache, parce que la seule carte SIM qui compte n'etait
# generee nulle part.
#
# On ecrit donc MS#1 dans LES DEUX fichiers. mobile.cfg reste la reference
# (d'autres modules la lisent), mobile_group1.cfg est celui que le lanceur
# ouvre. Verifier lequel tourne, avant de croire un reglage pose ici :
#     pgrep -a mobile
# ── IMSI et Ki des MS : le plan du HLR, pas celui de l'operateur 1 ──────────
# Ces valeurs etaient ecrites EN DUR sur le PLMN de l'operateur 1 (001-01) et
# sur son numero d'abonne. Sur l'operateur 2, dont le reseau diffuse un autre
# MCC, le mobile presentait donc l'IMSI d'un abonne d'un autre pays : ni le MSC
# ni le HLR n'ont de fiche a ce numero, le Location Updating reste sans reponse,
# et l'on cherche la panne dans la BTS alors que la pile entiere est debout.
# Le plan est celui de start.sh (section "HLR subscribers") :
#     IMSI = MCC MNC <operateur sur 4 chiffres> <index du MS sur 6 chiffres>
#     Ki   = 00112233445566778899aabbccdd <index du MS> <operateur>
# MCC et MNC sont relus dans la configuration REELLEMENT deployee - c'est elle
# que la BTS diffuse - et non redevines a partir d'une convention.
MS_OP_ID="${OPERATOR_ID:-1}"
MS_MCC="$(awk '$1=="network" && $2=="country" && $3=="code" {print $4; exit}' /etc/osmocom/osmo-bsc.cfg 2>/dev/null || true)"
MS_MNC="$(awk '$1=="mobile" && $2=="network" && $3=="code" {print $4; exit}' /etc/osmocom/osmo-bsc.cfg 2>/dev/null || true)"
# ── LES REPLIS SUIVENT LE PLAN, ILS NE L INVENTENT PAS ──────────────────────
# [2026-08-31] Ces deux lignes se declenchent quand osmo-bsc.cfg n est pas
# encore lisible (configs pas regenerees a cet instant), et elles etaient
# fausses toutes les deux :
#
#   MS_MNC="01"  en dur : l operateur 2 heritait du MNC de l operateur 1, et
#   ms_imsi() produisait 001|01|0002|000001 - MNC d un operateur, identifiant
#   d un autre. Ces abonnes-la arrivaient dans le HLR SANS MSISDN ni IMEI
#   ("none" / "-------------"), a cote des bons : le VLR ne pouvait les
#   rattacher a personne. Rien ne signalait l incoherence.
#
#   MS_MCC=<op sur 3 chiffres> : aurait donne MCC 002 pour l operateur 2,
#   alors que TOUT le banc est en 001 - c est le PLMN d essai, commun a tous
#   les operateurs, et c est le MNC qui les distingue.
#
# Le plan reel, verifie dans /var/tmp/osmo-annuaire.csv :
#   op 1 -> 001|01   op 2 -> 001|02   op 3 -> 001|03
[ -n "$MS_MCC" ] || MS_MCC="001"
[ -n "$MS_MNC" ] || MS_MNC="$(printf '%02d' "$MS_OP_ID")"
ms_imsi() { printf '%s%s%04d%06d' "$MS_MCC" "$MS_MNC" "$MS_OP_ID" "$1"; }

# ── LE QUATRIEME PROVISIONNEUR ──────────────────────────────────────────────
# run.sh embarque son propre module d'abonnes (run_modules/21-abonnes-hlr.sh,
# dans qosmo-grgsm). Il calcule son IMSI comme MCC+MNC+operateur+rang - la meme
# formule que nous - mais lit MCC et MNC dans /etc/osmocom/osmo-msc.cfg via un
# chemin qui n'aboutit pas toujours ; il retombe alors sur ses defauts, 001 et
# 01. Sur l'operateur 2 il fabriquait donc 001010002000001 : un abonne que
# personne ne presente, avec en prime un MSISDN de l'ancien plan (20001), qui
# RETIENT ce numero et empeche le bon abonne de le recuperer.
#
# Il honore MCC et MNC depuis l'environnement. En les posant, son IMSI devient
# exactement celui que start.sh a deja provisionne - et son propre controle
# d'etat (mod_abonnes_hlr_status) le trouve present, donc il ne fait rien.
# On neutralise ainsi un doublon par son idempotence, sans toucher a qosmo-grgsm.
export MCC="${MCC:-$MS_MCC}"
export MNC="${MNC:-$MS_MNC}"

# ── Le plan radio : on le LIT, on ne le recalcule pas ───────────────────────
# generate_configs.sh l'ecrit dans /etc/osmocom/radio-plan.env en meme temps
# que les configurations. Le relire ici garantit que le mobile s'accorde sur
# ce que la BTS diffuse VRAIMENT - et non sur une formule recopiee qui finira
# par diverger, comme l'ont fait l'unit-id, l'ARFCN et l'IMSI avant elle.
# Les replis restent, pour un banc monte sans ce fichier.
if [ -r /etc/osmocom/radio-plan.env ]; then
    # shellcheck disable=SC1091
    . /etc/osmocom/radio-plan.env
    printf '  %splan radio%s  lu dans /etc/osmocom/radio-plan.env\n' "${C_DIM:-}" "${C_Z:-}"
fi

# ── Les deux canaux radio, et a qui chaque MS s'accroche ────────────────────
# MS#1 et MS#2 avaient leur ARFCN ecrit EN DUR, 514 et 516 : les valeurs de
# l'operateur 1, et de lui seul. Sur l'operateur 2 la BTS principale passe a
# 516 et le side-car y restait aussi : deux cellules sur la meme frequence,
# leurs bursts melanges dans un milieu ou fake_trx apparie par frequence.
# Le mobile envoyait ses RACH, aucune Immediate Assignment ne revenait, et
# MS#1 restait accroche a 514 ou plus rien n'emettait.
#
# MS#1 suit la BTS principale, dont l'ARFCN est lu dans la configuration
# REELLEMENT deployee - c'est le BSC qui en decide et qui l'impose a la BTS
# par OML, donc c'est la qu'est la verite.
MS_ARFCN1="${PLAN_ARFCN:-}"
[ -n "$MS_ARFCN1" ] || MS_ARFCN1="$(awk '/^ bts 0$/{b=1} b && /^ *arfcn /{print $2; exit}' /etc/osmocom/osmo-bsc.cfg 2>/dev/null || true)"
[ -n "$MS_ARFCN1" ] || MS_ARFCN1=$(( 512 + MS_OP_ID * 2 ))

# MS#2 suit le side-car. Celui-la, c'est NOUS qui le decidons : le module
# 13-sidecar-cfg.sh de qosmo-grgsm lit SC_ARFCN et SC_UNIT_ID dans
# l'environnement (run_modules/_lib/radio.sh : « : "${SC_ARFCN:=516}" »).
# Les poser ici evite de modifier qosmo-grgsm, et garde une seule source par
# valeur - les memes formules que generate_configs.sh.
export SC_ARFCN="${SC_ARFCN:-${PLAN_ARFCN_BTS1:-$(( 612 + MS_OP_ID * 2 ))}}"
export SC_UNIT_ID="${SC_UNIT_ID:-${PLAN_UNIT_ID_BTS1:-$(( 6000 + MS_OP_ID * 10 + 2 ))}}"
MS_ARFCN2="$SC_ARFCN"
ms_ki()   { printf '00 11 22 33 44 55 66 77 88 99 aa bb cc dd %02x %02x' "$1" "$MS_OP_ID"; }

# IMEI tire au hasard, cle de Luhn calculee - un par MS, jamais deux identiques
# dans une meme trace.
rand_imei() {
    local body d i sum=0 dbl parity
    body="35892500$(printf '%06d' "$(( (RANDOM << 15 | RANDOM) % 1000000 ))")"
    for (( i = ${#body} - 1; i >= 0; i-- )); do
        d=${body:i:1}
        parity=$(( (${#body} - 1 - i) % 2 ))
        if [ "$parity" -eq 0 ]; then
            dbl=$(( d * 2 )); [ "$dbl" -gt 9 ] && dbl=$(( dbl - 9 ))
            sum=$(( sum + dbl ))
        else
            sum=$(( sum + d ))
        fi
    done
    printf '%s%d' "$body" "$(( (10 - sum % 10) % 10 ))"
}

printf '  %sPLMN MS%s    MCC %s  MNC %s  operateur %s  ->  IMSI %s / %s\n' \
    "${C_DIM:-}" "${C_Z:-}" "$MS_MCC" "$MS_MNC" "$MS_OP_ID" "$(ms_imsi 1)" "$(ms_imsi 2)"
printf '  %sradio%s      MS#1 sur ARFCN %s (BTS principale)   MS#2 sur ARFCN %s (side-car, unit-id %s)\n' \
    "${C_DIM:-}" "${C_Z:-}" "$MS_ARFCN1" "$MS_ARFCN2" "$SC_UNIT_ID"

say_begin "Generation mobile MS#1"
MS1_CFG="${MOBILE_CFG_MS1_PATH:-$BB_DIR/mobile.cfg}"
generate_mobile_cfg "$MS1_CFG" \
    4247 \
    /tmp/osmocom_l2 \
    /tmp/osmocom_sap_1 \
    "$MS_ARFCN1" \
    "$(ms_imsi 1)" \
    "$(ms_ki 1)"
# La copie que le lanceur ouvre reellement. On la REGENERE plutot que de la
# copier : generate_mobile_cfg fixe aussi le port VTY, les sockets et l'ARFCN,
# et un simple cp propagerait le fichier statique de qosmo-grgsm.
# ── ET ON EMPECHE QU'IL SOIT ECRASE ─────────────────────────────────────
# Ecrire ce fichier ne suffit pas : run.sh le REECRIT juste apres. Son module
# 20-mobile-cfg.sh copie MOBILE_CFG_SRC par-dessus, et ce chemin vaut par
# defaut $QEMU_TREE/cfgs/mobile_group1.cfg - le fichier statique de qosmo-grgsm,
# fige sur l'operateur 1. Mesure sur le banc :
#     mobile.cfg          18:22:22  imsi=002010002000001 arfcn=516
#     mobile_group1.cfg   18:22:27  imsi=001010001000001 arfcn=514
# Cinq secondes d'ecart, et c'est le second que le mobile ouvre.
# On designe donc NOTRE fichier comme source : le module recopie alors le bon
# contenu, et son controle d'idempotence (cmp -s src dest) passe puisque les
# deux sont identiques. Aucune modification de qosmo-grgsm.
export MOBILE_CFG_SRC="$MS1_CFG"

MS1_GROUP_CFG="$BB_DIR/mobile_group1.cfg"
if [ "$MS1_GROUP_CFG" != "$MS1_CFG" ]; then
    generate_mobile_cfg "$MS1_GROUP_CFG" \
        4247 \
        /tmp/osmocom_l2 \
        /tmp/osmocom_sap_1 \
        "$MS_ARFCN1" \
        "$(ms_imsi 1)" \
        "$(ms_ki 1)"
    printf '  %sMS#1%s       aussi ecrit dans %s (le fichier que run.sh ouvre)\n' \
        "${C_DIM:-}" "${C_Z:-}" "$MS1_GROUP_CFG"
fi
say_end " OK " "$C_OK" "Generation mobile MS#1" "$MS1_CFG"
say_begin "Generation mobile MS#2 (faketrx)"
MS2_CFG="$BB_DIR/mobile_faketrx_bts1.cfg"
generate_mobile_cfg "$MS2_CFG" \
    4248 \
    /tmp/ms2_l2 \
    /tmp/ms2_sap \
    "$MS_ARFCN2" \
    "$(ms_imsi 2)" \
    "$(ms_ki 2)" \
    gsm_out gsm_in
say_end " OK " "$C_OK" "Generation mobile MS#2 (faketrx)" "$MS2_CFG"
# ── LA SIM EMULEE LIT LE MEME FICHIER QUE LE MOBILE ─────────────────────────
# CALYPSO_SIM_CFG est la SEULE variable d'environnement que lit le modele QEMU
# (hw/arm/calypso/calypso_sim.c) : il y prend la section « test-sim » - imsi et
# ki comp128 - au format de mobile.cfg, donc exactement le fichier MS#1 genere
# ci-dessus. Sans elle, la SIM emulee garde son IMSI par defaut
# (001010000000001), inconnu du HLR provisionne avec le plan. Elle n'etait
# exportee nulle part.
# (MOBILE_CFG_MS1/MS2 et CALYPSO_MS2_CFG, exportes ici jusqu'au 2026-09-02,
#  n'avaient aucun lecteur dans qosmo-grgsm ; le side-car MS#2 lit
#  SC_MOBILE_CFG / le fichier lui-meme.)
export CALYPSO_SIM_CFG="$MS1_CFG"

# --- Mode PONT TRX (CALYPSO_BRIDGE=pont) : le pont maison est le transceiver --
# Le pont (pont/pont.py) se presente comme transceiver TRX-UDP a osmo-bts-trx
# (5700/5701/5702), decode les bursts DL en L2 -> GSMTAP 4730/4731 vers le
# modele QEMU, et encode l'UL depuis les sidebands /dev/shm/calypso_* -> TRXD
# -> BTS. Il a BESOIN d'osmo-bts-trx en face : c'est qosmo-grgsm/60-bts.sh qui
# le lance, et la barriere plus bas attend son retour avant de lancer le pont.
if [ "${CALYPSO_BRIDGE:-}" = pont ]; then
    export CALYPSO_BRIDGE
    # ── LE PONT VIENT DU DEPOT, POINT ──────────────────────────────────────
    # Le defaut etait /opt/GSM/pont/pont.py, un chemin HORS DEPOT alimente par un
    # `COPY pont/pont.py` de Dockerfile.run et fige par un `ENV PONT_PY=`. Rien
    # ne le gardait en phase avec pont/pont.py, et rien ne signalait sa derive.
    #
    # CE QUE CA A COUTE, le 2026-08-27 : la copie executee etait restee a une
    # version anterieure de 198 lignes, SANS AUCUN plan SDCCH/8 (ni PLAN_SD8, ni
    # _dcch_plan, ni sacch_dl102). Elle raisonnait sur le SDCCH/4 combine du TS0
    # pendant que le mobile parlait sur SDCCH/8 SS0 du TS1. Pendant deux heures,
    # sans un seul message d erreur : 79 % de CRC fail, montant emis dans la
    # fenetre du /4 (donc hors de celle que la BTS ecoute, avec hors_fenetre=0
    # puisque coherent avec sa propre table), SABM sans reponse, T200 x6, LU en
    # echec, SMS rejete, appel impossible. Restaurer la copie du depot a suffi a
    # retablir le LU complet, chiffrement A5/1 compris, des le premier essai.
    #
    # La copie libre et son COPY sont supprimes ; PONT_PY reste disponible pour
    # qui veut essayer une variante, mais c est alors un choix explicite.
    #
    # [2026-08-30] NITB_ROOT RETIRE DE CE CHEMIN. Le defaut passait par lui, et
    # NITB_ROOT n appartient pas a ce depot : qosmo-grgsm/environnement/paths.env
    # le pose a "$GSM_ROOT/osmo_egprs" - l ANCIEN nom de cet arbre. Le pont etait
    # donc cherche dans /opt/GSM/osmo_egprs/pont/pont.py, d ou
    # "pont introuvable (/opt/GSM/osmo_egprs/pont/pont.py)" sur un banc ou le
    # fichier est pourtant la, dans le depot. Pire quand ce vieux dossier existe
    # encore : on y trouve une COPIE, et on retombe mot pour mot dans la panne
    # decrite ci-dessus - un pont hors depot qui derive sans que rien ne le dise.
    # $HERE est le repertoire de CE script : le pont voyage avec lui, toujours.
    _PONT="${PONT_PY:-$HERE/pont/pont.py}"

    # ── LE PONT DOIT SAVOIR SUR QUELLE CELLULE IL TRAVAILLE ─────────────────
    # pont.py fait le codage/decodage de canal entre le Calypso de QEMU et la
    # BTS. Il lisait PONT_ARFCN et PONT_BSIC dans son environnement, avec pour
    # defauts 514 et 7 - les valeurs de l'operateur 1. Personne ne les posait.
    #
    # Le BSIC n'est pas decoratif : sa partie basse (BCC) EST la sequence
    # d'apprentissage des canaux dedies. Avec un BSIC faux, la correlation
    # echoue et les blocs ne se decodent pas. Constate sur l'operateur 2, dont
    # la cellule est en ARFCN 516 / BSIC 14 :
    #     dl_bursts=20660 dl_dec=1180 crc_fail=3984
    #     TS0/22 : 0/295        <- la voie ou arrive l'Immediate Assignment
    #     ul_sent=23 rach=23    <- le RACH partait bien
    # Le mobile emettait son RACH, le reseau repondait, et la reponse n'etait
    # jamais rendue au Calypso : "radio resource layer state: connection
    # pending", indefiniment. L'operateur 1 s'en sortait parce que ses valeurs
    # sont justement celles des defauts.
    #
    # On les prend dans le plan radio publie par generate_configs.sh, avec les
    # memes replis que les mobiles.
    export PONT_ARFCN="${PONT_ARFCN:-${PLAN_ARFCN:-$MS_ARFCN1}}"
    export PONT_BSIC="${PONT_BSIC:-${PLAN_BSIC:-$(( MS_OP_ID * 7 % 64 ))}}"
    printf '  %spont TRX%s : cellule ARFCN %s BSIC %s\n' \
        "${C_DIM:-}" "${C_Z:-}" "$PONT_ARFCN" "$PONT_BSIC"
    # [2026-08-16] N'ARMER LE LANCEUR QUE SUR UN VRAI DEMARRAGE.
    # Ce bloc s'execute AVANT le `case "$ACTION"` plus bas. Sur `--stop` (comme
    # sur --list/--status/--check-paths) on armait donc quand meme le lanceur
    # differe, que `run.sh --stop` tuait aussitot - d'ou le "line 450: Killed"
    # crache par bash en plein arret propre. Un arret qui programme un demarrage
    # n'a aucun sens, et le message faisait croire a une panne.
    # Le pont DEJA VIVANT, lui, est bien arrete : le teardown le connait
    # desormais (`f:pont/pont.py` dans _td_patterns) et mod_teardown_stop
    # l'applique aussi.
    if [ "$ACTION" != start ]; then
        printf '  %spont TRX%s : non arme (action=%s, pas un demarrage)\n' \
               "${C_DIM:-}" "${C_Z:-}" "$ACTION"
    elif [ -r "$_PONT" ]; then
        # SINGLETON : tuer un pont precedent, sinon il tient 5700-5702 et le
        # teardown de run.sh echoue ("restes du run precedent : port:5700...").
        pkill -f "$_PONT" 2>/dev/null || true
        # [2026-08-16] KILLALL DEMANDE PAR L'OPERATEUR, et assume.
        # Le motif nominatif ci-dessus rate tout pont lance sous un autre chemin
        # (PONT_PY surcharge, une sauvegarde .bak), et c'est ce qui faisait
        # echouer le teardown sur "port:5700 port:5701 port:5702".
        # ⚠️ CE QUE CA EMPORTE, en plus du pont : fake_trx.py (le transceiver de
        # MS#2), sms-interop-relay.py (routage SMS inter-operateurs, HORS pile
        # run.sh donc non relance automatiquement), gsm_sniff.py et les scripts
        # des panes tmux. C'est acceptable ICI parce qu'on est sur le chemin d'un
        # DEMARRAGE (ACTION=start) : le teardown de run.sh va de toute facon tout
        # arreter deux secondes plus tard. Ne PAS reprendre ce geste dans un
        # module de run.sh, ou il tuerait le filtre d'horodatage du run en cours.
        # CALYPSO_NO_KILLALL=1 pour s'en passer.
        if [ "${CALYPSO_NO_KILLALL:-0}" != "1" ]; then
            printf '  %spont TRX%s : killall -9 python3 (demande ; emporte aussi fake_trx / sms-interop-relay)\n' "${C_DIM:-}" "${C_Z:-}"
            killall -9 python3 2>/dev/null || true
        fi
        sleep 1
        printf '  %spont TRX%s : transceiver TRX-UDP d osmo-bts-trx (pont/pont.py)\n' "${C_DIM:-}" "${C_Z:-}"
        # LANCEMENT DIFFERE : run.sh fait d'abord son teardown (qui verifie que
        # 5700-5702 sont LIBRES). On ne binde donc qu'apres, sinon on se
        # bloque nous-memes. 25 s = teardown + demarrage des modules.
        # LOG HORS LOG_DIR : run.sh archive/efface LOG_DIR pendant son teardown ;
        # un pont.log ouvert avant se retrouve sur un inode SUPPRIME (sortie
        # invisible, "(deleted)" dans /proc/<pid>/fd/1). /dev/shm survit.
        # setsid : le pont doit survivre a la fin du shell de start-direct.
        # [2026-08-16] LE `sleep 25` ETAIT UNE COURSE, PAS UNE BARRIERE.
        # 25 s etait un pari sur la duree du teardown de run.sh. Des que
        # celui-ci archive de gros journaux (mesure : 62 Mo -> ~40 s), le pont
        # se rebindait sur 5700-5702 EN PLEIN teardown, qui echouait alors sur
        # "restes du run precedent : port:5700 port:5701 port:5702" et
        # abandonnait toute la sequence. Le symptome se deplacait avec la
        # taille des journaux, ce qui le faisait passer pour intermittent.
        # On attend maintenant un EVENEMENT et non une duree : osmo-bts-trx,
        # qui est l'interlocuteur TRX-UDP du pont et ne demarre qu'APRES le
        # teardown. Sa presence prouve donc que les ports sont a nous.
        # [2026-08-27] LA BARRIERE ATTEND D'ABORD QU'IL DISPARAISSE.
        # Elle n'attendait que sa PRESENCE, en se fiant a « osmo-bts-trx ne
        # demarre qu'APRES le teardown ». Vrai seulement si l'on a fait --stop
        # avant. Sans --stop, l'osmo-bts-trx du run PRECEDENT est encore la :
        # pgrep reussit immediatement (_n=0), le pont binde 5700-5702 en plein
        # teardown, run.sh le tue derriere (f:pont/pont.py est dans
        # _td_patterns) et rien ne le relance -- « le pont n'a pas de bursts ».
        # On attend donc les DEUX fronts : la disparition (le teardown a bien
        # eu lieu), puis l'apparition (la pile est remontee). Avec --stop le
        # premier front est deja franchi et rien ne change.
        ( _n=0
          while [ "$_n" -lt 180 ] && pgrep -x osmo-bts-trx >/dev/null 2>&1; do
              _n=$((_n + 1)); sleep 1
          done
          _dispar=$_n
          while [ "$_n" -lt 180 ] && ! pgrep -x osmo-bts-trx >/dev/null 2>&1; do
              _n=$((_n + 1)); sleep 1
          done
          if [ "$_n" -ge 180 ]; then
              echo "[pont] osmo-bts-trx jamais (re)vu apres 180 s - demarrage quand meme"
          else
              echo "[pont] osmo-bts-trx parti a ${_dispar}s puis revu a ${_n}s - la pile est debout, on binde"
          fi
          sleep 1
          pkill -f "$_PONT" 2>/dev/null; sleep 1
          exec setsid python3 -u "$_PONT" ) >/dev/shm/pont.log 2>&1 &
        printf '  %spont: journal%s /dev/shm/pont.log (hors LOG_DIR, efface par le teardown)\n' "${C_DIM:-}" "${C_Z:-}"
    else
        printf '  pont introuvable (%s)\n' "$_PONT"
    fi
fi

# --- MAILLAGE DE BURSTS (--air-mesh) : le milieu radio devient commun ---------
# Jusqu'ici deux noeuds se parlaient tout en haut de la pile - un trunk SIP,
# un relais SMS, du M3UA vers un hub - et chacun gardait sa radio hermetique :
# le mobile de l'operateur 1 ne POUVAIT PAS entendre la BTS de l'operateur 2.
# Ici c'est le burst qui traverse : pont/airmesh.py prend ce qui passe sur le
# fake_trx local et l'echange avec les pairs. Voir l'en-tete d'airmesh.py pour
# le detail - notamment pourquoi il faut DEUX emplacements de transceiver, et
# comment on les obtient sans modifier une ligne de qosmo-grgsm.
if [ "${AIR_MESH:-0}" = "1" ]; then
    _AIRMESH="${AIRMESH_PY:-${HERE}/pont/airmesh.py}"
    [ -f "$_AIRMESH" ] || _AIRMESH=/opt/GSM/osmo-operator/pont/airmesh.py
    if [ ! -f "$_AIRMESH" ]; then
        printf '  %sair-mesh%s : airmesh.py introuvable - maillage de bursts non lance\n' "${C_KO:-}" "${C_Z:-}"
    else
        # DEUX MOBILES DE PLUS QUE LE BANC N'EN A. fake_trx cree un transceiver
        # par mobile declare ; airmesh prend les deux derniers et les accorde
        # autrement (l'un comme un mobile, l'autre comme une BTS). Sans ce +2,
        # il n'y a aucun emplacement libre et airmesh se tait sans rien dire.
        _NMS_REEL="${N_MS:-2}"
        N_MS=$(( _NMS_REEL + 2 )); export N_MS
        _BBP="${BB_BASE_PORT:-6700}"; _BBS="${BB_PORT_STEP:-3}"
        _TRX_MS=$((  _BBP + (_NMS_REEL + 1 - 1) * _BBS ))   # emplacement N+1
        _TRX_BTS=$(( _BBP + (_NMS_REEL + 2 - 1) * _BBS ))   # emplacement N+2

        # Les pairs viennent de la table WAN : tout noeud qui n'est pas nous.
        _PAIRS=""
        for _tok in ${WAN_NODES:-}; do
            _pid="${_tok%%:*}"; _rest="${_tok#*:}"; _pip="${_rest%%:*}"
            [ -n "$_pid" ] && [ -n "$_pip" ] || continue
            [ "$_pid" = "${NODE_ID:-1}" ] && continue
            _PAIRS="$_PAIRS --pair ${_pid}:${_pip}:${AIRMESH_PORT:-4739}"
        done

        printf '  %sair-mesh%s   noeud %s  arfcn %s  emplacements TRXD %s et %s  pairs:%s\n' \
            "${C_DIM:-}" "${C_Z:-}" "${NODE_ID:-1}" "${ARFCN:-514}" \
            "$_TRX_MS" "$_TRX_BTS" "${_PAIRS:- aucun}"

        # On attend que fake_trx ait BINDE l'emplacement, pas une duree : il ne
        # demarre qu'apres le teardown de run.sh, dont la duree depend de la
        # taille des journaux archives. Une temporisation ferait de ce lancement
        # une course - c'est la lecon du pont, juste au-dessus.
        ( _n=0
          while [ "$_n" -lt 180 ] && ! ss -uln 2>/dev/null | grep -q ":$(( _TRX_MS + 1 ))\b"; do
              _n=$((_n + 1)); sleep 1
          done
          if [ "$_n" -ge 180 ]; then
              echo "[airmesh] emplacement $(( _TRX_MS + 1 )) jamais ouvert par fake_trx apres 180 s"
              echo "[airmesh] verifiez que le banc tourne bien avec N_MS=${N_MS} (deux de plus que ${_NMS_REEL})"
              exit 1
          fi
          echo "[airmesh] fake_trx a ouvert nos emplacements apres ${_n} s"
          sleep 1
          # shellcheck disable=SC2086
          exec setsid python3 -u "$_AIRMESH" \
              --node "${NODE_ID:-1}" --arfcn "${ARFCN:-514}" \
              --port "${AIRMESH_PORT:-4739}" \
              --trx-ms "$_TRX_MS" --trx-bts "$_TRX_BTS" \
              --topologie "${HERE}/data/air-mesh.txt" \
              $_PAIRS ) >/dev/shm/airmesh.log 2>&1 &
        printf '  %sair-mesh: journal%s /dev/shm/airmesh.log\n' "${C_DIM:-}" "${C_Z:-}"
    fi
fi

# --- 6. resume ----------------------------------------------------------------
banner
printf '  %sprofil%s     %s (%s)\n' "$C_DIM" "$C_Z" "$CALYPSO_PROFILE" "$MODE"
printf '  %senviron.%s   %s\n' "$C_DIM" "$C_Z" \
    "$(case "$RUNTIME_ENV" in docker) echo "conteneur docker" ;; vm) echo "machine virtuelle" ;; *) echo "machine physique" ;; esac)"
if [ -n "$NODE_ID" ]; then
    printf '  %snoeud%s      %s  %s(%s)%s\n' "$C_DIM" "$C_Z" "$NODE_ID" "$C_DIM" "${NODE_ID_SRC:-?}" "$C_Z"
else
    printf '  %snoeud%s      %s\n' "$C_DIM" "$C_Z" "aucun - identite SS7 inchangee"
fi
printf '  %srun.sh%s     %s\n' "$C_DIM" "$C_Z" "$RUN_SH"
printf '  %sMS#1%s       %s  IMSI %s  ARFCN %s  VTY 4247\n' "$C_DIM" "$C_Z" "$MS1_CFG" "$(ms_imsi 1)" "$MS_ARFCN1"
printf '  %sMS#2%s       %s  IMSI %s  ARFCN %s  VTY 4248\n' "$C_DIM" "$C_Z" "$MS2_CFG" "$(ms_imsi 2)" "$MS_ARFCN2"
printf '  %sjournaux%s   %s\n' "$C_DIM" "$C_Z" "$LOG_DIR"
printf '  %schiffrement%s %s\n' "$C_DIM" "$C_Z" "$ENCRYPTION"
printf '\n'
# --- 7. actions deleguees a run.sh --------------------------------------------
RUN_ARGS=()
[ "$DRY" -eq 1 ]     && RUN_ARGS+=(--dry-run)
[ "$FORCE" -eq 1 ]   && RUN_ARGS+=(--force)
[ "$VERBOSE" -eq 1 ] && RUN_ARGS+=(--verbose)
RUN_ARGS+=(--profile "$CALYPSO_PROFILE")
# [2026-08-08] --restart etait ajoute DIRECTEMENT sur la ligne exec, hors de
# RUN_ARGS. Consequence : `--dry-run` affichait une commande SANS --restart puis
# le vrai lancement en ajoutait un. Une simulation qui ne decrit pas ce qui va
# se passer est pire qu'absente. Il entre donc dans RUN_ARGS, ou il est visible.
# CALYPSO_NO_RESTART=1 pour demarrer sans reinitialiser (l'ancien comportement
# n'etait pas atteignable du tout).
[ "${CALYPSO_NO_RESTART:-0}" = "1" ] || RUN_ARGS+=(--restart)
# ── Les sessions tmux d'un run precedent ────────────────────────────────────
# Le teardown de run.sh arrete les processus mais laisse la session tmux
# debout. Au demarrage suivant, son module de panes RECREE ses fenetres dans la
# session survivante : il y a alors DEUX fenetres nommees « all ».
#
# Et deux fenetres de meme nom ne sont pas seulement inesthetiques : tmux ne
# sait plus resoudre la cible. Verifie sur le banc, tmux 3.2a :
#     $ tmux split-window -t calypso:all
#     can't find window: all
# Les trois splits echouent, le module les compte comme "pane refuse par tmux
# (place insuffisante)" - un diagnostic FAUX qui envoie chercher un probleme de
# taille de terminal - et la barriere conclut « la fenetre "all" n'a pas garde
# ses panes ». La cause etait deux fenetres plus haut.
#
# On efface donc les sessions du run precedent avec lui. Trois conventions
# cohabitent selon le lanceur, on les couvre toutes ; aucune n'a besoin
# d'exister.
purge_sessions_tmux() {
    local n=0 s
    for s in calypso gapk osmo; do
        if tmux has-session -t "$s" 2>/dev/null; then
            tmux kill-session -t "$s" 2>/dev/null && n=$((n + 1))
        fi
    done
    if tmux -S /tmp/osmocom_tmux has-session -t osmocom 2>/dev/null; then
        tmux -S /tmp/osmocom_tmux kill-session -t osmocom 2>/dev/null && n=$((n + 1))
    fi
    [ "$n" -gt 0 ] && printf '  %ssessions tmux%s   %s session(s) du run precedent effacee(s)\n' \
        "${C_DIM:-}" "${C_Z:-}" "$n"
    return 0
}

case "$ACTION" in
    list)
        exec env CALYPSO_PROFILE="$CALYPSO_PROFILE" bash "$RUN_SH" --list "${RUN_ARGS[@]}"
        ;;
    stop)
        say_begin "Arret de la pile via run.sh"
        bash "$RUN_SH" --stop --profile "$CALYPSO_PROFILE"
        say_end " OK " "$C_OK" "Arret de la pile via run.sh"
        purge_sessions_tmux
        # Filet identique a celui de run.sh, pose ici aussi : on peut arreter par
        # ce script sans passer par l'autre, et un fake_trx.py orphelin suffit a
        # faire echouer le run suivant sur « port UDP 5720 deja pris ».
        # run.sh le fait deja de son cote ; le repeter ne coute rien et couvre le
        # cas ou RUN_SH est introuvable ou sort en erreur.
        # PORTEE : tue TOUS les python3 de la machine. CALYPSO_STOP_KILL_PYTHON=0
        # le desactive.
        if [ "${CALYPSO_STOP_KILL_PYTHON:-1}" != 0 ]; then
            if killall -9 python3 2>/dev/null; then
                say_end " OK " "$C_OK" "python3 restants termines (killall)"
            fi
        fi
        exit 0
        ;;
    status)
        exec env CALYPSO_PROFILE="$CALYPSO_PROFILE" bash "$RUN_SH" --status --profile "$CALYPSO_PROFILE"
        ;;
    checkpaths)
        exec env CALYPSO_PROFILE="$CALYPSO_PROFILE" bash "$RUN_SH" --check-paths
        ;;
esac
# --- 7bis-menu. Ce qui manque, demande UNE fois, avec le cache en defaut ------
# Les trois valeurs qui decident de l'identite SS7 - numero de noeud, operateur,
# adresse du hub - se deduisent la plupart du temps : environnement transmis par
# start.sh, /etc/osmo-role fige dans l'image, table WAN. Quand la deduction ne
# donne rien, la pile demarrait quand meme et gardait les point codes du
# gabarit : 1.1.2 partout, ASP vers 127.0.0.1, interco morte sans un mot.
#
# On demande donc - mais seulement ce qui manque, et en proposant ce que le
# cache sait deja. Sans terminal, rien n'est demande : les defauts s'appliquent,
# une ISO qui demarre seule ne peut pas rester bloquee sur une question.
ask_node_identity() {
    # NO_MENU=1 est ce que start.sh pose devant le lanceur quand les choix ont
    # deja ete faits sur l'hote. Aujourd'hui la garde tty juste en dessous
    # suffit (start.sh lance par docker exec -d, sans terminal) : ce test ne
    # repare donc aucune panne observable, il aligne l'identite SS7 sur la
    # meme convention que les menus de pile de start.sh, pour le jour ou le
    # lanceur automatique aura un terminal.
    [ "${NO_MENU:-0}" = "1" ] && return 0
    [ -t 0 ] || return 0
    [ "$DRY" -eq 1 ] && return 0

    local cached_node cached_src cached_hub
    IFS='|' read -r cached_node cached_src <<< "$(detect_node_id)"

    # L'ENVIRONNEMENT NE SE DEMANDE PAS. C'est ainsi que start.sh transmet le
    # noeud a un conteneur : la reponse est deja connue, et la question n'a pas
    # de sens - pire, dans un conteneur ou personne ne lit le terminal, elle
    # repart vide. L'identite n'etait alors PAS appliquee, la pile regenerait
    # ses configs par defaut (point-code 1.1.2, ASP vers le hub docker, en
    # shutdown) et ecrasait celle que start.sh venait d'ecrire. Le fichier
    # portait les bonnes valeurs juste avant, les mauvaises juste apres.
    # Le rattrapage qui se trouvait ici ne servait plus a rien : il exigeait un
    # NODE_ID vide, alors que la resolution du noeud, bien plus haut, l'a deja
    # rempli depuis cette meme source. NODE_ID et HUB_IP sont donc repris de
    # l'environnement la-bas, une fois, et pas ici.
    cached_hub="$(awk -F= '/^OSMO_HUB_IP=/{gsub(/[ \r\t]/,"",$2);v=$2} END{print v}' /etc/osmo-role 2>/dev/null)"
    [ -n "$cached_hub" ] || cached_hub="192.168.1.49"

    if command -v whiptail >/dev/null 2>&1; then
        [ -n "$NODE_ID" ] || NODE_ID="$(whiptail --title "Identite du noeud" \
            --inputbox "Numero de ce noeud (1-9)${cached_src:+   [$cached_src]}\nVide = ne pas toucher a l'identite SS7." \
            11 66 "${cached_node:-}" 3>&1 1>&2 2>&3)" || NODE_ID=""
        if [ -n "$NODE_ID" ] && [ -z "$HUB_IP" ]; then
            HUB_IP="$(whiptail --title "Inter-STP" \
                --inputbox "Adresse du hub SS7 (PC 0.0.0)\nVide = laisser le remote-ip deja present." \
                11 66 "$cached_hub" 3>&1 1>&2 2>&3)" || HUB_IP=""
        fi
    else
        if [ -z "$NODE_ID" ]; then
            printf '  Numero de ce noeud (1-9)%s [%s] : ' \
                "${cached_src:+ - $cached_src}" "${cached_node:-aucun}"
            read -r NODE_ID || NODE_ID=""
            [ -n "$NODE_ID" ] || NODE_ID="${cached_node:-}"
        fi
        if [ -n "$NODE_ID" ] && [ -z "$HUB_IP" ]; then
            printf '  Adresse de l inter-STP [%s] : ' "$cached_hub"
            read -r HUB_IP || HUB_IP=""
            [ -n "$HUB_IP" ] || HUB_IP="$cached_hub"
        fi
    fi
}
ask_node_identity

# ══ La regeneration des configurations, POUR DE VRAI ═══════════════════════
# CE QUE --regen FAISAIT JUSQU'ICI : RIEN.
# Il levait OSMO_NO_REGEN et comptait sur run_modules/08-gabarits.sh pour
# rejouer apply_config_templates au demarrage. Ce module N'EXISTE PAS - il n'a
# jamais existe dans run_modules/ - et run.sh ne genere aucune configuration.
# Le message « regeneration demandee » s'affichait, la pile s'arretait, et pas
# un octet de /etc/osmocom ne changeait. Le seul effet reel de l'option etait
# d'arreter le banc.
#
# CE QU'IL FAIT MAINTENANT : la meme chose que start.sh cote docker, et que
# build-iso.sh a la construction - c'est-a-dire la sequence complete :
#
#   1. apply_config_templates      substitue configs/*.cfg et *.conf dans un
#                                  repertoire NEUF (jamais en place : une
#                                  substitution a moitie faite sur /etc/osmocom
#                                  laisse un banc qui ne demarre plus) ;
#   2. apply_native_post_patches   la recette NATIVE de l'ISO - sms-routing par
#                                  MS reel, SGSN/MSC sur l'adresse locale du
#                                  coeur, run.sh degraisse ;
#   3. install_configs_native      pose le resultat dans /etc/osmocom et
#                                  /etc/asterisk.
#
# EN SOUS-SHELL. lib/gabarits.sh charge globals.conf et pose une trentaine de
# variables - MCC, ARFCN, HOST_IP, N_OPERATORS, KI... - qui portent ici un tout
# autre sens (HOST_IP est l'adresse du banc pour le lanceur, l'adresse interne
# du coeur pour les gabarits). Les laisser fuir, c'est changer le plan radio
# d'un run parce qu'on a demande une regeneration.
#
#   $1 numero de noeud   $2 numero d'operateur   $3 inter-STP (peut etre vide)
# ── LE POINT CODE AFFICHE SUIT LE PLAN REELLEMENT APPLIQUE ──────────────────
# [2026-08-31] Ces bandeaux calculaient toujours 1.<noeud><op>.x, la formule du
# plan WAN, alors que set-node-id.sh recoit --local hors WAN et ecrit
# 1.<op>.x. On lisait donc, dans la meme sortie :
#     [ OK ] Identite SS7 (noeud 1 · PC 1.13.x · mode docker)
#       SS7  point-code 1.3.2
# deux valeurs contradictoires pour la meme chose - la premiere fausse. Sur un
# banc ou le desaccord de plan a justement coute une demi-journee, un bandeau
# qui annonce le mauvais plan est exactement ce qu il ne faut pas.
_pc_affiche() {   # $1 = noeud, $2 = operateur
    if [ "${WAN_MESH:-0}" != "1" ] && [ -z "${WAN_NODES:-}" ]; then
        printf '1.%s.x' "$2"          # plan local, celui qu on applique
    else
        printf '1.%s%s.x' "$1" "$2"   # plan WAN, entre machines
    fi
}

regen_configs_natif() {
    local node="$1" op="$2" hub="$3"
    local tmp rc=0

    # ── Le plan NATIF, celui de build-iso.sh, et non celui de docker ────────
    # 127.0.0.2 (__CONTAINER_IP__) porte le coeur : GGSN, SGSN, UPF, la nsvc du
    # BSC. Une adresse de boucle locale existe TOUJOURS et est prete avant tout
    # service ; une adresse de carte peut manquer au moment ou osmo-ggsn se lie,
    # et le bind echoue sans rapport visible avec le reseau.
    # 127.0.0.1 (__GATEWAY_IP__) recoit le gsmtap, la ou tshark capte deja.
    local core_ip="${OSMO_NATIVE_CORE_IP:-127.0.0.2}"
    local gw_ip="${OSMO_NATIVE_GW_IP:-127.0.0.1}"

    # ── L'identite SUIT LE NOEUD ────────────────────────────────────────────
    # 1.<noeud><op>.<role>, le plan de network/set-node-id.sh et de build-iso.sh
    # --node. Sans le noeud dans le point code, trois machines attachees au meme
    # hub y presentent trois fois 1.11.2 : un point code est une ADRESSE, et
    # deux equipements qui partagent la leur donnent du routage faux, silencieux.
    # Le routing context suit la meme regle : c'est lui que le hub lit pour
    # distinguer les AS, et la formule locale (op*100+50) rend la meme valeur
    # sur tous les noeuds.
    local mid="${node}${op}"
    local pc_msc="1.${mid}.1" pc_stp="1.${mid}.2" pc_bsc="1.${mid}.3"
    local shut="shutdown"
    [ -n "$hub" ] && shut="no shutdown"

    # ── Le PLMN : celui de la machine, pas celui de la table ────────────────
    # Relu dans osmo-msc.cfg installe. Reprendre les defauts de globals.conf
    # renommerait le reseau a chaque regeneration - OsmoDirect redevenait
    # OsmoQEMU - et un mobile deja provisionne ne reconnait plus sa cellule.
    local cfgdir="${OSMOCOM_CFG:-/etc/osmocom}"
    local mcc mnc opname
    mcc="$(awk '/^ *network country code /{print $4; exit}' "$cfgdir/osmo-msc.cfg" 2>/dev/null)"
    mnc="$(awk '/^ *mobile network code /{print $4; exit}'  "$cfgdir/osmo-msc.cfg" 2>/dev/null)"
    opname="$(awk '/^ *short name /{print $3; exit}'        "$cfgdir/osmo-msc.cfg" 2>/dev/null)"
    [ -n "$mcc" ]    || mcc="${MCC:-001}"
    [ -n "$mnc" ]    || mnc="${MNC:-01}"
    [ -n "$opname" ] || opname="${OP_NAME:-OsmoGSM}"

    # Le nombre de MS vient de coeur.env, ecrit par build-iso.sh : c'est lui qui
    # dit combien d'abonnes le HLR provisionne, donc combien de routes SMS il
    # faut. Au-dela, le relais rend "No route for destination".
    local n_ms="${N_MS:-}"
    if [ -z "$n_ms" ] && [ -r "$cfgdir/coeur.env" ]; then
        n_ms="$(awk -F'[:=}]' '/N_MS:=/{print $3; exit}' "$cfgdir/coeur.env" 2>/dev/null)"
    fi
    [ -n "$n_ms" ] || n_ms=2

    printf '  %splan natif%s coeur %s  gsmtap %s  PC %s  rctx %s  PLMN %s-%s (%s)  %s MS\n' \
        "${C_DIM:-}" "${C_Z:-}" "$core_ip" "$gw_ip" "$pc_stp" \
        "$(( node * 1000 + op * 100 + 50 ))" "$mcc" "$mnc" "$opname" "$n_ms"

    tmp="$(mktemp -d)" || return 1

    # ── La sauvegarde, AVANT d'ecrire ───────────────────────────────────────
    # Une regeneration ecrase des fichiers que quelqu'un a pu retoucher a la
    # main sur le banc. Qu'elle soit demandee ne veut pas dire qu'on accepte de
    # perdre l'etat d'avant sans trace.
    local sav="${LOG_DIR:-/tmp}/osmocom-avant-regen-$(date +%Y%m%d-%H%M%S).tar.gz"
    tar czf "$sav" -C / etc/osmocom etc/asterisk 2>/dev/null && \
        printf '  %ssauvegarde%s %s\n' "${C_DIM:-}" "${C_Z:-}" "$sav"

    (
        cd "$HERE" || exit 1
        # AVANT de sourcer. lib/gabarits.sh fige ENCRYPTION a "a5 0" et HOST_IP
        # a 127.0.0.1 par ":=" des qu'ils sont vides, et gc_load tient ensuite
        # ces valeurs pour un choix de l'appelant : le banc repartait en clair
        # apres un --regen, sans un mot.
        : "${ENCRYPTION:=a5 1}"
        HOST_IP="$core_ip"
        # Le routing context et l'adresse source de l'ASP : les deux surcharges
        # que build-iso.sh pose pour un noeud de WAN. 0.0.0.0 parce que
        # l'adresse du noeud vient du DHCP et n'est pas forcement montee quand
        # osmo-stp demarre - se lier a une adresse absente echoue au lancement.
        export RCTX_INTER_OVERRIDE=$(( node * 1000 + op * 100 + 50 ))
        export INTER_LOCAL_IP_OVERRIDE="0.0.0.0"
        # _apply_node_ss7_addressing (appele en fin de apply_config_templates)
        # ne rejoue set-node-id.sh que s'il sait QUI il sert, et il ne le lit
        # que la.
        export OSMO_ROLE=operator OSMO_WAN_NODE="$node"
        [ -n "$hub" ] && export OSMO_HUB_IP="$hub"
        unset OSMO_NO_REGEN

        # shellcheck source=lib/gabarits.sh
        . "$HERE/lib/gabarits.sh" || exit 1

        apply_config_templates "$tmp" \
            "$core_ip" "$gw_ip" \
            "$op" "$pc_msc" "$pc_stp" "$pc_bsc" \
            "$mcc" "$mnc" "$opname" \
            "${hub:-127.0.0.1}" "$shut" "1" || exit 1

        # Le noeud et la table WAN : c'est de la table que sms-routing.conf
        # tire l'adresse de chaque noeud, et du noeud qu'il tire les MSISDN
        # (<noeud>00<operateur><rang>).
        apply_native_post_patches "$tmp" "$op" "$n_ms" "$core_ip" \
            "$node" "${WAN_CONF_FILE:-/etc/osmo-wan.conf}" || exit 1

        # ── bb/ RETIRE, et ce n'est pas un oubli ───────────────────────────
        # install_configs_native poserait $tmp/bb/mobile.cfg par-dessus
        # /root/.osmocom/bb/mobile.cfg. Or ce fichier a deja ete ECRIT plus haut
        # par generate_mobile_cfg, qui seul fixe le port VTY, les sockets L1CTL
        # et l'ARFCN de CHAQUE mobile. Le gabarit, lui, est fige sur l'operateur
        # 1 : le remettre ici rendait les deux mobiles au meme ARFCN et a la
        # meme VTY, et le second restait muet pour toujours.
        rm -rf "$tmp/bb"

        install_configs_native "$tmp" "" || exit 1
    ) || rc=1

    rm -rf "$tmp"
    return "$rc"
}

# ── Les gabarits : conserves, sauf --regen ──────────────────────────────────
# Par defaut on ne touche a rien : dans un conteneur, ce que start.sh vient
# d'ecrire - point codes du noeud, inter-STP, ASP actif - ne doit pas etre
# remplace par les valeurs des gabarits, sans quoi l'identite SS7 est juste
# puis fausse 80 secondes plus tard, sans que rien ne l'annonce.
if [ "${REGEN_GABARITS:-0}" -eq 1 ]; then
    unset OSMO_NO_REGEN
    printf '  %sgabarits%s   regeneration demandee (--regen)\n' "${C_DIM:-}" "${C_Z:-}"

    # ── REGEN : ON ARRETE D ABORD ───────────────────────────────────────────
    # Regenerer /etc/osmocom sous une pile VIVANTE ne sert a rien et trompe :
    # les demons ont deja lu leur configuration, ils gardent l'ancienne en
    # memoire, et les fichiers neufs ne prennent effet qu'au demarrage suivant.
    # On lisait donc une conf juste sur le disque pendant que la pile tournait
    # sur la precedente -- le genre d'ecart qui se paie dix minutes plus tard.
    # Un --regen arrete donc la pile, puis regenere : l'etat est sans ambiguite.
    if [ -x "$RUN_SH" ] || [ -r "$RUN_SH" ]; then
        say_begin "Arret de la pile avant regeneration"
        bash "$RUN_SH" --stop --profile "$CALYPSO_PROFILE" >/dev/null 2>&1 || true
        say_end " OK " "$C_OK" "Arret de la pile avant regeneration"
        declare -F purge_sessions_tmux >/dev/null && purge_sessions_tmux
        # Meme filet qu'au --stop : un python3 orphelin (pont, fake_trx, trxcon)
        # garde son port et fait echouer le demarrage suivant sur « port UDP
        # 5720 deja pris ». PORTEE : tue TOUS les python3 de la machine.
        if [ "${CALYPSO_STOP_KILL_PYTHON:-1}" != 0 ]; then
            killall python3 2>/dev/null && \
                say_end " OK " "$C_OK" "python3 restants termines (killall)"
        fi
    fi

    # ── LE NOEUD, PAR DEFAUT 1 ──────────────────────────────────────────────
    # Une regeneration produit une identite COMPLETE, jamais un plan « sans
    # noeud ». Sans numero, apply_config_templates rendait le plan du gabarit -
    # point-code 1.1.2 pour toute machine - et deux noeuds du meme banc se
    # retrouvaient a la meme adresse SS7. On numerote donc toujours : le noeud
    # resolu plus haut (--node, l'environnement, /etc/osmo-role, la table WAN),
    # et 1 quand il n'y en a aucun. Les configurations s'incrementent alors
    # avec lui : PC 1.<noeud><op>.<role>, routing context <noeud>*1000+..., et
    # le segment prive 192.168.<noeud+1>.x.
    REGEN_NODE="${NODE_ID:-1}"
    if ! [[ "$REGEN_NODE" =~ ^[1-9]$ ]]; then
        say_end " KO " "$C_KO" "--regen" "numero de noeud invalide : $REGEN_NODE (1 a 9)"
        exit 2
    fi
    REGEN_OP="${NODE_OP:-1}"
    if ! [[ "$REGEN_OP" =~ ^[1-9]$ ]]; then
        say_end " KO " "$C_KO" "--regen" "numero d'operateur invalide : $REGEN_OP (1 a 9)"
        exit 2
    fi
    # cached_hub est LOCAL a ask_node_identity : le lire ici rendait le vide et,
    # sans inter-STP, l'ASP repartait en shutdown apres chaque regeneration.
    REGEN_HUB="${HUB_IP:-${OSMO_HUB_IP:-}}"
    [ -n "$REGEN_HUB" ] || REGEN_HUB="$(awk -F= '/^OSMO_HUB_IP=/{gsub(/[ \r\t]/,"",$2);v=$2} END{print v}' \
        "${ROLE_FILE:-/etc/osmo-role}" 2>/dev/null)"

    say_begin "Regeneration des configurations (noeud $REGEN_NODE, operateur $REGEN_OP)"
    if [ "$DRY" -eq 1 ]; then
        say_end " -- " "$C_DIM" "Regeneration des configurations" "dry-run"
    elif regen_configs_natif "$REGEN_NODE" "$REGEN_OP" "$REGEN_HUB"; then
        say_end " OK " "$C_OK" "Regeneration des configurations" \
            "/etc/osmocom + /etc/asterisk  ·  PC $(_pc_affiche "$REGEN_NODE" "$REGEN_OP")"
    else
        say_end " KO " "$C_KO" "Regeneration des configurations" \
            "echec - les fichiers d'avant sont dans ${LOG_DIR:-/tmp}"
        exit 1
    fi

    # ── LE HLR SUIT LA NUMEROTATION ─────────────────────────────────────────
    # Une regeneration change les MSISDN (<noeud>00<operateur><rang>) : ceux du
    # HLR datent du plan precedent. L'IMSI, lui, ne bouge pas - il ne porte pas
    # le noeud, precisement pour que le Ki et l'authentification restent
    # valables. L'etape d'abonnes ne peut donc pas se contenter de constater
    # que l'IMSI existe : elle relit desormais AUSSI le MSISDN
    # (run_modules/21-abonnes-hlr.sh, mod_abonnes_hlr_status) et rejoue
    # "subscriber ... update msisdn" quand il a change.
    #
    # On la force en plus ici : la pile vient d'etre arretee, le HLR repartira
    # avec run.sh, et l'etape doit s'executer meme si un cache d'etat la croit
    # deja faite. Sans ca, l'abonne s'attachait (IMSI connu) mais restait
    # injoignable, sans qu'une ligne ne dise que son numero avait change.
    FORCE=1
    printf '  %sHLR%s        abonnes a reapprovisionner : MSISDN %s..%s\n' \
        "${C_DIM:-}" "${C_Z:-}" \
        "$(( REGEN_NODE * 100000 + REGEN_OP * 100 + 1 ))" \
        "$(( REGEN_NODE * 100000 + REGEN_OP * 100 + ${N_MS:-2} ))"

    # Le noeud retenu vaut pour la suite du lancement : c'est celui qui vient
    # d'etre ecrit dans les fichiers. Le laisser vide ici, c'est laisser la
    # section « Identite SS7 » plus bas repartir sur une autre resolution et
    # reecrire par-dessus une identite differente de celle qu'on vient de poser.
    NODE_ID="$REGEN_NODE"
    NODE_OP="$REGEN_OP"
    [ -n "$REGEN_HUB" ] && HUB_IP="${HUB_IP:-$REGEN_HUB}"
    export OSMO_ROLE=operator
    export OSMO_WAN_NODE="$NODE_ID"
    [ -n "$HUB_IP" ] && export OSMO_HUB_IP="$HUB_IP"
elif [ "${RUNTIME_ENV:-native}" = "docker" ]; then
    # DANS UN CONTENEUR seulement. Les configs y sont deja ecrites par start.sh,
    # avec l'identite du noeud : les regenerer les remplace par les valeurs des
    # gabarits, et l'interco meurt.
    export OSMO_NO_REGEN=1
else
    # SUR UNE VM sans --regen : on CONSERVE ce qui est en place. C'est l'ISO qui
    # a genere ces fichiers a la construction, avec la meme recette que
    # regen_configs_natif ; les refaire a chaque demarrage ne changerait rien et
    # ecraserait toute retouche faite sur le banc. --regen est la pour ca, et il
    # le dit.
    if [ -n "$NODE_ID" ]; then
        export OSMO_ROLE=operator
        export OSMO_WAN_NODE="$NODE_ID"
        [ -n "$HUB_IP" ] && export OSMO_HUB_IP="$HUB_IP"
    fi
fi

# --- 7ter. Identite de noeud (--node) -------------------------------------------
# AVANT le WAN et avant run.sh : les point codes sont lus par osmo-stp, osmo-msc
# et osmo-bsc a leur demarrage. Les changer apres ne servirait a rien.
#
# UNE SEULE SOURCE DE VERITE. start.sh calcule deja l'identite d'un conteneur et
# la lui passe par l'environnement ; la recalculer ici en donnerait deux, et
# c'est la mauvaise qui gagnait. On reprend donc la sienne quand elle existe.
if [ -z "$NODE_ID" ] && [ -n "${OSMO_WAN_NODE:-${WAN_NODE_ID:-}}" ]; then
    NODE_ID="${OSMO_WAN_NODE:-$WAN_NODE_ID}"
    [ -n "$HUB_IP" ] || HUB_IP="${OSMO_HUB_IP:-}"
fi

# Sans identite alors que le WAN est demande, on ne part PAS en silence : la
# pile demarrerait avec les point codes du gabarit (1.1.2 pour tout le monde),
# deux noeuds porteraient la meme adresse SS7, et aucun ASP ne s'attacherait -
# sans qu'une seule ligne ne le signale.
if [ -z "$NODE_ID" ] && [ "${WAN_MESH:-0}" -eq 1 ]; then
    say_end " KO " "$C_KO" "Identite SS7" "WAN demande mais aucun noeud : --node N, ou WAN_NODE_ID"
    exit 1
fi

if [ -n "$NODE_ID" ]; then
    if ! [[ "$NODE_ID" =~ ^[1-9]$ ]]; then
        say_end " KO " "$C_KO" "--node" "un chiffre de 1 a 9"; exit 2
    fi
    SETID="$HERE/network/set-node-id.sh"
    if [ ! -r "$SETID" ]; then
        say_end " KO " "$C_KO" "--node" "network/set-node-id.sh absent"; exit 1
    fi
    say_begin "Identite SS7 du noeud $NODE_ID"
    setid_args=(--node "$NODE_ID" --op "$NODE_OP" --mode "$NODE_MODE")
    # ── PLAN LOCAL HORS WAN ─────────────────────────────────────────────────
    # [2026-08-31] --local n etait JAMAIS passe. set-node-id.sh prenait donc
    # toujours le plan WAN, MID=<noeud><op> :
    #     noeud 1 / op 2 -> 1.12.2      noeud 1 / op 3 -> 1.13.2
    # alors que le hub et la topologie du banc attendent le plan LOCAL,
    # MID=<op> :
    #     op 2 -> 1.2.2                 op 3 -> 1.3.2
    # Les conteneurs s enregistraient donc en 1.12.2 / 1.13.2 (visible cote hub
    # sous as-rkm-1250 / as-rkm-1350, ACTIFS), pendant que les AS statiques
    # as-op2 / as-op3 attendaient en 1.2.2 / 1.3.2 et restaient AS_DOWN.
    # Consequence : tout le monde attache, personne routable - les routes vers
    # 1.2.2 et 1.3.2 sortaient PROHIB chez le natif. Rien ne signalait le
    # desaccord de plan : les deux cotes avaient raison, chacun dans le sien.
    #
    # Le plan WAN n a de sens qu ENTRE MACHINES. Sans WAN, une seule machine
    # porte tout : c est le plan local qui vaut, et c est deja celui du natif.
    if [ "${WAN_MESH:-0}" != "1" ] && [ -z "${WAN_NODES:-}" ]; then
        setid_args+=(--local)
    fi
    [ -n "$HUB_IP" ] && setid_args+=(--hub-ip "$HUB_IP")
    if [ "$DRY" -eq 1 ]; then
        say_end " -- " "$C_DIM" "Identite SS7 du noeud $NODE_ID" "dry-run"
        bash "$SETID" "${setid_args[@]}" --dry-run 2>&1 | sed 's/^/  /'
    elif bash "$SETID" "${setid_args[@]}" </dev/null > "${LOG_DIR:-/tmp}/set-node-id.log" 2>&1; then
        say_end " OK " "$C_OK" "Identite SS7" "noeud $NODE_ID · PC $(_pc_affiche "$NODE_ID" "$NODE_OP") · mode $NODE_MODE"
        # Ce qui a REELLEMENT ete ecrit, relu dans le fichier. Jusqu'ici il
        # fallait deduire l'identite d'un routing-key pour savoir si elle avait
        # pris - et une regeneration ulterieure pouvait l'effacer sans bruit.
        _pc_now="$(awk "/^cs7 instance/{c=1} c && /^ *point-code /{print \$2; exit}" \
                   "${OSMOCOM_CFG:-/etc/osmocom}/osmo-stp.cfg" 2>/dev/null)"
        _hub_now="$(awk "/asp asp-to-inter/{f=1} f&&/remote-ip/{print \$2; exit}" \
                   "${OSMOCOM_CFG:-/etc/osmocom}/osmo-stp.cfg" 2>/dev/null)"
        printf "  %sSS7%s        point-code %s   inter-STP %s\n" \
            "${C_DIM:-}" "${C_Z:-}" "${_pc_now:-?}" "${_hub_now:-?}"
    else
        say_end " KO " "$C_KO" "Identite SS7" "voir ${LOG_DIR:-/tmp}/set-node-id.log"
        exit 1
    fi
    # Le numero de noeud vaut aussi pour la voix et les SMS : c'est le meme noeud.
    WAN_NODE_ID="$NODE_ID"
fi

# --- 7bis. WAN a N noeuds ------------------------------------------------------
# ICI et pas apres : ce processus va devenir run.sh (exec), il n'y a pas d'apres.
# Asterisk n'est pas encore lance - setup-wan-mesh.sh le voit, ecrit la conf et
# ne redemarre rien ; run.sh demarrera Asterisk avec le WAN deja en place.
#
# Sur vis-a-vis des gabarits : en natif rien ne reinstalle /etc/asterisk au
# demarrage (install_configs_native n'a aucun appelant, run_modules/08-gabarits.sh
# n'existe pas), donc le bloc WAN ecrit ici survit a run.sh. Si un module de
# gabarits revient un jour, il faudra rejouer le WAN APRES lui.
if [ -r "${WAN_CONF_FILE:-/etc/osmo-wan.conf}" ] && [ "$WAN_MESH" -eq 0 ]; then
    # Table figee dans l'image (ISO construite avec --wan) : WAN_AUTO=1 dit
    # "ce systeme EST un noeud", on n'oblige pas a retaper l'option.
    if grep -q '^WAN_AUTO=1' "${WAN_CONF_FILE:-/etc/osmo-wan.conf}" 2>/dev/null; then
        WAN_MESH=1
        printf '  %sWAN%s        table figee dans %s (WAN_AUTO=1)\n' \
            "$C_DIM" "$C_Z" "${WAN_CONF_FILE:-/etc/osmo-wan.conf}"
    fi
fi

if [ "$WAN_MESH" -eq 1 ] && [ "$ACTION" = "start" ]; then
    WAN_LIB="$HERE/network/wan-nodes.sh"
    WAN_MESH_SH="$HERE/network/setup-wan-mesh.sh"
    if [ ! -r "$WAN_LIB" ] || [ ! -r "$WAN_MESH_SH" ]; then
        say_end " KO " "$C_KO" "WAN" "network/wan-nodes.sh ou setup-wan-mesh.sh absent"
        exit 1
    fi
    # shellcheck source=network/wan-nodes.sh
    . "$WAN_LIB"
    # --virtualbox : segment host-only + VM + table, avant tout le reste.
    if [ "$VBOX_INTERCO" -eq 1 ] && [ -z "${WAN_NODES:-}" ]; then
        VB="$HERE/network/setup-vbox-interco.sh"
        [ -r "$VB" ] || { say_end " KO " "$C_KO" "WAN" "network/setup-vbox-interco.sh absent"; exit 1; }
        vb_args=(--host-node "$VBOX_HOST_NODE" --conf "${WAN_CONF_FILE:-/etc/osmo-wan.conf}")
        [ -n "$VBOX_NODES" ] && vb_args+=(--nodes "$VBOX_NODES")
        bash "$VB" "${vb_args[@]}" || exit 1
        wan_nodes_load "${WAN_CONF_FILE:-/etc/osmo-wan.conf}" || exit 1
        WAN_NODES="$(wan_nodes_spec)"
        WAN_NODE_ID="$VBOX_HOST_NODE"
    fi

    if [ -n "${WAN_NODES:-}" ]; then
        wan_nodes_parse "$WAN_NODES" || exit 1
        # --node DIT DEJA quel noeud est cette machine : c'est la meme notion
        # que --wan-id, vue depuis le SS7. S'en servir evite d'echouer sur une
        # detection par IP quand la table embarquee est plus ancienne que la
        # machine - le cas d'une ISO gravee avant le dernier plan d'adressage.
        if [ "${WAN_NODE_ID:-0}" = "0" ] && [ -n "$NODE_ID" ]; then
            WAN_NODE_ID="$NODE_ID"
            printf '  %sWAN%s        noeud %s (repris de --node)\n' "$C_DIM" "$C_Z" "$WAN_NODE_ID"
        fi
        [ "${WAN_NODE_ID:-0}" != "0" ] || wan_nodes_detect_self || {
            printf '  %sWAN%s : aucune IP locale dans la table - passez --wan-id N\n' "$C_KO" "$C_Z"
            printf '  %s      (ou --node N : le numero de noeud vaut pour les deux)%s\n' "$C_DIM" "$C_Z"
            exit 1; }
    else
        wan_nodes_load "${WAN_CONF_FILE:-/etc/osmo-wan.conf}" 2>/dev/null || true
        # Une ISO diffusee sur N machines porte la MEME table : chaque machine
        # se reconnait a son IP plutot que d'exiger une image par noeud.
        wan_nodes_detect_self 2>/dev/null || true
        if [ "${WAN_NODE_COUNT:-0}" -lt 1 ] || [ "${WAN_NODE_ID:-0}" = "0" ]; then
            wan_nodes_prompt || exit 1
        fi
    fi
    wan_nodes_validate || exit 1
    printf '\n'
    wan_nodes_summary
    printf '\n'
    if [ "$DRY" -eq 1 ]; then
        printf '  [dry-run] %s --native --id %s\n' "$WAN_MESH_SH" "$WAN_NODE_ID"
    else
        say_begin "WAN - application de la table"
        # --native / --docker : meme distinction que pour les point codes.
        if WAN_AUTO=1 bash "$WAN_MESH_SH" "--$NODE_MODE" --id "$WAN_NODE_ID" \
                --nodes "$(wan_nodes_spec)" --ops 1 \
                --config "${WAN_CONF_FILE:-/etc/osmo-wan.conf}" > "${LOG_DIR:-/tmp}/wan-mesh.log" 2>&1; then
            say_end " OK " "$C_OK" "WAN - noeud $WAN_NODE_ID, indicatif $(wan_local_ind)"
        else
            say_end " KO " "$C_KO" "WAN" "voir ${LOG_DIR:-/tmp}/wan-mesh.log"
        fi
    fi

    # --- 7quater. Adressage SS7 du noeud (set_stp_ip.sh) ----------------------
    # setup-wan-mesh.sh monte la VOIX et les SMS entre noeuds ; il ne touche pas
    # au SS7. Sans ce qui suit, --wan donnait un WAN ou les appels passaient mais
    # ou l'ASP inter-STP visait encore 127.0.0.1, en shutdown : l'interco SS7 ne
    # montait jamais, et rien dans la sortie ne disait pourquoi.
    #
    # set_stp_ip.sh est le seul a ecrire d'un coup les trois configs, la table
    # WAN et /etc/osmo-role. On lui passe --no-ip : l'adresse locale est deja
    # posee (DHCP ou bloc WAN ci-dessus), et --restart serait absurde ici
    # puisque run.sh va demarrer la pile juste apres, avec les fichiers corriges.
    STP_IP_SH="$HERE/set_stp_ip.sh"
    if [ -r "$STP_IP_SH" ] && [ "${WAN_NODE_ID:-0}" != "0" ]; then
        self_ip="${WAN_IP[$WAN_NODE_ID]:-}"
        hub_ip="$HUB_IP"
        [ -n "$hub_ip" ] || hub_ip="$(awk -F= '/^OSMO_HUB_IP=/{gsub(/[ \r\t]/,"",$2);v=$2} END{print v}' /etc/osmo-role 2>/dev/null)"

        stp_args=(--operator --node "$WAN_NODE_ID" --no-ip --conf-dir "${OSMOCOM_CFG:-/etc/osmocom}")
        [ -n "$self_ip" ] && stp_args+=(--ip "$self_ip")
        [ -n "$hub_ip" ]  && stp_args+=(--hub-ip "$hub_ip")

        if [ "$DRY" -eq 1 ]; then
            printf '  [dry-run] %s %s\n' "$STP_IP_SH" "${stp_args[*]}"
        else
            say_begin "Adressage SS7 - inter-STP ${hub_ip:-?}"
            # </dev/null : appel automatique, jamais interactif (set_stp_ip.sh
            # demande l'adresse du hub quand elle manque - ici, personne ne
            # repondrait et le lancement resterait fige).
            if bash "$STP_IP_SH" "${stp_args[@]}" </dev/null > "${LOG_DIR:-/tmp}/set-stp-ip.log" 2>&1; then
                say_end " OK " "$C_OK" "Adressage SS7" "noeud $WAN_NODE_ID -> inter-STP ${hub_ip:-?}"
            else
                # Non fatal : la pile locale tourne tres bien sans interco. On le
                # DIT, plutot que de laisser croire a un WAN complet.
                say_end " ~~ " "$C_SK" "Adressage SS7" "echec - voir ${LOG_DIR:-/tmp}/set-stp-ip.log"
            fi
        fi
    fi
fi

# --- 7quinquies. Le wrapper tcpdump ------------------------------------------
# La capture GSMTAP est lancee par run_modules/75-gsmtap.sh, dans qosmo-grgsm : un
# autre depot, qu'on ne modifie pas. Elle ecrit un pcap NON BORNE, et la racine
# du live etant un tmpfs, une capture un peu chargee finit par remplir la RAM.
#
# Le wrapper y substitue un anneau. Il est aussi pose par build-iso.sh, mais une
# ISO deja gravee ne le connait pas, et un live sans persistance le perd a chaque
# reboot : le reposer ICI, a chaque demarrage de la pile, est ce qui le rend
# effectif sans reconstruire ni toucher a qosmo-grgsm.
#
# DEUX PIEGES, DEUX PARADES - c'est tout l'objet de ce script :
#   -Z root : avec -C/-W, tcpdump cree les membres suivants de l'anneau APRES
#             avoir lache ses privileges. Sans lui : "Permission denied", et
#             AUCUNE capture - alors que sans -C il ouvrait le fichier avant.
#   le lien : avec -C/-W, tcpdump numerote (capture.pcap0, .pcap1...) ; le nom
#             exact demande n'existe jamais, et la barriere du module - un
#             "test -s <nom exact>" - conclut a un echec sur une capture qui
#             tourne. On maintient <nom exact> -> membre courant.
#
# OSMO_NO_PCAP_RING=1 saute l'installation.
install_pcap_wrapper() {
    local dst=/usr/local/bin/tcpdump tmp
    [ "${OSMO_NO_PCAP_RING:-0}" = "1" ] && return 0
    [ "$(id -u)" -eq 0 ] || return 0
    command -v tcpdump >/dev/null 2>&1 || return 0

    tmp="$(mktemp)" || return 0
    cat > "$tmp" <<'PCAPWRAP'
#!/bin/sh
# tcpdump - wrapper osmo-operator : impose un anneau aux captures sur fichier.
# Pose par start-direct.sh et par build-iso.sh. Voir leurs commentaires.
# Reglable : OSMO_PCAP_RING_MB (32), OSMO_PCAP_RING_FILES (5).
REAL=/usr/bin/tcpdump
[ -x "$REAL" ] || REAL=/usr/sbin/tcpdump

has_w=0; has_ring=0; has_z=0; wfile=""; next_is_w=0
for a in "$@"; do
    if [ "$next_is_w" = 1 ]; then wfile="$a"; next_is_w=0; continue; fi
    case "$a" in
        -w)            has_w=1; next_is_w=1 ;;
        -w?*)          has_w=1; wfile="${a#-w}" ;;
        -C|-C*|-W|-W*) has_ring=1 ;;
        -Z|-Z*)        has_z=1 ;;
    esac
done

# Rien a borner, ou l'appelant a deja choisi son anneau : on s'efface.
if [ "$has_w" != 1 ] || [ "$has_ring" = 1 ] || [ -z "$wfile" ]; then
    exec "$REAL" "$@"
fi

# Le veilleur du lien. Lance AVANT l'exec : apres, ce processus EST tcpdump.
# $$ survit a l'exec, donc il suit exactement sa vie et s'arrete avec lui.
( ppid=$$
  n=0
  while [ "$n" -lt 300 ]; do
      [ -e "${wfile}0" ] && break
      kill -0 "$ppid" 2>/dev/null || exit 0
      sleep 0.2; n=$((n + 1))
  done
  while kill -0 "$ppid" 2>/dev/null; do
      newest=$(ls -t "${wfile}"[0-9]* 2>/dev/null | head -1)
      if [ -n "$newest" ] && [ "$(readlink "$wfile" 2>/dev/null)" != "$newest" ]; then
          ln -sfn "$newest" "$wfile"
      fi
      sleep 2
  done ) >/dev/null 2>&1 &

if [ "$has_z" = 1 ]; then
    exec "$REAL" -C "${OSMO_PCAP_RING_MB:-32}" -W "${OSMO_PCAP_RING_FILES:-5}" "$@"
fi
exec "$REAL" -Z root -C "${OSMO_PCAP_RING_MB:-32}" -W "${OSMO_PCAP_RING_FILES:-5}" "$@"
PCAPWRAP

    # Le wrapper s'appelle "tcpdump" et vit dans /usr/local/bin, qui precede
    # /usr/bin : il ne doit donc JAMAIS s'installer par-dessus le vrai binaire,
    # sous peine de recursion infinie. On verifie ou pointe le vrai.
    case "$(command -v tcpdump)" in
        "$dst") ;;                       # deja le wrapper, on remplace
        /usr/bin/tcpdump|/usr/sbin/tcpdump|/bin/tcpdump) ;;
        *) rm -f "$tmp"; return 0 ;;     # emplacement inattendu : on s'abstient
    esac

    if cmp -s "$tmp" "$dst" 2>/dev/null; then
        rm -f "$tmp"                     # deja a jour, rien a dire
    else
        install -m 0755 "$tmp" "$dst" 2>/dev/null \
            && printf '  %spcap%s       anneau de capture arme (%s)\n' \
                 "$C_DIM" "$C_Z" "$dst"
        rm -f "$tmp"
    fi
}
[ "$DRY" -eq 1 ] || install_pcap_wrapper

# --- 7ter. ARRET SYSTEMATIQUE AVANT DEMARRAGE ---------------------------------
# Tout demarrage arrete d'abord la pile. Ce n'est pas de la prudence : sans cela
# le lanceur differe du pont part sur un etat ambigu.
#
# CE QUE CA CORRIGE. La barriere du pont attend osmo-bts-trx pour savoir que les
# ports 5700-5702 sont a lui. Si un run precedent tourne encore, son osmo-bts-trx
# est deja la : la barriere est franchie a l'instant meme, le pont binde EN PLEIN
# teardown de run.sh, qui le tue derriere (f:pont/pont.py est dans _td_patterns)
# -- et rien ne le relance. Symptome : « le pont n'a pas de bursts », sans un
# message pour le dire. La barriere attend desormais les deux fronts (disparition
# puis apparition), mais partir d'une pile arretee rend la question sans objet.
#
# Et cela libere les ports : un fake_trx ou un trxcon orphelin suffit a faire
# echouer le demarrage suivant sur « port UDP 5720 deja pris » -- constate le
# 27/08. Le killall couvre les processus Python que le registre ne connait pas.
#
# CALYPSO_NO_AUTOSTOP=1 desactive ce comportement.
if [ "$DRY" -eq 0 ] && [ "${CALYPSO_NO_AUTOSTOP:-0}" != 1 ]; then
    say_begin "Arret de la pile avant demarrage"
    bash "$RUN_SH" --stop --profile "$CALYPSO_PROFILE" >/dev/null 2>&1 || true
    declare -F purge_sessions_tmux >/dev/null && purge_sessions_tmux
    if [ "${CALYPSO_STOP_KILL_PYTHON:-1}" != 0 ]; then
        killall python3 2>/dev/null || true
    fi
    say_end " OK " "$C_OK" "Arret de la pile avant demarrage"
fi

# --- 8. lancement : exec run.sh -----------------------------------------------
# ── LES ADRESSES QUE LES CONFIGS RECLAMENT DOIVENT EXISTER ──────────────────
# Un demon qui se lie a une adresse absente ne demarre pas, et il le dit dans
# SON journal, pas dans le notre :
#     osmo-ggsn : « gtp bind-ip 192.168.2.10 » -> adresse introuvable localement
#     osmo-sgsn : « listen 192.168.2.10 23000 » -> idem
# Ces adresses viennent du generateur de gabarits (op_private_ip), pas du
# reseau : rien ne les pose. Elles etaient figees dans 20-dhcp.network cote ISO,
# donc absentes de toute machine montee autrement - et la panne se lisait comme
# un defaut de coeur paquet.
#
# osmo-ip-plan.sh les EXTRAIT des configs plutot que de les recalculer (le plan
# peut venir d'un autre generateur que le notre), choisit la carte qui fournit
# reellement Internet, et retombe sur la boucle locale quand aucune ne mene
# nulle part. Il ne fait rien dans un conteneur : la, c'est docker qui adresse.
if [ "$ACTION" = "start" ] && [ "$DRY" -ne 1 ]; then
    IPPLAN="$HERE/network/osmo-ip-plan.sh"
    if [ -r "$IPPLAN" ]; then
        say_begin "Adresses privees du noeud"
        _ipout="$(OSMOCOM_CFG="${OSMOCOM_CFG:-/etc/osmocom}" \
                  OSMO_WAN_NODE="${NODE_ID:-${OSMO_WAN_NODE:-}}" \
                  bash "$IPPLAN" --apply 2>&1)" || true
        say_end " OK " "$C_OK" "Adresses privees du noeud" \
            "$(printf '%s' "$_ipout" | tail -1 | sed 's/^osmo-ip-plan : //')"
    fi
fi

say_begin "Transmission a run.sh"
if [ $DRY -eq 1 ]; then
    say_end " -- " "$C_DIM" "Transmission a run.sh" "dry-run"
    printf '  commande : CALYPSO_PROFILE=%s bash %s %s\n' \
        "$CALYPSO_PROFILE" "$RUN_SH" "${RUN_ARGS[*]}"
    exit 0
fi
say_end " OK " "$C_OK" "Transmission a run.sh" "profil=$CALYPSO_PROFILE"
# Hand-off total : ce processus devient run.sh
exec bash "$RUN_SH" "${RUN_ARGS[@]}"

start-interstp.sh

.sh 328 lignes - 16KB

start-interstp.sh
#!/bin/bash
# =============================================================================
# start-interstp.sh - lance le NOEUD INTER-STP : le hub SS7 commun au WAN
# =============================================================================
#
# Dans un lab a une seule machine, l'inter-STP est un conteneur parmi d'autres,
# a 172.20.0.10, et il ne dessert que les operateurs de CETTE machine. Des qu'on
# repartit les operateurs sur plusieurs noeuds, il devient ce qu'il est vraiment :
# un equipement a part, avec sa propre adresse, auquel tous les noeuds
# s'attachent. C'est lui - et lui seul - qui fait traverser le SS7 au WAN.
#
#   noeud 1 (1.11.2) ─┐
#   noeud 2 (1.21.2) ─┼─ M3UA/SCTP 2908 ─► inter-STP 192.168.1.49    PC 0.0.0
#   noeud 3 (1.31.2) ─┘
#
# LE PLAN DE POINT CODES, ET POURQUOI IL CHANGE
# Sur une machine isolee, chaque operateur N porte 1.N.2 - et cette formule se
# recalcule a l'identique partout. Trois noeuds attaches au meme hub y
# presenteraient donc trois fois le meme point code. Un point code est une
# ADRESSE : deux equipements qui partagent la leur, ce n'est pas un conflit de
# nom, c'est du routage faux. Le plan WAN encode donc le noeud dedans :
#
#     PC   = 1.<noeud><op>.<role>     role 1=MSC 2=STP 3=BSC
#     RCTX = noeud*1000 + op*100 + 50
#
# Usage :
#   sudo ./start-interstp.sh [--nodes 3] [--ops 1] [--ip 192.168.1.49]
#   sudo ./start-interstp.sh --status | --stop
#
#   --nodes N     nombre de noeuds desservis (1-9, defaut : lu dans /etc/osmo-wan.conf)
#   --ops K       operateurs par noeud (1-9, defaut 1)
#   --ip ADRESSE  adresse du hub ; posee sur l'interface si elle manque
#   --iface NOM   interface ou poser l'adresse (defaut : detectee)
#   --port N      port M3UA (defaut 2908)
#   --menu        pose les questions meme si une table existe deja
#   --defaut      applique la table du banc, en ignorant celle deja posee
#   --ips "a b c" les IP des noeuds operateurs, dans l'ordre (sans question)
#   --no-ip       ne touche pas a la configuration reseau
#   --foreground  reste au premier plan (journal a l'ecran)
# =============================================================================
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'

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF_DIR="${CONF_DIR:-/etc/osmocom}"
CFG="${CONF_DIR}/osmo-stp-interop.cfg"
LOG="${LOG:-/var/log/osmocom/osmo-stp-interop.log}"
PIDFILE="/run/osmo-interstp.pid"

NODES=""
OPS=1
MENU=0
USE_DEFAULTS=0
IPS=""
# Le hub du banc, en acces par pont. L'ancien defaut (192.168.56.1, host-only
# VirtualBox) n'existe sur aucun segment des que les VM sont pontees : le hub
# s'y liait sans que personne ne puisse l'atteindre.
HUB_IP="192.168.1.49"
# ── La table du banc, defaut de tous les prompts ────────────────────────────
# Les conteneurs d'abord, dans l'ordre de leur nom, puis les machines distantes.
# C'est le meme ordre que start.sh et build-iso.sh : une table lue ici doit
# ressembler a celle qu'ils ecrivent, sinon les point codes d'un cote ne
# correspondent plus aux AS declares de l'autre.
#   noeud 1  172.20.0.11  osmo-operator-1  (conteneur)
#   noeud 2  172.20.0.12  osmo-operator-2  (conteneur)
#   noeud 3  192.168.1.2  la VM
WAN_NODES_DEFAULT="1:172.20.0.11:11 2:172.20.0.12:22 3:192.168.1.2:33"

# L'adresse par defaut du noeud i, lue dans cette table.
_default_node_ip() {
    printf '%s' "$WAN_NODES_DEFAULT" | tr ' ' '\n' \
        | awk -F: -v n="$1" '$1==n{print $2; exit}'
}
IFACE=""
PORT=2908
SET_IP=1
FOREGROUND=0
ACTION=start

while [ $# -gt 0 ]; do
    case "$1" in
        --nodes)      NODES="${2:-}"; shift ;;
        --nodes=*)    NODES="${1#*=}" ;;
        --ops)        OPS="${2:-1}"; shift ;;
        --ops=*)      OPS="${1#*=}" ;;
        --ip)         HUB_IP="${2:-}"; shift ;;
        --ip=*)       HUB_IP="${1#*=}" ;;
        --iface)      IFACE="${2:-}"; shift ;;
        --iface=*)    IFACE="${1#*=}" ;;
        --port)       PORT="${2:-2908}"; shift ;;
        --port=*)     PORT="${1#*=}" ;;
        --menu)       MENU=1 ;;
        --defaut|--default) USE_DEFAULTS=1 ;;
        --ips)        IPS="${2:-}"; shift ;;
        --ips=*)      IPS="${1#*=}" ;;
        --no-ip)      SET_IP=0 ;;
        --foreground) FOREGROUND=1 ;;
        --status)     ACTION=status ;;
        --stop)       ACTION=stop ;;
        -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

banner() {
    echo -e "${CYAN}${BOLD}"
    echo "╔══════════════════════════════════════════════════════════════════╗"
    echo "║   Inter-STP - hub SS7 du WAN osmo-operator   ·   PC 0.0.0           ║"
    echo "╚══════════════════════════════════════════════════════════════════╝"
    echo -e "${NC}"
}

stp_pid() {
    if [ -r "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
        cat "$PIDFILE"; return 0
    fi
    pgrep -f 'osmo-stp .*osmo-stp-interop\.cfg' | head -1
}

# ── status ───────────────────────────────────────────────────────────────────
if [ "$ACTION" = "status" ]; then
    banner
    pid="$(stp_pid)"
    if [ -z "$pid" ]; then
        echo -e "  ${RED}✗${NC} inter-STP arrete"; exit 1
    fi
    echo -e "  ${GREEN}✓${NC} inter-STP actif (pid ${pid})"
    echo -e "  Ecoute : $(ss -lna 2>/dev/null | grep -E "[:.]${PORT}\b" | head -3 | tr -s ' ' | cut -d' ' -f1,5 | tr '\n' ' ')"
    echo ""
    # Le VTY est la seule source de verite sur QUI est attache : un ASP peut
    # avoir ouvert sa SCTP sans jamais passer ASP-ACTIVE, et rien d'autre ne le
    # montre. Le port ouvert, lui, ne prouve que l'ecoute.
    if command -v nc >/dev/null 2>&1; then
        echo -e "  ${BOLD}ASP attaches :${NC}"
        printf 'enable\nshow cs7 instance 0 asp\nshow cs7 instance 0 as all\nexit\n' \
            | nc -q2 127.0.0.1 4239 2>/dev/null | sed 's/^/    /' | grep -vE '^\s*$' | head -40 \
            || echo -e "    ${YELLOW}VTY 4239 muet${NC}"
    else
        echo -e "  ${YELLOW}nc absent : ${CYAN}telnet 127.0.0.1 4239${NC} puis "show cs7 instance 0 asp""
    fi
    exit 0
fi

# ── stop ─────────────────────────────────────────────────────────────────────
if [ "$ACTION" = "stop" ]; then
    pid="$(stp_pid)"
    if [ -n "$pid" ]; then
        kill "$pid" 2>/dev/null; sleep 1
        kill -9 "$pid" 2>/dev/null || true
        rm -f "$PIDFILE"
        echo -e "${GREEN}Inter-STP arrete.${NC}"
    else
        echo -e "${YELLOW}Inter-STP deja arrete.${NC}"
    fi
    exit 0
fi

banner
[ "$(id -u)" -eq 0 ] || { echo -e "${RED}Root requis.${NC}" >&2; exit 1; }
command -v osmo-stp >/dev/null 2>&1 || {
    echo -e "${RED}osmo-stp introuvable.${NC} Ce noeud doit porter la pile Osmocom." >&2; exit 1; }

# ══════════════════════════════════════════════════════════════════════════════
# Qui sont les noeuds ? - le menu
# ══════════════════════════════════════════════════════════════════════════════
# Le hub n'a pas besoin des IP pour ROUTER : il ecoute, les ASP viennent a lui
# (accept-asp-connections dynamic-permitted) et c'est le point code qui decide
# du reste. Il en a besoin pour trois choses concretes :
#   • ouvrir son pare-feu a ces adresses et a elles seules ;
#   • ecrire /etc/osmo-wan.conf, la table que liront les noeuds et le diagnostic ;
#   • dire, dans --status, QUI devrait etre attache - sans cette liste, un noeud
#     absent est indiscernable d'un noeud qui n'existe pas.
# shellcheck source=network/wan-nodes.sh
. "$HERE/network/wan-nodes.sh"

ask_ips() {
    local n i ip def
    echo -e "${BOLD}Configuration du hub SS7${NC}"
    echo -e "  Saisissez les adresses des noeuds operateurs, dans l'ordre."
    echo ""
    n=$(_wan_ask "Inter-STP" "Nombre de noeuds operateurs (1-9) :" "${NODES:-3}") || exit 1
    [[ "$n" =~ ^[1-9]$ ]] || { echo -e "${RED}Nombre invalide : $n${NC}" >&2; exit 2; }

    WAN_IP=(); WAN_IND=(); WAN_NODE_LIST=()
    for i in $(seq 1 "$n"); do
        # Defaut : la table du banc. Le repli ".1N sur le segment du hub" ne
        # vaut plus rien depuis que les deux premiers noeuds sont des
        # conteneurs, sur le backbone docker et non sur le LAN.
        def="$(_default_node_ip "$i")"
        [ -n "$def" ] || def="${HUB_IP%.*}.$((10 + i))"
        ip=$(_wan_ask "Noeud $i/$n" "IP du noeud operateur $i :" "$def") || exit 1
        [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] \
            || { echo -e "${RED}'$ip' n'est pas une IPv4${NC}" >&2; exit 2; }
        WAN_IP[$i]="$ip"; WAN_IND[$i]="$(wan_default_ind "$i")"; WAN_NODE_LIST+=("$i")
    done
    WAN_NODE_COUNT="$n"; NODES="$n"
    # Le hub ne porte aucun operateur : il n'est pas un noeud de la table. On note
    # 0, que wan_nodes_validate accepte, plutot qu'un numero qui le ferait
    # ressembler a un noeud et lui volerait un point code.
    WAN_NODE_ID=0
    WAN_OPS="$OPS"
}

# --defaut : la table du banc, quoi qu'il y ait sur la machine.
# Sans cette option, une table deja posee dans /etc/osmo-wan.conf est rechargee
# telle quelle - c'est le bon comportement au quotidien, mais c'est aussi ce qui
# fait qu'un hub garde indefiniment des adresses perimees apres un changement de
# plan : le nouveau defaut du script n'est jamais consulte.
if [ "$USE_DEFAULTS" = "1" ] && [ -z "$IPS" ]; then
    IPS="$(printf '%s' "$WAN_NODES_DEFAULT" | tr ' ' '\n' | awk -F: '{print $2}' | tr '\n' ' ')"
    [ -n "$NODES" ] || NODES="$(printf '%s' "$WAN_NODES_DEFAULT" | wc -w)"
    echo -e "  ${CYAN}--defaut : table du banc${NC} ${WAN_NODES_DEFAULT}"
fi

if [ -n "$IPS" ]; then
    # Forme scriptable : --ips "10.0.0.11 10.0.0.12 10.0.0.13"
    WAN_IP=(); WAN_IND=(); WAN_NODE_LIST=(); i=0
    for ip in $IPS; do
        i=$((i+1))
        WAN_IP[$i]="$ip"; WAN_IND[$i]="$(wan_default_ind "$i")"; WAN_NODE_LIST+=("$i")
    done
    WAN_NODE_COUNT="$i"; NODES="$i"; WAN_NODE_ID=0; WAN_OPS="$OPS"
elif [ "$MENU" = "1" ] || { [ -z "$NODES" ] && [ ! -r "$WAN_CONF_FILE" ]; }; then
    if [ -t 0 ]; then
        ask_ips
    else
        echo -e "${YELLOW}Pas de terminal et pas de table : on part sur 3 noeuds sans adresses.${NC}"
        NODES=3
    fi
elif [ -z "$NODES" ] && [ -r "$WAN_CONF_FILE" ]; then
    wan_nodes_load "$WAN_CONF_FILE" 2>/dev/null || true
    NODES="${WAN_NODE_COUNT:-3}"
    OPS="${WAN_OPS:-$OPS}"
    echo -e "  Table lue dans ${CYAN}${WAN_CONF_FILE}${NC} : ${NODES} noeud(s)"
    echo -e "  ${CYAN}--menu${NC} pour la ressaisir."
fi

[[ "$NODES" =~ ^[1-9]$ ]] || { echo -e "${RED}--nodes : 1 a 9${NC}" >&2; exit 2; }
[[ "$OPS"   =~ ^[1-9]$ ]] || { echo -e "${RED}--ops : 1 a 9${NC}" >&2; exit 2; }

# Table connue : on la persiste et on ouvre le pare-feu pour ces adresses.
if [ "${#WAN_NODE_LIST[@]}" -gt 0 ] && [ -n "${WAN_IP[1]:-}" ]; then
    echo ""
    wan_nodes_summary 2>/dev/null || true
    WAN_AUTO=0 wan_nodes_save "$WAN_CONF_FILE" \
        && echo -e "  ${GREEN}✓${NC} table ecrite dans ${CYAN}${WAN_CONF_FILE}${NC}"
    if [ -x "$HERE/network/firewall-wan.sh" ] || [ -r "$HERE/network/firewall-wan.sh" ]; then
        for i in "${WAN_NODE_LIST[@]}"; do
            bash "$HERE/network/firewall-wan.sh" "${WAN_IP[$i]}" "$OPS" >/dev/null 2>&1 || true
        done
        echo -e "  ${GREEN}✓${NC} pare-feu ouvert pour ${#WAN_NODE_LIST[@]} noeud(s)"
    fi
fi

# ── L'adresse du hub ─────────────────────────────────────────────────────────
# Elle est ecrite en dur dans la config de CHAQUE noeud : si elle manque au
# demarrage, tous les ASP echouent a s'attacher et le seul symptome est un
# "connection refused" cote noeuds, jamais cote hub.
if [ "$SET_IP" = "1" ]; then
    if ip -4 addr show 2>/dev/null | grep -q "inet ${HUB_IP}/"; then
        echo -e "  ${GREEN}✓${NC} ${CYAN}${HUB_IP}${NC} deja presente sur cette machine"
    else
        if [ -z "$IFACE" ]; then
            # L'interface qui porte deja le meme /24 : c'est le segment du WAN.
            IFACE="$(ip -4 -o addr show scope global 2>/dev/null \
                | awk -v pfx="${HUB_IP%.*}." '$4 ~ "^"pfx {print $2; exit}')"
            [ -z "$IFACE" ] && IFACE="$(ip -4 -o addr show scope global 2>/dev/null | awk 'NR==1{print $2}')"
        fi
        if [ -n "$IFACE" ] && ip addr add "${HUB_IP}/24" dev "$IFACE" 2>/dev/null; then
            echo -e "  ${GREEN}✓${NC} ${CYAN}${HUB_IP}/24${NC} posee sur ${IFACE}"
        else
            echo -e "  ${YELLOW}⚠${NC} impossible de poser ${HUB_IP} (interface ${IFACE:-?})"
            echo -e "     Les noeuds ne pourront pas s'attacher tant qu'elle manque."
        fi
    fi
fi

# ── Config du hub ────────────────────────────────────────────────────────────
mkdir -p "$CONF_DIR" "$(dirname "$LOG")"
if [ ! -x "$HERE/helpers/create_interop.sh" ] && [ ! -r "$HERE/helpers/create_interop.sh" ]; then
    echo -e "${RED}helpers/create_interop.sh introuvable${NC}" >&2; exit 1
fi
# --listen-ip : le hub se lie a SON adresse, pas a 0.0.0.0. Sans elle, SCTP
# annonce toutes les adresses de la machine dans son INIT et les noeuds tentent
# des chemins morts - l'ASP monte puis retombe, en boucle.
bash "$HERE/helpers/create_interop.sh" --listen-ip "$HUB_IP" \
     --wan "$NODES" "$OPS" "$CFG" || exit 1
[ "$PORT" = "2908" ] || sed -i "s/^ listen m3ua 2908/ listen m3ua ${PORT}/" "$CFG"
echo ""

# ── Lancement ────────────────────────────────────────────────────────────────
if pid="$(stp_pid)" && [ -n "$pid" ]; then
    echo -e "  ${YELLOW}Un inter-STP tourne deja (pid ${pid}) - arret puis relance.${NC}"
    kill "$pid" 2>/dev/null; sleep 1; kill -9 "$pid" 2>/dev/null || true
fi

echo -e "  ${BOLD}Noeuds desservis :${NC} ${NODES} × ${OPS} operateur(s)"
for n in $(seq 1 "$NODES"); do
    for o in $(seq 1 "$OPS"); do
        printf '    noeud %s op %s → STP %s  MSC %s  BSC %s  (rctx %s)\n' \
            "$n" "$o" "1.${n}${o}.2" "1.${n}${o}.1" "1.${n}${o}.3" "$(( n*1000 + o*100 + 50 ))"
    done
done
echo ""

if [ "$FOREGROUND" = "1" ]; then
    echo -e "  ${GREEN}osmo-stp au premier plan - Ctrl-C pour arreter${NC}"
    exec osmo-stp -c "$CFG"
fi

osmo-stp -c "$CFG" >> "$LOG" 2>&1 &
echo $! > "$PIDFILE"
sleep 2
if kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
    echo -e "  ${GREEN}✓ inter-STP lance${NC} - M3UA ${CYAN}${HUB_IP}:${PORT}${NC}, VTY 4239"
    echo -e "  Journal : ${CYAN}${LOG}${NC}"
    echo ""
    echo -e "  ${BOLD}Cote noeuds :${NC} leur osmo-stp doit viser ${CYAN}${HUB_IP}${NC}"
    echo -e "    ISO construite avec ${CYAN}--role operator --node N${NC} : deja fait."
    echo -e "  ${BOLD}Verifier qui est attache :${NC} ${CYAN}./start-interstp.sh --status${NC}"
else
    echo -e "  ${RED}✗ osmo-stp n'a pas demarre${NC} - ${CYAN}tail -30 ${LOG}${NC}"
    tail -15 "$LOG" 2>/dev/null | sed 's/^/    /'
    exit 1
fi

start-multi.sh

.sh 729 lignes - 38KB

start-multi.sh
#!/bin/bash
# =============================================================================
# start-multi.sh - LE BANC MULTI-OPERATEUR : natif + docker, relies en SS7
#
#   op 1  NATIF   deja en place (start-direct.sh)   PC 1.1.2
#   op 2  DOCKER  conteneur                          PC 1.2.2   172.20.0.12
#   op 3  DOCKER  conteneur                          PC 1.3.2   172.20.0.13
#   hub   DOCKER  inter-STP                          PC 0.0.0   172.20.0.10
#                                                    M3UA/SCTP 2908
#
# CE SCRIPT NE REIMPLEMENTE RIEN. start.sh sait deja lancer des operateurs en
# conteneur, poser le hub (--hub-ip) et DECLARER un operateur sans lui lancer de
# conteneur (--operator IP:PREFIXE) - ce dernier point est exactement le cas du
# natif, qui tourne deja et qu on veut seulement raccorder. Refaire ce travail
# ici donnerait une seconde formule a maintenir, et le depot montre deja ou cela
# mene : start.sh et lib/gabarits.sh portent les MEMES fonctions en double.
# start-multi.sh se contente donc de trois choses : verifier, traduire la
# topologie en options, et deleguer.
#
# LA TOPOLOGIE VIENT DE addition.sh, pas d ici : /etc/osmocom/osmo-multi.conf.
# Un seul endroit la calcule.
#
# Usage :
#   sudo ./start-multi.sh              monte le banc multi-operateur
#   sudo ./start-multi.sh --status     etat des conteneurs et du hub
#   sudo ./start-multi.sh --stop       arrete les conteneurs (le natif reste)
#   sudo ./start-multi.sh --dry-run    affiche la commande sans la lancer
# =============================================================================
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'

DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
MULTI_CONF="${MULTI_CONF:-/etc/osmocom/osmo-multi.conf}"
ACTION="start"
for a in "$@"; do
    case "$a" in
        --status)  ACTION="status" ;;
        --stop)    ACTION="stop" ;;
        --dry-run) ACTION="dry" ;;
        -h|--help) sed -n '2,29p' "$0"; exit 0 ;;
    esac
done

# ── LANCE PAR UNE ICONE : TERMINAL ET PRIVILEGES ────────────────────────────
# [2026-08-31] Sans ce preambule, l antenne bleue du bureau ne faisait RIEN de
# visible. Le .desktop est en Terminal=false ; le script exigeait root et
# ecrivait son refus sur stdout, c est-a-dire nulle part. Un double-clic
# lancait donc bien le script, qui sortait aussitot sur "Root requis" sans une
# fenetre ni un message : l icone paraissait morte, alors qu elle marchait.
# launch.sh avait deja ce traitement ; celui-ci ne l avait pas.
#
# On teste la presence d un TTY, pas $DISPLAY : c est la difference reelle
# entre "lance a la main" et "lance par une icone".
# ⚠️ SEULEMENT pour le LANCEMENT. --status, --dry-run et --help sont des
# lectures : les rejouer dans un terminal graphique ouvrirait une fenetre a
# chaque appel depuis un script ou un pipe - et leur sortie n irait pas a
# l appelant, qui n obtiendrait RIEN. Constate en les testant d ici.
_multi_readonly=0
case " $* " in
    *" --status "*|*" --dry-run "*|*" -h "*|*" --help "*) _multi_readonly=1 ;;
esac

# [2026-08-31] stdin ET stdout : `[ ! -t 0 ]` seul prenait un
# `./start-multi.sh < fichier` lance depuis un vrai terminal pour un
# double-clic, et ouvrait une fenetre dont la sortie n allait pas a l appelant.
_multi_besoin=0
if [ "$_multi_readonly" = "0" ]; then
    { [ ! -t 0 ] || [ ! -t 1 ]; } && _multi_besoin=1
fi
if [ "$_multi_besoin" = "1" ] && [ "${OSMO_MULTI_TERM:-0}" != "1" ]; then
    export OSMO_MULTI_TERM=1
    for _t in gnome-terminal xfce4-terminal konsole xterm; do
        command -v "$_t" >/dev/null 2>&1 || continue
        case "$_t" in
            gnome-terminal) exec "$_t" --title="osmo-operator multi-operateur" -- "$0" "$@" ;;
            *)              exec "$_t" -T "osmo-operator multi-operateur" -e "$0" "$@" ;;
        esac
    done
fi

# Le drapeau ne sert qu a ne pas boucler sur la relance. On le RETIRE de
# l environnement : exporte, il descendait dans start.sh, dans le raccourci du
# natif et jusqu a launch.sh, ou il n a aucun sens. La valeur reste ici, dans
# une variable de shell : c est elle qui decide de retenir la fenetre a la fin.
_multi_own_window=0
[ "${OSMO_MULTI_TERM:-0}" = "1" ] && _multi_own_window=1
unset OSMO_MULTI_TERM

# Meme raison pour root : une lecture n a pas a demander de mot de passe.
if [ "$_multi_readonly" = "0" ]; then
        if [ "$(id -u)" -ne 0 ]; then
            if command -v pkexec >/dev/null 2>&1 && [ -n "${DISPLAY:-}" ]; then
                exec pkexec env DISPLAY="${DISPLAY:-}" XAUTHORITY="${XAUTHORITY:-}" \
                     OSMO_MULTI_TERM="$_multi_own_window" "$0" "$@"
            fi
            command -v sudo >/dev/null 2>&1 && exec sudo -E "$0" "$@"
        fi
fi

echo -e "${CYAN}${BOLD}"
echo "╔══════════════════════════════════════════════════════╗"
echo "║   osmo-operator - banc MULTI-OPERATEUR (SS7)         ║"
echo "╚══════════════════════════════════════════════════════╝"
echo -e "${NC}"

# ── PREFLIGHT ───────────────────────────────────────────────────────────────
# Docker N EST PAS dans l ISO (voir addition.sh) : son absence est le cas
# NORMAL au premier lancement, pas une panne. On le dit comme tel, et on
# renvoie vers le supplement plutot que de laisser docker repondre
# "command not found" - message qui n indique nulle part quoi faire.
manque() {
    echo -e "  ${RED}✗ $1${NC}"
    echo -e "    ${CYAN}→ sudo ${DIR}/addition.sh${NC}  (ou l icone ${BOLD}Supplements${NC} du bureau)"
    exit 1
}
command -v docker >/dev/null 2>&1 || manque "docker n est pas installe"
docker info >/dev/null 2>&1        || manque "le demon docker ne repond pas"

if [ ! -f "$MULTI_CONF" ]; then
    manque "topologie absente : $MULTI_CONF"
fi
# shellcheck disable=SC1090
. "$MULTI_CONF"

: "${MULTI_HUB_IP:=172.20.0.10}"
: "${MULTI_M3UA_PORT:=2908}"
: "${MULTI_OPS:=}"
: "${MULTI_IMAGE:=osmocom-run}"

# ── LIRE LE NATIF PLUTOT QUE LE SUPPOSER ────────────────────────────────────
# [2026-08-31] La topologie ecrite par addition.sh dit ce qu on ATTEND du
# natif ; ces deux fonctions disent ce qu il EST. Les confondre a coute cher :
# on affichait "op natif actif" sur la seule presence d un processus, sans voir
# qu il portait le point code d un conteneur.
#
# Le point code depend du plan choisi, et les deux existent :
#   plan LOCAL (set-node-id.sh --local) : 1.<op>.<role>        - une machine
#   plan WAN   (defaut)                 : 1.<noeud><op>.<role> - plusieurs
# Sur un banc a une machine c est le plan local qui vaut, mais rien n empeche
# la config d avoir ete posee autrement : on LIT, on ne recalcule pas.
natif_pc() {
    awk '/^[[:space:]]*point-code[[:space:]]/{print $2; exit}' \
        /etc/osmocom/osmo-stp.cfg 2>/dev/null
}

# L adresse par laquelle le natif sort vers le hub : le local-ip de son ASP
# asp-to-inter. C est elle qui doit exister sur l hote - un local-ip inbindable
# est un ASP qui ne monte jamais, sans message clair (cf. le 172.20.0.11 des
# gabarits, corrige dans generate_configs.sh).
natif_asp_ip() {
    local ip
    ip="$(awk '/^[[:space:]]*asp[[:space:]]+asp-to-inter[[:space:]]/{a=1; next}
               a && /^[[:space:]]*local-ip[[:space:]]/{print $2; exit}' \
          /etc/osmocom/osmo-stp.cfg 2>/dev/null)"
    [ -n "$ip" ] || return 0
    # [2026-08-31] ON VERIFIE QUE L ADRESSE EXISTE ICI.
    # Cette colonne affichait bêtement ce que disait la config, et la config
    # portait 172.20.0.11 - l adresse du PREMIER CONTENEUR, remise par les
    # gabarits a chaque demarrage du natif. On lisait donc "op 1 native
    # 172.20.0.11", ce qui est impossible : le natif tourne sur l hote.
    # Une adresse absente de la machine est un ASP qui ne montera jamais ; on le
    # DIT dans la colonne plutot que de laisser croire a une topologie saine.
    if ip -4 -o addr show 2>/dev/null | awk '{print $4}' | cut -d/ -f1 \
         | grep -qx "$ip"; then
        printf '%s\n' "$ip"
    else
        printf '%s(absente!)\n' "$ip"
    fi
}

# ── UN PROCESSUS DE L HOTE, PAS CELUI D UN CONTENEUR ────────────────────────
# [2026-08-31] `pgrep -x osmo-bsc` et `pgrep -f 'osmo-bsc|asterisk'` repondaient
# OUI des qu un CONTENEUR tournait : l hote voit TOUS les processus, y compris
# les leurs (mesure : osmo-bsc pid 140689 et asterisk pid 140878, enfants du
# systemd de osmo-operator-2, listes par le `ps` de l hote). Le natif etait donc
# confondu avec n importe quelle pile du banc. Deux consequences, vues toutes
# les deux :
#   - etat() affichait « op 1 native actif » sans rien de natif en marche ;
#   - lancer_natif_si_absent() sortait aussitot et NE LANCAIT JAMAIS le natif
#     des qu un conteneur tournait deja - precisement le cas d un start-multi
#     relance, celui ou on en a le plus besoin.
# Le discriminant est le NAMESPACE PID : un conteneur a le sien, l hote garde
# celui de son init. Plus sur que le cgroup, qui depend de --cgroupns.
#
# `pgrep -x` ET NON `pgrep -f` : on compare le NOM du processus, pas sa ligne de
# commande. `-f 'osmo-bsc|asterisk'` repondait OUI sur n importe quelle ligne
# CITANT ces mots - le `tail -F /var/log/osmocom/osmo-bsc.log` que le natif
# ouvre lui-meme, un editeur, ou le shell appelant. Un nom exact ne peut pas
# mentir de cette facon, et rend inutile l exclusion de notre propre PID.
_ns_hote="$(readlink /proc/1/ns/pid 2>/dev/null)"
_est_natif() {          # $@ = noms EXACTS ; vrai si l un tourne hors conteneur
    local n p
    for n in "$@"; do
        for p in $(pgrep -x "$n" 2>/dev/null); do
            [ "$(readlink "/proc/$p/ns/pid" 2>/dev/null)" = "$_ns_hote" ] && return 0
        done
    done
    return 1
}

# ── ETAT ────────────────────────────────────────────────────────────────────
etat() {
    local spec idx mode ip pc _r
    echo -e "  ${BOLD}Topologie${NC} ($MULTI_CONF)"
    for spec in $MULTI_OPS; do
        IFS=: read -r idx mode ip pc _rctx <<< "$spec"
        if [ "$mode" = "native" ]; then ip="$(natif_asp_ip)"; fi
        printf '    op %-2s %-7s %-13s PC %-8s ' "$idx" "$mode" "${ip:-(hote)}" "$pc"
        if [ "$mode" = "native" ]; then
            if _est_natif osmo-bsc asterisk \
               || systemctl is-active --quiet asterisk 2>/dev/null; then
                _r="$(natif_pc)"
                if [ -n "$_r" ] && [ "$_r" != "$pc" ]; then
                    echo -e "${GREEN}actif${NC} ${YELLOW}(PC reel ${_r})${NC}"
                else
                    echo -e "${GREEN}actif${NC}"
                fi
            else
                echo -e "${YELLOW}arrete${NC}"
            fi
        else
            if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "osmo-operator-${idx}"; then
                echo -e "${GREEN}actif${NC}"
            else
                echo -e "${YELLOW}arrete${NC}"
            fi
        fi
    done
    printf '    hub  %-7s %-13s PC %-8s ' "docker" "$MULTI_HUB_IP" "${MULTI_HUB_PC:-0.0.0}"
    if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "${MULTI_HUB_NAME:-osmo-inter-stp}"; then
        echo -e "${GREEN}actif${NC}"
    else
        echo -e "${YELLOW}arrete${NC}"
    fi
}

case "$ACTION" in
  status) etat; exit 0 ;;
  stop)
        for spec in $MULTI_OPS; do
            IFS=: read -r idx mode _ <<< "$spec"
            [ "$mode" = "docker" ] || continue
            docker rm -f "osmo-operator-${idx}" >/dev/null 2>&1 \
                && echo -e "  ${GREEN}✓${NC} osmo-operator-${idx} arrete"
        done
        docker rm -f "${MULTI_HUB_NAME:-osmo-inter-stp}" >/dev/null 2>&1 \
            && echo -e "  ${GREEN}✓${NC} hub arrete"
        echo -e "  ${CYAN}i${NC} l operateur NATIF n est pas touche - ${BOLD}sudo ${DIR}/start-direct.sh stop${NC} pour lui."
        exit 0 ;;
esac

# ── L IMAGE ─────────────────────────────────────────────────────────────────
# NOM DE L IMAGE : osmocom-run, PAS "osmo-operator".
# build.sh produit osmocom-nitb (Dockerfile, ~11 Go) puis Dockerfile.run en
# derive osmocom-run, et c est CELLE-LA que start.sh lance - verifie sur le
# banc : `docker inspect osmo-operator-1 --format {{.Config.Image}}` rend
# osmocom-run. Chercher "osmo-operator" (le nom du DEPOT, pas de l image)
# rendait la sonde toujours negative : "image absente" en permanence, et une
# recompilation Osmocom de 40 minutes relancee pour rien a chaque passage.
# [2026-09-02] SI SEULE LA BASE EST LA, ON DERIVE ICI. addition.sh peut
# desormais TELECHARGER osmocom-nitb (docker pull bastienbaranoff/norf_gsm)
# au lieu de la compiler ; osmocom-run en decoule par Dockerfile.run en
# quelques secondes. Refuser de demarrer pour ca et renvoyer vers addition.sh
# - qui aurait repondu "image deja presente" - etait une boucle sans issue.
if ! docker image inspect "$MULTI_IMAGE" >/dev/null 2>&1; then
    if docker image inspect osmocom-nitb >/dev/null 2>&1; then
        echo -e "  ${CYAN}→${NC} image '$MULTI_IMAGE' absente, base osmocom-nitb presente : derivation (Dockerfile.run)"
        ( cd "$DIR" && docker build --build-arg QEMU_CACHE_BUST=$(date +%s) \
                           -f Dockerfile.run -t "$MULTI_IMAGE" . ) \
            || manque "derivation de l image '$MULTI_IMAGE' echouee"
    else
        manque "image '$MULTI_IMAGE' absente"
    fi
fi

# ── LE RACCORD DU NATIF AU HUB ──────────────────────────────────────────────
# [2026-08-31] RESOLU, dans start.sh, au lancement du hub.
# /etc/osmocom/osmo-stp.cfg (natif) declare
#       asp asp-to-inter 2908 2910 m3ua
#        remote-ip 127.0.0.1
# et rien n ecoutait sur 127.0.0.1:2908 : le hub vit dans le conteneur et ne
# publiait aucun port. Le hub publie desormais 2908 en SCTP sur la boucle
# locale (docker run -p 127.0.0.1:2908:2908/sctp), ce qui rend le remote-ip du
# natif exact sans toucher a une seule config regeneree.
# Verifie sur le banc : docker port -> "2908/sctp -> 127.0.0.1:2908",
# ss -an -> "sctp LISTEN 127.0.0.1:2908", docker-proxy -proto sctp actif.
#
# L autre voie - repointer l ASP natif sur 172.20.0.10 / local-ip 172.20.0.1 -
# a ete ecartee : ces deux adresses n existent que tant que le bridge docker
# est monte, et disparaissent avec les conteneurs.
#
# Le second defaut du meme bloc, lui, etait dans la config : local-ip valait
# 172.20.0.11, l adresse du PREMIER CONTENEUR, inbindable sur l hote. Corrige
# dans generate_configs.sh (apply_native_post_patches), qui ne couvrait pas
# osmo-stp.cfg.

# ── COMBIEN DE CONTENEURS ───────────────────────────────────────────────────
# Ceux marques "docker" dans la table, pas un compte fige : la topologie est
# la seule source.
N_DOCKER=0
for spec in $MULTI_OPS; do
    IFS=: read -r idx mode _ <<< "$spec"
    [ "$mode" = "docker" ] && N_DOCKER=$((N_DOCKER + 1))
done
[ "$N_DOCKER" -ge 1 ] || { echo -e "  ${RED}✗ aucun operateur docker dans la topologie${NC}"; exit 1; }

# OPERATOR_COUNT_HINT : start.sh l utilise pour NE PAS reposer la question du
# nombre de conteneurs (voir start_bridge_mode). Sans elle, le script s arrete
# sur une boite de dialogue - fatal pour un lancement par icone.
# [2026-08-31] PAS DE --wan ICI, ET C EST LE POINT IMPORTANT.
# La premiere version passait --wan --wan-id --hub-ip --operator. Resultat
# mesure : le check de fin cherchait le hub en 192.168.1.49 et l operateur
# portait le point code 1.23.2, alors que la topologie dit 172.20.0.10 et
# 1.2.2. --wan est la machinerie MULTI-MACHINE : il lit/ecrit
# /etc/osmo-wan.conf (qui n existe meme pas ici), renumerote les point codes en
# 1.<noeud><op>.<role> et attend un hub a une adresse routable entre machines.
# Sur UNE machine, c est l inverse qu on veut - et c est deja le defaut :
# checks/wan_ss7_check.sh le dit noir sur blanc, "sans WAN l ASP de chaque
# operateur pointe sur 172.20.0.10", le hub local, avec un catch-all
# "route 0.0.0 0.0.0 → as-inter". Il n y a donc rien a demander a start.sh
# au-dela du nombre de conteneurs.
#
# OPERATOR_COUNT_HINT : start.sh l utilise pour NE PAS reposer la question du
# nombre de conteneurs (voir start_bridge_mode). Sans elle, le script s arrete
# sur une boite de dialogue - fatal pour un lancement par icone.
# OSMO_QUICK=1 : reutiliser le cache docker. Sans elle start.sh partait en
# "Build : normal (--no-cache)", soit une recompilation Osmocom complete de ~40
# minutes A CHAQUE lancement du multi-operateur - alors que l image est deja la
# (addition.sh s en charge, et le prealable plus haut le verifie).
# OSMO_NONINTERACTIVE=1 : les boites whiptail de start.sh rendent leur valeur
# par defaut au lieu d attendre. Sans elle, le lancement s arretait sur
# « Nombre d'operateurs (1-36) » - un dialogue que personne ne voit quand on a
# clique sur une icone, et le banc paraissait mort.
# --operators : le drapeau EXPLICITE de start.sh (l.2552), et non la seule
# variable d environnement. start.sh remettait OPERATOR_COUNT_HINT a vide des
# sa ligne 70 - corrige depuis, mais l argument reste la voie sure : il est
# analyse apres, il prime, et il documente l intention dans la ligne de
# commande qu on affiche.
# HANDOFF_MODE=faketrx-qemu : la radio MIXTE, un faketrx + un QEMU Calypso par
# operateur - le meme montage que le natif. start.sh ne la choisissait seul
# qu en mode WAN ; impose ici, elle vaut aussi pour ce banc a une machine.
# OSMO_SKIP_CHECKS=1 : start.sh joue desormais ss7_check et operator_summary en
# fin de lancement. Ici on enchaine verifier(), qui rejoue ss7_check avec en
# plus la lecture de la topologie - inutile de le passer deux fois de suite.
# OP_ID_BASE=2 : les conteneurs prennent les rangs 2 et 3, le 1 reste au natif.
# OSMO_NO_ATTACH=1 : ne pas finir bloque dans le tmux d un conteneur - le script
# doit rendre la main pour enchainer verifier().
CMD=(env "OSMO_QUICK=1" "OSMO_NONINTERACTIVE=1" "HANDOFF_MODE=faketrx-qemu" "OSMO_SKIP_CHECKS=1"
     "OP_ID_BASE=2" "OSMO_NO_ATTACH=1"
     "$DIR/start.sh" virtual --operators "$N_DOCKER")

echo -e "  ${BOLD}Topologie${NC} : ${N_DOCKER} conteneur(s) + 1 natif + hub ${MULTI_HUB_IP}"
echo -e "  ${CYAN}→${NC} ${CMD[*]}"
echo

if [ "$ACTION" = "dry" ]; then
    echo -e "  ${YELLOW}--dry-run : rien n a ete lance.${NC}"
    exit 0
fi

[ "$(id -u)" -eq 0 ] || { echo -e "${RED}Root requis : sudo $0${NC}"; exit 1; }

# ── SE PLACER DANS LE DEPOT AVANT D APPELER start.sh ────────────────────────
# [2026-08-31] start.sh appelle ses aides en chemin RELATIF - ligne 2595 :
#     ./helpers/prepare_host.sh
# Lance depuis ailleurs (une icone du bureau demarre dans $HOME), il sortait
# donc sur
#     start.sh: line 2595: ./helpers/prepare_host.sh: No such file or directory
# et abandonnait AVANT de creer le moindre conteneur. Le bilan affichait
# ensuite "conteneur absent" trois fois, ce qui faisait chercher un probleme
# de docker alors que le script n avait jamais atteint le docker run.
cd "$DIR" || { echo -e "${RED}Impossible d entrer dans $DIR${NC}"; exit 1; }

# ── ALIGNER L IDENTITE SS7 DU NATIF, AVANT DE LANCER ────────────────────────
# start.sh numerote SES conteneurs 1..N sans decalage possible : avec 2
# conteneurs il produit les point codes 1.1.2 et 1.2.2. Un natif laisse a son
# defaut porte 1.1.2 lui aussi - deux equipements a la meme adresse SS7, ce que
# ce depot decrit comme "pas un conflit de nom, du routage faux".
#
# On repointe donc le natif sur le rang que la topologie lui donne, AVANT de
# lancer les conteneurs. Deux drapeaux comptent :
#   --local   plan d une seule machine, MID=<op> -> 1.<op>.<role>. SANS lui,
#             set-node-id.sh prend le plan WAN (MID=<noeud><op>) et rend
#             1.13.2 au lieu de 1.3.2 - verifie en dry-run.
#   --hub-ip  sinon l ASP est repointe sur 192.168.1.49, le hub WAN par defaut,
#             qui n existe pas ici : c est de la que venait le "Aucune
#             association SCTP vers 192.168.1.49:2908" des premiers essais.
# ── L IDENTITE DU NATIF NE TIENT PAS QU AU POINT CODE ───────────────────────
# [2026-08-31] set-node-id.sh repointe les point codes, et rien d autre. Or
# start-direct.sh derive TOUT le reste de son operateur depuis OPERATOR_ID
# (/etc/osmocom/coeur.env) :
#     MS_OP_ID  = OPERATOR_ID
#     ms_imsi() = MCC MNC <op sur 4> <ms sur 6>      -> l IMSI
#     MSISDN    = 100<op><ms>
#     MS_ARFCN1 = 512 + MS_OP_ID * 2                 -> la FREQUENCE
# Un natif realigne en operateur 3 mais laisse a OPERATOR_ID=1 gardait donc les
# abonnes de l operateur 1 - MSISDN 100101, IMSI 001010001000001 - exactement
# ceux que start.sh donne a son conteneur osmo-operator-1. Deux abonnes avec le
# meme MSISDN dans deux HLR, et deux cellules sur le MEME ARFCN : rien ne
# proteste au demarrage, et c est a l appel que ca part de travers.
_aligner_coeur_env() {
    local idx="$1" f="/etc/osmocom/coeur.env"
    [ -f "$f" ] || return 0
    if grep -qE '^[[:space:]]*:[[:space:]]*"\$\{OPERATOR_ID:=' "$f"; then
        sed -i -E "s|^([[:space:]]*:[[:space:]]*\"\\\$\\{OPERATOR_ID:=)[0-9]+(\\}\")|\\1${idx}\\2|" "$f"
        grep -qE "OPERATOR_ID:=${idx}\\}" "$f" \
            || echo -e "  ${GREEN}✓${NC} coeur.env : OPERATOR_ID=${idx} (IMSI, MSISDN et ARFCN du natif suivent)"
    else
        echo ": \"\${OPERATOR_ID:=${idx}}\"" >> "$f"
        echo -e "  ${GREEN}✓${NC} coeur.env : OPERATOR_ID=${idx} ajoute"
    fi
}

aligner_natif() {
    local idx pc spec reel
    for spec in $MULTI_OPS; do
        IFS=: read -r idx mode _ip pc _rctx <<< "$spec"
        [ "$mode" = "native" ] || continue
        # [2026-08-31] coeur.env est aligne D ABORD, et INCONDITIONNELLEMENT.
        # Il etait traite dans la branche "point code different" plus bas : une
        # fois les point codes deja bons, aligner_natif sortait par le
        # raccourci "deja en PC 1.3.2" et OPERATOR_ID restait a 1 pour
        # toujours. Le natif gardait alors les IMSI/MSISDN de l operateur 1 -
        # 001010001000001, 100101 - exactement ceux du conteneur op1, et
        # l ARFCN avec. Les deux reglages sont INDEPENDANTS : point codes
        # (set-node-id.sh) et plan d abonnes (OPERATOR_ID) ; les lier faisait
        # que le second ne se rattrapait jamais.
        _aligner_coeur_env "$idx"
        reel="$(natif_pc)"
        [ -n "$reel" ] || return 0
        [ "$reel" = "$pc" ] && { echo -e "  ${GREEN}✓${NC} natif deja en PC ${pc} (op ${idx})"; return 0; }
        echo -e "  ${CYAN}→${NC} natif en PC ${reel}, attendu ${pc} : realignement sur l operateur ${idx}"
        if [ -x "$DIR/network/set-node-id.sh" ] || [ -r "$DIR/network/set-node-id.sh" ]; then
            if bash "$DIR/network/set-node-id.sh" --node "${MULTI_NODE:-1}" --op "$idx" \
                    --local --hub-ip 127.0.0.1 >/dev/null 2>&1; then
                echo -e "  ${GREEN}✓${NC} natif realigne : PC $(natif_pc)"
                echo -e "  ${YELLOW}!${NC} les demons natifs gardent l ancienne identite en memoire :"
                echo -e "    ${CYAN}sudo ${DIR}/start-direct.sh${NC} pour qu ils la relisent."
            else
                echo -e "  ${RED}✗${NC} realignement du natif echoue - voir network/set-node-id.sh"
            fi
        fi
        return 0
    done
}
aligner_natif

# ── TOUT ARRETER AVANT DE RELANCER ──────────────────────────────────────────
# [2026-08-31] Un clic = un banc NEUF. Sans ca, un lancement par-dessus un banc
# deja debout ne relance presque rien : les demons Osmocom ne relisent PAS leur
# configuration a chaud. On l a paye plusieurs fois - un osmo-stp realigne sur
# le bon point code gardait l ancien en memoire, et `kill -HUP` n y change rien
# (verifie : config a jour sur le disque, ASP toujours ASP_DOWN sur l ancienne
# adresse). Le seul geste qui compte est l arret complet.
#
# On arrete le NATIF (il delegue a run.sh --stop, qui demonte proprement radio
# et coeur). Rien ici ne doit faire echouer le lancement : ce qui est deja
# arrete l est tres bien, d ou les || true.
tout_arreter() {
    echo -e "  ${CYAN}→${NC} arret du banc en place (un clic = un banc neuf)"
    if [ -x "$DIR/start-direct.sh" ]; then
        timeout 120 "$DIR/start-direct.sh" --stop >/dev/null 2>&1 || true
        echo -e "    ${GREEN}✓${NC} pile native arretee"
    fi
    # Les conteneurs ne sont PAS touches ici : start.sh fait deja
    # `docker rm -f $(docker ps -aq --filter name=osmo-)` en tete de course.
    # Le refaire serait redondant, et surtout destructeur depuis launch.sh -
    # un clic sur le telephone rouge, qui ne monte que le natif, balayerait
    # les conteneurs multi-operateur d a cote.
    # Les ponts audio survivent aux conteneurs (setsid) : sans ca on empile un
    # relais de plus a chaque relance, et le son se dedouble.
    pkill -f 'paplay --server=tcp:' 2>/dev/null || true
}

tout_arreter

# ── SI LE NATIF EST A TERRE, ON LANCE SON RACCOURCI ─────────────────────────
# Le natif n est PAS lance par ce script : il a le sien (le telephone rouge du
# bureau -> launch.sh -> start-direct.sh). Mais monter les conteneurs sans lui,
# c est monter un banc a trois operateurs dont le premier manque : le hub
# n aura qu un ASP sur les trois attendus, et le bilan de fin sortira en
# DEGRADE pour une raison qui n a rien a voir avec ce qu on vient de lancer.
#
# On declenche donc son raccourci, et AVANT les conteneurs : l ordre compte,
# start-direct.sh a besoin de poser ses configs et son coeur pendant que le
# plan d adressage est encore libre.
#
# Pourquoi le .desktop et pas launch.sh en direct : c est le meme chemin que le
# double-clic, donc le meme terminal, la meme elevation pkexec et le meme
# comportement. Passer a cote reviendrait a tester une autre facon de demarrer
# que celle des utilisateurs.
NATIF_DESKTOP="${NATIF_DESKTOP:-/root/Desktop/osmo-launch.desktop}"

lancer_natif_si_absent() {
    _est_natif osmo-bsc asterisk && return 0

    echo -e "  ${YELLOW}!${NC} operateur natif a l arret - lancement de son raccourci"
    local d="$NATIF_DESKTOP"
    # Repli sur les autres emplacements : Bureau (locale fr) puis le menu.
    for _c in "$d" "${d%/Desktop/*}/Bureau/${d##*/}" "/usr/share/applications/${d##*/}"; do
        [ -f "$_c" ] && { d="$_c"; break; }
    done
    if [ ! -f "$d" ]; then
        echo -e "  ${RED}✗${NC} raccourci introuvable ($NATIF_DESKTOP)"
        echo -e "    ${CYAN}sudo ${DIR}/start-direct.sh${NC}"
        return 1
    fi

    # gio launch est LA facon d executer un .desktop : il lit Exec, applique
    # Terminal= et passe par le bus de session. gtk-launch en repli (il veut un
    # ID d application, pas un chemin - d ou le basename sans .desktop).
    # En dernier recours on extrait Exec a la main : mieux vaut demarrer le banc
    # que d echouer sur l outillage du bureau.
    # Le natif nous sert de PILE, pas de poste de travail : wireshark, linphone
    # et firefox sont deja ouverts par ailleurs (ou le seront par nos soins pour
    # les dashboards). Sans ce drapeau, launch.sh les rouvrait une seconde fois.
    export OSMO_LAUNCH_APPS=0
    # (a) LE NATIF PREND SA PROPRE FENETRE. Sans ce marqueur, launch.sh voyait
    # un tty - LE NOTRE - le prenait pour le sien, et start-direct.sh le tenait
    # jusqu au bout : notre sortie ecrasee, et la main rendue seulement a la fin
    # du natif. Voir launch.sh, garde « 1. Un terminal ».
    export OSMO_TERM_TAKEN=1
    export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
    export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=${XDG_RUNTIME_DIR}/bus}"
    if command -v gio >/dev/null 2>&1 && gio launch "$d" >/dev/null 2>&1; then
        echo -e "  ${GREEN}✓${NC} raccourci lance : ${CYAN}${d}${NC}"
    elif command -v gtk-launch >/dev/null 2>&1 \
         && gtk-launch "$(basename "$d" .desktop)" >/dev/null 2>&1; then
        echo -e "  ${GREEN}✓${NC} raccourci lance (gtk-launch) : ${CYAN}${d}${NC}"
    else
        local _exec
        _exec="$(sed -n 's/^Exec=//p' "$d" | head -1 | sed 's/ *%[fFuUdDnNickvm]//g')"
        [ -n "$_exec" ] || { echo -e "  ${RED}✗${NC} pas de ligne Exec dans $d"; return 1; }
        echo -e "  ${YELLOW}○${NC} gio/gtk-launch indisponibles - execution directe : ${CYAN}${_exec}${NC}"
        setsid bash -c "$_exec" </dev/null >/dev/null 2>&1 &
        disown 2>/dev/null || true
    fi

    # On attend qu il soit reellement debout : enchainer les conteneurs pendant
    # que le natif se monte, c est se disputer les memes ressources.
    echo -ne "  ${CYAN}→${NC} attente du natif"
    local i
    for i in $(seq 1 120); do
        _est_natif osmo-bsc asterisk && { echo -e " ${GREEN}✓${NC}"; return 0; }
        sleep 1; echo -n "."
    done
    echo -e " ${YELLOW}toujours absent apres 120 s - on continue quand meme${NC}"
    return 0
}
lancer_natif_si_absent



"${CMD[@]}"
rc=$?

# ── LE CHECK DE FIN ─────────────────────────────────────────────────────────
# start.sh peut rendre 0 alors que le SS7 n est pas monte : son code de retour
# dit que les conteneurs ont DEMARRE, pas que les ASP se sont associes. C est
# la distinction que le depot documente ailleurs sous le nom de sonde
# mensongere - un superviseur vivant au-dessus d un pont qui n a jamais
# transporte un echantillon. On verifie donc l etat REEL, apres coup.
verifier() {
    local ko=0 spec idx mode nom _ip pc _rctx _pc_reel
    echo
    echo -e "  ${BOLD}── Verification ──${NC}"

    # 1. Les quatre elements sont-ils la ?
    for spec in $MULTI_OPS; do
        IFS=: read -r idx mode _ip pc _rctx <<< "$spec"
        if [ "$mode" = "docker" ]; then
            nom="osmo-operator-${idx}"
            if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$nom"; then
                echo -e "    ${GREEN}✓${NC} conteneur ${nom}"
            else
                echo -e "    ${RED}✗${NC} conteneur ${nom} absent"; ko=1
            fi
        else
            if ! _est_natif osmo-bsc asterisk; then
                echo -e "    ${RED}✗${NC} operateur natif arrete - ${CYAN}sudo ${DIR}/start-direct.sh --op ${idx}${NC}"; ko=1
            else
                # ── LE POINT CODE DU NATIF DOIT CORRESPONDRE A SON RANG ──
                # Un natif lance sans --op tourne en operateur 1, donc en
                # PC 1.1.2 - le meme que le PREMIER CONTENEUR, que start.sh
                # numerote toujours a partir de 1. La pile demarre des deux
                # cotes, tout parait vert, et c est le ROUTAGE SS7 qui est faux :
                # deux equipements a la meme adresse. On le verifie donc au lieu
                # de se contenter de "le processus tourne".
                _pc_reel="$(natif_pc)"
                if [ -z "$_pc_reel" ]; then
                    echo -e "    ${YELLOW}?${NC} operateur natif actif, point code illisible (/etc/osmocom/osmo-stp.cfg)"
                elif [ "$_pc_reel" = "$pc" ]; then
                    echo -e "    ${GREEN}✓${NC} operateur natif (op ${idx}, PC ${_pc_reel})"
                else
                    echo -e "    ${RED}✗${NC} operateur natif en PC ${_pc_reel}, attendu ${pc}"
                    echo -e "      il tourne en operateur $(echo "$_pc_reel" | cut -d. -f2), rang deja pris par un CONTENEUR"
                    echo -e "      ${CYAN}sudo ${DIR}/start-direct.sh --op ${idx}${NC}  (puis relancer ce script)"
                    ko=1
                fi
            fi
        fi
    done
    nom="${MULTI_HUB_NAME:-osmo-inter-stp}"
    if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$nom"; then
        echo -e "    ${GREEN}✓${NC} inter-STP ${nom}"
    else
        echo -e "    ${RED}✗${NC} inter-STP ${nom} absent"; ko=1
    fi

    # 2. Le point d entree M3UA du natif. On interroge `ss`, PAS /dev/tcp :
    #    M3UA roule sur SCTP, et une sonde TCP y est muette - la version
    #    precedente de ce check l avouait ("non concluant"), ce qui en faisait
    #    une ligne inutile. `ss -an` voit les sockets SCTP nommement.
    # awk par CHAMPS et pas un regex : `ss -an` aligne ses colonnes avec un
    # nombre d espaces VARIABLE selon la longueur des adresses, et un
    # "^sctp[[:space:]]+LISTEN.*" s y casse sans prevenir - teste, il ne
    # trouvait pas une ligne pourtant bien presente. $1/$2/$5 sont stables.
    if ss -an 2>/dev/null | awk -v p=":${MULTI_M3UA_PORT}" \
         '$1=="sctp" && $2=="LISTEN" && index($5,p)==length($5)-length(p)+1 {f=1} END{exit !f}'; then
        echo -e "    ${GREEN}✓${NC} M3UA en SCTP sur 127.0.0.1:${MULTI_M3UA_PORT} (l operateur natif peut s attacher)"
    else
        echo -e "    ${RED}✗${NC} rien n ecoute en SCTP sur le port ${MULTI_M3UA_PORT}"
        echo -e "      l ASP du natif (osmo-stp.cfg, remote-ip 127.0.0.1) ne trouvera personne"
        ko=1
    fi

    # 3. Le verificateur SS7 du depot, qui sait lire les ASP et les routes.
    if [ -x "$DIR/checks/ss7_check.sh" ]; then
        echo -e "    ${CYAN}→${NC} checks/ss7_check.sh"
        "$DIR/checks/ss7_check.sh" || ko=1
    else
        echo -e "    ${YELLOW}○${NC} checks/ss7_check.sh absent - verification SS7 sautee"
    fi

    echo
    if [ "$ko" = "0" ]; then
        echo -e "  ${GREEN}${BOLD}Banc multi-operateur OPERATIONNEL.${NC}"
    else
        echo -e "  ${RED}${BOLD}Le banc n est PAS complet${NC} - voir les lignes ✗ ci-dessus."
        echo -e "  ${CYAN}→${NC} etat detaille : ${BOLD}sudo $0 --status${NC}"
    fi
    return "$ko"
}

# ── LES DASHBOARDS DES CONTENEURS, EN ONGLETS SUPPLEMENTAIRES ───────────────
# launch.sh a deja ouvert Firefox sur le dashboard du NATIF et le tutoriel, et
# lance Linphone et Wireshark avec lui : on ne redouble rien de tout ca. Il
# manque seulement les consoles des conteneurs, une par operateur docker.
#
# Les adresses viennent de la topologie, pas d une liste en dur : ajouter un
# operateur a osmo-multi.conf suffit a lui ouvrir son onglet.
#
# On attend que chaque port reponde avant d ouvrir : un onglet lance trop tot
# affiche une page d erreur, et il faut alors recharger a la main - le
# dashboard d un conteneur met plusieurs secondes a monter apres le demarrage.
ouvrir_dashboards() {
    command -v firefox >/dev/null 2>&1 || return 0

    local spec idx mode ip urls="" i
    for spec in $MULTI_OPS; do
        IFS=: read -r idx mode ip _pc _rctx <<< "$spec"
        [ "$mode" = "docker" ] && [ -n "$ip" ] || continue
        for i in $(seq 1 60); do
            (exec 3<>"/dev/tcp/${ip}/8080") 2>/dev/null && { exec 3>&- 2>/dev/null; break; }
            sleep 1
        done
        if (exec 3<>"/dev/tcp/${ip}/8080") 2>/dev/null; then
            exec 3>&- 2>/dev/null
            urls="$urls http://${ip}:8080"
        else
            echo -e "  ${YELLOW}○${NC} dashboard op ${idx} (${ip}:8080) ne repond pas - onglet non ouvert"
        fi
    done
    [ -n "$urls" ] || return 0

    # Sous le compte de la session : Firefox y tourne deja, il ajoutera les URL
    # comme ONGLETS a la fenetre existante plutot que d en ouvrir une seconde.
    local u uid home
    u="$(natif_session_user)"; uid="$(id -u "$u" 2>/dev/null || echo 0)"
    home="$(getent passwd "$u" 2>/dev/null | cut -d: -f6)"
    # shellcheck disable=SC2086
    setsid sudo -u "$u" \
        env DISPLAY="${DISPLAY:-:0}" \
            XAUTHORITY="${XAUTHORITY:-${home:-/root}/.Xauthority}" \
            DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
        firefox $urls </dev/null >/dev/null 2>&1 &
    disown 2>/dev/null || true
    echo -e "  ${GREEN}✓${NC} onglets dashboards :${CYAN}${urls}${NC}"
}

# Le compte de la session graphique, meme critere qu ailleurs : la presence de
# son socket pulse, et non son nom (l ISO ouvre la session sous root).
natif_session_user() {
    local u uid sock
    for u in "${HOST_PULSE_USER:-}" "${SUDO_USER:-}" "$(logname 2>/dev/null || true)"; 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 "${SUDO_USER:-$(id -un)}"
}

# EN TACHE DE FOND, et c est essentiel : cette fonction attend jusqu a 60 s par
# conteneur que le port 8080 reponde. En avant-plan, ca fige le script jusqu a
# deux minutes AVANT les verifications - et le silence ressemble a un blocage.
# Pire quand le dashboard ne monte pas du tout : on attendait le maximum pour
# n ouvrir aucun onglet. Les onglets s ouvriront quand ils pourront ; les tests,
# eux, partent tout de suite.
ouvrir_dashboards &
disown 2>/dev/null || true

echo
etat
verifier || rc=1
# Ouverte par nos soins : on la retient, sinon le bilan disparait avec elle.
if [ "$_multi_own_window" = "1" ]; then
    echo
    echo "Fenetre maintenue ouverte - Entree pour fermer."
    read -r _ || true
fi
exit "$rc"

start-nitb.sh

.sh 73 lignes - 4,1KB

start-nitb.sh
#!/bin/bash
# start-nitb.sh - meme lanceur que start.sh, mais sur l'image osmocom-nitb.
#
# POURQUOI UN WRAPPER ET PAS UNE COPIE. start.sh fait 1 300 lignes (reseaux,
# generation de configs, SS7 inter-operateurs, ports, audio). Le dupliquer
# creerait une 2e copie qui divergerait - le depot en a deja paye le prix
# ailleurs (4 copies de si_bridge.py, 3 de gapk-start.sh, toutes divergentes).
# Ici il n'y a qu'UNE variable qui change : l'image.
#
# CE QUI A ETE VERIFIE AVANT D'ECRIRE CE SCRIPT (12/08/2026) :
#   - les deux images n'ont PAS le meme entrypoint :
#       osmocom-run  -> /scripts/entrypoint.sh
#       osmocom-nitb -> /etc/osmocom/entrypoint.sh
#   - start.sh monte le dossier de config genere SUR /etc/osmocom, donc il
#     MASQUE l'entrypoint de l'image nitb. Ce n'est pas un probleme : le
#     dossier genere contient bien un entrypoint.sh (copie de scripts/
#     entrypoint.sh, le meme fichier que /scripts/entrypoint.sh dans l'image
#     run - 1988 o, identique). C'est la condition qui rend ce wrapper viable :
#     SI scripts/entrypoint.sh disparaissait du dossier genere, l'image nitb
#     demarrerait sans entrypoint alors que l'image run continuerait de
#     marcher. Le controle ci-dessous le verifie a chaque lancement.
#
# USAGE : identique a start.sh, tous les arguments sont transmis tels quels.
#   ./start-nitb.sh [args de start.sh...]
#
# REVENIR A L'IMAGE RUN : lancer ./start.sh directement.

set -eu

HERE="$(cd "$(dirname "$0")" && pwd)"
IMAGE_NITB="${IMAGE_NITB:-osmocom-nitb}"

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

# ── 1. L'image doit EXISTER : on ne la construit pas ici ──────────────────────
# start.sh a un check_image() qui, si l'image nommee par IMAGE_RUN est absente,
# lance build_run_image() -> `docker build -f Dockerfile.run -t "$IMAGE_RUN" .`.
# Avec IMAGE_RUN=osmocom-nitb, cela ECRASERAIT osmocom-nitb par une image
# construite depuis Dockerfile.run. On barre la route en verifiant d'abord.
if ! docker image inspect "$IMAGE_NITB" >/dev/null 2>&1; then
    echo -e "${RED}Image '${IMAGE_NITB}' introuvable.${NC}"
    echo -e "Ce script ne la construit pas volontairement : le build integre de"
    echo -e "start.sh utilise ${YELLOW}Dockerfile.run${NC} et ecraserait l'image nitb."
    echo -e "Construire d'abord, depuis ${CYAN}${HERE}${NC} :"
    echo -e "  ${CYAN}docker build -f Dockerfile -t ${IMAGE_NITB} .${NC}"
    exit 1
fi

# ── 2. L'entrypoint doit survivre au montage de /etc/osmocom ──────────────────
# On teste la PRESENCE, pas le bit d'execution : dans le depot le fichier est en
# 644, et c'est normal. Le bit +x est pose EN AVAL, aux deux endroits qui en ont
# besoin - generate_configs.sh l.204-205 (`cp scripts/*` puis `chmod +x` dans le
# dossier genere, celui qui est monte sur /etc/osmocom) et Dockerfile.run l.75
# (`chmod +x /scripts/*.sh` dans l'image). Tester `-x` ici refusait donc de
# demarrer sur un depot parfaitement sain.
if [ ! -s "${HERE}/scripts/entrypoint.sh" ]; then
    echo -e "${RED}scripts/entrypoint.sh absent ou vide.${NC}"
    echo -e "L'image ${IMAGE_NITB} a son entrypoint dans /etc/osmocom, que start.sh"
    echo -e "recouvre par le dossier de config genere. Sans ce fichier, le"
    echo -e "conteneur demarrerait sans entrypoint - alors que l'image"
    echo -e "osmocom-run, elle, continuerait de marcher (son entrypoint est dans"
    echo -e "/scripts). Panne asymetrique et illisible : on refuse avant."
    exit 1
fi

echo -e "${GREEN}Lanceur nitb${NC} : image = ${CYAN}${IMAGE_NITB}${NC} (au lieu d'osmocom-run)"
echo -e "  entrypoint attendu dans le conteneur : /etc/osmocom/entrypoint.sh (monte)"

# ── 3. Delegation ─────────────────────────────────────────────────────────────
# start.sh lit IMAGE_RUN avec l'idiome `:=` : la valeur posee ici gagne.
export IMAGE_RUN="$IMAGE_NITB"
cd "$HERE"
exec ./start.sh "$@"

start.sh

.sh 2863 lignes - 158KB

start.sh
#!/bin/bash
# start.sh - Lance la stack Osmocom GSM multi-operateurs
#
# Modes : net-host (1 operateur, SDR physique) | bridge (N operateurs SS7 inter-op)
set -eu

# ── Ce script est le lanceur de l'HOTE. Refus net s'il tourne DANS le conteneur ──
# [2026-08-12] start.sh pilote docker (build, reseaux, `docker run`, volumes) ;
# start-direct.sh, lui, prepare l'environnement Calypso et lance run.sh A
# L'INTERIEUR. Les confondre ne donne pas une erreur lisible : `docker` n'existe
# pas dans l'image, donc on part sur une cascade de "command not found" apres
# avoir deja cree des fichiers et touche des configs. Mieux vaut s'arreter avant
# d'avoir rien fait, et nommer le script a utiliser.
#
# Discriminant verifie dans LES DEUX SENS le 12/08 : `/.dockerenv` et
# `/etc/docker-entrypoint-cmd` (pose par scripts/entrypoint.sh) sont presents
# dans le conteneur et absents sur l'hote. On exige les DEUX : `/.dockerenv`
# seul serait vrai dans n'importe quel conteneur, y compris un ou ce script
# aurait legitimement sa place.
if [ -f /.dockerenv ] && [ -f /etc/docker-entrypoint-cmd ]; then
    printf '\033[1;31mVous etes dans le docker ! Utilisez start-direct.sh\033[0m\n' >&2
    printf '\n  \033[0;36m/opt/GSM/osmo-operator/start-direct.sh\033[0m   (ou ./start-direct.sh depuis le depot)\n' >&2
    printf '\n  start.sh est le lanceur de l'"'"'HOTE : il construit l'"'"'image, cree les\n' >&2
    printf '  reseaux et fait le "docker run". Rien de tout ca n'"'"'a de sens ici.\n' >&2
    exit 1
fi

DEBUG=
if [[ -n "$DEBUG" ]]; then
    set -x
    PS4='[DEBUG] + ${BASH_SOURCE}:${LINENO}: '
    echo "=== MODE DEBUG ACTIVE ==="
fi

IMAGE_BASE="osmocom-nitb"
# [2026-08-12] Surchargeable depuis l'environnement - c'est ce que fait
# start-nitb.sh, qui relance exactement ce script avec IMAGE_RUN=osmocom-nitb.
# Idiome `:=` du projet : une valeur posee explicitement en amont gagne, le
# defaut ne s'applique qu'a vide. NE PAS remettre en dur : start-nitb.sh
# deviendrait silencieusement un alias de start.sh, ce qui est indiscernable
# d'un lanceur qui marche.
IMAGE_RUN="${IMAGE_RUN:-osmocom-run}"

INTER_NET="gsm-inter"
INTER_NET_SUBNET="172.20.0.0/24"
INTER_NET_GATEWAY="172.20.0.1"

# Quel noeud du WAN heberge le hub SS7. UN SEUL doit le faire : deux hubs, c'est
# deux reseaux SS7 separes qui s'ignorent, et rien ne le signale.
WAN_HUB_NODE="${WAN_HUB_NODE:-1}"
# --hub-ip : l'inter-STP designe par son ADRESSE, pas par un numero de noeud.
# --hub-node oblige a le faire figurer dans la table WAN ; sur un banc ou le hub
# est une machine a part - une VM dediee, sans operateur - cela forcait a
# inventer un noeud pour lui, qui apparaissait ensuite dans les indicatifs et le
# routage voix/SMS comme s'il portait des abonnes. Avec --hub-ip la table ne
# contient que de vrais noeuds.
WAN_HUB_IP="${WAN_HUB_IP:-}"
# --node-per-op : chaque conteneur est un NOEUD, pas un operateur de la machine.
# A n'utiliser que quand les conteneurs ont une adresse joignable de l'exterieur
# (backbone route vers le LAN) : sinon les autres noeuds ne peuvent pas les
# appeler, et un noeud injoignable vaut moins qu'un operateur bien range.
WAN_NODE_PER_OP="${WAN_NODE_PER_OP:-0}"
# --operator IP:PREFIXE : declarer un operateur du WAN, ou qu'il soit.
# La table WAN se saisissait jusqu'ici par --wan-nodes "id:ip:ind", une forme
# exacte mais qui suppose qu'on connaisse deja les numeros de noeud. Declarer
# "une machine a cette adresse, joignable par ce prefixe" est plus proche de ce
# qu'on a sous les yeux - une VM, un conteneur - et les numeros suivent l'ordre
# de declaration.
OPERATOR_DECLS=()
# [2026-08-31] On HERITE de l environnement au lieu de l ecraser.
# Cette ligne valait OPERATOR_COUNT_HINT="" sec : un appelant qui posait
# la variable (env OPERATOR_COUNT_HINT=2 ./start.sh ...) la voyait effacee
# ici, bien avant sa lecture l.1421 - et start.sh reposait la question
# « Nombre d'operateurs (1-36) » qu on croyait justement avoir evitee.
# Silencieux de bout en bout : la variable etait bien passee, bien nommee,
# et simplement remise a zero en chemin.
# --operators N reste la voie explicite (l.2552) et prime, puisqu il est
# analyse plus tard.
OPERATOR_COUNT_HINT="${OPERATOR_COUNT_HINT:-}"
# --host : l'adresse de CETTE machine, telle que les VM doivent la joindre.
# Elle est deduite de l'interface par defaut ; --host la fixe quand la deduction
# se trompe - plusieurs cartes, un wifi et un ethernet actifs en meme temps, ou
# une adresse qui vient de changer. Elle sert de recours, pas de reglage
# quotidien : c'est elle que les VM inscrivent dans leur route vers le backbone.
HOST_LAN_IP=""
IMAGE_STP="${IMAGE_STP:-osmocom-stp}"
INTER_STP_CONTAINER="osmo-inter-stp"
INTER_STP_IP="172.20.0.10"

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

# ── SMS Routing ────────────────────────────────────────────────────────────────
SMS_ROUTING_SCRIPT="$(dirname "$0")/scripts/sms-routing-setup.sh"
if [ -f "$SMS_ROUTING_SCRIPT" ]; then
    source "$SMS_ROUTING_SCRIPT"
fi
SMS_ROUTING_DIR=""

# ══════════════════════════════════════════════════════════════════════════════
# WAN Interop
# ══════════════════════════════════════════════════════════════════════════════
# Deux WAN coexistent ici, et ils ne font PAS la meme chose :
#
#   WAN_ENABLED (legacy, --pas d'option, question whiptail) : DEUX serveurs,
#     prefixe unique 66, network/setup-wan-interop.sh. Conserve tel quel.
#
#   WAN_MESH (option --wan) : N noeuds (1 a 9), UN INDICATIF PAR NOEUD,
#     network/setup-wan-mesh.sh. C'est le seul qui tienne au-dela de deux
#     serveurs - le legacy reecrit son bloc de conf a chaque appel, donc
#     l'enchainer sur trois pairs ne laisse que le dernier.
#
# Les deux sont EXCLUSIFS et aucun n'est actif par defaut.
WAN_MESH=0
# --virtualbox : les pairs du WAN sont des VM VirtualBox sur CETTE machine, et
# cette machine est elle-meme un noeud. Voir network/setup-vbox-interco.sh.
VBOX_INTERCO=0
VBOX_NODES=""
VBOX_HOST_NODE=1
BUILD_STP=0
# shellcheck source=network/wan-nodes.sh
. "$(dirname "$0")/network/wan-nodes.sh"

WAN_ENABLED="false"
WAN_LOCAL_IP=""
WAN_REMOTE_IP=""
WAN_N_REMOTE=""
WAN_PREFIX="66"
WAN_SIP_BASE=5080
WAN_RTP_BASE=20000
WAN_RTP_PER_OP=500
PHY_MODE="faketrx"   # faketrx | virtphy
# Choix passes au start-direct.sh DANS le conteneur via le handoff (NO_MENU=1),
# pour eviter les menus whiptail laggy en docker exec -ti. Fixes sur l'HOTE.
# Retenu AVANT le defaut : "l'appelant a-t-il impose le mode ?". C'est ce qui
# permet a --wan de basculer en hybride sans ecraser un HANDOFF_MODE=... explicite.
HANDOFF_MODE_FROM_ENV="${HANDOFF_MODE:+1}"
HANDOFF_MODE="${HANDOFF_MODE:-qemu}"               # qemu | faketrx-qemu (combine)
HANDOFF_QEMU_CHOICE="${HANDOFF_QEMU_CHOICE:-full-grgsm}"

# ── Helpers ────────────────────────────────────────────────────────────────────
# ── Les deux plans d'adressage d'un operateur ───────────────────────────────
# backbone  172.20.0.<10+op>  le segment que TOUS partagent avec l'inter-STP
#                             (.10). C'est par la que passe le SS7, et c'est ce
#                             reseau que network/setup-docker-lan-route.sh rend
#                             joignable depuis le LAN des VM.
# prive     192.168.<op+1>.x  un segment par operateur : son BTS, son PCU, son
#                             GGSN. Rien n'y entre de l'exterieur.
#
# POURQUOI <op+1> ET PAS <op>
# Le LAN du banc est 192.168.1.0/24 - celui des VM et du hub SS7 en .49. Un
# operateur 1 en 192.168.1.0/24 entrerait en collision avec lui : deux routes
# pour le meme reseau sur l'hote, et un conteneur qui ne peut plus joindre le
# hub. Le decalage d'un rang laisse le .1 au LAN et commence les operateurs a
# 192.168.2.0/24.
# ── LE RANG DU PREMIER CONTENEUR ────────────────────────────────────────────
# [2026-08-31] start.sh numerotait SES operateurs 1..N, en dur. C est juste
# quand la machine ne porte que des conteneurs ; ca ne l est plus des qu un
# operateur NATIF tourne a cote, car il occupe deja un rang - et deux
# equipements au meme point code, ce n est pas un conflit de nom, c est du
# routage faux (checks/wan_ss7_check.sh).
# OP_ID_BASE=2 fait donc commencer les conteneurs a l operateur 2, laissant le 1
# au natif. TOUT en decoule sans autre reglage, puisque tout derive de l index :
# nom du conteneur (osmo-operator-N), backbone (172.20.0.<10+N>), point code
# (1.N.2), RCTX, segment prive (192.168.<N+1>.0/24) et ports publies.
OP_ID_BASE="${OP_ID_BASE:-1}"
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_container()    { echo "osmo-operator-$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 )); }

# ── Le PLMN : le MCC est le PAYS, donc le NOEUD ; le MNC, l'operateur ────────
# Un MCC fige a 001 pour tout le monde ne laissait que le MNC pour distinguer
# les reseaux, et toute valeur ecrite en dur ailleurs - les IMSI des MS
# l'etaient - retombait sur un PLMN plausible mais etranger : le mobile de
# l'operateur 2 presentait 001-01, son reseau diffusait 001-02, et le Location
# Updating restait sans reponse, pile entiere debout.
# Le noeud, c'est ce que --node passe a start-direct.sh. Avec --node-per-op
# chaque conteneur est son propre noeud, donc son propre pays, et le MNC
# retombe a 01 ; sans lui, les conteneurs sont N operateurs d'un meme pays.
op_plmn_node()  { if [ "${WAN_NODE_PER_OP:-0}" = "1" ]; then echo $(( ${WAN_NODE_ID:-1} + $1 - 1 )); else echo "${WAN_NODE_ID:-1}"; fi; }
op_plmn_index() { if [ "${WAN_NODE_PER_OP:-0}" = "1" ]; then echo 1; else echo "$1"; fi; }
# printf '%03d' sur une valeur vide ou nulle rend "000", et osmo-msc REFUSE
# "network country code 000" : il abandonne la lecture a cette ligne et systemd
# le relance trois fois par seconde jusqu'a y renoncer. Le lanceur, lui,
# affichait une croix puis "Core Osmocom pret". Trois chiffres, une heure de
# recherche dans le SS7. Un numero de noeud a zero est un bug, pas un MCC : on
# le borne et on le dit, une fois.
_plmn_borne() {                 # $1 = valeur, $2 = plancher, $3 = quoi
    case "$1" in
        ''|*[!0-9]*) echo "  ATTENTION : $3 illisible ('$1') - ramene a $2" >&2; echo "$2" ;;
        *) if [ "$1" -lt "$2" ]; then echo "  ATTENTION : $3 vaut $1 - ramene a $2" >&2; echo "$2"; else echo "$1"; fi ;;
    esac
}
op_mcc()        { printf '%03d' "$(_plmn_borne "$(op_plmn_node "$1")" 1 "numero de noeud (MCC)")"; }
op_mnc()        { printf '%02d' "$(_plmn_borne "$(op_plmn_index "$1")" 1 "rang d'operateur (MNC)")"; }

# Noms sans espace : la commande VTY "short name" d'osmo-msc prend un mot, et
# un nom coupe en deux y passe pour un argument surnumeraire.
# ── Les jeux de donnees du banc : data/*.txt ─────────────────────────────────
# Noms d'operateur, pays, etat civil et adresses ne sont pas ecrits dans ce
# script : ils vivent dans data/, un par ligne, editables sans toucher au code.
# On les MELANGE au chargement - c'est la le tirage au hasard - puis on les
# fige pour toute la duree du lancement : un operateur qui changerait de nom ou
# de pays entre deux lignes du journal ne serait plus le meme reseau.
OSMO_DATA_DIR="${OSMO_DATA_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/data}"

# $1 = nom du tableau a remplir, $2 = fichier, le reste = valeurs de secours.
# Le depot peut etre incomplet (une ISO n'embarque pas toujours data/) : sans
# repli, un banc entier s'arreterait sur un fichier d'agrement absent.
_load_pool() {
    local -n __out=$1
    local __file=$2; shift 2
    __out=()
    if [ -r "$__file" ]; then
        if command -v shuf >/dev/null 2>&1; then
            mapfile -t __out < <(grep -vE '^[[:space:]]*(#|$)' "$__file" | shuf)
        else
            mapfile -t __out < <(grep -vE '^[[:space:]]*(#|$)' "$__file")
        fi
    fi
    [ "${#__out[@]}" -gt 0 ] || __out=("$@")
}
declare -a POOL_OP POOL_PAYS POOL_NOMS POOL_ADR
_load_pool POOL_OP   "$OSMO_DATA_DIR/op.txt"        OsmoOP1 OsmoOP2 OsmoOP3
_load_pool POOL_PAYS "$OSMO_DATA_DIR/pays.txt"      Syldavie Bordurie Poldevie
_load_pool POOL_NOMS "$OSMO_DATA_DIR/noms.txt"      "Jean Dupont" "Marie Martin"
_load_pool POOL_ADR  "$OSMO_DATA_DIR/adresses.txt"  "rue des Ondes Courtes, Fontaine-sur-Onde"

op_default_name() { echo "${POOL_OP[$(( ($1 - 1) % ${#POOL_OP[@]} ))]}"; }
op_country()      { local n; n=$(op_plmn_node "$1"); echo "${POOL_PAYS[$(( (n - 1) % ${#POOL_PAYS[@]} ))]}"; }

# WAN helpers
wan_sip_port()    { echo $(( WAN_SIP_BASE + ($1 - 1) * 2 )); }
wan_rtp_start()   { echo $(( WAN_RTP_BASE + ($1 - 1) * WAN_RTP_PER_OP )); }
wan_rtp_end()     { echo $(( WAN_RTP_BASE + $1 * WAN_RTP_PER_OP - 1 )); }

wan_sms_port()    { echo $(( 7890 + $1 - 1 )); }
# Un WAN est actif - legacy OU mesh. Sert partout ou la question est "faut-il
# ouvrir les ports WAN", independamment du mecanisme choisi.
wan_active()      { [ "$WAN_ENABLED" = "true" ] || [ "${WAN_MESH:-0}" = "1" ]; }

# Linphone helpers - port SIP/RTP expose sur le host
# ── 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 )); }

# Global - detecte avant la boucle operateurs
HOST_IP="127.0.0.1"
ALSA_OUTPUT="${ALSA_OUTPUT:-default}"
ALSA_INPUT="${ALSA_INPUT:-default}"
HOST_AUDIO_RELAY=""
WSLG_TCP_PORT="${WSLG_TCP_PORT:-4713}"

banner() {
    echo -e "${CYAN}"
    echo "╔══════════════════════════════════════════════════════╗"
    echo "║         Osmocom GSM/EGPRS Virtual Network            ║"
    echo "║        Multi-Operator SS7 + osmo-gapk Audio          ║"
    echo "╚══════════════════════════════════════════════════════╝"
    echo -e "${NC}"
}

# ── Whiptail helpers ────────────────────────────────────────────────────────
WT_BACKTITLE="Osmocom GSM/EGPRS Virtual Network"
WT_WIDTH=72

check_whiptail() {
    if ! command -v whiptail >/dev/null 2>&1; then
        echo -e "${RED}whiptail introuvable.${NC} Installez-le :" >&2
        echo -e "  ${CYAN}apt-get install -y whiptail${NC}  (Debian/Ubuntu)" >&2
        echo -e "  ${CYAN}dnf install -y newt${NC}          (Fedora/RHEL)" >&2
        exit 1
    fi
}

# ── MODE NON INTERACTIF : LE QUESTIONNAIRE REPOND TOUT SEUL ─────────────────
# [2026-08-31] Chaque wt_* ouvre une boite whiptail et ATTEND. C est parfait au
# terminal, et fatal partout ailleurs : lance par une icone du bureau, par
# start-multi.sh ou par un script, start.sh s arretait sur « Nombre
# d'operateurs (1-36) » - un dialogue que personne ne voit et que personne ne
# valide. Le lancement paraissait bloque ou mort, alors qu il attendait une
# reponse.
#
# On repond donc AUTOMATIQUEMENT, avec la valeur que la boite proposait deja :
#   wt_input -> son defaut ($3), celui qui est pre-rempli dans le champ ;
#   wt_menu  -> la PREMIERE entree, celle sur laquelle le curseur se pose ;
#   wt_yesno -> non, parce que les boites sont en --defaultno ;
#   wt_msg   -> ecrit sur stderr au lieu d attendre un <Ok>.
# Aucune de ces reponses n est inventee : c est exactement ce qu on obtient en
# appuyant sur Entree.
#
# DEUX DECLENCHEURS. OSMO_NONINTERACTIVE=1 le force (start-multi.sh le pose).
# Et l absence de TTY sur l entree standard le declenche seule : c est la
# signature d un appel par icone, par cron ou par pipe - la ou aucun dialogue
# ne peut aboutir. Au terminal, rien ne change : les questions sont posees.
_wt_auto() { [ "${OSMO_NONINTERACTIVE:-0}" = "1" ] || [ ! -t 0 ]; }

wt_input() {
    if _wt_auto; then
        echo "[auto] $1 : ${3}" >&2
        printf '%s' "$3"; return 0
    fi
    whiptail --backtitle "$WT_BACKTITLE" --title "$1" \
        --inputbox "$2" 10 "$WT_WIDTH" "$3" 3>&1 1>&2 2>&3
}

wt_yesno() {
    if _wt_auto; then echo "[auto] $1 : non" >&2; return 1; fi
    whiptail --backtitle "$WT_BACKTITLE" --title "$1" \
        --defaultno --yesno "$2" 11 "$WT_WIDTH" 3>&1 1>&2 2>&3
}

wt_menu() {
    local title="$1" prompt="$2"; shift 2
    local nitems=$(( $# / 2 ))
    if _wt_auto; then
        echo "[auto] $title : ${1}" >&2
        printf '%s' "$1"; return 0
    fi
    whiptail --backtitle "$WT_BACKTITLE" --title "$title" \
        --menu "$prompt" 20 "$WT_WIDTH" "$nitems" "$@" 3>&1 1>&2 2>&3
}

wt_msg() {
    if _wt_auto; then echo "$1" >&2; return 0; fi
    whiptail --backtitle "$WT_BACKTITLE" --msgbox "$1" 10 "$WT_WIDTH"
}

# ── Loopback audio cote session utilisateur ───────────────────────────────
# ── Session audio de l'hote : QUI, et OU ────────────────────────────────────
# [2026-08-12] Ces deux fonctions codaient "nirvana" en dur et cherchaient
# /home/<user>/osmo-operator/loopback.sh - un chemin QUI N'EXISTE PAS (le script
# est network/loopback.sh). Resultat : enable_user_loopback echouait sur toute
# machine sans utilisateur nirvana, et disable_user_loopback (appele a l'arret)
# etait deja du code mort ICI. On resout les deux a l'execution.
#
# Le critere du bon utilisateur n'est pas son nom mais la PRESENCE de son socket
# pulse : c'est la seule chose qui distingue une session graphique vivante d'un
# compte qui existe. Meme logique que session_user() de wslg-audio-bridge.sh,
# etendue au balayage de /run/user/*.
session_pulse_user() {
    local u uid sock
    for u in "${HOST_PULSE_USER:-}" "${SUDO_USER:-}" "$(logname 2>/dev/null || true)" "${USER:-}"; do
        [ -n "$u" ] || continue
        uid="$(id -u "$u" 2>/dev/null)" || continue
        [ -S "/run/user/${uid}/pulse/native" ] && { echo "$u"; return 0; }
    done
    # Dernier recours : proprietaire du premier socket pulse trouve. Couvre le
    # `sudo -i` (SUDO_USER perdu) et les sessions sans logname (cron, service).
    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
    return 1
}

# Le script de loopback vit dans le depot, pas dans un $HOME.
find_loopback_script() {
    local d c
    d="$(cd "$(dirname "$0")" && pwd)"
    for c in "$d/network/loopback.sh" "$d/loopback.sh"; do
        [ -x "$c" ] && { echo "$c"; return 0; }
    done
    return 1
}

enable_user_loopback() {
    local target_user target_uid target_runtime loopback_script
    target_user="$(session_pulse_user)" || {
        echo -e "  ${YELLOW}[audio] aucune session PulseAudio utilisateur - loopback ignore${NC}"
        return 0
    }
    loopback_script="$(find_loopback_script)" || {
        echo -e "  ${YELLOW}[audio] network/loopback.sh introuvable - loopback ignore${NC}"
        return 0
    }
    target_uid="$(id -u "$target_user")"
    target_runtime="/run/user/${target_uid}"
    echo -e "${GREEN}=== [audio] Loopback user session ===${NC}"
    echo -e "  user    : ${CYAN}${target_user}${NC}"
    echo -e "  runtime : ${CYAN}${target_runtime}${NC}"
    echo -e "  script  : ${CYAN}${loopback_script}${NC}"
    sudo -u "$target_user" \
        XDG_RUNTIME_DIR="$target_runtime" \
        PULSE_SERVER="unix:${target_runtime}/pulse/native" \
        "$loopback_script" enable >/dev/null 2>&1 || {
            echo -e "  ${RED}[FAIL]${NC} enable loopback"
            return 1
        }
    echo -e "[ OK ]${NC} loopback active"
}

disable_user_loopback() {
    local target_user target_uid target_runtime loopback_script
    target_user="$(session_pulse_user)"       || return 0
    loopback_script="$(find_loopback_script)" || return 0
    target_uid="$(id -u "$target_user")"
    target_runtime="/run/user/${target_uid}"
    sudo -u "$target_user" \
        XDG_RUNTIME_DIR="$target_runtime" \
        PULSE_SERVER="unix:${target_runtime}/pulse/native" \
        "$loopback_script" disable || true
}

build_alsa_args() {
    local alsa_args=""
    local src_asound="$(dirname "$0")/configs/asound.conf"
    local host_asound="/tmp/osmocom-alsa/asound.conf"
    mkdir -p /tmp/osmocom-alsa
    # Docker cree un REPERTOIRE quand la source d'un bind -v n'existe pas :
    # si un run precedent a laisse ce repertoire, le mount sur /etc/asound.conf
    # (un fichier) echoue avec "not a directory". On le nettoie avant la copie.
    if [ -d "$host_asound" ]; then
        rm -rf "$host_asound"
    fi
    if [ -d /dev/snd ]; then
        alsa_args="${alsa_args} --device /dev/snd"
        if getent group audio >/dev/null 2>&1; then
            alsa_args="${alsa_args} --group-add $(getent group audio | cut -d: -f3)"
        fi
    fi
    local has_pulse="true"
    # ── LE SOCKET PULSE DOIT ETRE MONTE, PAS SEULEMENT NOMME ────────────────
    # [2026-08-31] Cette ligne posait PULSE_SERVER=unix:/run/pulse/native sans
    # que RIEN ne monte ce socket dans le conteneur. Le chemin existe sur
    # l hote, pas chez l invite : le plugin ALSA-pulse ouvrait donc dans le
    # vide, et gapk mourait a l initialisation -
    #     pulse.c:242 (pulse_connect) PulseAudio: Unable to connect: Connection refused
    #     pq_alsa.c:168 Couldn't init ALSA device 'gsm_out': Connection refused
    #     gapk_io.c:468 Failed to initialize GAPK I/O
    # soit un appel parfaitement etabli et TOTALEMENT muet, sans une ligne qui
    # parle d audio dans les logs du coeur. Verifie le 31/08 dans
    # osmo-operator-1 : `docker inspect` ne montrait aucun montage pulse, et
    # `PULSE_SERVER=unix:/run/pulse/native pactl info` echouait dans le
    # conteneur la ou tcp:172.20.0.1:4713 repondait.
    #
    # On monte donc le socket quand il existe - c est le chemin le plus court
    # et le moins bavard - et on retombe sur le relais TCP sinon. Le socket est
    # en srwxrwxrwx et le module unix est en auth-anonymous=1 : l utilisateur
    # osmocom du conteneur peut s y connecter.
    local _pa_sock=""
    for _s in /var/run/pulse/native /run/pulse/native "/run/user/$(id -u)/pulse/native"; do
        [ -S "$_s" ] && { _pa_sock="$_s"; break; }
    done
    if [ -n "$_pa_sock" ]; then
        alsa_args="${alsa_args} -v ${_pa_sock}:/run/pulse/native"
        alsa_args="${alsa_args} -e PULSE_SERVER=unix:/run/pulse/native"
    elif [ -n "${HOST_AUDIO_RELAY:-}" ]; then
        alsa_args="${alsa_args} -e PULSE_SERVER=${HOST_AUDIO_RELAY}"
    else
        # Ni socket ni relais : on le DIT. Sans ce message, le silence de
        # l appel restait la seule trace du probleme.
        alsa_args="${alsa_args} -e PULSE_SERVER=tcp:${INTER_NET_GATEWAY:-172.20.0.1}:${WSLG_TCP_PORT:-4713}"
        echo -e "  ${YELLOW}Audio : aucun socket pulse trouve et relais non arme - repli TCP a l aveugle${NC}" >&2
    fi
    local asound_ok="false"
    if [ -f "$src_asound" ]; then
        if cp -f "$src_asound" "$host_asound" 2>/dev/null && [ -f "$host_asound" ]; then
            asound_ok="true"
        else
            echo -e "  ${YELLOW}Audio : copie de asound.conf vers ${host_asound} impossible${NC}" >&2
        fi
    fi
    if [ "$asound_ok" = "true" ]; then
        alsa_args="${alsa_args} -v ${host_asound}:/etc/asound.conf:rw"
        alsa_args="${alsa_args} -e ALSA_OUTPUT=gsm_out -e ALSA_INPUT=gsm_in"
        alsa_args="${alsa_args} -e ALSA_CARD=gsm_out -e GAPK_ALSA_DEV=gsm_out"
        if [ "$has_pulse" = "true" ]; then
            echo -e "  ${GREEN}Audio : PulseAudio + asound.conf${NC}" >&2
        else
            echo -e "  ${YELLOW}Audio : asound.conf sans PulseAudio${NC}" >&2
        fi
    else
        alsa_args="${alsa_args} -e ALSA_OUTPUT=default -e ALSA_INPUT=default"
        alsa_args="${alsa_args} -e ALSA_CARD=default -e GAPK_ALSA_DEV=default"
        echo -e "  ${YELLOW}Audio : configs/asound.conf absent, fallback default${NC}" >&2
    fi
    echo "$alsa_args"
}

# ── Audio hote : expose le pulse de l'hote en TCP ──────────────────────────
ensure_host_audio_relay() {
    [ -n "${HOST_AUDIO_RELAY_DONE:-}" ] && return 0
    HOST_AUDIO_RELAY_DONE=1
    local bridge="$(dirname "$0")/scripts/wslg-audio-bridge.sh"
    [ -x "$bridge" ] || { echo -e "  ${YELLOW}[host-audio] $bridge introuvable${NC}" >&2; return 0; }
    if "$bridge" host-relay; then
        # [2026-08-31] ON VERIFIE QUE LE PORT REPOND, on ne se fie plus au seul
        # code de retour. `host-relay` rend 0 des que le chargement du module a
        # ete DEMANDE - pas quand pulseaudio accepte des connexions. On
        # annoncait donc "relai pret" pendant que gapk, dans le conteneur,
        # recevait :
        #     lib pulse.c:242:(pulse_connect) PulseAudio: Unable to connect:
        #         Connection refused
        #     pq_alsa.c:168 Couldn't init ALSA device 'gsm_out': Connection refused
        #     gapk_io.c:468 Failed to initialize GAPK I/O
        # -> appel etabli et TOTALEMENT muet, avec un demarrage qui affichait
        # "relai pret" en vert. Constate le 31/08, cinq minutes apres un boot :
        # la course se gagne ou se perd selon ce que la machine fait par
        # ailleurs, ce qui en fait un "ca marche une fois sur deux".
        # gapk n init son I/O QU UNE FOIS : rater cette fenetre, c est le
        # silence pour tout l appel.
        local _p=0 _i
        for _i in $(seq 1 30); do
            if (exec 3<>"/dev/tcp/${INTER_NET_GATEWAY}/${WSLG_TCP_PORT:-4713}") 2>/dev/null; then
                exec 3>&- 2>/dev/null; _p=1; break
            fi
            sleep 1
        done
        if [ "$_p" = "1" ]; then
            HOST_AUDIO_RELAY="tcp:${INTER_NET_GATEWAY}:${WSLG_TCP_PORT:-4713}"
            echo -e "  ${GREEN}[host-audio] relai pret → ${HOST_AUDIO_RELAY}${NC}"
        else
            HOST_AUDIO_RELAY=""
            echo -e "  ${RED}[host-audio] relai charge mais ${INTER_NET_GATEWAY}:${WSLG_TCP_PORT:-4713} NE REPOND PAS apres 30 s${NC}" >&2
            echo -e "  ${YELLOW}             → l audio du conteneur sera muet. Verifier :${NC}" >&2
            echo -e "  ${YELLOW}               pactl list modules | grep -A2 native-protocol-tcp${NC}" >&2
        fi
    else
        HOST_AUDIO_RELAY=""
        echo -e "  ${YELLOW}[host-audio] relai non ouvert - fallback carte locale dans le conteneur${NC}"
    fi

    # [2026-08-12] Sens MONTANT (micro). Le relai ci-dessus ne couvre que la
    # descente (gsm_audio.monitor → HP de l'hote). Le micro, lui, vient du
    # navigateur, qui capture la source PAR DEFAUT de l'hote - laquelle peut
    # tres bien etre une entree fantome qui ne sort que du zero (vecu ici :
    # defaut non mute a 93 % et pourtant silence total, cf. host_mic()).
    # On choisit donc la source a l'oreille, pas au nom. Non fatal.
    "$bridge" host-mic || true
}

# ══════════════════════════════════════════════════════════════════════════════
# Build
# ══════════════════════════════════════════════════════════════════════════════
# [2026-08-31] QUICK PAR DEFAUT, et sans poser la question.
# Le defaut etait 0 - "normal (--no-cache)" - et, faute de choix explicite,
# start.sh ouvrait une boite whiptail (l.2578, choose_build_mode) pour la
# demander. Deux consequences :
#   - la recompilation Osmocom complete, ~40 minutes, etait ce qu on obtenait
#     en repondant trop vite, alors que l image ne change quasiment jamais ;
#   - la QUESTION elle-meme rendait le script inutilisable depuis une icone ou
#     un appel scripte : il s arretait sur un dialogue que personne ne voit.
# Le cache docker est la bonne reponse dans la vie de tous les jours ; on ne
# reconstruit a neuf que quand on le DEMANDE.
#
# Comment forcer une reconstruction complete, les deux marchent :
#     ./start.sh normal ...        (argument positionnel)
#     OSMO_QUICK=0 ./start.sh ...  (variable d environnement)
QUICK="${OSMO_QUICK:-1}"
# EXPLICIT a 1 meme sans demande : c est ce qui supprime le dialogue. Le passer
# a 0 ici ferait revenir la question a chaque lancement non interactif.
QUICK_EXPLICIT=1

build_run_image() {
    if [ "${QUICK:-0}" = "1" ]; then
        echo -e "${GREEN}Build de l'image run...${NC} ${YELLOW}(quick - cache docker reutilise)${NC}"
        docker build --build-arg QEMU_CACHE_BUST=$(date +%s) -f Dockerfile.run -t "$IMAGE_RUN" .
    else
        echo -e "${GREEN}Build de l'image run...${NC} ${CYAN}(normal - --no-cache)${NC}"
        docker build --no-cache --build-arg QEMU_CACHE_BUST=$(date +%s) -f Dockerfile.run -t "$IMAGE_RUN" .
    fi
    echo -e "${GREEN}Image '$IMAGE_RUN' prete.${NC}"
}

check_image() {
    if ! docker image inspect "$IMAGE_RUN" &>/dev/null; then
        echo -e "${RED}Image '$IMAGE_RUN' introuvable - build en cours...${NC}"
        build_run_image
    fi
}

# ══════════════════════════════════════════════════════════════════════════════
# Generation dynamique - pjsip interop trunks
# ══════════════════════════════════════════════════════════════════════════════
generate_pjsip_interop_trunks() {
    local op_id=$1
    local n_operators=$2
    # ── 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
        local remote_ip
        # 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
    local n_operators=$2
    # Le prefixe porte le NOEUD : MSISDN = <noeud>00<operateur><rang>. Fige a
    # "600", le motif n'accrochait plus rien des que le numero commencait par le
    # numero de noeud, et les appels inter-operateurs sortaient en Congestion
    # sans qu'une ligne ne designe le motif.
    local _pfx; _pfx="$(osmo_msisdn_pfx "$(osmo_node_id)")"
    cat <<'EOF'
[interop_out]

EOF
    # Un seul motif : les MSISDN font six chiffres, <noeud>00<operateur><rang>.
    # 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.
}

# ══════════════════════════════════════════════════════════════════════════════
# SMS routing fallback
# ══════════════════════════════════════════════════════════════════════════════
_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
    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
        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> - PAS de concatenation
    done
    printf '\n[relay]\nport = 7890\nconnect_timeout = 10\nretry_count = 3\nretry_delay = 5\n'
}

# ══════════════════════════════════════════════════════════════════════════════
# ─────────────────────────────────────────────────────────────────────────────
# Configuration du reseau - deleguee a ./generate_configs.sh
#
# [2026-08-03] Ce fichier portait ici une copie de 132 lignes de
# apply_config_templates(), jumelle de celle de lib/gabarits.sh (70 lignes).
# Deux implementations de la meme chose, qui divergeaient en silence : celle-ci
# copiait tout scripts/, l'autre une liste blanche perimee. Elles vivent
# desormais dans generate_configs.sh, une seule fois.
#
# generate_configs.sh cree globals.conf a la racine s'il n'existe pas - et ne
# l'ecrase JAMAIS ensuite. Tes reglages survivent donc a tous les runs. Pour
# repartir des valeurs d'usine : ./generate_configs.sh --force
# ─────────────────────────────────────────────────────────────────────────────
# start.sh n'a pas de variable de repertoire (il travaille en chemins relatifs
# depuis la racine du depot) : on resout ici, sans rien supposer.
#
# BASH_SOURCE ne suffit pas : build-iso.sh extrait ce fichier en bibliotheque
# ($WORK/start.lib.sh) puis la source, et BASH_SOURCE designe alors la COPIE,
# dans un repertoire de travail ou generate_configs.sh n'existe pas.
# OSMO_REPO_DIR laisse l'appelant nommer la vraie racine du depot.
_GC_SH="${OSMO_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}/generate_configs.sh"
"$_GC_SH" >/dev/null || true
. "$_GC_SH"


# ── Mise a jour forcee des trois arbres, AVANT run.sh ─────────────────────────
# [2026-08-12] Les trois depots sont clones et `git pull`-es DANS LE Dockerfile,
# donc a l'etat du BUILD. Entre deux builds d'image (11,5 Go, plusieurs dizaines
# de minutes) un `git push` n'atteignait jamais un conteneur neuf : on lancait un
# run sur du code perime sans le voir. On les rafraichit donc a chaque demarrage,
# avant que run.sh ne lise quoi que ce soit.
#
# ORDRE : cette fonction DOIT etre appelee apres `docker run` et AVANT le
# `docker exec ... run.sh`. Apres, run.sh aurait deja lu l'ancien code.
#
# `--ff-only`, PAS de `reset --hard` : le depot qosmo-grgsm du conteneur recoit des
# commits faits a la main pendant une session (c'est le mode de travail : on
# corrige, on compile, on commite dans le conteneur). Un reset dur les
# effacerait sans le dire. En avance-rapide seule, une divergence s'ARRETE et se
# VOIT au lieu d'etre ecrasee en silence.
#
# qosmo-grgsm est RECOMPILE apres le pull : mettre la source a jour sans relier le
# binaire ne change rien a ce qui tourne - et c'est indiscernable d'un pull qui
# aurait echoue. Le juge d'un binaire a jour reste `stat -L /proc/<pid>/exe`.
#
# Non bloquant (reseau absent = on continue), mais BRUYANT : un pull rate doit
# se lire dans la sortie du lanceur, pas se deviner.
force_update_trees() {
    local c=$1
    echo -e "  ${GREEN}[*] Mise a jour forcee des depots (avant run.sh)...${NC}"
    for repo in /opt/GSM/qosmo-grgsm /opt/GSM/osmo-operator /opt/GSM/osmo-egprs-web; do
        if ! docker exec "$c" test -d "$repo/.git" 2>/dev/null; then
            echo -e "    ${YELLOW}$repo : pas un depot git - ignore${NC}"
            continue
        fi
        local before after
        before=$(docker exec "$c" git -C "$repo" rev-parse --short HEAD 2>/dev/null || echo '?')
        if docker exec "$c" git -C "$repo" pull --ff-only 2>&1 | tail -2 | sed 's/^/    /'; then
            after=$(docker exec "$c" git -C "$repo" rev-parse --short HEAD 2>/dev/null || echo '?')
            if [ "$before" = "$after" ]; then
                echo -e "    ${CYAN}$repo${NC} : deja a jour ($after)"
            else
                echo -e "    ${GREEN}$repo${NC} : $before -> $after"
                # ── LE SERVEUR WEB TOURNE DEJA, ET NODE A DEJA LU SON FICHIER ──
                # [2026-08-31] Exactement le piege du ninja ci-dessous, sur un
                # autre maillon : le pull change le FICHIER, pas le PROCESSUS.
                # osmo-egprs-web.service est une unite systemd, donc lancee au
                # BOOT du conteneur - avant cet appel - et node ne lit server.js
                # qu'une fois. Mesure du 31/08 dans osmo-operator-2 :
                #     node demarre 18:58:18   server.js reecrit 18:58:21
                # Le disque portait le correctif Kc (grep kcPollVty : 2), le
                # process tournait encore sur l'ancien code, et le dashboard
                # affichait « Kc absent » - panne indiscernable d'un pull rate.
                # On ne relance QUE si le pull a reellement bouge.
                if [ "$repo" = /opt/GSM/osmo-egprs-web ]; then
                    if docker exec "$c" systemctl restart osmo-egprs-web 2>/dev/null; then
                        echo -e "    ${GREEN}osmo-egprs-web.service relance${NC} (le dashboard charge le nouveau code)"
                    else
                        echo -e "    ${YELLOW}osmo-egprs-web.service : relance KO - le dashboard garde l'ancien code${NC}"
                    fi
                fi
            fi
        else
            echo -e "    ${RED}$repo : pull KO (divergence ou reseau) - le run part sur $before${NC}"
        fi
    done
    # Recompilation de QEMU : sans elle le pull ci-dessus ne change RIEN au
    # binaire qui tourne. ninja ne reconstruit que ce qui a bouge.
    echo -e "  ${GREEN}[*] Recompilation QEMU (ninja)...${NC}"
    if docker exec "$c" bash -c 'cd /opt/GSM/qosmo-grgsm/build && ninja' >/dev/null 2>&1; then
        echo -e "    ${GREEN}qemu-system-arm relie${NC} ($(docker exec "$c" stat -L -c %y /opt/GSM/qosmo-grgsm/build/qemu-system-arm 2>/dev/null | cut -c1-19))"
    else
        echo -e "    ${RED}ninja KO - le run utilisera le binaire de l'image${NC}"
        docker exec "$c" bash -c 'cd /opt/GSM/qosmo-grgsm/build && ninja 2>&1 | tail -15' | sed 's/^/      /'
    fi
}

build_vol_args() {
    local tmpdir=$1
    local vol_args=""
    [ -d "$tmpdir/osmocom" ] && vol_args="$vol_args -v $tmpdir/osmocom:/etc/osmocom"
    for f in "$tmpdir/asterisk"/*.conf; do
        [ -f "$f" ] || continue
        vol_args="$vol_args -v $f:/etc/asterisk/$(basename "$f")"
    done
    if [ -f "$tmpdir/bb/mobile.cfg" ]; then
        vol_args="$vol_args -v $tmpdir/bb/mobile.cfg:/root/.osmocom/bb/mobile.cfg"
    fi
    if [ -f "$tmpdir/bb/mobile_group1.cfg" ]; then
        vol_args="$vol_args -v $tmpdir/bb/mobile_group1.cfg:/root/.osmocom/bb/mobile_group1.cfg"
    fi
    echo "$vol_args"
}

# ══════════════════════════════════════════════════════════════════════════════
# TUN hote
# ══════════════════════════════════════════════════════════════════════════════
prepare_host_tun() {
    echo -e "${GREEN}[*] Configuration TUN sur l'hote...${NC}"
    modprobe tun 2>/dev/null || true
    mkdir -p /dev/net
    if [ ! -c /dev/net/tun ]; then
        mknod /dev/net/tun c 10 200
        chmod 666 /dev/net/tun
    fi
    ip link del apn0 2>/dev/null || true
    ip tuntap add dev apn0 mode tun
    ip addr add 176.16.32.0/24 dev apn0
    ip link set apn0 up
}

# ══════════════════════════════════════════════════════════════════════════════
# Inter-STP
# ══════════════════════════════════════════════════════════════════════════════
start_inter_stp() {
    local n_operators=$1
    local tmpdir
    tmpdir=$(mktemp -d)
    local inter_cfg="${tmpdir}/osmo-stp-interop.cfg"
    if [ "${WAN_MESH:-0}" = "1" ]; then
        # Plan WAN : un AS par couple (noeud, operateur), point codes
        # 1.<noeud><op>.<role>. Le hub doit connaitre TOUS les noeuds, pas
        # seulement les operateurs locaux - sinon les ASP distants s'attachent
        # a un AS qui n'existe pas et sont rejetes sans explication.
        echo -e "${GREEN}Generation config inter-STP WAN (${WAN_NODE_COUNT} noeuds × ${n_operators})...${NC}"
        bash ./helpers/create_interop.sh --wan "$WAN_NODE_COUNT" "$n_operators" "$inter_cfg" > /dev/null
    else
        echo -e "${GREEN}Generation config inter-STP (${n_operators} operateurs)...${NC}"
        bash ./helpers/create_interop.sh "$n_operators" "$inter_cfg" > /dev/null
    fi
    if [ ! -f "$inter_cfg" ]; then
        echo -e "${RED}Echec generation config inter-STP${NC}"; exit 1
    fi
    # Le hub ne fait que router du M3UA : l'image osmocom-stp (Dockerfile.stp)
    # lui suffit et pese une fraction d'osmocom-run. On ne la CONSTRUIT pas ici
    # a l'insu de l'appelant - si elle manque, on reste sur l'image complete,
    # qui contient aussi osmo-stp.
    local stp_image="$IMAGE_RUN"
    if [ "${BUILD_STP:-0}" = "1" ] && ! docker image inspect "$IMAGE_STP" >/dev/null 2>&1; then
        echo -e "  ${GREEN}Construction de ${IMAGE_STP} (Dockerfile.stp)...${NC}"
        docker build -f "$(dirname "$0")/Dockerfile.stp" -t "$IMAGE_STP" "$(dirname "$0")" \
            || echo -e "  ${YELLOW}⚠ echec - on restera sur ${IMAGE_RUN}${NC}"
    fi
    if docker image inspect "$IMAGE_STP" >/dev/null 2>&1; then
        stp_image="$IMAGE_STP"
        echo -e "  ${CYAN}image ${IMAGE_STP} (STP seul)${NC}"
    elif [ "${WAN_MESH:-0}" = "1" ]; then
        echo -e "  ${YELLOW}image ${IMAGE_STP} absente - on utilise ${IMAGE_RUN}.${NC}"
        echo -e "  ${CYAN}Pour la construire : docker build -f Dockerfile.stp -t ${IMAGE_STP} .${NC}"
    fi

    echo -e "${GREEN}Lancement inter-STP @ ${INTER_STP_IP}:2908 (PC 0.0.0)...${NC}"
    docker rm -f "$INTER_STP_CONTAINER" &>/dev/null || true
    # --restart, PAS --rm : avec --rm, un reboot de l'hote ne laissait pas le hub
    # arrete, il le faisait DISPARAITRE - au retour, plus rien a redemarrer, et
    # des ASP qui ouvrent leur SCTP vers personne. Les deux options sont
    # exclusives (docker refuse la combinaison au parsing), et le "docker rm -f"
    # ci-dessus tient deja le role de --rm : aucun reliquat du lancement d'avant.
    # ── 2908 PUBLIE SUR LA BOUCLE LOCALE : LE RACCORD DE L OPERATEUR NATIF ──
    # [2026-08-31] Sans cette ligne, un operateur NATIF (celui de
    # start-direct.sh, hors conteneur) ne pouvait PAS s attacher au hub.
    # Son /etc/osmocom/osmo-stp.cfg declare
    #       asp asp-to-inter 2908 2910 m3ua
    #        remote-ip 127.0.0.1
    # ce qui est le bon choix - c est la seule adresse dont on soit sur - mais
    # RIEN n ecoutait sur 127.0.0.1:2908 cote hote : le hub vit dans le
    # conteneur, sur le reseau prive, et ne publiait aucun port
    # ("docker port osmo-inter-stp" : vide). L ASP ouvrait donc son SCTP vers
    # personne, et le diagnostic sortait tres loin de la cause -
    #     Aucune association SCTP vers ...:2908 (0/1)
    #     as-inter : AS_DOWN  /  Route par defaut -> inter-STP absente
    # ce qui envoie chercher un hub en panne ou un filtrage, alors que le hub
    # va bien et que rien ne le rendait joignable.
    #
    # /sctp, PAS /tcp : M3UA roule sur SCTP. Docker sait publier ce protocole
    # (verifie sur ce banc : noyau avec module sctp, et
    # "docker run -p 127.0.0.1:PORT:PORT/sctp" accepte).
    #
    # Lie a 127.0.0.1 et pas a 0.0.0.0 : ce point d entree ne sert QU au natif,
    # sur la meme machine. L exposer sur toutes les interfaces ouvrirait le
    # coeur SS7 au LAN - checks/wan_ss7_check.sh signale d ailleurs "un port
    # M3UA expose sur l interface publique" comme un defaut a part entiere.
    #
    # L autre solution possible etait de repointer l ASP natif sur
    # 172.20.0.10 avec local-ip 172.20.0.1. Ecartee : ces deux adresses
    # n existent que TANT QUE le bridge docker est monte, et elles disparaissent
    # avec les conteneurs - on aurait remplace une adresse absente par deux
    # adresses intermittentes, dans une config que start-direct.sh regenere.
    docker run -d \
        --restart unless-stopped \
        --name "$INTER_STP_CONTAINER" \
        --hostname "$INTER_STP_CONTAINER" \
        --network "$INTER_NET" \
        --ip "$INTER_STP_IP" \
        -p "127.0.0.1:2908:2908/sctp" \
        --cap-add NET_ADMIN \
        -v "${inter_cfg}:/etc/osmocom/osmo-stp-interop.cfg" \
        --entrypoint bash \
        "$stp_image" \
        -c "sleep infinity" > /dev/null
    # tmux si l'image en a un, sinon un simple processus detache.
    #
    # osmocom-stp est l'image "STP seul" : elle ne porte que osmo-stp et ses
    # bibliotheques - c'est tout son interet, 877 Mo contre 11 Go. tmux n'y est
    # pas, et l'exec echouait alors sur "tmux: executable file not found in
    # $PATH". Le conteneur tournait pourtant (sleep infinity) : un hub bien
    # demarre, mais SANS osmo-stp dedans - il n'ecoutait rien, et les noeuds ne
    # s'attachaient a personne.
    #
    # tmux n'apportait ici que le confort de s'attacher a la session. La
    # barriere qui suit ne lit que /tmp/osmo-stp.log : un processus detache qui
    # redirige vers ce meme fichier remplit le contrat, et l'image complete
    # garde son tmux quand elle en a un.
    if docker exec "$INTER_STP_CONTAINER" sh -c 'command -v tmux >/dev/null 2>&1'; then
        docker exec "$INTER_STP_CONTAINER" \
            tmux new-session -d -s stp \
            "osmo-stp -c /etc/osmocom/osmo-stp-interop.cfg 2>&1 | tee /tmp/osmo-stp.log"
    else
        docker exec -d "$INTER_STP_CONTAINER" \
            sh -c 'exec osmo-stp -c /etc/osmocom/osmo-stp-interop.cfg > /tmp/osmo-stp.log 2>&1'
    fi
    echo -ne "${GREEN}[*] Attente demarrage inter-STP"
    local retries=25
    while [ $retries -gt 0 ]; do
        if docker exec "$INTER_STP_CONTAINER" \
            grep -qE "listening|m3ua.*[Ss]erver|bound" /tmp/osmo-stp.log 2>/dev/null; then
            echo -e " ${GREEN}✓${NC}"
            return 0
        fi
        if [ "$(docker inspect -f '{{.State.Running}}' "$INTER_STP_CONTAINER" 2>/dev/null)" != "true" ]; then
            echo -e " ${RED}CRASH${NC}"
            docker logs "$INTER_STP_CONTAINER"
            exit 1
        fi
        echo -n "."
        sleep 1
        ((retries--)) || true
    done
    echo -e " ${YELLOW}(timeout log, on continue)${NC}"
}

wait_inter_stp_ready() {
    local n_operators=$1
    echo -ne "${GREEN}[*] Verification stabilisation inter-STP (routes SS7)${NC}"
    for i in {1..15}; do sleep 1; echo -n "."; done
    echo -e " ${GREEN}✓${NC}"
    return 0
}

wait_bb_vty() {
    local container="$1"
    local timeout=120
    local elapsed=0
    echo -ne "  Attente BB VTY 4247 "
    while ! docker exec "$container" bash -c "echo >/dev/tcp/127.0.0.1/4247" 2>/dev/null; do
        sleep 2; elapsed=$((elapsed + 2)); echo -n "."
        if [ "$elapsed" -ge "$timeout" ]; then echo -e " ${RED}TIMEOUT${NC}"; return 1; fi
    done
    echo -e " ${GREEN}OK${NC}"
}

wait_stp_vty() {
    local container="$1"
    local timeout=60
    local elapsed=0
    echo -ne "  Attente STP VTY 4239 "
    while ! docker exec "$container" bash -c "echo >/dev/tcp/127.0.0.1/4239" 2>/dev/null; do
        sleep 2; elapsed=$((elapsed + 2)); echo -n "."
        if [ "$elapsed" -ge "$timeout" ]; then echo -e " ${RED}TIMEOUT${NC}"; return 1; fi
    done
    echo -e " ${GREEN}OK${NC}"
}

# ══════════════════════════════════════════════════════════════════════════════
# WAN Interop
# ══════════════════════════════════════════════════════════════════════════════
setup_wan_interop() {
    local n_local=$1
    local n_remote=$2
    local script_path
    script_path="$(dirname "$0")/setup-wan-interop.sh"
    if [ ! -f "$script_path" ]; then
        echo -e "${RED}[WAN] network/setup-wan-interop.sh introuvable${NC}"
        return 1
    fi
    chmod +x "$script_path"
    bash "$script_path" "$WAN_LOCAL_IP" "$WAN_REMOTE_IP" "$n_local" "$n_remote"
}

# ══════════════════════════════════════════════════════════════════════════════
# WAN mesh (--wan)
# ══════════════════════════════════════════════════════════════════════════════
# Renseigne la table des noeuds : sans interaction si WAN_NODES est deja pose
# (option --wan-nodes ou variable d'environnement), par questions sinon.
wan_mesh_configure() {
    local n_operators=$1
    WAN_OPS="$n_operators"

    # Le point code WAN vaut 1.<noeud><op>.<role> : le champ central du format
    # ITU 3-8-3 tient sur 8 bits, soit 255. Avec 12 operateurs, le noeud 9
    # donnerait "912" - un point code invalide, refuse par osmo-stp au
    # demarrage avec une erreur de parsing qui ne dit rien du nombre
    # d'operateurs. On arrete ici, en nommant la cause.
    if [ "$n_operators" -gt 9 ]; then
        echo -e "${RED}[WAN] ${n_operators} operateurs : le plan de point codes WAN${NC}" >&2
        echo -e "${RED}      (1.<noeud><op>.<role>) n'en admet que 9 par noeud.${NC}" >&2
        echo -e "${CYAN}      Repartissez-les sur plusieurs noeuds, ou lancez sans --wan.${NC}" >&2
        exit 1
    fi

    # --virtualbox : le segment, les VM et la table sont fabriques AVANT tout le
    # reste, puis relus comme n'importe quelle table. Le WAN qui suit ne sait
    # pas - et n'a pas a savoir - que ses pairs sont des VM.
    if [ "${VBOX_INTERCO:-0}" = "1" ] && [ -z "${WAN_NODES:-}" ]; then
        local _vb=("$(dirname "$0")/network/setup-vbox-interco.sh"
                   --host-node "$VBOX_HOST_NODE" --conf "$WAN_CONF_FILE")
        [ -n "${VBOX_NODES:-}" ] && _vb+=(--nodes "$VBOX_NODES")
        bash "${_vb[@]}" || { echo -e "${RED}[WAN] interconnexion VirtualBox echouee${NC}" >&2; exit 1; }
        wan_nodes_load "$WAN_CONF_FILE" || exit 1
        WAN_NODES="$(wan_nodes_spec)"
        WAN_NODE_ID="$VBOX_HOST_NODE"
    fi

    wan_menu_table

    if [ -n "${WAN_NODES:-}" ]; then
        wan_nodes_parse "$WAN_NODES" || exit 1
        if [ "${WAN_NODE_ID:-0}" = "0" ]; then
            # La detection par IP echoue par construction quand les premiers
            # noeuds sont des CONTENEURS : leurs adresses (172.20.0.11, .12)
            # appartiennent au backbone docker, pas a l'hote. L'hote n'est donc
            # dans la table sous aucune de ses adresses - et il n'a pas a y
            # etre : il porte des noeuds, il n'en est pas un.
            #
            # Son numero est celui de son PREMIER conteneur, c'est-a-dire 1
            # dans le modele "conteneur i = noeud i". On ne s'arrete donc plus :
            # on le pose, et on ne demande --wan-id que si la table ne contient
            # meme pas ce noeud-la.
            if wan_nodes_detect_self 2>/dev/null; then
                :
            elif [ -n "${WAN_IP[1]:-}" ]; then
                WAN_NODE_ID=1
                echo -e "  ${CYAN}[WAN] cette machine porte les conteneurs : noeud de base 1${NC}"
            else
                echo -e "${RED}[WAN] table vide ou sans noeud 1 : passez --wan-id N${NC}" >&2
                exit 1
            fi
        fi
        wan_nodes_validate || exit 1
    else
        # Une table deja posee sur cette machine sert de valeurs par defaut :
        # relancer le lab ne redemande donc pas tout, il propose l'existant.
        wan_nodes_load "$WAN_CONF_FILE" 2>/dev/null || true
        wan_nodes_prompt || exit 1
    fi

    # ── Ce que la table WAN ne dit pas, et qu'il faut pourtant savoir ───────
    # Elle donne les noeuds et leurs adresses. Elle ne dit ni ou est le hub SS7,
    # ni si les conteneurs de CETTE machine sont ses operateurs ou des noeuds a
    # part entiere. Sans ces reponses, --wan partait sur ses defauts - hub
    # local, conteneurs = operateurs - et sur un banc ou le hub est une VM
    # dediee, cela donnait un second hub inutile et des ASP attaches au mauvais.
    wan_menu_complements

    WAN_ENABLED="false"          # exclusif du WAN legacy
    WAN_LOCAL_IP="$(wan_local_ip)"
    echo ""
    wan_nodes_summary
    if [ -n "${WAN_HUB_IP:-}" ]; then
        echo -e "  inter-STP : ${CYAN}${WAN_HUB_IP}${NC}  (hors table : ce n'est pas un noeud)"
    else
        echo -e "  inter-STP : porte par le noeud ${CYAN}${WAN_HUB_NODE}${NC}"
    fi
    [ "${WAN_NODE_PER_OP:-0}" = "1" ] \
        && echo -e "  conteneurs: ${CYAN}un noeud chacun${NC}, a partir du noeud ${CYAN}${WAN_NODE_ID}${NC}"
    # Qui tourne ICI, et qui est seulement DECLARE. La distinction n'a rien de
    # cosmetique : un noeud declare doit deja tourner ailleurs, sinon ses
    # abonnes figurent dans les tables SMS et le dialplan sans que personne ne
    # reponde - un appel qui sonne dans le vide se diagnostique mal.
    for _nid in ${WAN_NODE_LIST[@]+"${WAN_NODE_LIST[@]}"}; do
        if [ "$_nid" = "${WAN_NODE_ID:-0}" ] || \
           { [ "${WAN_NODE_PER_OP:-0}" = "1" ] && [ "$_nid" -gt "${WAN_NODE_ID:-0}" ]; }; then
            printf '    noeud %-2s %-16s %s\n' "$_nid" "${WAN_IP[$_nid]}" "conteneur lance ici"
        else
            printf '    noeud %-2s %-16s %s\n' "$_nid" "${WAN_IP[$_nid]}" "declare (doit tourner ailleurs)"
        fi
    done
    echo ""
}

# ── La table WAN, batie dans l'ordre ou on la pense ─────────────────────────
# D'abord COMBIEN d'operateurs en tout, puis combien tournent ICI en conteneur,
# puis les autres un par un. Les conteneurs prennent les premiers rangs -
# osmo-operator-1 est le noeud 1, -2 le noeud 2 - de sorte que le nom du
# conteneur donne son numero de noeud, sans table a consulter. Les operateurs
# distants (une VM deja demarree) prennent les rangs suivants : on ne demande
# que ce qu'on ne peut pas deduire, leur adresse et leur indicatif.
wan_menu_table() {
    command -v whiptail >/dev/null 2>&1 || return 0
    [ -t 0 ] || return 0
    [ -n "${WAN_NODES:-}" ] && return 0            # deja donne en CLI
    [ "${#OPERATOR_DECLS[@]}" -gt 0 ] && return 0  # deja declare par --operator

    local total cont k ip ind spec=""
    total=$(wt_input "WAN" "Nombre d'operateurs du WAN, tous sites confondus (1-9) :" "3") || return 0
    [[ "$total" =~ ^[1-9]$ ]] || { wt_msg "Nombre invalide : $total"; return 0; }

    cont=$(wt_input "WAN" "Combien tournent ICI, en conteneur ? (0-${total})" "$(( total > 1 ? total - 1 : total ))") || return 0
    [[ "$cont" =~ ^[0-9]$ ]] && [ "$cont" -le "$total" ] \
        || { wt_msg "Entre 0 et ${total}."; return 0; }

    # Les conteneurs : adresse du backbone, indicatif du rang. Rien a demander.
    for k in $(seq 1 "$cont"); do
        spec="${spec}${spec:+ }${k}:$(op_backbone_ip "$k"):$(( k * 11 ))"
    done
    # Les autres : eux seuls ont besoin d'une reponse.
    for k in $(seq $(( cont + 1 )) "$total"); do
        # Defaut : la VM du banc, seul noeud distant du montage courant.
        _defip=""; [ "$k" = 3 ] && _defip="192.168.1.2"
        ip=$(wt_input "Operateur distant ${k}/${total}" \
             "Adresse du noeud ${k} (une VM, une autre machine) :" "$_defip") || return 0
        [ -n "$ip" ] || { wt_msg "Adresse vide : noeud ${k} ignore."; continue; }
        ind=$(wt_input "Operateur distant ${k}/${total}" \
              "Indicatif (prefixe d'appel) du noeud ${k} :" "$(( k * 11 ))") || return 0
        spec="${spec}${spec:+ }${k}:${ip}:${ind:-$(( k * 11 ))}"
    done

    WAN_NODES="$spec"
    OPERATOR_COUNT_HINT="$cont"
    WAN_NODE_ID="${WAN_NODE_ID:-1}"
    [ "$cont" -ge 1 ] && WAN_NODE_PER_OP=1 && WAN_NODE_PER_OP_GIVEN=1
    echo -e "  ${CYAN}Table WAN :${NC} $WAN_NODES"
}

# ── Les questions WAN que la table ne porte pas ──────────────────────────────
# Chacune se saute des que l'option correspondante a ete donnee en ligne de
# commande : une invocation scriptee reste muette, une invocation a la main est
# guidee. Sans terminal (CI, cron), on garde les defauts plutot que d'attendre
# une reponse que personne ne donnera.
wan_menu_complements() {
    command -v whiptail >/dev/null 2>&1 || return 0
    [ -t 0 ] || return 0

    if [ -z "${WAN_HUB_IP:-}" ] && [ "${WAN_HUB_NODE_GIVEN:-0}" != "1" ]; then
        local _c
        _c=$(wt_menu "Inter-STP" "Ou se trouve le hub SS7 (PC 0.0.0) ?" \
            "ip"    "sur une machine dediee, hors table (une VM par exemple)" \
            "ici"   "sur CETTE machine - un conteneur inter-STP sera lance" \
            "noeud" "sur un autre noeud de la table WAN") || return 0
        case "$_c" in
            ip)
                local _ip
                _ip=$(wt_input "Inter-STP" "Adresse du hub (ex: 192.168.1.49) :" "${WAN_HUB_IP:-}") || return 0
                if [ -n "$_ip" ]; then WAN_HUB_IP="$_ip"
                else wt_msg "Adresse vide : le hub restera local."; fi ;;
            noeud)
                local _n
                _n=$(wt_input "Inter-STP" "Numero du noeud qui porte le hub :" "${WAN_HUB_NODE:-1}") || return 0
                [ -n "$_n" ] && WAN_HUB_NODE="$_n" ;;
            *)  WAN_HUB_NODE="$WAN_NODE_ID" ;;
        esac
    fi

    if [ "${WAN_NODE_PER_OP_GIVEN:-0}" != "1" ]; then
        if wt_yesno "Conteneurs" \
"Chaque conteneur doit-il etre un NOEUD a part entiere ?

Oui : conteneur 1 -> noeud ${WAN_NODE_ID}, conteneur 2 -> noeud $((WAN_NODE_ID + 1))...
      (leurs adresses backbone doivent etre joignables des autres noeuds)
Non : ce sont les operateurs du noeud ${WAN_NODE_ID}."; then
            WAN_NODE_PER_OP=1
        fi
    fi
}

# Applique la table (Asterisk, iptables, SMS) une fois les containers debout.
wan_mesh_apply() {
    local n_operators=$1
    local script_path="$(dirname "$0")/network/setup-wan-mesh.sh"
    if [ ! -f "$script_path" ]; then
        echo -e "${RED}[WAN] network/setup-wan-mesh.sh introuvable${NC}"; return 1
    fi
    # ── Le 4e champ de la table : combien d'operateurs porte chaque noeud ────
    # WAN_NOPS n'avait AUCUN producteur dans le depot : la table sortait
    # toujours a trois champs, setup-wan-mesh.sh se repliait sur une supposition
    # et fabriquait - ou supprimait - des trunks au jugee. On l'ecrit ici, ou
    # l'information est certaine : avec --op-is-node chaque noeud EST un
    # conteneur et porte donc un operateur ; sinon, notre noeud les porte tous.
    # wan_nodes_spec n'ecrit le champ que lorsqu'il vaut autre chose que 1, donc
    # une table de conteneurs garde exactement la forme que les autres lecteurs
    # (build-iso.sh, set_stp_ip.sh, les diagnostics) lui connaissent.
    local _k
    if [ "${WAN_NODE_PER_OP:-0}" = "1" ]; then
        for _k in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
            WAN_NOPS[$(( ${WAN_NODE_ID:-1} + _k - 1 ))]=1
        done
    else
        WAN_NOPS[${WAN_NODE_ID:-1}]="$n_operators"
    fi

    if [ "${WAN_NODE_PER_OP:-0}" = "1" ]; then
        # UN SEUL passage, avec --op-is-node : le script associe alors
        # l'operateur i au noeud i et recalcule SES pairs. Un passage par noeud,
        # comme on le faisait, ecrivait dans TOUS les conteneurs a chaque fois :
        # le dernier gagnait, l'un d'eux se routait vers lui-meme et l'autre
        # n'avait aucun maillage.
        bash "$script_path" --docker --op-is-node \
            --nodes "$(wan_nodes_spec)" --id 1 --ops "$n_operators" \
            || echo -e "${RED}[WAN] setup-wan-mesh.sh a echoue${NC}"
    else
        bash "$script_path" --docker \
            --nodes "$(wan_nodes_spec)" --id "$WAN_NODE_ID" --ops "$n_operators" \
            || echo -e "${RED}[WAN] setup-wan-mesh.sh a echoue${NC}"
    fi

}

# ── Rendre au trafic sortant son adresse d'origine ───────────────────────────
# Docker masque tout ce qui quitte un bridge : vues du LAN, les associations des
# conteneurs semblent toutes venir de l'hote. Ce n'est PAS ce qui empeche un ASP
# de rejoindre son AS : l'inter-STP est en "accept-asp-connections
# dynamic-permitted" (helpers/create_interop.sh) et apparie par ROUTING CONTEXT,
# pas par adresse source - il n'a aucun bloc "match=", contrairement a ce qui
# etait ecrit ici. Preserver l'adresse reste utile pour autre chose : un
# diagnostic qui nomme l'operateur au lieu de montrer trois fois l'hote
# (checks/diag-interstp.sh), et un pare-feu qui peut distinguer les sources.
# network/setup-docker-lan-route.sh pose donc un RETURN en tete de POSTROUTING.
#
# POURQUOI LE REJOUER ICI
# Docker REINSERE ses MASQUERADE en tete a chaque reseau cree - et start.sh en
# cree un par operateur. Le RETURN se retrouve alors DERRIERE eux et ne sert
# plus a rien : "iptables -L" le montre toujours, et pourtant le hub revoit
# l'adresse de l'hote. On le remet donc en premiere position une fois TOUS les
# reseaux crees.
reassert_docker_lan_return() {
    command -v iptables >/dev/null 2>&1 || return 0
    [ "$(id -u)" -eq 0 ] || return 0

    local lan_ip lan_net
    lan_ip="${HOST_LAN_IP:-}"
    [ -n "$lan_ip" ] || lan_ip="$(ip route show default 2>/dev/null | awk '/^default/{print $9; exit}')"
    [ -n "$lan_ip" ] || lan_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
    [ -n "$lan_ip" ] || return 0
    lan_net="${lan_ip%.*}.0/24"

    # Si LAN et backbone se recouvrent, la regle porterait sur du trafic interne
    # au bridge : on s'abstient plutot que de deviner.
    [ "${lan_net%%/*}" = "${INTER_NET_SUBNET%%/*}" ] && return 0

    # ── LA REGLE RETURN EST RETIREE, PAS POSEE ──────────────────────────────
    # [2026-08-26] Elle preservait l'adresse source des conteneurs vers le LAN :
    # l'inter-STP voyait 172.20.0.11 et 172.20.0.12 au lieu de l'hote. C'etait
    # l'intention. Le resultat etait l'inverse : ASP_DOWN des deux cotes et
    # "connect failed (-110)" en boucle.
    #
    # MESURE, plutot que raisonnement. Capture pendant une reconnexion forcee :
    #   wlp0s20f3 Out  172.20.0.12.2910 > 192.168.1.49.2908  [INIT]
    #   wlp0s20f3 Out  192.168.1.49.2908 > 172.20.0.12.2910  [INIT ACK]
    # L'INIT part, le hub repond - et l'INIT ACK n'est JAMAIS recu en entree par
    # l'hote (tcpdump -Q in : rien). Il est adresse a 172.20.0.12, une adresse
    # que l'hote doit ROUTER, et le chemin ne la lui remet pas.
    #
    # Ce n'est PAS le drop de Docker 29 dans la table raw, contrairement a ce
    # qu'on a cru : ses compteurs sont restes a 0 pendant toute la mesure.
    #   ip daddr 172.20.0.12 iifname != "br-..." counter packets 0 bytes 0 drop
    #
    # Sans la regle, le trafic ressort masque en 192.168.1.31 : le hub repond a
    # l'adresse PROPRE de l'hote, qui la recoit, et conntrack rend le paquet au
    # conteneur. Verifie : as-inter AS_ACTIVE sur les deux operateurs, en huit
    # secondes.
    #
    # Et l'on ne perd rien : l'inter-STP ne distingue pas ses noeuds par leur
    # adresse source. Il est en "accept-asp-connections dynamic-permitted"
    # (helpers/create_interop.sh) et les apparie par ROUTING CONTEXT - 1150,
    # 2150, 3150. Deux noeuds derriere la meme adresse gardent des contextes
    # distincts, donc des AS distincts.
    #
    # On RETIRE donc la regle si un lancement precedent l'a laissee.
    iptables -t nat -D POSTROUTING -s "$INTER_NET_SUBNET" -d "$lan_net" -j RETURN 2>/dev/null \
        && echo -e "  ${CYAN}[WAN] regle RETURN retiree : les noeuds sortent masques (l'inter-STP les apparie par routing context)${NC}" \
        || true

    # ── Le FORWARD, sans quoi le routage ne sert a rien ─────────────────────
    # Docker pose un FORWARD par defaut a DROP : un paquet du LAN vers le
    # backbone (ou l'inverse) est jete avant d'atteindre le conteneur, sans
    # refus ni trace - cote VM, on ne voit qu'un silence. DOCKER-USER est
    # consultee AVANT les regles de docker et il ne la reecrit pas.
    # network/setup-docker-lan-route.sh pose ces deux memes regles, mais aucun
    # chemin automatique ne l'appelle : elles n'existaient donc sur le banc que
    # si on avait pense a lancer ce script a la main. On les pose ici, ou la
    # fonction est deja rejouee apres chaque reseau cree.
    iptables -N DOCKER-USER 2>/dev/null || true
    local _spec
    for _spec in "-s ${lan_net} -d ${INTER_NET_SUBNET}" "-s ${INTER_NET_SUBNET} -d ${lan_net}"; do
        # shellcheck disable=SC2086
        if iptables -C DOCKER-USER $_spec -j ACCEPT 2>/dev/null; then
            continue
        fi
        # shellcheck disable=SC2086
        if iptables -I DOCKER-USER 1 $_spec -j ACCEPT 2>/dev/null; then
            echo -e "  ${CYAN}[WAN] FORWARD autorise (${_spec})${NC}"
        fi
    done

    # ── Les traductions DEJA etablies ───────────────────────────────────────
    # La regle ne vaut que pour les nouvelles associations. Celles qui ont ete
    # masquees avant elle gardent leur traduction tant qu'elles vivent : le hub
    # continue de les voir sous l'adresse de l'hote, et l'on croit la regle
    # inoperante alors qu'elle est bien la. On efface donc ces entrees - les
    # piles se reconnectent, cette fois avec leur vraie adresse.
    #
    # SAUF quand docker 29 a pose son DROP. Sa table "ip raw" jette, en
    # prerouting, tout paquet entrant vers le backbone qui n'arrive pas par le
    # bridge : une association qui repart avec sa vraie adresse source ne recoit
    # alors plus aucune reponse et reste en COOKIE_WAIT. Purger conntrack dans
    # cet etat coupe des ASP qui ne tiennent QUE par le masquage, pour les faire
    # renaitre muets - c'est exactement la panne qu'on croit corriger.
    local _nft_drop=0
    if command -v nft >/dev/null 2>&1; then
        if nft list table ip raw 2>/dev/null \
           | grep -F "${INTER_NET_SUBNET%.*}." | grep -q 'drop'; then
            _nft_drop=1
        fi
    fi
    if [ "$_nft_drop" = "1" ]; then
        echo -e "  ${YELLOW}[WAN] DROP nft de docker actif sur ${INTER_NET_SUBNET} :${NC}"
        echo -e "  ${YELLOW}      purge conntrack SAUTEE (elle couperait les ASP attaches).${NC}"
        echo -e "  ${CYAN}      Backbone en mode routed pour le lever : OSMO_DOCKER_ROUTED=1${NC}"
    elif command -v conntrack >/dev/null 2>&1; then
        # BORNEE a la destination LAN. Sans -d, la meme commande effacait aussi
        # les flux 172.20.0.x <-> 172.20.0.x, c'est-a-dire tout le SS7
        # intra-backbone : les ASP du hub local tombaient d'un seul coup, et le
        # message parlait pourtant de "traductions heritees".
        local _purged
        _purged="$(conntrack -D -s "$INTER_NET_SUBNET" -d "$lan_net" 2>/dev/null | grep -c . || true)"
        echo -e "  ${CYAN}[WAN] ${_purged} traduction(s) heritee(s) effacee(s) vers ${lan_net}${NC}"
    else
        # Le cas de l'hote du banc. Le bloc etait saute sans un mot : on croyait
        # les traductions effacees alors qu'elles vivaient encore, et le hub
        # continuait de voir l'adresse de l'hote sans que rien ne l'explique.
        echo -e "  ${YELLOW}[WAN] conntrack absent : les associations deja masquees gardent${NC}"
        echo -e "  ${YELLOW}      leur traduction jusqu'a leur reconnexion (apt install conntrack)${NC}"
    fi
}

# ══════════════════════════════════════════════════════════════════════════════
# PoC QEMU - entree par defaut du premier menu
# ══════════════════════════════════════════════════════════════════════════════
start_qemu_poc() {
    echo -e "${GREEN}PoC QEMU Calypso - bring-up minimal + qosmo-grgsm/run.sh...${NC}"
    QEMU_POC=1
    start_bridge_mode
}

# ══════════════════════════════════════════════════════════════════════════════
# Mode virtual (multi-operateurs SS7 - ex-bridge)
# ══════════════════════════════════════════════════════════════════════════════
start_bridge_mode() {
    # Ce mode CREE les conteneurs : le segment prive se derive de l operateur,
    # pas du noeud (voir _osmo_priv_index).
    export OSMO_PRIV_BY_OP=1
    local n_operators
    declare -A OP_MCC OP_MNC OP_NAME OP_MS

    if [ "${QEMU_POC:-0}" = "1" ]; then
        # ── PoC QEMU ──────────────────────────────────────────────────────────
        n_operators=1
        # Meme base que les boucles : sans ca, un OP_ID_BASE=2 ferait chercher
        # OP_MCC[2] alors que tout est pose en [1] - tableaux vides et
        # operateur sans MCC, sans que rien ne le signale.
        _b="${OP_ID_BASE:-1}"
        OP_MCC[$_b]="001"; OP_MNC[$_b]="01"; OP_NAME[$_b]="OsmoQEMU"; OP_MS[$_b]=2
        # PoC a un seul operateur : le PLMN de reference, celui des IMSI du
        # depot. On ne le derive pas du noeud, il n'y a pas de WAN ici.
        WAN_ENABLED="false"
        # --wan reste valable en PoC QEMU : un noeud = un operateur, c'est
        # exactement la maquette "N machines qui s'appellent".
        [ "${WAN_MESH:-0}" = "1" ] && wan_mesh_configure 1
        # BRIDGE_NO_PROCESS=0 : la radio et le mobile demarrent avec le coeur
        # (voir le commentaire "NO-ATTACH, PLUS NO-PROCESS" plus bas).
        PHY_MODE="faketrx"; BRIDGE_NO_PROCESS="${BRIDGE_NO_PROCESS:-0}"; BRIDGE_QEMU=1
        ENCRYPTION="a5 0"
        echo -e "  ${CYAN}[PoC QEMU] 1 operateur · no-process + qosmo-grgsm/run.sh · A5/1${NC}"
    else
        # ── Mode interactif ──────────────────────────────────────────────────
        # Deja repondu dans le menu WAN (« combien tournent ICI ») : on ne
        # repose pas la question, deux reponses divergentes donneraient une
        # table et un nombre de conteneurs qui ne se correspondent plus.
        if [ -n "${OPERATOR_COUNT_HINT:-}" ]; then
            n_operators="$OPERATOR_COUNT_HINT"
            echo -e "  ${CYAN}Conteneurs a lancer : ${n_operators} (choisis dans le menu WAN)${NC}"
        else
            # 3 par defaut : c'est la taille du banc (deux conteneurs et une
            # VM). Un defaut qui correspond au montage courant evite la moitie
            # des saisies, et surtout evite qu'un oubli produise une table plus
            # courte que la realite - un noeud absent de la table n'existe pour
            # personne, et son absence ne se voit qu'a l'appel qui n'aboutit pas.
            n_operators=$(wt_input "Operateurs" "Nombre d'operateurs (1-36) :" "3") || exit 1
        fi
        n_operators=${n_operators:-2}
        if ! [[ "$n_operators" =~ ^[0-9]+$ ]] || [ "$n_operators" -lt 1 ] || [ "$n_operators" -gt 36 ]; then
            wt_msg "Nombre invalide (1-36)."; exit 1
        fi

        # ── LE PLAN DE NOEUDS SE DECIDE AVANT LE PLMN ────────────────────────
        # Ce bloc etait 50 lignes plus bas, APRES le menu des operateurs. Or
        # c'est lui qui repond a "chaque conteneur est-il un noeud a part
        # entiere ?", donc lui qui fixe WAN_NODE_PER_OP - et le MCC vaut le
        # numero de NOEUD. Le menu calculait donc les PLMN alors que la reponse
        # n'etait pas encore connue : tout le monde recevait le MCC du noeud 1.
        # Constate sur le banc - "Operateur 2 : MCC=001 MNC=02" annonce juste au-
        # dessus de "STP PC 1.21.2", c'est-a-dire noeud 2. Deux plans dans le
        # meme ecran, le point code juste et le PLMN faux.
        # Les questions de WAN passent donc devant. Elles ne dependent que du
        # nombre d'operateurs, connu ci-dessus.
        # ── WAN ──────────────────────────────────────────────────────────────
        if [ "${WAN_MESH:-0}" = "1" ]; then
            wan_mesh_configure "$n_operators"
        elif wt_yesno "WAN Interop" "Activer WAN vers un serveur distant (2 serveurs, prefixe 66) ?"; then
            WAN_ENABLED="true"
            local auto_ip=""
            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) || true
            [ -z "$auto_ip" ] && auto_ip=$(hostname -I 2>/dev/null | awk '{print $1}') || true
            local wan_local wan_remote wan_nremote wan_pfx
            wan_local=$(wt_input "WAN" "IP publique locale :" "$auto_ip") || exit 1
            WAN_LOCAL_IP="${wan_local:-$auto_ip}"
            [ -z "$WAN_LOCAL_IP" ] && WAN_ENABLED="false"
            if [ "$WAN_ENABLED" = "true" ]; then
                wan_remote=$(wt_input "WAN" "IP publique distante :" "") || exit 1
                WAN_REMOTE_IP="$wan_remote"
                [ -z "$WAN_REMOTE_IP" ] && WAN_ENABLED="false"
            fi
            if [ "$WAN_ENABLED" = "true" ]; then
                wan_nremote=$(wt_input "WAN" "Nb operateurs distants :" "$n_operators") || exit 1
                WAN_N_REMOTE="${wan_nremote:-$n_operators}"
                wan_pfx=$(wt_input "WAN" "Prefixe WAN :" "$WAN_PREFIX") || exit 1
                WAN_PREFIX="${wan_pfx:-$WAN_PREFIX}"
                echo -e "  ${GREEN}WAN: ${WAN_LOCAL_IP} ↔ ${WAN_REMOTE_IP} (local=${n_operators} remote=${WAN_N_REMOTE} prefix=${WAN_PREFIX})${NC}"
            fi
        fi

        local use_defaults="N"
        wt_yesno "Valeurs par defaut" "Valeurs par defaut pour tous (MCC=001/002/... MNC=01/02/...) ?" && use_defaults="O"

        local same_ms_all="N"
        if [ "$use_defaults" != "O" ]; then
            wt_yesno "MS" "Meme nombre de MS pour tous les operateurs ?" && same_ms_all="O"
        fi

        declare -A OP_MCC OP_MNC OP_NAME OP_MS

        if [ "$use_defaults" = "O" ]; then
            local common_ms
            common_ms=$(wt_input "MS" "MS par operateur (1-64) :" "8") || exit 1
            common_ms=${common_ms:-8}
            if ! [[ "$common_ms" =~ ^[0-9]+$ ]] || [ "$common_ms" -lt 1 ] || [ "$common_ms" -gt 64 ]; then common_ms=8; fi
            for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
                # Un PLMN par operateur, sur les DEUX composantes. Le MCC
                # etait fige a 001 pour tout le monde : seul le MNC separait
                # les reseaux, et la moindre valeur ecrite en dur ailleurs
                # (les IMSI des MS l'etaient) retombait sur un PLMN plausible
                # mais etranger. 001/002/003 rend la confusion visible.
                OP_MCC[$i]=$(op_mcc "$i"); OP_MNC[$i]=$(op_mnc "$i")
                OP_NAME[$i]="$(op_default_name "$i")"; OP_MS[$i]=$common_ms
            done
        else
            if [ "$same_ms_all" = "O" ]; then
                local common_ms
                common_ms=$(wt_input "MS" "MS par operateur (1-64) :" "8") || exit 1
                common_ms=${common_ms:-8}
                if ! [[ "$common_ms" =~ ^[0-9]+$ ]] || [ "$common_ms" -lt 1 ] || [ "$common_ms" -gt 64 ]; then common_ms=8; fi
                for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
                    local mcc mnc name dmcc dmnc dname
                    dmcc=$(op_mcc "$i"); dmnc=$(op_mnc "$i"); dname="$(op_default_name "$i")"
                    mcc=$(wt_input "Operateur ${i}" "MCC (pays = noeud $(op_plmn_node "$i"), $(op_country "$i")) :" "$dmcc") || exit 1; OP_MCC[$i]=${mcc:-$dmcc}
                    mnc=$(wt_input "Operateur ${i}" "MNC :" "$dmnc") || exit 1; OP_MNC[$i]=${mnc:-$dmnc}
                    name=$(wt_input "Operateur ${i}" "Nom :" "$dname") || exit 1; OP_NAME[$i]=${name:-$dname}
                    OP_MS[$i]=$common_ms
                done
            else
                for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
                    local mcc mnc name n_ms dmcc dmnc dname
                    dmcc=$(op_mcc "$i"); dmnc=$(op_mnc "$i"); dname="$(op_default_name "$i")"
                    mcc=$(wt_input "Operateur ${i}" "MCC (pays = noeud $(op_plmn_node "$i"), $(op_country "$i")) :" "$dmcc") || exit 1; OP_MCC[$i]=${mcc:-$dmcc}
                    mnc=$(wt_input "Operateur ${i}" "MNC :" "$dmnc") || exit 1; OP_MNC[$i]=${mnc:-$dmnc}
                    name=$(wt_input "Operateur ${i}" "Nom :" "$dname") || exit 1; OP_NAME[$i]=${name:-$dname}
                    # 2 par defaut, pas 1 : c est le minimum pour une radio
                    # MIXTE - un faketrx et un QEMU Calypso, comme le natif.
                    # A 1, l operateur n avait qu un seul MS et le mode hybride
                    # n avait plus rien a repartir.
                    n_ms=$(wt_input "Operateur ${i}" "Nombre de MS (1-64) :" "2") || exit 1; OP_MS[$i]=${n_ms:-2}
                    if ! [[ "${OP_MS[$i]}" =~ ^[0-9]+$ ]] || [ "${OP_MS[$i]}" -lt 1 ] || [ "${OP_MS[$i]}" -gt 64 ]; then OP_MS[$i]=1; fi
                done
            fi
        fi


        # ── RAN / Encryption ─────────────────────────────────────────────────
        # BRIDGE_NO_PROCESS=0 : la radio et le mobile demarrent avec le coeur
        # (voir le commentaire "NO-ATTACH, PLUS NO-PROCESS" plus bas).
        PHY_MODE="faketrx"; BRIDGE_NO_PROCESS="${BRIDGE_NO_PROCESS:-0}"; BRIDGE_QEMU=1
        ENCRYPTION="${ENCRYPTION:-a5 0}"
        if [ -n "$HANDOFF_MODE_FROM_ENV" ]; then
            # [2026-08-31] L APPELANT A IMPOSE LE MODE : on ne lui repose pas la
            # question. Sans cette branche, seul --wan pouvait choisir l hybride
            # et tout autre lancement retombait sur choose_ran_host - un menu.
            # En non interactif ce menu s auto-repondait par sa PREMIERE entree,
            # donc "full-grgsm" : un HANDOFF_MODE=faketrx-qemu passe en
            # environnement etait lu (HANDOFF_MODE_FROM_ENV=1, l.137) puis
            # ignore ici. On ne pouvait pas obtenir la radio mixte hors WAN.
            echo -e "  ${GREEN}[RAN] mode impose par l appelant : ${CYAN}${HANDOFF_MODE}${NC}"
        elif [ "${WAN_MESH:-0}" = "1" ]; then
            # Un noeud de WAN est une maquette complete : il lui faut une radio
            # des DEUX types pour que l'appel inter-noeud soit vraiment porte de
            # bout en bout. D'ou l'hybride par defaut - un faketrx + un QEMU
            # Calypso - au lieu du menu. HANDOFF_MODE=... en prefixe le change.
            HANDOFF_MODE="faketrx-qemu"; HANDOFF_QEMU_CHOICE="faketrx-qemu"
            echo -e "  ${GREEN}[RAN] WAN → hybride par defaut : ${CYAN}1 faketrx + 1 QEMU Calypso${NC}"
        else
            choose_ran_host
        fi
    fi

    # ── Detection IP hote ────────────────────────────────────────────────────
    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
    if [ -z "$HOST_IP" ]; then
        HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}') || true
    fi
    HOST_IP="${HOST_IP:-127.0.0.1}"
    REAL_UID=$(id -u "${SUDO_USER:-$(logname 2>/dev/null || echo root)}")
    echo -e "${GREEN}IP hote : ${CYAN}${HOST_IP}${NC}  (Linphone)${NC}"
    echo ""

    # ── Ou joindre le hub SS7, vu d'ici ──────────────────────────────────────
    # Le hub est UNIQUE pour tout le WAN. Deux cas, et le mauvais donne un ASP
    # qui ne s'attache jamais :
    #   ce noeud heberge le hub  → les conteneurs le joignent en interne (172.20.0.10)
    #   un autre noeud l'heberge → ils sortent sur le WAN, vers SON adresse
    WAN_STP_TARGET="$INTER_STP_IP"
    HUB_IS_LOCAL=1
    if [ -n "${WAN_HUB_IP:-}" ]; then
        # Hub designe par son adresse : il n'est pas un noeud, et rien ne se
        # lance ici. C'est le cas d'une VM inter-STP dediee.
        WAN_STP_TARGET="$WAN_HUB_IP"; HUB_IS_LOCAL=0
    elif [ "${WAN_MESH:-0}" = "1" ] && [ "${WAN_NODE_ID}" != "${WAN_HUB_NODE}" ]; then
        WAN_STP_TARGET="${WAN_IP[$WAN_HUB_NODE]:-}"; HUB_IS_LOCAL=0
        if [ -z "$WAN_STP_TARGET" ]; then
            echo -e "${RED}[SS7] le noeud ${WAN_HUB_NODE} (hub) n'est pas dans la table WAN${NC}" >&2
            echo -e "${RED}      (ou designez-le par son adresse : --hub-ip ADRESSE)${NC}" >&2
            exit 1
        fi
    fi

    # ── Reseau backbone ──────────────────────────────────────────────────────
    # OSMO_DOCKER_ROUTED=1 : bridge en mode "routed" (docker >= 28). Docker
    # cesse alors de masquer ce qui sort du backbone, et surtout n'installe pas
    # dans sa table "ip raw" le DROP qui jette, en prerouting, tout paquet
    # entrant vers 172.20.0.x n'arrivant pas par le bridge. Avec ce DROP, un
    # conteneur joint depuis le LAN a sa VRAIE adresse ne recoit jamais de
    # reponse : son association SCTP reste en COOKIE_WAIT, sans un mot dans les
    # journaux. C'est le seul reglage qui leve les deux a la fois.
    # Optionnel et non fatal : docker < 28 ne connait pas l'option et refuse la
    # creation, on retombe alors en silence sur le reseau ordinaire (masque),
    # celui avec lequel le banc a toujours tourne.
    if docker network inspect "$INTER_NET" &>/dev/null; then
        echo -e "  ${CYAN}Reseau backbone ${INTER_NET} deja present${NC}"
    else
        if [ "${OSMO_DOCKER_ROUTED:-0}" = "1" ] \
           && docker network create --subnet="$INTER_NET_SUBNET" --gateway="$INTER_NET_GATEWAY" \
                -o com.docker.network.bridge.gateway_mode_ipv4=routed "$INTER_NET" &>/dev/null; then
            echo -e "  ${GREEN}✓ Reseau backbone ${INTER_NET} (${INTER_NET_SUBNET}) cree - mode routed${NC}"
        elif docker network create --subnet="$INTER_NET_SUBNET" --gateway="$INTER_NET_GATEWAY" "$INTER_NET" &>/dev/null; then
            [ "${OSMO_DOCKER_ROUTED:-0}" = "1" ] \
                && echo -e "  ${YELLOW}  (mode routed refuse par ce docker - reseau ordinaire)${NC}"
            echo -e "  ${GREEN}✓ Reseau backbone ${INTER_NET} (${INTER_NET_SUBNET}) cree${NC}"
        else
            echo -e "  ${RED}✗ Echec creation reseau backbone ${INTER_NET} (${INTER_NET_SUBNET})${NC}"; exit 1
        fi
    fi

    # ── SMS Routing ──────────────────────────────────────────────────────────
    SMS_ROUTING_DIR=$(mktemp -d)
    if declare -f sms_routing_generate_all > /dev/null 2>&1; then
        local _ms_counts=()
        for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do _ms_counts+=("${OP_MS[$i]}"); done
        sms_routing_generate_all "$n_operators" "$SMS_ROUTING_DIR" "${_ms_counts[@]}"
        sms_routing_summary "$n_operators" "${_ms_counts[@]}"
    fi

    # ── Inter-STP ────────────────────────────────────────────────────────────
    if [ "$HUB_IS_LOCAL" = "1" ]; then
        start_inter_stp "$n_operators"
        wait_inter_stp_ready "$n_operators"
    else
        echo -e "${CYAN}[SS7] hub porte par le noeud ${WAN_HUB_NODE} (${WAN_STP_TARGET}) - pas de hub ici.${NC}"
        echo -e "${CYAN}      Les ASP de ce noeud s'y attacheront en M3UA/SCTP 2908.${NC}"
    fi

    # ── HLR subscribers ──────────────────────────────────────────────────────
    local all_subscribers_file
    all_subscribers_file=$(mktemp)
    echo "# op_id:ms_idx:imsi:msisdn:ki" > "$all_subscribers_file"
    # ── L'annuaire : un etat civil pour chaque IMSI ──────────────────────────
    # Un banc ne rend que des numeros a 15 chiffres, et l'on finit par lire
    # "001010001000002" comme un mot de passe : impossible de dire de tete a
    # quel operateur, a quel pays, a quel telephone il appartient. Cette table
    # met un nom et une adresse en face de chaque abonne. Elle est FABRIQUEE -
    # personnages et rues n'existent pas - mais elle n'invente pas l'IMSI :
    # c'est exactement celui qui part au HLR trois lignes plus bas, dans la
    # meme boucle, a partir de la meme variable.
    # Les fiches sont tirees dans data/noms.txt et data/adresses.txt, melanges
    # au chargement. On les parcourt EN ORDRE a partir de la liste melangee
    # plutot que de retirer au sort a chaque abonne : deux abonnes du meme banc
    # ne peuvent alors pas porter le meme nom tant que le fichier n'est pas
    # epuise - et deux homonymes dans une trace, c'est une heure perdue.
    local ANNUAIRE_FILE="${OSMO_ANNUAIRE:-/var/tmp/osmo-annuaire.csv}"
    # ── L'annuaire du SS7 : le meme service, un etage plus bas ──────────────
    # L'annuaire d'abonnes met un nom sur un MSISDN. Celui-ci met un nom sur un
    # POINT CODE. On lit "1.21.2" dans une trace M3UA sans savoir de quelle
    # machine il s'agit, ni quel operateur elle porte, ni vers quel hub elle
    # s'attache - et les trois se deduisent d'un plan qui n'est ecrit nulle
    # part ailleurs que dans la tete de celui qui a lance le banc.
    # Il est produit par la MEME boucle que les configurations : ce n'est pas
    # une documentation a cote, c'est le releve de ce qui vient d'etre ecrit.
    local ANNUAIRE_SS7="${OSMO_ANNUAIRE_SS7:-/var/tmp/osmo-annuaire-ss7.csv}"
    printf 'noeud;operateur;pays;mcc;mnc;pc_msc;pc_stp;pc_bsc;rctx_msc;rctx_stp;rctx_bsc;rctx_inter;hub;as_inter;asp_inter;backbone;prive\n' \
        > "$ANNUAIRE_SS7"
    local _pick=0
    printf 'imsi;msisdn;operateur;pays;mcc;mnc;nom;adresse;ki\n' > "$ANNUAIRE_FILE"
    for op_id in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
        local mcc="${OP_MCC[$op_id]}" mnc="${OP_MNC[$op_id]}" n_ms="${OP_MS[$op_id]}"
        local _pays; _pays="$(op_country "$op_id")"
        for ms_idx in $(seq 1 "$n_ms"); do
            local msin; msin=$(printf '%04d%06d' "${op_id}" "${ms_idx}")
            local imsi="${mcc}${mnc}${msin}"
            # MSISDN a SIX chiffres : <noeud>00<operateur><rang>.
            # L'ancien plan, op * 10000 + rang, commencait a 10001 - cinq
            # chiffres dont le premier EST le numero d'operateur, ce qui melait
            # le plan d'abonnes au plan de composition (les services du dialplan
            # sont a 100, 200, 500, 600, 700) et obligeait chaque motif local a
            # dependre de l'operateur. Le prefixe 600 qui l'a remplace etait
            # commun a TOUTES les machines : deux noeuds y revendiquaient les
            # memes numeros. Le premier chiffre porte donc le noeud, et le
            # routage se lit dans le numero : 100101, 100102, 200101...
            local msisdn; msisdn=$(osmo_msisdn "$(osmo_node_id)" "$op_id" "$ms_idx")
            local ki; ki=$(printf '00112233445566778899aabbccdd%02x%02x' "${ms_idx}" "${op_id}")
            echo "${op_id}:${ms_idx}:${imsi}:${msisdn}:${ki}" >> "$all_subscribers_file"
            printf '%s;%s;%s;%s;%s;%s;%s;%s %s;%s\n' \
                "$imsi" "$msisdn" "${OP_NAME[$op_id]}" "$_pays" "$mcc" "$mnc" \
                "${POOL_NOMS[$(( _pick % ${#POOL_NOMS[@]} ))]}" \
                "$(( RANDOM % 97 + 1 ))" \
                "${POOL_ADR[$(( _pick % ${#POOL_ADR[@]} ))]}" \
                "$ki" >> "$ANNUAIRE_FILE"
            _pick=$(( _pick + 1 ))
        done
    done
    local total_subs; total_subs=$(( $(wc -l < "$all_subscribers_file") - 1 ))
    echo -e "${GREEN}Abonnes total : ${total_subs}${NC}"
    # Aperçu : les premieres fiches, puis le chemin du fichier complet. Une
    # table de 8 MS par operateur noierait le reste du demarrage.
    awk -F';' 'NR==1 {next}
               NR<=9 {printf "  %-16s %-6s %-16s %-12s %-22s %s\n", $1,$2,$3,$4,$7,$8}
               END {if (NR>9) printf "  ... et %d autres\n", NR-9}' "$ANNUAIRE_FILE"
    echo -e "  ${CYAN}Annuaire complet : ${ANNUAIRE_FILE}${NC}  (aussi /etc/osmocom/annuaire.csv dans chaque conteneur)"
    echo ""

    # ── Table rase sur la serie d'operateurs ────────────────────────────────
    # Le "docker rm -f" plus bas ne vise que le conteneur de MEME NOM. Un
    # reliquat d'un lancement precedent - trois operateurs hier, deux
    # aujourd'hui, ou une serie interrompue en cours de route - garde ses
    # publications de ports et fait echouer la creation du suivant :
    #     Bind for 0.0.0.0:5082 failed: port is already allocated
    # Le message nomme le port, jamais le conteneur qui le tient : on cherche
    # longtemps. Cette fonction RECREE toute la serie, on efface donc d'abord.
    local _stale
    _stale="$(docker ps -aq --filter "name=^osmo-operator-" 2>/dev/null)"
    if [ -n "$_stale" ]; then
        echo -e "  ${CYAN}Nettoyage : $(echo "$_stale" | wc -l) conteneur(s) operateur d'un lancement precedent${NC}"
        # shellcheck disable=SC2086
        docker rm -f $_stale >/dev/null 2>&1 || true
    fi

    # ── Demarrage sequentiel des operateurs ──────────────────────────────────
    for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
        local container_name net_name subnet gateway container_ip inter_local_ip rctx_inter
        local n_groups last_group_ip

        container_name=$(op_container "$i")
        net_name="gsm-net-op${i}"
        subnet=$(op_private_net "$i")
        gateway=$(op_private_gw "$i")
        container_ip=$(op_private_ip "$i")
        inter_local_ip=$(op_backbone_ip "$i")
        rctx_inter=$(op_rctx_inter "$i")
        n_groups=$(( (${OP_MS[$i]} + 7) / 8 ))
        last_group_ip="127.0.0.${n_groups}"

        echo -e "${CYAN}── Operateur ${i} : ${OP_NAME[$i]} ($(op_country "$i"), MCC=${OP_MCC[$i]} MNC=${OP_MNC[$i]}) ──${NC}"
        echo -e "  Backbone   : ${CYAN}${inter_local_ip}${NC}  Prive : ${CYAN}${container_ip}${NC}"
        local _node_i="$WAN_NODE_ID" _op_i="$i"
        if [ "${WAN_NODE_PER_OP:-0}" = "1" ]; then
            _node_i=$(( WAN_NODE_ID + i - 1 ))
            _op_i=1
            # Le numero de noeud vit dans 1..9 : au-dela, le point code ecrit
            # ici (1.<noeud><op>.<role>) sort du format ITU 3-8-3, aucun AS
            # correspondant n'existe au hub (helpers/create_interop.sh borne a
            # 9 noeuds), et network/set-node-id.sh comme start-direct.sh
            # refusent l'argument. Mieux vaut le dire ici, avant de fabriquer
            # une identite SS7 que personne ne connaitra.
            if [ "$_node_i" -gt 9 ] || [ "$_node_i" -lt 1 ]; then
                echo -e "${RED}[WAN] --node-per-op : l'operateur ${i} tomberait sur le noeud ${_node_i}${NC}" >&2
                echo -e "${RED}      Les numeros de noeud vont de 1 a 9. Avec --wan-id ${WAN_NODE_ID},"' '"${NC}" >&2
                echo -e "${RED}      le maximum est $(( 10 - WAN_NODE_ID )) operateur(s) : baissez --wan-id,"' '"${NC}" >&2
                echo -e "${RED}      reduisez --operators, ou retirez --node-per-op.${NC}" >&2
                exit 1
            fi
        fi
        local _pc_mid="$i" _rctx_inter=""
        if [ "${WAN_MESH:-0}" = "1" ]; then
            _pc_mid="${_node_i}${_op_i}"
            _rctx_inter=$(( _node_i * 1000 + _op_i * 100 + 50 ))
        fi

        # L'identite AFFICHEE est celle qui sera ECRITE. Cette ligne montrait
        # "1.<op>.2" en dur et le routing context du plan local : elle annoncait
        # donc 1.1.2/150 alors que la config recevait 1.21.2/2150. On cherche
        # longtemps une panne quand le journal dit le contraire du fichier.
        echo -e "  STP PC     : 1.${_pc_mid}.2  RCTX : ${_rctx_inter:-$rctx_inter}  MS : ${CYAN}${OP_MS[$i]}${NC}"

        # Le releve SS7 de CET operateur, avec les valeurs qui partent dans sa
        # configuration deux lignes plus bas - pas une reconstitution.
        printf '%s;%s;%s;%s;%s;1.%s.1;1.%s.2;1.%s.3;%s;%s;%s;%s;%s;as-n%sop%s;asp-n%sop%s;%s;%s\n' \
            "$_node_i" "${OP_NAME[$i]}" "$(op_country "$i")" \
            "${OP_MCC[$i]}" "${OP_MNC[$i]}" \
            "$_pc_mid" "$_pc_mid" "$_pc_mid" \
            "$(op_rctx_msc "$i")" "$(op_rctx_stp "$i")" "$(op_rctx_bsc "$i")" \
            "${_rctx_inter:-$rctx_inter}" "${WAN_STP_TARGET:-127.0.0.1}" \
            "$_node_i" "$_op_i" "$_node_i" "$_op_i" \
            "$inter_local_ip" "$container_ip" >> "$ANNUAIRE_SS7"

        if docker network inspect "$net_name" &>/dev/null; then
            echo -e "  Reseau     : ${CYAN}${net_name}${NC} (deja present)"
        elif docker network create --subnet="$subnet" --gateway="$gateway" "$net_name" &>/dev/null; then
            echo -e "  Reseau     : ${GREEN}✓ ${net_name} (${subnet}) cree${NC}"
        else
            echo -e "  ${RED}✗ Echec creation reseau ${net_name} (${subnet})${NC}"; exit 1
        fi

        # ── La regle de non-masquage, AVANT que la pile ne se connecte ───────
        # Une decision de NAT se prend sur le PREMIER paquet d'une association :
        # conntrack la conserve ensuite pour toute sa duree. Poser la regle en
        # fin de lancement arrivait donc trop tard - l'ASP s'etait deja attache,
        # masque, et le hub le voyait sous l'adresse de l'hote jusqu'a sa
        # prochaine reconnexion. Deux operateurs y devenaient indiscernables.
        #
        # Ici, elle est reaffirmee apres CHAQUE reseau cree (docker reinsere ses
        # MASQUERADE en tete a chacun) et donc avant tout demarrage de pile.
        reassert_docker_lan_return >/dev/null

        local tmpdir
        tmpdir=$(mktemp -d)
        # Plan de point codes. Hors WAN : 1.<op>.<role>, comme toujours. En WAN :
        # le numero de noeud entre dedans, sans quoi deux machines presentent au
        # hub des adresses SS7 identiques - routage faux, et silencieux.
        # Seul RCTX_INTER est surcharge : c'est le seul routing context que le
        # hub voit. Ceux du MSC et du BSC restent internes au conteneur.
        #
        # UN NOEUD PAR CONTENEUR (--node-per-op)
        # Par defaut, la machine EST le noeud et ses conteneurs en sont les
        # operateurs : 1.<noeud><op>.<role>. C'est juste quand une machine
        # heberge un operateur complet.
        #
        # Mais des que les conteneurs sont joignables chacun a SON adresse
        # (172.20.0.11, .12... routees depuis le LAN), ce ne sont plus des
        # operateurs d'un meme site : ce sont des noeuds a part entiere, au meme
        # titre qu'une VM. Les traiter en operateurs obligeait alors a inscrire
        # la MACHINE dans la table WAN - une entree qui ne correspond a aucun
        # equipement, et qui recevait pourtant un indicatif et des routes.
        #
        # Avec --node-per-op, le conteneur i devient le noeud (base + i - 1),
        # porte l'operateur 1 de ce noeud, et son point code suit : 1.<n>1.<role>.
        RCTX_INTER_OVERRIDE="$_rctx_inter" \
        apply_config_templates "$tmpdir" \
            "$container_ip" "$gateway" \
            "$i" "1.${_pc_mid}.1" "1.${_pc_mid}.2" "1.${_pc_mid}.3" \
            "${OP_MCC[$i]}" "${OP_MNC[$i]}" "${OP_NAME[$i]}" \
            "$WAN_STP_TARGET" "no shutdown" \
            "$n_operators"

        # L'annuaire voyage avec la pile : c'est dans le conteneur qu'on lit
        # un IMSI dans un journal, pas sur l'hote.
        [ -f "$ANNUAIRE_FILE" ] && cp "$ANNUAIRE_FILE" "$tmpdir/osmocom/annuaire.csv"
        [ -f "$ANNUAIRE_SS7" ]   && cp "$ANNUAIRE_SS7"   "$tmpdir/osmocom/annuaire-ss7.csv"

        # ── Les fiches du dialplan ─────────────────────────────────────────
        # Asterisk ne sait pas lire un CSV. On lui ecrit donc les memes fiches
        # sous la forme qu'il comprend : une extension par MSISDN dans le
        # contexte sub-annuaire (voir configs/extensions.conf). L'appel arrive
        # alors sur le combine avec un nom, et non avec cinq chiffres.
        {
            printf '; annuaire.conf - genere par start.sh, ne pas editer a la main.\n'
            printf '; Une fiche par abonne du banc. Le repli _X. est dans extensions.conf.\n'
            printf '[sub-annuaire]\n'
            awk -F';' 'NR>1 && $2 != "" {
                gsub(/[^A-Za-z0-9 ._-]/, "", $7); gsub(/[^A-Za-z0-9 ._-]/, "", $3)
                gsub(/[^A-Za-z0-9 ._-]/, "", $4); gsub(/[^A-Za-z0-9 .,_-]/, "", $8)
                printf "exten => %s,1,Set(CALLERID(name)=%s)\n", $2, $7
                printf " same => n,Set(ANNU_OP=%s)\n", $3
                printf " same => n,Set(ANNU_PAYS=%s)\n", $4
                printf " same => n,Set(ANNU_ADR=%s)\n", $8
                printf " same => n,Set(ANNU_IMSI=%s)\n", $1
                printf " same => n,Set(CDR(userfield)=%s / %s / %s)\n", $7, $3, $4
                printf " same => n,NoOp(=== ANNUAIRE %s = %s, %s (%s) ===)\n", $2, $7, $3, $4
                printf " same => n,Return()\n"
            }' "$ANNUAIRE_FILE"
        } > "$tmpdir/asterisk/annuaire.conf" 2>/dev/null || true

        local vol_args alsa_args
        vol_args=$(build_vol_args "$tmpdir")
        alsa_args=$(build_alsa_args)

        # [2026-08-12] Le chown est OBLIGATOIRE, pas cosmetique. Ce repertoire est
        # monte sur /var/log/osmocom et les demons tournent en osmocom (999:1000
        # dans l'image). Cree en root:root, osmo-hlr ne peut pas creer son fichier
        # de log → "% Unable to create file '/var/log/osmocom/osmo-hlr.log'" →
        # echec du parse de sa config → boucle de restart systemd → osmo-start.sh
        # `exit 1` ("HLR indispensable") → run.sh meurt (set -e) et n'atteint
        # aucun de ses etages suivants. Un simple probleme de droits fait donc
        # tomber tout le pipeline, audio compris.
        mkdir -p /tmp/osmocom-logs/op${i}
        chown 999:1000 /tmp/osmocom-logs/op${i} 2>/dev/null || true

        # ── Ports a exposer ──────────────────────────────────────────────────
        local port_args=""

        # ── SIP/RTP : PLUS AUCUNE PUBLICATION SUR L HOTE ─────────────────
        # [2026-08-31] On publiait le 5060 et la plage RTP de chaque conteneur
        # sur l hote. C etait la SEULE source du conflit de ports : l operateur
        # natif tient 5060/udp (Asterisk) et 5061 (osmo-sip-connector) en
        # permanence, puisqu il tourne EN MEME TEMPS que les conteneurs.
        #
        # On a d abord decale les publications (+100). Ca marchait, mais ca
        # reposait sur une sonde "5060 est-il pris ?" prise a un instant t -
        # donc sur une course : lancee juste apres l arret du banc, la sonde
        # voyait le port libre, ne decalait rien, et le conteneur mourait sur
        #     failed to bind host port 0.0.0.0:5061/udp: address already in use
        # en restant en etat "Created", des que le natif remontait.
        #
        # La publication etait de toute facon INUTILE : les conteneurs sont sur
        # un bridge routable depuis l hote. On les joint en direct, a leur
        # propre adresse, sur le port nominal. Supprimer la publication
        # supprime le conflit, l offset ET la course, d un coup.
        local _cip; _cip="$(op_backbone_ip "$i")"
        echo -e "  Linphone   : ${CYAN}${_cip}:5060${NC}  RTP 30000-30199 (direct, sans publication)"

        # WAN en plus si active (legacy ou mesh)
        if wan_active; then
            local sip_port rtp_start rtp_end sms_port
            sip_port=$(wan_sip_port "$i")
            rtp_start=$(wan_rtp_start "$i")
            rtp_end=$(wan_rtp_end "$i")
            port_args="${port_args} -p ${sip_port}:5060/udp -p ${sip_port}:5060/tcp -p ${rtp_start}-${rtp_end}:${rtp_start}-${rtp_end}/udp"
            echo -e "  WAN        : SIP ${sip_port} RTP ${rtp_start}-${rtp_end}"
            if [ "${WAN_MESH:-0}" = "1" ]; then
                # Un port SMS PAR operateur : le relais distant doit joindre le
                # container du BON operateur, or son injection MT passe par le
                # HLR local a son container. Un port unique livrerait tous les
                # SMS au premier operateur, qui ne connait pas les abonnes des
                # autres - "MSISDN not found in HLR", sans autre trace.
                sms_port=$(wan_sms_port "$i")
                port_args="${port_args} -p ${sms_port}:7890/tcp"
                echo -e "  WAN SMS    : ${sms_port} → relay 7890"
            fi
        fi

        docker rm -f "$container_name" 2>/dev/null || true
        ensure_host_audio_relay

        # --restart, PAS --rm : au reboot, les conteneurs operateurs ne se
        # retrouvaient pas arretes mais DISPARUS - avec leur base HLR alimentee
        # et tout ce qui n'est pas monte depuis l'hote. Le banc etait a remonter
        # entierement apres une simple coupure. Le "docker rm -f" juste au-dessus
        # rend --rm inutile, et docker refuse --rm avec --restart au parsing.
        #
        # OSMO_ROLE=operator : le conteneur regenere ses configs quand run.sh
        # rejoue apply_config_templates, et le rattrapage d'identite SS7 de
        # generate_configs.sh s'arrete net sur un role vide - sans rien
        # reappliquer et sans rien dire. Le conteneur repartait alors sur le
        # point code du gabarit, 1.1.2 pour TOUS, donc deux equipements a la
        # meme adresse SS7 et aucun ASP attache.
        # shellcheck disable=SC2086
        docker run -d \
            --restart unless-stopped \
            --name "$container_name" \
            --hostname "$container_name" \
            --network "$INTER_NET" \
            --ip "$inter_local_ip" \
            --cap-add NET_ADMIN \
            --cap-add SYS_ADMIN \
            --cap-add NET_RAW \
            --ulimit rtprio=18 \
            --shm-size=8g \
            --cgroupns host \
            --device /dev/net/tun:/dev/net/tun \
            $alsa_args \
            $port_args \
            -v /sys/fs/cgroup:/sys/fs/cgroup:rw \
            -v /tmp/osmocom-logs/op${i}:/var/log/osmocom \
            --tmpfs /tmp:exec,rw,mode=1777,size=2g \
            --tmpfs /run:exec,size=64m \
            --tmpfs /run/lock \
            -e OPERATOR_ID="$i" \
            -e OSMO_ROLE=operator \
            -e N_MS="${OP_MS[$i]}" \
            -e CONTAINER_IP="$container_ip" \
            -e GATEWAY_IP="$gateway" \
            -e INTER_STP_IP="${WAN_STP_TARGET:-$INTER_STP_IP}" \
            -e OSMO_HUB_IP="${WAN_STP_TARGET:-$INTER_STP_IP}" \
            -e WAN_NODE_ID="${_node_i}" \
            -e OSMO_WAN_NODE="${_node_i}" \
            -e HOST_IP="${HOST_IP}" \
            -e SIP_HOST_PORT="5060" \
            -e PHY_MODE="${PHY_MODE}" \
            -e HOST_AUDIO_RELAY="${HOST_AUDIO_RELAY}" \
            $vol_args \
            "$IMAGE_RUN" \
            sleep infinity

        docker network connect --ip "$container_ip" "$net_name" "$container_name"

        force_update_trees "$container_name"

        # ── LE CONTENEUR S'ARRETE AU COEUR ──────────────────────────────────
        # [2026-08-31] NO-ATTACH, PLUS NO-PROCESS.
        # no-process arretait run.sh au coeur : STP, HLR, MSC, BSC, MGW, GGSN,
        # SGSN, PCU montaient, puis plus rien - ni PHY, ni mobile, ni Asterisk,
        # ni SMSC. Il fallait lancer start-direct.sh A LA MAIN derriere, et en
        # attendant le conteneur affichait
        #     [no-process] mobile non lance - attente 4247 sautee
        # c est-a-dire un operateur sans radio ni abonne joignable, alors que
        # tout paraissait "pret".
        #
        # La pile complete demarre donc maintenant. Ce n est PAS un retour a
        # l attachement bloquant : run.sh ne s attache jamais de lui-meme - il
        # lance tout dans tmux et se contente d imprimer la commande d attache
        # (scripts/run.sh, "Attach : tmux -S ... attach"). Le pipeline QEMU
        # delegue, lui, prend CALYPSO_NO_ATTACH=1 (qosmo-grgsm/run.sh l.36).
        #
        # BRIDGE_NO_PROCESS=1 ./start.sh retablit l ancien comportement quand on
        # veut vraiment le coeur seul.
        local run_cmd="RUN_NO_PROCESS=${BRIDGE_NO_PROCESS:-0} CALYPSO_NO_ATTACH=1 /etc/osmocom/run.sh"
        echo -e "  ${GREEN}[*] Lancement run.sh...${NC}"
        # DANS UN TMUX, pas en simple processus detache.
        #
        # Le terminal courant est reserve a osmo-operator-1 (handoff QEMU plus
        # bas) : c'est celui qu'on regarde. Les autres tournaient en
        # "docker exec -d", c'est-a-dire sans session a laquelle se rattacher -
        # on ne pouvait plus que LIRE leur journal, jamais reprendre la main sur
        # la pile qui tourne.
        #
        # Une session nommee "osmo" rend chaque operateur joignable a la
        # demande, sans rien afficher tant qu'on ne le demande pas :
        #     docker exec -ti osmo-operator-2 tmux attach -t osmo
        # (ou par tools/vty-menu.sh, qui liste les conteneurs)
        #
        # tee : le journal reste ecrit meme quand personne n'est attache, sinon
        # un incident survenu hors session serait perdu. Repli sans tmux pour
        # une image qui n'en aurait pas - le comportement d'avant, a l'identique.
        # UN SEUL chemin de demarrage par conteneur.
        # En mode QEMU, chaque conteneur est demarre par start-direct.sh - le
        # premier dans le terminal courant, les autres en session tmux. Lancer
        # run.sh EN PLUS donnait deux piles concurrentes dans le meme
        # conteneur : la seconde commencait par "--stop" et coupait ce que la
        # premiere venait de monter. L'ASP de l'operateur 2 s'attachait, puis
        # tout s'arretait - "Client connected" suivi d'un silence.
        # run.sh EST conservé : c'est lui qui monte le coeur, et c'est de son
        # HLR que depend l'etape suivante - l'attente du port 4258 puis le
        # provisionnement des abonnes. L'en priver figeait le lancement sur
        # "Attente HLR" pour un demarrage prevu plus tard.
        #
        # Les conteneurs qui doivent porter la pile COMPLETE la relancent
        # ensuite via start-direct.sh, une fois les abonnes en base : la base
        # HLR survit au redemarrage, le travail n'est pas perdu.
        docker exec -d "$container_name" bash -c "mkdir -p /var/log/osmocom && { ${run_cmd}; } > /var/log/osmocom/run.sh.log 2>&1"

        # Attente HLR
        echo -ne "  ${GREEN}[*] Attente HLR (4258)${NC}"
        local retry=0
        while ! docker exec "$container_name" bash -c "echo >/dev/tcp/127.0.0.1/4258" 2>/dev/null; do
            sleep 2; echo -n "."; retry=$((retry + 1))
            if [ $retry -ge 45 ]; then echo -e " ${RED}TIMEOUT${NC}"; break; fi
        done
        echo -e " ${GREEN}✓${NC}"

        # ── CHAQUE HLR NE PORTE QUE SES PROPRES ABONNES ─────────────────────
        # On y versait TOUS les abonnes du banc, tous operateurs confondus. Cela
        # simulait un roaming qui n'existe pas : un abonne de l'operateur 1
        # inscrit dans le HLR de l'operateur 2 y est joignable sans qu'aucun
        # lien MAP n'ait ete etabli. Et surtout, la liste devenait illisible -
        # six abonnes chez un operateur qui n'en a que deux, dont on ne savait
        # plus lesquels etaient les siens.
        # Un vrai roaming passe par le SS7, pas par une copie de la base.
        local _mine; _mine="$(awk -F: -v op="$i" '$1 == op' "$all_subscribers_file")"
        local _n_mine; _n_mine="$(printf '%s\n' "$_mine" | grep -c . || true)"
        echo -e "  ${GREEN}[*] Alimentation HLR Op${i} (${_n_mine} abonnes)...${NC}"

        # ── ET ON EFFACE CE QUI N'EST PLUS AU PLAN ──────────────────────────
        # hlr.db survit aux relances. Un changement de plan - MCC par noeud,
        # MSISDN a six chiffres - laisse donc derriere lui des abonnes d'un plan
        # mort : IMSI que plus aucun mobile ne presente, et surtout MSISDN qu'ils
        # RETIENNENT. osmo-hlr impose l'unicite du numero, si bien que le bon
        # abonne ne peut plus recuperer le sien - l'erreur est alors
        # "cannot update MSISDN", qui ne nomme pas celui qui le tient.
        local _garder; _garder="$(printf '%s\n' "$_mine" | cut -d: -f3 | paste -sd'|' -)"
        {
            echo "#!/bin/bash"
            echo "cat > /tmp/hlr_feed.vty << 'VTYCMDS'"
            echo "enable"
            if [ -n "$_garder" ]; then
                echo "__PURGE__${_garder}"
            fi
            while IFS=: read -r feed_op feed_ms feed_imsi feed_msisdn feed_ki; do
                [[ "$feed_op" =~ ^#.*$ ]] && continue
                echo "subscriber imsi ${feed_imsi} create"
                echo "subscriber imsi ${feed_imsi} update msisdn ${feed_msisdn}"
                echo "subscriber imsi ${feed_imsi} update aud2g comp128v1 ki ${feed_ki}"
            done <<< "$_mine"
            echo "end"
            echo "VTYCMDS"
        } | docker exec -i "$container_name" bash

        # ON NE COMPTE PAS LES REFUS. Le compte n'a pas de valeur ici : osmo-hlr
        # prefixe d'un '%' TOUTES ses reponses, succes compris, et un
        # "Subscriber already exists" est la reponse NORMALE d'une relance -
        # le banc est reprovisionne a chaque demarrage. Toute variante de ce
        # comptage finissait par signaler un probleme la ou il n'y en a pas,
        # et un controle qui crie au loup ne sert plus a rien le jour ou il a
        # raison.
        #
        # La seule preuve qu'un abonne est servi, c'est sa fiche : on RELIT la
        # base. Un abonne sans cle est pire qu'un abonne absent - osmo-msc le
        # trouve, reclame ses vecteurs d'authentification, n'obtient rien, et
        # rejette le mobile avec un motif qui accuse la carte SIM.
        local _sans_cle
        # La purge : on LISTE avec sqlite (le VTY n'a pas de "show all" filtrable)
        # et on SUPPRIME par le VTY - osmo-hlr tient un cache en memoire, un
        # DELETE direct dans la base ne l'en previendrait pas.
        docker exec "$container_name" bash -c '
            garder="$(sed -n "s/^__PURGE__//p" /tmp/hlr_feed.vty)"
            sed -i "/^__PURGE__/d" /tmp/hlr_feed.vty
            if [ -n "$garder" ] && command -v sqlite3 >/dev/null 2>&1; then
                sqlite3 /var/lib/osmocom/hlr.db "select imsi from subscriber;" 2>/dev/null \
                  | grep -vE "^($garder)$" \
                  | sed "s|^|subscriber imsi |; s|$| delete|" >> /tmp/hlr_purge.vty
            fi
            if [ -s /tmp/hlr_purge.vty ]; then
                { echo enable; cat /tmp/hlr_purge.vty; } > /tmp/hlr_purge_full.vty
                if command -v nc >/dev/null 2>&1; then
                    (sleep 1; cat /tmp/hlr_purge_full.vty; sleep 2) | nc -q2 127.0.0.1 4258 2>/dev/null
                else
                    (sleep 1; cat /tmp/hlr_purge_full.vty; sleep 3) | telnet 127.0.0.1 4258 2>/dev/null
                fi
                echo "  purge : $(grep -c . /tmp/hlr_purge.vty) abonne(s) hors plan supprime(s)" >&2
            fi
            rm -f /tmp/hlr_purge.vty /tmp/hlr_purge_full.vty
            if command -v nc >/dev/null 2>&1; then
                (sleep 1; cat /tmp/hlr_feed.vty; sleep 2) | nc -q2 127.0.0.1 4258 2>/dev/null
            else
                (sleep 1; cat /tmp/hlr_feed.vty; sleep 3) | telnet 127.0.0.1 4258 2>/dev/null
            fi
            rm -f /tmp/hlr_feed.vty
        ' >/dev/null 2>&1 || true

        _sans_cle="$(docker exec "$container_name" bash -c '
            command -v sqlite3 >/dev/null 2>&1 || exit 0
            sqlite3 /var/lib/osmocom/hlr.db "
                select count(*) from subscriber s
                left join auc_2g a on a.subscriber_id = s.id
                where a.subscriber_id is null;" 2>/dev/null' 2>/dev/null)" || true

        if [ "${_sans_cle:-0}" -gt 0 ]; then
            echo -e "  ${RED}✗ HLR Op${i} : ${_sans_cle} abonne(s) SANS CLE - ils ne pourront pas s'authentifier${NC}"
            echo -e "    ${CYAN}docker exec ${container_name} sqlite3 /var/lib/osmocom/hlr.db \"select s.imsi from subscriber s left join auc_2g a on a.subscriber_id=s.id where a.subscriber_id is null;\"${NC}"
        else
            echo -e "  ${GREEN}✓ HLR Op${i} alimente (${_n_mine} abonnes, tous avec cle)${NC}"
        fi

        # Attente dernier groupe - sautee en no-process
        if [ "${BRIDGE_NO_PROCESS:-0}" = "1" ]; then
            echo -e "  ${YELLOW}[no-process] mobile non lance - attente 4247 sautee${NC}"
        else
            echo -ne "  ${GREEN}[*] Attente groupe (${last_group_ip}:4247)${NC}"
            retry=0
            while ! docker exec "$container_name" bash -c "echo >/dev/tcp/${last_group_ip}/4247" 2>/dev/null; do
                sleep 2; echo -n "."; retry=$((retry + 1))
                if [ $retry -ge 90 ]; then echo -e " ${RED}TIMEOUT${NC}"; break; fi
            done
            echo -e " ${GREEN}OK${NC}"
            sleep 3
        fi
        echo -e "  ${GREEN}✓${NC} ${container_name} pret"
        echo ""
    done

    rm -f "$all_subscribers_file"

    # ── Attente relays SMS ────────────────────────────────────────────────────
    if [ "${BRIDGE_NO_PROCESS:-0}" != "1" ] && declare -f sms_routing_wait_ready > /dev/null 2>&1; then
        for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
            sms_routing_wait_ready "$(op_container "$i")" 90 || true
        done
    fi

    # ── WAN ────────────────────────────────────────────────────────────────────
    if [ "${WAN_MESH:-0}" = "1" ]; then
        wan_mesh_apply "$n_operators"
    elif [ "$WAN_ENABLED" = "true" ]; then
        setup_wan_interop "$n_operators" "$WAN_N_REMOTE"
    fi

    # ── Resume ─────────────────────────────────────────────────────────────────
    echo ""
    echo -e "${GREEN}${BOLD}Stack multi-operateurs demarree !${NC}"
    echo ""
    echo -e "  Inter-STP @ ${CYAN}${INTER_STP_IP}:2908${NC}  PC=0.0.0"
    for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
        local rctx bb_ip n_groups
        rctx=$(op_rctx_inter "$i"); bb_ip=$(op_backbone_ip "$i")
        n_groups=$(( (${OP_MS[$i]} + 7) / 8 ))
        echo -e "  Op${i} ${OP_NAME[$i]}  STP 1.${i}.2 @ ${bb_ip}  RCTX ${rctx}  [${OP_MS[$i]} MS]"
    done

    echo ""
    echo -e "  ${BOLD}Linphone (depuis l'hote) :${NC}"
    for i in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
        local bb_ip; bb_ip=$(op_backbone_ip "$i")
        echo -e "    Op${i}: ${CYAN}${bb_ip}:5060${NC}"
    done
    echo -e "    linphone_A / tester → 100  |  linphone_B / testerB → 200"

    if [ "${WAN_MESH:-0}" = "1" ]; then
        echo ""
        echo -e "  ${BOLD}WAN mesh :${NC} noeud ${CYAN}${WAN_NODE_ID}${NC}/${WAN_NODE_COUNT} - indicatif ${CYAN}$(wan_local_ind)${NC}"
        for _r in "${WAN_NODE_LIST[@]}"; do
            [ "$_r" = "$WAN_NODE_ID" ] && continue
            echo -e "    ${CYAN}${WAN_IND[$_r]}${NC}10001 → MS 10001 op1 du noeud ${_r} (${WAN_IP[$_r]})"
        done
    elif [ "$WAN_ENABLED" = "true" ]; then
        echo ""
        echo -e "  ${BOLD}WAN :${NC} ${CYAN}${WAN_LOCAL_IP}${NC} (${n_operators} ops) ↔ ${CYAN}${WAN_REMOTE_IP}${NC} (${WAN_N_REMOTE} ops)  prefix=${WAN_PREFIX}"
    fi
    echo ""

    # ── LES CHECKS, JUSTE APRES LE LANCEMENT ────────────────────────────────
    # [2026-08-31] Ils tournent ICI depuis que la pile complete demarre
    # (no-attach au lieu de no-process). Avant, il n y avait rien a mesurer : le
    # coeur seul tournait, ni PHY ni mobile ni Asterisk, et "Stack demarree !"
    # etait tout ce qu on obtenait - un banc annonce pret dont personne
    # n avait verifie qu il portait un appel.
    #
    #   ss7_check.sh        ASP, AS, routes, SSN - la couche qui porte les appels.
    #   operator_summary.sh ce que chaque operateur a REELLEMENT : abonnes
    #                       HLR/VLR, BTS, contextes PDP. --live lui fait generer
    #                       son propre dump ; sans ce drapeau il analyserait
    #                       celui d un lab precedent et decrirait un autre banc.
    #
    # Chemins RELATIFS, comme ./helpers/prepare_host.sh plus haut : ce script
    # suppose deja d etre lance depuis la racine du depot.
    # Ni l un ni l autre ne doit faire echouer le lancement - la pile est
    # debout, on RAPPORTE son etat, d ou le || true.
    # OSMO_SKIP_CHECKS=1 les saute (start-multi.sh le pose : il enchaine ses
    # propres verifications juste apres, inutile de jouer ss7_check deux fois).
    if [ "${OSMO_SKIP_CHECKS:-0}" != "1" ]; then
        if [ -r ./checks/ss7_check.sh ]; then
            echo -e "${GREEN}${BOLD}── Check SS7 ──${NC}"
            bash ./checks/ss7_check.sh || true
            echo ""
        else
            echo -e "  ${YELLOW}checks/ss7_check.sh introuvable - check SS7 saute${NC}"
        fi
        if [ -r ./checks/operator_summary.sh ]; then
            echo -e "${GREEN}${BOLD}── Check operateurs ──${NC}"
            bash ./checks/operator_summary.sh --live || true
            echo ""
        else
            echo -e "  ${YELLOW}checks/operator_summary.sh introuvable - resume saute${NC}"
        fi
    fi

    # --virtualbox : le "set" ne se limite pas a cette machine. Les VM sont
    # creees avant (setup-vbox-interco.sh) ; on les demarre ici, une fois que le
    # hub et les operateurs locaux sont debout - un noeud qui monte avant le hub
    # voit sa SCTP refusee.
    if [ "${VBOX_INTERCO:-0}" = "1" ]; then
        local _lab="$(dirname "$0")/tools/vbox-wan-lab.sh"
        if [ -x "$_lab" ] || [ -r "$_lab" ]; then
            echo ""
            echo -e "${GREEN}[*] Demarrage des VM du WAN...${NC}"
            bash "$_lab" start || echo -e "  ${YELLOW}⚠ demarrage des VM incomplet${NC}"
        fi
    fi

    # ICI, et pas plus tot : docker reinsere ses MASQUERADE en tete de
    # POSTROUTING a CHAQUE reseau cree, et il y en a un par operateur. Rejouee
    # depuis l'application du WAN, la regle se retrouvait derriere eux - donc
    # inerte, alors qu'"iptables -L" la montrait toujours. A cet endroit, tous
    # les reseaux existent : plus rien ne passera devant.
    reassert_docker_lan_return

    # L'adresse par laquelle les VM doivent router vers les conteneurs. Elle
    # change avec l'interface active - un cable debranche, et c'est le wifi qui
    # repond, sous une autre adresse. L'afficher ici evite d'aller la chercher
    # apres coup, quand plus rien ne s'attache et que rien n'a "bouge".
    local _hostip
    _hostip="${HOST_LAN_IP:-$(ip route show default 2>/dev/null | awk '/^default/{print $9; exit}')}"
    if [ -n "$_hostip" ] && [ "${WAN_MESH:-0}" = "1" ]; then
        echo -e "\n  ${BOLD}Cote VM${NC}, pour joindre les conteneurs :"
        echo -e "    ${CYAN}bash network/set_ip_vm.sh --via ${_hostip} --persist${NC}"
    fi

    echo -e "\n${GREEN}${BOLD}Stack prete !${NC}"

    # ── Mode QEMU : handoff vers start-direct.sh ──────────────────────────────
    if [ "${BRIDGE_QEMU:-0}" = "1" ]; then
        local _qemu_container; _qemu_container=$(op_container 1)
        echo -e "  ${CYAN}[*] run.sh calypso → ${_qemu_container} (terminal courant)${NC}"
        echo -e "  ${CYAN}[*] mode=${HANDOFF_MODE} pipeline=${HANDOFF_QEMU_CHOICE} chiffrement='${ENCRYPTION}' (choisis sur l'hote, NO_MENU)${NC}"

        # Le start-direct lance DANS le conteneur doit connaitre son noeud : il
        # reverifie (et corrige) l'identite SS7 avant de demarrer la pile, au
        # lieu de dependre de ce que start.sh a ecrit dans les configs.
        # Ces arguments sont fabriques par la fonction _node_args() plus bas,
        # une par conteneur. La variable `_node_args` qui vivait ici n'etait
        # LUE nulle part : une seconde version, figee sur "--op 1", du meme
        # calcul. Supprimee pour qu'il n'en reste qu'une.

        local _wan_env=""
        if [ "${WAN_MESH:-0}" = "1" ]; then
            _wan_env="WAN_NODES='$(wan_nodes_spec)' WAN_NODE_ID='${WAN_NODE_ID}' WAN_OPS='${n_operators}'"
            echo -e "  ${CYAN}[*] WAN mesh transmis au conteneur (noeud ${WAN_NODE_ID}, indicatif $(wan_local_ind))${NC}"
        elif [ "$WAN_ENABLED" = "true" ]; then
            _wan_env="WAN_REMOTE_IP='${WAN_REMOTE_IP}' WAN_PREFIX='${WAN_PREFIX}' WAN_N_REMOTE='${WAN_N_REMOTE:-$n_operators}'"
            echo -e "  ${CYAN}[*] SMS inter-WAN → ${WAN_REMOTE_IP} (prefixe ${WAN_PREFIX})${NC}"
        fi

        # ── LANCEMENT DE LA PILE RADIO ──────────────────────────────────────
        # Les conteneurs se sont arretes au COEUR (no-process) : STP, HLR, MSC,
        # BSC, MGW, GGSN, SGSN, PCU sont debout, mais ni PHY, ni mobile, ni
        # Asterisk, ni SMSC. start.sh enchaine maintenant start-direct.sh sur
        # chaque conteneur pour monter la pile radio - la sortie [ OK ] defile
        # dans le terminal courant, on voit ce qui se passe.
        #
        # LE COMPORTEMENT DEPEND DU NOMBRE DE CONTENEURS (voir plus bas) :
        #   - un seul   : on lance PUIS on s'attache au tmux "calypso" ;
        #   - plusieurs : on lance chacun en NO-ATTACH, les [ OK ] defilent.
        #
        # Les commandes executees ne sont pas indicatives : ce sont EXACTEMENT
        # celles que start.sh imprimait auparavant a recopier a la main - meme
        # identite de noeud, meme hub, meme table WAN.

        # Les arguments de noeud d'un conteneur : sa place dans le plan WAN.
        _node_args() {                # $1=indice du conteneur
            # Le rang d'operateur n'est 1 que si CHAQUE conteneur est un noeud.
            # Sinon les conteneurs partagent le noeud et se distinguent par
            # leur rang : figer "--op 1" faisait reecrire par set-node-id.sh
            # les trois configs du conteneur 2 avec l'identite du conteneur 1
            # (meme point code, meme routing context) - le hub, en override,
            # donnait alors tout le trafic au dernier attache. Meme convention
            # que le plan ecrit plus haut (_node_i / _op_i).
            local _n _op
            if [ "${WAN_NODE_PER_OP:-0}" = "1" ]; then
                _n=$(( ${WAN_NODE_ID:-1} + $1 - 1 )); _op=1
            else
                _n="${WAN_NODE_ID:-1}"; _op="$1"
            fi
            # Repli sur le noeud 1, TOUJOURS. Deux trous se referment ici :
            #   - sans WAN (`./start.sh` sans argument), la fonction n'imprimait
            #     RIEN : start-direct.sh partait sans identite et montait la
            #     pile sur les point codes du gabarit (1.1.2), les memes pour
            #     tout le monde ;
            #   - avec WAN mais WAN_NODE_ID vide, elle imprimait `--node --op 1`
            #     — bash recolle les mots, start-direct.sh lisait "--op" comme
            #     numero de noeud et sortait sur
            #     « [ KO ] --node (un chiffre de 1 a 9) ».
            # Un noeud unique EST le noeud 1 : c'est deja ce que start.sh se
            # donne lui-meme (WAN_NODE_ID="${WAN_NODE_ID:-1}" plus haut), on
            # transmet donc la meme reponse au conteneur au lieu de rien.
            [[ "$_n"  =~ ^[1-9]$ ]] || _n=1
            [[ "$_op" =~ ^[1-9]$ ]] || _op=1
            printf -- '--node %s --op %s' "$_n" "$_op"
            # Le hub n'a de sens qu'en mesh : hors mesh il n'y a personne a
            # joindre, et un `--hub-ip` vide decalerait les arguments suivants.
            [ "${WAN_MESH:-0}" = "1" ] && [ -n "${WAN_STP_TARGET:-}" ] && \
                printf -- ' --hub-ip %s' "$WAN_STP_TARGET"
            return 0
        }

        echo ""

        # La commande start-direct.sh d'un conteneur : identite de noeud + hub
        # + table WAN. Rien d'indicatif ici, ce sont EXACTEMENT les arguments
        # que start.sh executait quand il imprimait les lignes a recopier.
        _hcmd() {                      # $1=indice du conteneur -> imprime la commande
            local _na; _na="$(_node_args "$1")"
            local _cmd="cd /opt/GSM/osmo-operator && NO_MENU=1"
            # [2026-08-31] CALYPSO_NO_ATTACH SUIT LE HANDOFF.
            # L attache ne venait ni d ici ni de start-direct.sh, mais du
            # qosmo-grgsm/run.sh qu il appelle en bout de chaine :
            #     NO_ATTACH="${CALYPSO_NO_ATTACH:-0}"      (run.sh l.36)
            #     [ $TTY -eq 1 ] && [ "$NO_ATTACH" != 1 ] && exec tmux attach
            # Le defaut est donc D ATTACHER, et `docker exec -t` fournit
            # justement un TTY. Resultat : meme avec deux conteneurs - donc dans
            # la branche NO-ATTACH de ce script - la fenetre finissait dans le
            # tmux du conteneur, start-multi.sh ne reprenait jamais la main, et
            # ni les checks ni le bilan ne s executaient.
            # La variable posee plus haut sur /etc/osmocom/run.sh ne suffisait
            # pas : elle ne traversait pas ce handoff-ci.
            [ "${OSMO_NO_ATTACH:-0}" = "1" ] && _cmd="$_cmd CALYPSO_NO_ATTACH=1"
            _cmd="$_cmd MODE='${HANDOFF_MODE}' QEMU_CHOICE='${HANDOFF_QEMU_CHOICE}'"
            _cmd="$_cmd ENCRYPTION='a5 1' CALYPSO_BRIDGE=pont CALYPSO_MODE=shunt_legit"
            [ -n "$_wan_env" ] && _cmd="$_cmd ${_wan_env}"
            _cmd="$_cmd ./start-direct.sh ${_na} --force"
            printf '%s' "$_cmd"
        }

        local _c _ct _cmd
        if [ "$n_operators" -eq 1 ] && [ "${OSMO_NO_ATTACH:-0}" != "1" ]; then
            # ── UN SEUL CONTENEUR : ON LANCE PUIS ON S'ATTACHE ──────────────
            # [2026-08-31] ...SAUF si l appelant a demande le contraire.
            # A un seul conteneur, ce bloc s attachait TOUJOURS a la session
            # tmux du conteneur et n en ressortait plus. C est le bon geste
            # quand on tape la commande soi-meme ; c est le mauvais quand le
            # lancement vient d une icone ou d un script : le terminal reste
            # bloque dans le tmux, le script appelant n a jamais la main, et
            # rien de ce qui devait suivre - les checks, le bilan - ne
            # s execute. Constate depuis start-multi.sh, dont la fenetre
            # finissait sur les panneaux de osmo-operator-1.
            # OSMO_NO_ATTACH=1 saute l attache ; la pile reste debout dans sa
            # session calypso, on s y rattache a la main quand on veut.
            # start.sh lance lui-meme la pile radio, dans le terminal courant :
            # les [ OK ] de start-direct.sh puis de run.sh defilent sous les
            # yeux, et une fois le coeur monte on S'ATTACHE a la session tmux
            # "calypso" du conteneur - on reste dedans, on regarde la pile
            # vivre. Ctrl-b puis d pour se detacher sans rien arreter.
            _ct="$(op_container 1)"; _cmd="$(_hcmd 1)"
            echo -e "  ${BOLD}Lancement de la pile radio sur ${_ct} (terminal courant)...${NC}"
            echo ""
            docker exec -ti "$_ct" bash -c "$_cmd"
            echo ""
            echo -e "  ${BOLD}Attache a la session tmux ${CYAN}calypso${NC}${BOLD} de ${_ct}${NC}  ${CYAN}(Ctrl-b puis d pour se detacher)${NC}"
            echo ""
            docker exec -ti "$_ct" tmux attach -t calypso
        else
            # ── PLUSIEURS CONTENEURS : NO-ATTACH, ET DETACHE ────────────────
            # On ne peut pas s'attacher a N sessions tmux dans un seul terminal.
            #
            # [2026-08-31] `docker exec -t` REMPLACE PAR `-d`. Le `-t` allouait
            # un pty et le rendait au retour du script ; Docker le fermait, et
            # le noyau envoyait SIGHUP a tout le groupe de premier plan - donc a
            # TOUS les demons que run_modules lance en `cmd &` (un shell non
            # interactif n'a pas de controle de tache : ils restent dans le
            # groupe du script, dont ce pty est le terminal de controle). QEMU
            # sortait sur "terminating on signal 1", fake_trx et tcpdump avec
            # lui, et les deux `mobile` sur "Layer2 socket failed" - une
            # quinzaine de secondes apres un demarrage annonce reussi, sans que
            # rien ne relie la panne au lanceur. Les modules posent desormais
            # `setsid` (voir run_modules/_lib/radio.sh, bloc SIGHUP) ; `-d`
            # supprime la cause en amont : pas de pty, donc pas de hangup.
            #
            # CE QU'ON PERD : les [ OK ] ne defilent plus ici. Ils sont donc
            # rediriges, DANS le conteneur, vers /var/log/osmocom/start-direct.log
            # - chemin deja monte sur l'hote (/tmp/osmocom-logs/opN), rien n'est
            # perdu. Meme convention que le lancement de run.sh plus haut.
            # CE QU'ON GARDE : le sequencement. `wait_bb_vty` ne rend la main
            # qu'une fois la pile du conteneur en ligne, donc un conteneur a la
            # fois, et "Toutes les piles tournent" reste vrai a l'impression.
            echo -e "  ${BOLD}${n_operators} conteneurs : lancement detache - journal ${CYAN}/var/log/osmocom/start-direct.log${NC}${BOLD} dans chaque conteneur.${NC}"
            echo ""
            for _c in $(seq "${OP_ID_BASE:-1}" "$(( ${OP_ID_BASE:-1} + n_operators - 1 ))"); do
                _ct="$(op_container "$_c")"; _cmd="$(_hcmd "$_c")"
                echo -e "  ${GREEN}────── ${_ct} ──────────────────────────────────${NC}"
                docker exec -d "$_ct" bash -c "mkdir -p /var/log/osmocom && { ${_cmd}; } > /var/log/osmocom/start-direct.log 2>&1"
                wait_bb_vty "$_ct" || echo -e "    ${RED}pile non prete - voir /var/log/osmocom/start-direct.log${NC}"
                echo ""
            done
            echo -e "  ${BOLD}Toutes les piles tournent.${NC} Pour en suivre une :"
            echo -e "    ${CYAN}docker exec -ti <conteneur> tmux attach -t calypso${NC}"
            echo -e "  ${CYAN}Ctrl-b puis d${NC} pour se detacher sans rien arreter."
            echo ""
        fi
    fi
}

# ══════════════════════════════════════════════════════════════════════════════
# Mode net-host
# ══════════════════════════════════════════════════════════════════════════════
start_host_mode() {
    echo -e "${GREEN}Demarrage en mode net-host (1 operateur)...${NC}"
    local src_ip gw_ip
    gw_ip=$(ip route get 1 | awk '{print $3; exit}')
    src_ip=$(ip route get 1 | awk '{print $7; exit}')
    [ -z "$src_ip" ] && { echo -e "${RED}Impossible de detecter l'IP hote.${NC}"; exit 1; }
    HOST_IP="$src_ip"

    local n_ms
    n_ms=$(wt_input "net-host" "Nombre de MS :" "1") || exit 1
    n_ms=${n_ms:-1}

    prepare_host_tun
    docker rm -f egprs &>/dev/null || true

    SMS_ROUTING_DIR=$(mktemp -d)
    if declare -f sms_routing_generate > /dev/null 2>&1; then
        sms_routing_generate 1 1 "$SMS_ROUTING_DIR" "$n_ms"
    fi

    local tmpdir; tmpdir=$(mktemp -d)
    apply_config_templates "$tmpdir" \
        "$src_ip" "$gw_ip" \
        "1" "1.1.1" "1.1.2" "1.1.3" \
        "001" "01" "OsmoGSM" \
        "127.0.0.1" "shutdown" "1"

    local vol_args alsa_args
    vol_args=$(build_vol_args "$tmpdir")
    alsa_args=$(build_alsa_args)

    ensure_host_audio_relay
    local nethost_relay=""
    [ -n "$HOST_AUDIO_RELAY" ] && nethost_relay="tcp:127.0.0.1:${WSLG_TCP_PORT}"

    # --rm CONSERVE ici, contrairement aux conteneurs operateurs. Celui-la a
    # pour commande run.sh et non "sleep infinity" : il s'arrete des que run.sh
    # rend la main, et --restart le relancerait alors en boucle - sur le reseau
    # de l'HOTE, en concurrence avec le gnome-terminal qui ouvre sa propre
    # session juste apres. Il depend en outre de prepare_host_tun (apn0), qui
    # n'existe plus apres un reboot : un redemarrage automatique repartirait
    # dans un environnement incomplet. Ce mode se relance donc a la main.
    # shellcheck disable=SC2086
    docker run -d --rm --name egprs --net host \
        --cap-add NET_ADMIN --cap-add SYS_ADMIN --cap-add NET_RAW \
        --shm-size=8g \
        --cgroupns host \
        --device /dev/net/tun:/dev/net/tun \
        $alsa_args \
        -v /sys/fs/cgroup:/sys/fs/cgroup:rw \
        --tmpfs /run --tmpfs /run/lock --tmpfs /tmp:exec,rw,mode=1777,size=2g \
        -e CONTAINER_IP="$src_ip" -e GATEWAY_IP="$gw_ip" \
        -e OPERATOR_ID="1" -e N_MS="$n_ms" \
        -e INTER_STP_IP="127.0.0.1" \
        -e HOST_IP="${HOST_IP}" -e SIP_HOST_PORT="5060" \
        -e ALSA_OUTPUT="${ALSA_OUTPUT}" -e ALSA_INPUT="${ALSA_INPUT}" \
        -e HOST_AUDIO_RELAY="${nethost_relay}" \
        $vol_args \
        "$IMAGE_RUN" /root/run.sh

    wait_bb_vty "egprs"
    sleep 3

    local _du="${SUDO_USER:-$(logname 2>/dev/null || echo "$USER")}"
    local _duid; _duid=$(id -u "$_du" 2>/dev/null)
    local _disp="${DISPLAY:-:0}"
    local _xauth="${XAUTHORITY:-/home/$_du/.Xauthority}"
    local _hscript="/tmp/osmo-gnome-host.sh"
    cat > "$_hscript" <<'EOF'
#!/usr/bin/env bash
printf '\033]0;net-host - egprs\007'
echo "=== net-host - egprs run.sh ==="
exec sudo docker exec -ti egprs /bin/bash -c "/root/run.sh; exec bash"
EOF
    chmod +x "$_hscript"
    echo -e "  ${CYAN}[*] run.sh → gnome-terminal${NC}"
    sudo -u "$_du" \
        DISPLAY="$_disp" XAUTHORITY="$_xauth" \
        XDG_RUNTIME_DIR="/run/user/${_duid}" \
        DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${_duid}/bus" \
        gnome-terminal -- bash "$_hscript" &
    sleep 0.3
}

# ══════════════════════════════════════════════════════════════════════════════
stop_all() {
    echo -e "${YELLOW}Arret de tous les containers Osmocom...${NC}"
    docker ps -a --filter "name=osmo-" --format "{{.Names}}" | xargs -r docker rm -f 2>/dev/null || true
    docker ps -a --filter "name=egprs"  --format "{{.Names}}" | xargs -r docker rm -f 2>/dev/null || true
    for _chain in OSMO_WAN_INTEROP OSMO_WAN_MESH; do
        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
    done
    echo -e "${GREEN}Arrete.${NC}"
    disable_user_loopback
}

# ══════════════════════════════════════════════════════════════════════════════
# Menus
# ══════════════════════════════════════════════════════════════════════════════
choose_build_mode() {
    [ "${QUICK_EXPLICIT:-0}" = "1" ] && return 0
    local choice
    choice=$(wt_menu "Build de l'image" "Comment (re)construire l'image run ?" \
        "quick"  "Quick - reutilise le cache docker (rapide)" \
        "normal" "Normal - docker build --no-cache (image a neuf)") \
        || { echo "Annule."; exit 1; }
    case "$choice" in
        quick)  QUICK=1 ;;
        normal) QUICK=0 ;;
    esac
}

choose_ran_host() {
    local r
    r=$(wt_menu "RAN - Pipeline QEMU Calypso" \
        "Pipeline radio (conteneur de l'operateur) :" \
        "full-grgsm"   "gr-gsm = le DSP (defaut, valide)" \
        "start-clean"  "Pipeline historique (start-clean.sh)" \
        "full"         "full radio + vrai c54x (WIP)" \
        "shunt"        "DSP shunt canned (bissection FBSB)" \
        "bare"         "QEMU + osmocon only" \
        "faketrx-qemu" "COMBINE : fake_trx vivant + Calypso QEMU" \
        "free"         "menu complet run.sh (--menu)") || exit 1

    if [ "$r" = "faketrx-qemu" ]; then
        HANDOFF_MODE="faketrx-qemu"; HANDOFF_QEMU_CHOICE="faketrx-qemu"
    else
        HANDOFF_MODE="qemu";         HANDOFF_QEMU_CHOICE="$r"
    fi

    local e
    e=$(wt_menu "Chiffrement A5" "Chiffrement :" \
        "1" "A5/1 - chiffrement legacy (defaut)" \
        "0" "A5/0 - pas de chiffrement" \
        "2" "A5/2 - (casse, usage test)") || exit 1
    case "$e" in 0) ENCRYPTION="a5 0" ;; 2) ENCRYPTION="a5 2" ;; *) ENCRYPTION="a5 0" ;; esac
    echo -e "  ${GREEN}[RAN] mode=${CYAN}${HANDOFF_MODE}${NC}${GREEN} pipeline=${CYAN}${HANDOFF_QEMU_CHOICE}${NC}${GREEN} chiffrement=${CYAN}${ENCRYPTION}${NC}"
}

choose_network_mode() {
    local choice
    choice=$(wt_menu "Que lancer ?" "Premier choix :" \
        "qemu"    "PoC QEMU Calypso - qosmo-grgsm/run.sh (defaut)" \
        "virtual" "Reseau VIRTUEL multi-operateurs SS7 (config niveau 2)" \
        "hw"      "SDR physique (net-host) - hw (in dev)") || { echo "Annule."; exit 1; }
    case "$choice" in
        qemu)    NETWORK_MODE="qemu" ;;
        virtual) NETWORK_MODE="bridge" ;;
        hw)      NETWORK_MODE="host" ;;
        *) echo -e "${RED}Choix invalide.${NC}"; exit 1 ;;
    esac
}

# ══════════════════════════════════════════════════════════════════════════════
# Main
# ══════════════════════════════════════════════════════════════════════════════
# ── Options longues, extraites AVANT le mode positionnel ──────────────────────
# start.sh a toujours pris son mode en $1 (qemu|virtual|hw|stop) et son build en
# quick|normal. On garde ca intact : la pre-passe ne retire que les --wan* et
# rend la main avec les positionnels d'origine.
usage_wan() {
    cat <<'USAGE'
Usage : sudo ./start.sh [quick|normal] [--wan ...] [qemu|virtual|hw|stop]

  --wan                   active le WAN a N noeuds (1 a 9) et pose les questions :
                            • nombre de noeuds
                            • IP publique de chaque noeud
                            • indicatif de chaque noeud (prefixe d'appel)
                            • numero du noeud construit par CE lancement
  --wan-nodes "1:IP:IND ..."  meme chose sans question (scriptable)
  --wan-id N              numero du noeud local (sinon deduit des IP locales)
  --wan-conf FICHIER      table a lire/ecrire (defaut /etc/osmo-wan.conf)

  --virtualbox[=N]        monte le WAN entre CETTE machine et des VM VirtualBox
                          (implique --wan). Cette machine devient un noeud, les
                          autres sont des VM sur un segment host-only.
  --vbox-node N           numero de noeud porte par cette machine (defaut 1)
  --hub-node N            noeud qui heberge l'inter-STP, le hub SS7 (defaut 1).
  --hub-ip ADRESSE        inter-STP a une adresse a lui, hors table WAN. Le hub
                          n'est alors PAS un noeud : aucun indicatif, aucun
                          abonne, et rien n'est lance localement.
  --node-per-op           chaque conteneur est un NOEUD (base = --wan-id) et non
                          un operateur de la machine : conteneur 1 -> noeud N,
                          conteneur 2 -> noeud N+1... Point codes 1.<n>1.<role>.
  --host ADRESSE          adresse de CETTE machine vue des VM (defaut : deduite
                          de la route par defaut). Recours quand la machine a
                          plusieurs cartes ou vient de changer d'adresse : c'est
                          par elle que les VM routent vers les conteneurs.
  --operator IP:PREFIXE   declare un operateur du WAN par son adresse et son
                          prefixe d'appel. Repetable ; l'ordre donne les numeros
                          de noeud. Il DECLARE seulement : aucun conteneur n'est
                          lance pour lui - c'est ainsi qu'une VM deja demarree
                          entre dans la table SMS et le dialplan. Les conteneurs
                          restent gouvernes par le nombre d'operateurs demande.
                            --operator 192.168.1.2:11   (la VM, deja lancee)
                            --operator 172.20.0.11:22   (un conteneur d'ici)
                          UN SEUL noeud du WAN doit le porter.
  --build-stp             construit l'image legere osmocom-stp (Dockerfile.stp)
                          pour le hub. Sans elle, le hub tourne sur l'image
                          complete, qui contient aussi osmo-stp.

  Sans --wan, rien ne change : aucun WAN n'est monte.
  Avec --wan, la radio passe en HYBRIDE par defaut (1 faketrx + 1 QEMU Calypso).

  Composer <indicatif><numero> joint ce numero sur le noeud correspondant.
    ex. noeud 1 = indicatif 11, noeud 2 = 22 → depuis 2, "1110001" appelle
    le MS 10001 de l'operateur 1 du noeud 1.
USAGE
}

_pos_args=()
while [ $# -gt 0 ]; do
    case "$1" in
        --wan)          WAN_MESH=1 ;;
        --wan=*)        WAN_MESH=1; WAN_NODES="${1#*=}" ;;
        --wan-nodes)    WAN_MESH=1; WAN_NODES="${2:-}"; shift ;;
        --wan-nodes=*)  WAN_MESH=1; WAN_NODES="${1#*=}" ;;
        --wan-id)       WAN_MESH=1; WAN_NODE_ID="${2:-}"; shift ;;
        --wan-id=*)     WAN_MESH=1; WAN_NODE_ID="${1#*=}" ;;
        --wan-conf)     WAN_CONF_FILE="${2:-}"; shift ;;
        --wan-conf=*)   WAN_CONF_FILE="${1#*=}" ;;
        --build-stp)      BUILD_STP=1 ;;
        --hub-node)       WAN_HUB_NODE="${2:-1}"; WAN_HUB_NODE_GIVEN=1; shift ;;
        # --hub-ip et --node-per-op IMPLIQUENT le plan WAN. Sans WAN_MESH=1,
        # _pc_mid retombe sur le plan LOCAL (_pc_mid="$i") : les point codes
        # deviennent 1.1.2 / 1.2.2 et les routing contexts 150 / 250, calcules
        # a l'identique sur CHAQUE machine du WAN. Deux noeuds attaches au meme
        # hub presentent alors le meme PC et le meme contexte ; le hub etant en
        # traffic-mode override, le dernier ASP attache vole le trafic du
        # premier, en silence. C'est exactement ce que le plan WAN, indexe par
        # numero de noeud, existe pour eviter.
        --hub-ip)         WAN_HUB_IP="${2:-}"; WAN_MESH=1; shift ;;
        --node-per-op)    WAN_NODE_PER_OP=1; WAN_NODE_PER_OP_GIVEN=1; WAN_MESH=1 ;;
        --host)           HOST_LAN_IP="${2:-}"; shift ;;
        --host=*)         HOST_LAN_IP="${1#*=}" ;;
        --operators)      OPERATOR_COUNT_HINT="${2:-}"; shift ;;
        --operators=*)    OPERATOR_COUNT_HINT="${1#*=}" ;;
        --operator)       OPERATOR_DECLS+=("${2:-}"); WAN_MESH=1; shift ;;
        --operator=*)     OPERATOR_DECLS+=("${1#*=}"); WAN_MESH=1 ;;
        --hub-ip=*)       WAN_HUB_IP="${1#*=}"; WAN_MESH=1 ;;
        --hub-node=*)     WAN_HUB_NODE="${1#*=}" ;;
        --virtualbox)     WAN_MESH=1; VBOX_INTERCO=1 ;;
        --virtualbox=*)   WAN_MESH=1; VBOX_INTERCO=1; VBOX_NODES="${1#*=}" ;;
        --vbox-node)      VBOX_HOST_NODE="${2:-1}"; shift ;;
        --vbox-node=*)    VBOX_HOST_NODE="${1#*=}" ;;
        -h|--help)      usage_wan; exit 0 ;;
        *)              _pos_args+=("$1") ;;
    esac
    shift
done

# ── --operator : de la declaration a la table WAN ───────────────────────────
# "IP:PREFIXE" est ce qu'on a sous les yeux ; "id:ip:ind" est ce que le reste du
# code attend. La conversion se fait ici, une fois, plutot que de demander a
# l'utilisateur de tenir lui-meme la numerotation des noeuds.
if [ "${#OPERATOR_DECLS[@]}" -gt 0 ]; then
    if [ -n "${WAN_NODES:-}" ]; then
        echo -e "\033[0;31m--operator et --wan-nodes sont exclusifs : choisissez l'un des deux.\033[0m" >&2
        exit 2
    fi
    # LES CONTENEURS D'ABORD, dans l'ordre : osmo-operator-1 est le noeud 1,
    # -2 le noeud 2... Leur adresse est celle du backbone, leur indicatif le
    # defaut du rang. Les operateurs declares en CLI prennent les rangs
    # suivants. Numerotation lisible : le nom du conteneur donne son noeud.
    _n=0; _spec=""
    _ncont="${OPERATOR_COUNT_HINT:-0}"
    while [ "$_n" -lt "$_ncont" ]; do
        _n=$(( _n + 1 ))
        _spec="${_spec}${_spec:+ }${_n}:172.20.0.$(( 10 + _n )):$(( _n * 11 ))"
    done
    for _d in "${OPERATOR_DECLS[@]}"; do
        _ip="${_d%%:*}"; _ind="${_d##*:}"
        if [ -z "$_ip" ] || [ "$_ip" = "$_d" ] || [ -z "$_ind" ]; then
            echo -e "\033[0;31m--operator : attendu IP:PREFIXE (ex: 192.168.1.2:11), recu '$_d'\033[0m" >&2
            exit 2
        fi
        _n=$(( _n + 1 ))
        _spec="${_spec}${_spec:+ }${_n}:${_ip}:${_ind}"
    done
    WAN_NODES="$_spec"
    echo -e "\033[0;36m  Operateurs declares :\033[0m $WAN_NODES"
fi

set -- ${_pos_args[@]+"${_pos_args[@]}"}

banner
[ "${1:-}" = "stop" ] && { stop_all; exit 0; }
[ "$(id -u)" -ne 0 ] && { echo -e "${RED}Root requis${NC}"; exit 1; }

case "${1:-}" in
    quick)  QUICK=1; QUICK_EXPLICIT=1; shift ;;
    normal) QUICK=0; QUICK_EXPLICIT=1; shift ;;
esac

docker rm -f $(docker ps -aq --filter "name=osmo-") 2>/dev/null || true
docker rm -f $(docker ps -aq --filter "name=egprs") 2>/dev/null || true
docker network ls --filter "name=gsm-" -q | xargs -r docker network rm 2>/dev/null || true

# Selection du mode
case "${1:-}" in
    qemu)    NETWORK_MODE="qemu" ;;
    virtual) NETWORK_MODE="bridge" ;;
    hw)      NETWORK_MODE="host" ;;
    "")
        if [ "${WAN_MESH:-0}" = "1" ]; then
            # --wan sans mode : on ne redemande pas "que lancer ?". Un WAN se
            # monte sur des operateurs, donc mode virtuel, et le menu de build
            # reste pose (quick/normal) s'il n'a pas ete tranche en ligne.
            NETWORK_MODE="bridge"
            [ "${QUICK_EXPLICIT:-0}" = "1" ] || { check_whiptail; choose_build_mode; }
        elif [ "${OSMO_MULTI:-0}" = "1" ]; then
            NETWORK_MODE="bridge"
        elif [ "${OSMO_MENU:-1}" = "0" ]; then
            NETWORK_MODE="qemu"
        else
            check_whiptail
            choose_build_mode
            choose_network_mode
        fi
        ;;
    *) echo -e "${RED}Mode inconnu : '$1' (attendu : qemu|virtual|hw|stop)${NC}"; exit 1 ;;
esac

[ "$NETWORK_MODE" = "bridge" ] && check_whiptail
echo -e "${GREEN}Mode : ${CYAN}${NETWORK_MODE}${NC}  ${GREEN}Build : ${CYAN}$([ "${QUICK:-0}" = "1" ] && echo "quick (cache)" || echo "normal (--no-cache)")${NC}"

./helpers/prepare_host.sh
build_run_image
check_image

case "$NETWORK_MODE" in
    qemu)   start_qemu_poc ;;
    host)   start_host_mode ;;
    bridge) start_bridge_mode ;;
esac

tools/daram_sweep.sh

.sh 103 lignes - 5,4KB

tools/daram_sweep.sh
#!/bin/bash
# tools/daram_sweep.sh - balaye CALYPSO_BSP_DARAM_ADDR en full mode et compare
# l'activite du correlateur FB-det (fb0_att / snr / toa / pm).
# But : trancher empiriquement quelle zone DARAM debloque le FB-det.
#
# Robuste : run.sh est lance NO_ATTACH EN AVANT-PLAN (il rend la main seul
# apres avoir lance qemu, qui survit orphelin). On NE backgroude PAS run.sh
# avec & (ca cassait le pipe du log → qemu mourait). On verifie que qemu est
# vivant avant de mesurer ; sinon on le signale.
#
# Usage (conteneur) : /opt/GSM/daram_sweep.sh
# Env : ADDRS (defaut "0x0080 0x2a00 0x0060"), SETTLE (attente post-boot, 50s)
set -u
RUN=/opt/GSM/qosmo-grgsm/run.sh
QLOG=/root/qemu.log
ADDRS="${ADDRS:-0x0080 0x2a00 0x0060}"
SETTLE="${SETTLE:-50}"
# Profil d'emulation du balayage. "full" a disparu de environment/modes.env :
# les profils sont empty | shunt_legit | shunt_legit_no_inject | native |
# native_twl | native_twl_host_demod | native_helped.
MODE="${MODE:-${CALYPSO_MODE:-native}}"
declare -A R_FB R_SB R_NZ R_BEST R_ALIVE R_STATES R_TOT

kill_all() {
    tmux kill-session -t calypso 2>/dev/null
    for p in $(pgrep -f qemu-system-arm); do kill -9 "$p" 2>/dev/null; done
    pkill -9 -f osmo-bts-trx 2>/dev/null; pkill -9 -f osmo-trx-ipc 2>/dev/null
    pkill -9 -f "mobile" 2>/dev/null; pkill -9 -f osmocon 2>/dev/null
    sleep 4
}

for a in $ADDRS; do
    echo "############ DARAM_ADDR=$a (profil $MODE) ############ $(date +%H:%M:%S)"
    kill_all
    if pgrep -f qemu-system-arm >/dev/null; then
        echo "  !! un qemu survit a kill_all : la supervision le relance." >&2
        echo "     Arretez la pile (CALYPSO_NO_RESTART=1 ou ./run.sh --stop) avant le balayage." >&2
        exit 3
    fi
    : > "$QLOG"
    # run.sh NO_ATTACH rend la main seul apres le launch (~30-60s). timeout = filet.
    echo "  launch run.sh (NO_ATTACH, avant-plan, le boot prend ~30-60s)..."
    timeout 150 env CALYPSO_NO_ATTACH=1 CALYPSO_MODE="$MODE" CALYPSO_ICOUNT=off \
      CALYPSO_NO_RESTART=1 CALYPSO_BSP_DARAM_ADDR="$a" "$RUN" \
      </dev/null >"/tmp/sweep_$a.out" 2>&1
    rc_run=$?
    # Le profil a-t-il seulement ete applique ? Sans ce controle, un mode
    # inconnu faisait avorter run.sh a son deuxieme module, l'ancien qemu
    # (relance par la supervision) continuait d'ecrire dans le MEME journal,
    # et le balayage mesurait cette instance-la : les trois adresses rendaient
    # alors la meme "premiere detection", au caractere pres.
    if grep -qa "CALYPSO_MODE inconnu\|sequence aborted" "/tmp/sweep_$a.out"; then
        echo "  !! run.sh a avorte : profil '''$MODE''' refuse par environment/modes.env" >&2
        sed -n '''/valeurs :/,+2p''' "/tmp/sweep_$a.out" | sed "s/^/     /" >&2
        echo "     Relancez avec MODE=<profil valide> $0" >&2
        exit 2
    fi
    echo "  run.sh rendu (code=$?). qemu doit tourner orphelin. settle ${SETTLE}s..."
    alive=no
    for i in $(seq 1 $((SETTLE/5))); do
        sleep 5
        if pgrep -f qemu-system-arm >/dev/null; then alive=yes; else alive=no; fi
        # stop tot si on a deja du FB data non trivial
        grep -qa "real DSP path" "$QLOG" 2>/dev/null && [ "$i" -ge 4 ] && break
    done

    fb=$(grep -a "real DSP path" "$QLOG" 2>/dev/null | grep -oE "fb0_att=[0-9]+" | grep -oE "[0-9]+" | sort -n | tail -1)
    sb=$(grep -a "real DSP path" "$QLOG" 2>/dev/null | grep -oE "sb_att=[0-9]+"  | grep -oE "[0-9]+" | sort -n | tail -1)
    # Le format des lignes FBSB a change : le correlateur s'ecrit desormais
    #   data[](det=0 toa=0 pm=0 ang=0 snr=0x0000) api[](...)
    # et non plus "last(snr=... toa=... ang=... pm=...)". Les deux motifs sont
    # acceptes : sans cela le balayage annoncait "non0=0 / 1er_corr=none" pour
    # TOUTES les adresses, y compris celles ou le correlateur voit tres bien
    # l'I/Q - un faux negatif qui condamnait la bonne zone DARAM.
    NUL='(det=0 toa=0 pm=0 ang=0 snr=0x0000)|snr=0 toa=0 ang=0 pm=0'
    nz=$(grep -a "real DSP path" "$QLOG" 2>/dev/null | grep -avcE "$NUL")
    best=$(grep -a "real DSP path" "$QLOG" 2>/dev/null | grep -avE "$NUL" \
             | grep -oE "(data\[\]\(det=[0-9-]+ toa=[0-9-]+ pm=[0-9-]+ ang=[0-9-]+ snr=0x[0-9a-fA-F]+\))|(last\(snr=[0-9-]+ toa=[0-9-]+ ang=[0-9-]+ pm=[0-9a-fx]+\))" \
             | head -1)
    # Combien de passages en tout : le taux de detection compte autant que le
    # premier succes (19 % ne suffit pas a verrouiller la synchro).
    tot=$(grep -ac "real DSP path" "$QLOG" 2>/dev/null)
    states=$(grep -aoE 'state=[A-Z0-9_]+' "$QLOG" 2>/dev/null | sort | uniq -c | tr '\n' ' ')
    qlines=$(wc -l < "$QLOG" 2>/dev/null)
    R_FB[$a]="${fb:-0}"; R_SB[$a]="${sb:-0}"; R_NZ[$a]="${nz:-0}"
    R_BEST[$a]="${best:-none}"; R_ALIVE[$a]="$alive"; R_STATES[$a]="${states:-<aucun>}"
    R_TOT[$a]="${tot:-0}"
    echo "  -> qemu_vivant=$alive  qemu.log=${qlines}l  fb0_att=${fb:-0}  sb_att=${sb:-0}  non0=${nz:-0}/${tot:-0}"
    echo "  -> etats: ${states:-<aucun>}"
    echo "  -> 1er correlateur non-nul: ${best:-none}"
done

kill_all
echo
echo "================= COMPARAISON ================="
printf "%-9s %-7s %-9s %-8s %-12s %s\n" "ADDR" "alive" "fb0_att" "sb_att" "non0/total" "1er_corr"
for a in $ADDRS; do
    printf "%-9s %-7s %-9s %-8s %-12s %s\n" \
      "$a" "${R_ALIVE[$a]}" "${R_FB[$a]}" "${R_SB[$a]}" \
      "${R_NZ[$a]}/${R_TOT[$a]}" "${R_BEST[$a]}"
done
echo
echo "Gagnant = fb0_att>0 et/ou non0>0 (correlateur voit l'I/Q). alive=no => boot KO."

tools/fft2.sh

.sh 14 lignes - 686B

tools/fft2.sh
#!/usr/bin/env bash
# tools/fft2.sh - FFT du 2e cfile. Reutilise tools/fft.sh avec un autre CFILE.
#
# tools/fft.sh  = dsp_iq.cfile = MS  (entree DSP Calypso : le mobile qui repond a la BTS)
# tools/fft2.sh = record.cfile = BTS (ce que la BTS emet)
# -> MEME signal aux deux bouts de la chaine (BTS TX <-> MS RX) ; la difference
#    entre les deux spectres = ce que la chaine (shunt/gr-gsm) fait au signal.
#
# Usage : ./tools/fft2.sh            (defaut CFILE=/dev/shm/record.cfile)
#         CFILE=... ./tools/fft2.sh
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export CFILE="${CFILE:-/dev/shm/record.cfile}"
export ARFCN="${ARFCN:-BTS-record}"
exec bash "$HERE/fft.sh" "$@"

tools/fft_global.sh

.sh 146 lignes - 5,6KB

tools/fft_global.sh
#!/usr/bin/env bash
# tools/fft_global.sh - FFT "jolie" d'un cfile LOCAL passe en argument (fichier statique).
#
# Derive de tools/fft.sh (osmo-operator) mais SANS docker / ring / tail-live : lit
# directement un cfile sur l'hote, le balaye en boucle, et affiche
# PSD moyennee (Welch maison) + waterfall defilant (FuncAnimation).
#
# Usage :
#   ./tools/fft_global.sh my.cfile
#   ./tools/fft_global.sh my.cfile --save spectre.png
#   RATE=1083333 NSAMP=32768 REFRESH=0.25 FORMAT=cf32 ARFCN=514 ./tools/fft_global.sh my.cfile
#
# FORMAT : cf32 (complex64, defaut, ce qu'ecrit le shunt) | cs8 | cs16
#          (record.cfile d'osmo-trx est souvent cs8 -> FORMAT=cs8)
set -euo pipefail

CFILE="${1:?usage: tools/fft_global.sh <fichier.cfile> [--save out.png]}"
[ -f "$CFILE" ] || { echo "[fft] introuvable: $CFILE"; exit 1; }
export CFILE="$(readlink -f "$CFILE")"

export RATE="${RATE:-1083333}"      # 4 SPS natif = 26e6/24
export NSAMP="${NSAMP:-32768}"      # complex samples par fenetre
export REFRESH="${REFRESH:-0.25}"   # periode de rafraichissement (s)
export ARFCN="${ARFCN:-514}"        # juste pour le titre
export FORMAT="${FORMAT:-cf32}"     # cf32 | cs8 | cs16
export DISPLAY="${DISPLAY:-:0}"
export FFT_SAVE=""
[ "${2:-}" = "--save" ] && export FFT_SAVE="${3:?--save needs a path}"

python3 - <<'PYEOF'
import os, numpy as np
import matplotlib
SAVE = os.environ.get("FFT_SAVE","")
if SAVE:
    matplotlib.use("Agg")
else:
    for bk in ("TkAgg","Qt5Agg","GTK3Agg"):
        try: matplotlib.use(bk); break
        except Exception: continue
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

CFILE = os.environ["CFILE"]
RATE  = float(os.environ["RATE"]); NS = int(os.environ["NSAMP"])
REFRESH = float(os.environ["REFRESH"]); ARFCN = os.environ["ARFCN"]
FORMAT = os.environ.get("FORMAT","cf32").lower()
NFFT  = 4096                       # resolution FFT
WROWS = 120                        # lignes du waterfall

# ---- format d'echantillon -> dtype brut + bytes/sample complexe ----
if FORMAT in ("cf32","fc32","complex64"):
    RAWDT, ITEMS = np.float32, 2          # I,F float32
elif FORMAT in ("cs16","sc16","int16"):
    RAWDT, ITEMS = np.int16, 2
elif FORMAT in ("cs8","sc8","int8"):
    RAWDT, ITEMS = np.int8, 2
else:
    raise SystemExit(f"FORMAT inconnu: {FORMAT} (cf32|cs16|cs8)")
SAMP_BYTES = np.dtype(RAWDT).itemsize * ITEMS     # octets / sample complexe
FSIZE = os.path.getsize(CFILE)
NSAMP_TOT = FSIZE // SAMP_BYTES
print(f"[fft] {CFILE}  {FSIZE} o  format={FORMAT}  {NSAMP_TOT} samples  Fs={RATE/1e6:.4f} MHz")
if NSAMP_TOT < NFFT:
    raise SystemExit("[fft] fichier trop court pour une FFT")

win = np.hanning(NFFT).astype(np.float32)
freqs = np.fft.fftshift(np.fft.fftfreq(NFFT, 1.0/RATE)) / 1e3   # kHz

_off = [0]    # offset courant en SAMPLES (balayage en boucle)

def grab():
    """Lit NS samples complexes a l'offset courant, avance, boucle a la fin."""
    start = _off[0]
    if start + NS > NSAMP_TOT:
        start = 0
    raw = np.fromfile(CFILE, dtype=RAWDT, count=NS*ITEMS,
                      offset=start*SAMP_BYTES)
    _off[0] = start + NS
    if len(raw) < NFFT*ITEMS:
        return None
    raw = raw.astype(np.float32)
    iq = raw[0::2] + 1j*raw[1::2]
    if RAWDT == np.int8:   iq /= 128.0
    if RAWDT == np.int16:  iq /= 32768.0
    return iq.astype(np.complex64)

def welch_psd(iq):
    """PSD moyennee (segments NFFT, overlap 50%, Hann) en dB."""
    step = NFFT//2; nseg = max(1,(len(iq)-NFFT)//step + 1)
    acc = np.zeros(NFFT, dtype=np.float64)
    used = 0
    for i in range(nseg):
        seg = iq[i*step:i*step+NFFT]
        if len(seg) < NFFT: break
        sp = np.fft.fftshift(np.fft.fft(seg*win))
        acc += (sp.real**2 + sp.imag**2); used += 1
    psd = acc/max(1,used)
    return 10*np.log10(psd/(NFFT*RATE) + 1e-20)

# ---- figure "jolie" : PSD (haut) + waterfall (bas) alignees ----
plt.style.use("dark_background")
fig = plt.figure(figsize=(11,8))
gs = fig.add_gridspec(2, 2, width_ratios=[40, 1], height_ratios=[1, 1.4],
                      hspace=0.18, wspace=0.015)
axp = fig.add_subplot(gs[0, 0])
axw = fig.add_subplot(gs[1, 0], sharex=axp)
cax = fig.add_subplot(gs[1, 1])
fig.suptitle(f"FFT - {os.path.basename(CFILE)}  |  ARFCN={ARFCN}  Fs={RATE/1e6:.4f} MHz  fmt={FORMAT}",
             fontsize=11, color="#7fd")
(line,) = axp.plot(freqs, np.full(NFFT,-120), lw=0.5, color="#39f")
axp.set_xlim(freqs[0],freqs[-1]); axp.set_ylim(-130,-30)
axp.set_ylabel("PSD (dB)"); axp.grid(alpha=0.25)
axp.axvline(0, color="#f55", lw=0.7, alpha=0.7)
plt.setp(axp.get_xticklabels(), visible=False)

wf = np.full((WROWS,NFFT), -120.0)
im = axw.imshow(wf, aspect="auto", origin="upper", cmap="turbo",
                extent=[freqs[0],freqs[-1],0,WROWS], vmin=-120, vmax=-50,
                interpolation="bilinear")
axw.set_ylabel("temps (fenetres)"); axw.set_xlabel("offset (kHz)")
axw.axvline(0, color="#fff", lw=0.7, alpha=0.5)
fig.colorbar(im, cax=cax, label="dB")
fig.subplots_adjust(left=0.08, right=0.92, top=0.92, bottom=0.08)

def update(_):
    iq = grab()
    if iq is None: return line, im
    psd = welch_psd(iq)
    line.set_ydata(psd)
    lo,hi = np.percentile(psd,5), np.percentile(psd,99.5)
    axp.set_ylim(lo-5, hi+8)
    global wf
    wf = np.roll(wf,1,axis=0); wf[0,:] = psd
    im.set_data(wf); im.set_clim(lo, hi+5)
    return line, im

if SAVE:
    update(0)
    fig.savefig(SAVE, dpi=110, facecolor=fig.get_facecolor())
    print("ecrit", SAVE)
else:
    ani = FuncAnimation(fig, update, interval=int(REFRESH*1000),
                        blit=False, cache_frame_data=False)
    print("FFT - ferme la fenetre pour quitter.")
    plt.show()
PYEOF

tools/fft.sh

.sh 199 lignes - 9,2KB

tools/fft.sh
#!/usr/bin/env bash
# tools/fft.sh - FFT "jolie" du flux I/Q Calypso, lue DIRECTEMENT dans le docker.
#
# Lit /tmp/record.cfile (le ring record CONTINU, ecrit par record_drain.py qui
# draine iq_record.fifo en O_RDWR - DECOUPLE de la boucle relay temps-reel) DANS
# le container, le rapatrie en fc32 (complex64), et affiche sur le X de l'HOTE :
# PSD moyennee (Welch maison) + waterfall defilant. Boucle (FuncAnimation).
#
# /!\ Le mode 'fifo' (tap LIVE de /tmp/iq_fft.fifo) a ete RETIRE : ouvrir ce fifo
#     en lecture force l'IPC device a l'ecrire et perturbe le relai temps-reel ->
#     le decode grgsm s'arrete (si-bridge 'fini') et le camping fige. record.cfile
#     est near-live et n'a AUCUN effet sur le pipeline.
#
# Usage :
#   ./tools/fft.sh                      # tail de record.cfile (defaut, non-perturbant)
#   FFT_SRC=sweep ./tools/fft.sh        # balaye tout le ring record
#   ./tools/fft.sh --save spectre.png   # une capture PNG (si pas de X)
#   CONTAINER=osmo-operator-1 CFILE=/tmp/record.cfile RATE=1083333 \
#   NSAMP=262144 REFRESH=1.0 ARFCN=514 ./tools/fft.sh
#
set -euo pipefail
export CONTAINER="${CONTAINER:-osmo-operator-1}"
export FFT_SRC="${FFT_SRC:-tail}"   # tail (defaut, NON-perturbant) | sweep - lisent record.cfile
# NB: le mode 'fifo' (tap LIVE de /tmp/iq_fft.fifo) a ete RETIRE : ouvrir ce
# fifo en lecture force l'IPC device a l'ecrire, ce qui perturbe la boucle relay
# temps-reel et COUPE le decode grgsm (si-bridge 'fini') -> camping fige. On lit
# desormais record.cfile, alimente par record_drain.py (decouple, always-on).
export CFILE="${CFILE:-/dev/shm/dsp_iq.cfile}"    # entree DSP shunt = MS (le mobile qui repond a la BTS ; cfile fc32 en /dev/shm RAM). Fichier qui GRANDIT -> tail-c lit le frais. (override CFILE=/dev/shm/record.cfile = BTS)
export RATE="${RATE:-1083333}"      # 4 SPS natif = 26e6/24
export NSAMP="${NSAMP:-32768}"      # complex samples par fenetre (256 KB) - court = ca gigote
export REFRESH="${REFRESH:-0.25}"   # periode de rafraichissement (s) - court = fluide
export ARFCN="${ARFCN:-514}"        # juste pour le titre (DCS1800)
export DISPLAY="${DISPLAY:-:0}"
export FFT_SAVE=""
[ "${1:-}" = "--save" ] && export FFT_SAVE="${2:?--save needs a path}"

# garde-fous
command -v docker >/dev/null || { echo "docker introuvable"; exit 1; }
case "$FFT_SRC" in
  sweep|tail) ;;
  *) echo "[fft] FFT_SRC='$FFT_SRC' invalide. Le mode 'fifo' (tap live) a ete retire"
     echo "[fft] (il perturbe le relai temps-reel et coupe le decode grgsm). -> tail."
     export FFT_SRC=tail ;;
esac
docker exec "$CONTAINER" test -f "$CFILE" \
  || { echo "record absent $CONTAINER:$CFILE (lance ./run.sh d'abord ; record_drain.py alimente record.cfile)"; exit 1; }

python3 - <<'PYEOF'
import os, sys, subprocess, numpy as np
import matplotlib
SAVE = os.environ.get("FFT_SAVE","")
if SAVE:
    matplotlib.use("Agg")
else:
    for bk in ("TkAgg","Qt5Agg","GTK3Agg"):
        try: matplotlib.use(bk); break
        except Exception: continue
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

CONT  = os.environ["CONTAINER"]; CFILE = os.environ["CFILE"]
RATE  = float(os.environ["RATE"]); NS = int(os.environ["NSAMP"])
REFRESH = float(os.environ["REFRESH"]); ARFCN = os.environ["ARFCN"]
NBYTES = NS*8                      # complex64 = 8 o/sample
NFFT  = 4096                       # resolution FFT (segment Welch)
WROWS = 120                        # lignes du waterfall

win = np.hanning(NFFT).astype(np.float32)
freqs = np.fft.fftshift(np.fft.fftfreq(NFFT, 1.0/RATE)) / 1e3   # kHz

SRC  = os.environ.get("FFT_SRC","tail")    # tail (defaut) | sweep - lisent record.cfile
if SRC not in ("sweep","tail"): SRC = "tail"   # 'fifo' retire (tap live nocif)
BS   = 4096; NBLK = max(1, NBYTES//BS)

# ---- modes fichier : balaye le ring (sweep) ou lit le tail de record.cfile
#      (alimente par record_drain.py, decouple de la boucle relay temps-reel).
def _filesize():
    try:
        return int(subprocess.run(["docker","exec",CONT,"stat","-c","%s",CFILE],
                                  capture_output=True,timeout=5).stdout or 0)
    except Exception: return NBYTES
def _ringsize():
    # taille du ring publiee par record_drain (modulo pour le wrap LIVE)
    try:
        v = int(subprocess.run(["docker","exec",CONT,"cat",CFILE+".ring"],
                               capture_output=True,timeout=5).stdout or 0)
        if v > 0: return v
    except Exception: pass
    return _filesize()
RING = _ringsize()
_off = [0]

def _dd(skip_blk, count_blk):
    return subprocess.run(["docker","exec",CONT,"dd","if="+CFILE,"bs="+str(BS),
        "skip="+str(skip_blk),"count="+str(count_blk),"status=none"],
        capture_output=True, timeout=8).stdout

def grab():
    """Rend NBYTES d'I/Q complex64 (le plus frais), depuis record.cfile.
    SRC=tail  : derniers octets (defaut, near-live, non-perturbant).
    SRC=sweep : balaye le ring record (offset qui avance)."""
    if SRC == "sweep":
        skip = _off[0] // BS
        try:
            raw = subprocess.run(["docker","exec",CONT,"dd","if="+CFILE,
                "bs="+str(BS),"skip="+str(skip),"count="+str(NBLK),"status=none"],
                capture_output=True, timeout=8).stdout
        except Exception:
            return None
        _off[0] += NBYTES
        if _off[0] + NBYTES > RING: _off[0] = 0
    else:
        # LIVE (tail) : lit la fenetre fraiche FINISSANT a l'offset d'ecriture
        # publie par record_drain (<cfile>.off). Corrige le freeze : avec le ring
        # seek(0) le frais est a `w` (milieu), PAS au tail. Aligne blocs + wrap.
        try:
            w = int(subprocess.run(["docker","exec",CONT,"cat",CFILE+".off"],
                                   capture_output=True, timeout=5).stdout or -1)
        except Exception:
            w = -1
        try:
            if w >= 0:
                wblk = w // BS; start_blk = wblk - NBLK
                if start_blk >= 0:
                    raw = _dd(start_blk, NBLK)
                else:                              # fenetre a cheval sur le wrap
                    ring_blk = RING // BS; n_end = -start_blk
                    raw = _dd(ring_blk - n_end, n_end) + _dd(0, wblk)
            else:                                   # repli : pas d'offset publie
                raw = subprocess.run(["docker","exec",CONT,"tail","-c",str(NBYTES),CFILE],
                                     capture_output=True, timeout=8).stdout
        except Exception:
            return None
    n = (len(raw)//8)*8
    if n < NFFT*8: return None
    return np.frombuffer(raw[:n], dtype=np.complex64)

def welch_psd(iq):
    """PSD moyennee (segments NFFT, overlap 50%, fenetre Hann) en dB"""
    step = NFFT//2; nseg = max(1,(len(iq)-NFFT)//step + 1)
    acc = np.zeros(NFFT, dtype=np.float64)
    for i in range(nseg):
        seg = iq[i*step:i*step+NFFT]
        if len(seg) < NFFT: break
        sp = np.fft.fftshift(np.fft.fft(seg*win))
        acc += (sp.real**2 + sp.imag**2)
    psd = acc/max(1,nseg)
    return 10*np.log10(psd/ (NFFT*RATE) + 1e-20)

# ---- figure "jolie" : PSD (haut) + waterfall (bas) ALIGNEES verticalement.
#      GridSpec 2x2 : col 0 = les 2 plots (MEME largeur), col 1 = colorbar (seulement
#      sur la ligne waterfall) -> la colorbar ne retrecit plus la waterfall, donc x=0
#      tombe sur la MEME verticale dans la PSD et la waterfall. sharex lie les axes. ----
plt.style.use("dark_background")
fig = plt.figure(figsize=(11,8))
gs = fig.add_gridspec(2, 2, width_ratios=[40, 1], height_ratios=[1, 1.4],
                      hspace=0.18, wspace=0.015)
axp = fig.add_subplot(gs[0, 0])
axw = fig.add_subplot(gs[1, 0], sharex=axp)   # x partage -> aligne avec la PSD
cax = fig.add_subplot(gs[1, 1])               # colorbar dans sa colonne (n'affecte pas axw)
fig.suptitle(f"Calypso I/Q live - {CONT}:{CFILE}  |  ARFCN={ARFCN}  Fs={RATE/1e6:.4f} MHz",
             fontsize=11, color="#7fd")
(line,) = axp.plot(freqs, np.full(NFFT,-120), lw=0.5, color="#39f")
axp.set_xlim(freqs[0],freqs[-1]); axp.set_ylim(-130,-30)
axp.set_ylabel("PSD (dB)"); axp.grid(alpha=0.25)
axp.axvline(0, color="#f55", lw=0.7, alpha=0.7)
plt.setp(axp.get_xticklabels(), visible=False)   # l'axe x est porte par la waterfall en bas

wf = np.full((WROWS,NFFT), -120.0)
im = axw.imshow(wf, aspect="auto", origin="upper", cmap="turbo",
                extent=[freqs[0],freqs[-1],0,WROWS], vmin=-120, vmax=-50,
                interpolation="bilinear")
axw.set_ylabel("temps (frames)"); axw.set_xlabel("offset (kHz)")
axw.axvline(0, color="#fff", lw=0.7, alpha=0.5)  # meme 0 vertical que la PSD
fig.colorbar(im, cax=cax, label="dB")
fig.subplots_adjust(left=0.08, right=0.92, top=0.92, bottom=0.08)

def update(_):
    iq = grab()
    if iq is None: return line, im
    psd = welch_psd(iq)
    line.set_ydata(psd)
    lo,hi = np.percentile(psd,5), np.percentile(psd,99.5)
    axp.set_ylim(lo-5, hi+8)
    global wf
    wf = np.roll(wf,1,axis=0); wf[0,:] = psd
    im.set_data(wf); im.set_clim(lo, hi+5)
    return line, im

if SAVE:
    update(0)                       # une passe
    fig.savefig(SAVE, dpi=110, facecolor=fig.get_facecolor())
    print("ecrit", SAVE)
else:
    ani = FuncAnimation(fig, update, interval=int(REFRESH*1000),
                        blit=False, cache_frame_data=False)
    print("FFT live - ferme la fenetre pour quitter.")
    plt.show()
PYEOF

tools/make-docker-image.sh

.sh 68 lignes - 2,2KB

tools/make-docker-image.sh
#!/bin/bash
set -euo pipefail

# 0. Parsing des arguments
NO_CACHE=""
for arg in "$@"; do
    case "$arg" in
        --no-cache)
            NO_CACHE="--no-cache"
            ;;
        -h|--help)
            echo "Usage: sudo $0 [--no-cache]"
            echo "  --no-cache   Force la reconstruction complete de l'image (paquets propres)."
            exit 0
            ;;
        *)
            echo -e "\033[0;33m[WARN] Argument inconnu ignore : $arg\033[0m"
            ;;
    esac
done

# 1. Verification des privileges ROOT
if [[ $EUID -ne 0 ]]; then
   echo -e "\033[0;31m[ERREUR] Ce script doit etre lance en tant que root (sudo).\033[0m"
   exit 1
fi

echo "--- Preparation complete de l'hote (SDR & Docker) ---"

# 2. Installation de Docker (si non present)
if ! command -v docker &> /dev/null; then
    echo "[*] Docker n'est pas installe. Installation en cours..."
    apt-get update
    apt-get install -y docker.io docker-compose-v2
fi

# 3. Installation des dependances critiques sur l'hote
# SCTP est vital pour les protocoles de signalisation Osmocom
echo "[*] Installation de SCTP, TUN et D-Bus sur l'hote..."
apt-get update
apt-get install -y lksctp-tools libsctp-dev dbus uml-utilities libusb-1.0-0-dev \
     wireshark linphone* gnome-terminal whiptail expect netcat-openbsd \
     telnet iproute2 pulseaudio-utils

# 4. Chargement des modules noyau
echo "[*] Chargement des modules noyau (SCTP & TUN)..."
modprobe sctp
modprobe tun

# Verification du module SCTP (lecture directe de /proc/modules : pas de tube,
# donc pas de SIGPIPE/pipefail, et ancrage sur le nom de module exact)
if grep -q '^sctp ' /proc/modules; then
    echo -e "\033[0;32m[OK] Module SCTP charge sur l'hote.\033[0m"
else
    echo -e "\033[0;31m[ERREUR] Impossible de charger SCTP.\033[0m"
fi

# 5. Lancement du build Docker
echo "--- Lancement du build de l'image osmocom-nitb ---"
if [[ -n "$NO_CACHE" ]]; then
    echo "[*] Mode --no-cache actif : reconstruction complete, paquets propres."
fi
if docker build $NO_CACHE . -t osmocom-nitb; then
    echo -e "\033[0;32m[OK] Image osmocom-nitb construite avec succes.\033[0m"
else
    echo -e "\033[0;31m[ERREUR] Le build Docker a echoue.\033[0m"
    exit 1
fi

tools/matrix.sh

.sh 119 lignes - 6,1KB

tools/matrix.sh
#!/usr/bin/env bash
# tools/matrix.sh <burst|si> - MATRICE DYNAMIQUE du treillis 51-multiframe (cote mobile).
#
# Deux vues "temps/canal" complementaires des 2 FFT (frequence) :
#   tools/matrix.sh burst  -> dynamique des BURSTS (TRXD ts0 DL :5702) :
#                       lignes = FN%51 (structure canal), colonnes = temps,
#                       couleur = vide / dummy(idle) / reel(actif).
#   tools/matrix.sh si     -> dynamique des SI/L2 decodes (GSMTAP :4729/:4730) :
#                       lignes = FN%51, couleur = type de message (SI/IMM/UA/PAGING/?).
#
# Sniff PASSIF (AF_PACKET via gsm_sniff.py dans le conteneur, ne vole rien, aucune
# fifo) -> pipe -> rendu matplotlib sur l'hote (X). Ferme la fenetre pour quitter.
#
# Usage : ./tools/matrix.sh burst    |    ./tools/matrix.sh si
#         CONTAINER=osmo-operator-66 TS=0 W=240 ./tools/matrix.sh burst
set -euo pipefail
MODE="${1:?usage: tools/matrix.sh <burst|si>}"
case "$MODE" in burst|si) ;; *) echo "mode invalide: $MODE (burst|si)"; exit 1;; esac
export MODE
export CONTAINER="${CONTAINER:-osmo-operator-66}"
export TS="${TS:-0}"          # timeslot a afficher (0 = canal combine CCCH+SDCCH4)
export W="${W:-240}"          # colonnes (multiframes d'historique)
export DISPLAY="${DISPLAY:-:0}"
PY="${PY:-/root/.env/bin/python3}"; command -v "$PY" >/dev/null 2>&1 || PY=python3

SNIFF=/opt/GSM/qosmo-grgsm/opt-gsm-scripts/gsm_sniff.py
docker exec "$CONTAINER" test -f "$SNIFF" || { echo "gsm_sniff absent dans $CONTAINER"; exit 1; }

# gsm_sniff (conteneur, passif) -> stdout -> python hote (rendu matrice live)
docker exec "$CONTAINER" sh -c "SNIFF_IFACE=lo stdbuf -oL python3 $SNIFF $MODE" \
| "$PY" - <<'PYEOF'
import sys, os, re, threading, collections
import numpy as np, matplotlib
matplotlib.use("TkAgg") if not os.environ.get("MATRIX_SAVE") else matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.colors import ListedColormap, BoundaryNorm

MODE = os.environ.get("MODE","burst"); TS = int(os.environ.get("TS","0")); W = int(os.environ.get("W","240"))
DUMMY = "00011111011011101100000101001001"   # debut du dummy burst GSM (idle)

# --- type SI/L2 -> code couleur (mode si) ---
SI_CODE = {"?":1,"SI1":2,"SI2":3,"SI3":4,"SI4":5,"SI2bis":6,"SI2ter":7,"SI13":8,
           "PAGING-REQ-1":9,"PAGING-REQ-2":9,"PAGING-REQ-3":9,
           "IMM-ASSIGN":10,"IMM-ASSIGN-EXT":10,"IMM-ASSIGN-REJ":10}
# LAPDm (SDCCH/SACCH) detecte par octet de controle : on tag "DEDIE"
SI_LABELS = ["·","?","SI1","SI2","SI3","SI4","SI2b","SI2t","SI13","PAG","IMM","DEDIE"]
SI_COLORS = ["#101018","#888","#1f77b4","#2ca02c","#17becf","#9467bd","#8c564b","#e377c2",
             "#7f7f7f","#ff7f0e","#d62728","#ffd700"]

mat = np.zeros((51, W)); curmf=[None]; lock=threading.Lock()

re_ts  = re.compile(r"ts(\d)\b")
re_m51 = re.compile(r"%51=(\d+)")
re_fn  = re.compile(r"\bfn=(\d+)|\bFN=(\d+)")
re_148 = re.compile(r"\b([01]{148})\b")
re_si  = re.compile(r"\b(SI2bis|SI2ter|SI1[3]?|SI[2-4]|IMM-ASSIGN-?\w*|PAGING-REQ-\d)\b")
re_port= re.compile(r":(\d{4})\b")

def reader():
    for line in sys.stdin:
        m51 = re_m51.search(line);  mts = re_ts.search(line)
        if not m51: continue
        if mts and int(mts.group(1)) != TS:   # filtre timeslot (defaut ts0)
            # en mode si les lignes :4730 n'ont pas toujours ts -> on garde
            if MODE=="burst": continue
        fnm = re_fn.search(line); fn = int(fnm.group(1) or fnm.group(2)) if fnm else None
        r = int(m51.group(1)) % 51
        if MODE=="burst":
            mb = re_148.search(line)
            if "NOPE" in line or "vide" in line: v = 0.0          # burst vide
            elif mb and mb.group(1).startswith(DUMMY): v = 1.0    # dummy (idle)
            elif mb: v = 2.0                                       # reel (actif)
            else: v = 0.0
        else:
            ms = re_si.search(line)
            if ms: v = float(SI_CODE.get(ms.group(1), 1))
            elif "?" in line:                                     # LAPDm dedie (SDCCH/SACCH UA/I)
                v = 11.0 if (22<=r<=25 or 26<=r<=39 or 42<=r<=49) else 1.0
            else: v = 1.0
        mf = fn//51 if fn is not None else (curmf[0] or 0)
        with lock:
            if curmf[0] is None: curmf[0]=mf
            while mf > curmf[0]:                # nouvelle multiframe -> shift gauche
                mat[:, :-1] = mat[:, 1:]; mat[:, -1] = 0; curmf[0]+=1
            if mf == curmf[0]:
                mat[r, -1] = v

threading.Thread(target=reader, daemon=True).start()

plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=(12,7))
title = ("BURSTS ts%d (·=vide  bleu=dummy/idle  rouge=ACTIF)" % TS) if MODE=="burst" \
        else "SI / L2 decodes (type par couleur)"
fig.suptitle("Matrice dynamique 51-multiframe - %s  [%s]" % (title, os.environ.get("CONTAINER","")),
             color="#7fd", fontsize=11)
if MODE=="burst":
    cmap = ListedColormap(["#101018","#1f77b4","#d62728"]); norm = BoundaryNorm([-.5,.5,1.5,2.5], cmap.N)
else:
    cmap = ListedColormap(SI_COLORS); norm = BoundaryNorm([i-.5 for i in range(len(SI_COLORS)+1)], cmap.N)
im = ax.imshow(mat, aspect="auto", origin="lower", cmap=cmap, norm=norm, interpolation="nearest")
ax.set_ylabel("FN % 51  (FCCH=0,10..  SCH=1,11..  BCCH=2-5  CCCH=6-19  SDCCH=22-39  SACCH=42-49)")
ax.set_xlabel("temps (multiframes 51) →")
# reperes des bandes canal (combine BCCH+SDCCH4 ts0)
for y in (0,10,20,30,40): ax.axhline(y, color="#444", lw=.4)
for y0,y1,lbl,c in [(2,5,"BCCH","#17becf"),(22,25,"SDCCH/0","#ffd700"),(42,45,"SACCH/0","#ff7f0e")]:
    ax.axhspan(y0-.5,y1+.5, xmin=0, xmax=0.015, color=c, alpha=.8)
if MODE=="si":
    from matplotlib.patches import Patch
    ax.legend(handles=[Patch(color=SI_COLORS[i], label=SI_LABELS[i]) for i in range(1,len(SI_LABELS))],
              loc="upper left", ncol=4, fontsize=7, framealpha=.3)

def update(_):
    with lock: im.set_data(mat.copy())
    return (im,)
ani = FuncAnimation(fig, update, interval=250, blit=False, cache_frame_data=False)
print("[matrix:%s] fenetre live - ferme pour quitter." % MODE, flush=True)
plt.show()
PYEOF