Aller au contenu
Sylvain Joffraud l4w
Retour

Tunnel SSH invisible des conteneurs Docker : bind 0.0.0.0

L’API métier d’un partenaire (éditique, documents, référentiels) est sur un réseau privé, accessible uniquement depuis le serveur de dev qui, lui, a la route VPN vers ce backend interne. Notre stack applicative, elle, tourne en Docker local sur nos postes.

Pendant deux semaines, deux mauvaises options se sont disputées :

La bonne option : un tunnel SSH depuis notre poste vers le serveur de dev, qui relaie vers l’API interne. Sauf qu’un détail a coûté une demi-journée.

Symptôme

Le tunnel « classique » se monte sans broncher :

ssh -L 18080:<ip-api-interne>:8080 user@serveur-dev

Depuis le host, ça marche : curl http://127.0.0.1:18080/__health répond. Victoire… jusqu’à ce qu’on lance le même appel depuis un conteneur :

docker compose exec app curl http://127.0.0.1:18080/__health
# curl: (7) Failed to connect to 127.0.0.1 port 18080: Connection refused

Et même en visant le pont Docker :

docker compose exec app curl http://host.docker.internal:18080/__health
# curl: (7) Connection refused

Le tunnel est bien là, le port écoute… mais le conteneur ne le voit pas.

Cause

Par défaut, ssh -L 18080:... bind le port local sur 127.0.0.1 uniquement (la loopback du host). Or un conteneur Docker a sa propre stack réseau : son 127.0.0.1 à lui, ce n’est pas celui du host. Quand le conteneur tape host.docker.internal, il atteint l’IP du host sur le réseau Docker — qui n’est pas 127.0.0.1. Le tunnel n’écoute pas sur cette interface-là, donc : Connection refused.

Autrement dit : le forward SSH est lié à une interface (127.0.0.1) que les conteneurs ne peuvent pas atteindre. Le port existe, mais pas là où Docker regarde.

Solution

Binder le forward sur 0.0.0.0 (toutes les interfaces du host), pas seulement la loopback :

ssh -L 0.0.0.0:18080:<ip-api-interne>:8080 user@serveur-dev

Maintenant le tunnel écoute aussi sur l’IP que les conteneurs joignent via host.docker.internal. Côté application, on pointe simplement la base URL dessus :

# .env.local
API_BASE_URL=http://host.docker.internal:18080

Et depuis le conteneur, ça passe :

docker compose exec app curl http://host.docker.internal:18080/__health
# {"status":"ok"}  ✓

On développe alors en local contre la vraie API, comme si on était sur le serveur de dev — sans installer le VPN du partenaire sur chaque poste, sans maintenir de mock.

Comme ce tunnel se monte/démonte souvent et qu’on oublie toujours un flag, on l’a enrobé dans un wrapper bash versionné dans le repo, avec un garde de sécurité explicite sur le mode 0.0.0.0 :

cmd_up() {
    local bind="127.0.0.1"
    if [ "${1:-}" = "--docker" ]; then
        bind="0.0.0.0"
        warn "Mode --docker : bind sur 0.0.0.0 → l'API sera exposée à TOUTE"
        warn "machine joignable sur le LAN tant que le tunnel est actif."
        warn "→ à n'utiliser que le temps des tests, à fermer ensuite ($0 down)."
    fi

    ssh -i "$SSH_KEY" -f -N \
        -o ExitOnForwardFailure=yes \
        -o StrictHostKeyChecking=accept-new \
        -o ServerAliveInterval=60 \
        -L "${bind}:${LOCAL_PORT}:${REMOTE_HOST}:${REMOTE_PORT}" \
        "$SSH_HOST"

    # Smoke test après montage : on ne dit "OK" que si /__health répond
    sleep 1
    curl -fsS "http://127.0.0.1:${LOCAL_PORT}/__health" >/dev/null \
        && ok "Tunnel monté et API joignable." \
        || warn "Tunnel monté mais /__health ne répond pas encore."
}

Trois options SSH valent le détour : -f -N (passe en arrière-plan sans ouvrir de shell), ExitOnForwardFailure=yes (échoue franchement si le port est déjà pris, au lieu d’un tunnel à moitié monté), et ServerAliveInterval=60 (garde la connexion vivante sur un tunnel qui dort entre deux requêtes).

Lessons learned

#ssh #docker #devops #networking #tunnel


Partager cet article :

Article précédent
iOS Safari injecte une NBSP : la regex `\s` ne la voit pas
Article suivant
Tracker GPS : positions « fantôme » sans fix (sat absent)