guest@6admin.io:~$

Piloter opencode derrière un tunnel SSH, avec direnv

J’ai raconté ailleurs comment je tiens ma config ssh et git dans un dépôt versionné, avec un ~/.ssh/config qui porte les identités, les sauts de rebond et le durcissement (myflec ). Ce billet en est la suite logique, un cran plus loin, là où l’accès ne se résume plus à ouvrir une session mais à faire vivre un tunnel en continu pour qu’un agent de code et un navigateur travaillent derrière. Le liant, cette fois, c’est direnv .

Le principe : direnv charge l’environnement par répertoire

direnv évalue un fichier .envrc à chaque fois qu’on entre dans un répertoire et nettoie l’environnement quand on en sort. Le bootstrap tient en quatre lignes, à faire une seule fois.

sudo apt install direnv
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc   # apres les autres hooks de prompt
exec $SHELL
direnv allow                                     # on fait confiance a ce .envrc

À partir de là, chaque cd dans le projet charge d’un coup les secrets, les variables du gateway et les garde-fous, sans que j’aie à sourcer quoi que ce soit à la main. Les secrets vivent dans un .env.secrets en mode 600, gitignoré, chargé par la fonction dotenv_if_exists de direnv, jamais commité.

dotenv_if_exists .env.secrets

Un tunnel SSH qui se répare tout seul

Le cœur du montage, c’est que direnv évalue le .envrc de façon synchrone à chaque invite. Un tunnel qui bloque ou un script qui échoue gèlerait donc le shell à chaque cd, ce qui est inacceptable. Trois règles en découlent et elles ne sont pas négociables : la sonde doit être rapide, la réparation doit tourner détachée en arrière-plan et le script doit toujours finir par exit 0 pour que le jeu d’exports survive même gateway éteint.

La santé du tunnel se mesure en deux temps, un listener local présent et une vraie réponse HTTP qui passe à travers. N’importe quel code de retour compte, un 401 prouve déjà que le chemin est vivant.

gw_port=4443
gw_url="https://gateway.internal:${gw_port}"
gw_fwd="-L 0.0.0.0:${gw_port}:gateway.internal:443"

_gw_healthy() {
  [[ -n $(ss -ltnH "sport = :${gw_port}") ]] || return 1
  curl -ksS --connect-timeout 1 --max-time 3 \
       -o /dev/null "${gw_url}/v1/models" 2>/dev/null
}

_gw_repair() {
  pkill -f -- "${gw_fwd}" 2>/dev/null || true
  ssh -f -N -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes \
      -o ConnectTimeout=5 "${gw_fwd}" jump >/dev/null 2>&1 || true
}

La réparation, elle, est throttlée à une tentative par minute et par tunnel, via un fichier d’horodatage dans le répertoire de layout de direnv. Sans ce garde-fou, un gateway mort relancerait un ssh à chaque cd. Et le détachement se fait en double fork, tous les descripteurs fermés, parce qu’un enfant d’arrière-plan qui garderait un tube de direnv ouvert suffirait à figer l’invite.

_repair_throttled() {
  local name=$1; shift
  local stamp now last
  stamp="$(direnv_layout_dir)/${name}-repair.stamp"
  now=$(date +%s)
  last=$(cat "$stamp" 2>/dev/null || echo 0)

  (( now - last < 60 )) && return 1

  date +%s > "$stamp" 2>/dev/null || true
  ( ( "$@" ) </dev/null >/dev/null 2>&1 & ) &
}

if ! _gw_healthy && _repair_throttled gw _gw_repair; then
  for _ in {1..4}; do _gw_healthy && break; sleep 0.25; done
fi

exit 0

Le exit 0 final n’est pas un détail, c’est l’invariant. Que le gateway réponde ou pas, les exports doivent passer et le shell doit rendre la main instantanément.

Le SOCKS5, pour le navigateur et les CLIs

Le même principe sert un tunnel SOCKS5 dynamique, déclaré une seule fois dans la config ssh et réutilisé partout, pour éviter la prolifération des ports qui ne finissent jamais par se recouper.

Host socks5
    DynamicForward 9050
    SessionType none
    ForkAfterAuthentication yes
    ServerAliveInterval 60

Côté navigateur, un petit script monte le tunnel s’il n’écoute pas déjà, attend que le port soit réellement ouvert par un sondage borné plutôt qu’un sleep aveugle, puis lance le navigateur dessus.

port=9050
listening() { [[ -n $(ss -ltnH "sport = :$port") ]]; }

if ! listening; then
  ssh -o ConnectTimeout=5 socks5
  for _ in {1..20}; do listening && break; sleep 0.25; done
fi
listening || { echo "error: socks tunnel down on :$port" >&2; exit 1; }

chromium --proxy-server="socks5://127.0.0.1:$port" &

Côté lignes de commande, proxychains-ng pointe sur exactement le même port, qui reste la seule source de vérité.

strict_chain
proxy_dns
[ProxyList]
socks5 127.0.0.1 9050
proxychains4 -f my_proxy.conf glab ci lint

La config SSH qui tient tout

Rien de tout cela ne fonctionnerait sans la config ssh que je décris dans myflec et qui fait le gros du travail en amont. Le saut de rebond est déclaratif (ProxyJump ), le multiplexage évite de rouvrir une session à chaque commande et le durcissement est là pour qu’un rebond mort se détecte au lieu de pendre.

Host *.internal
    ProxyJump jump
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
    Compression yes

# durcissement par defaut
ServerAliveInterval 60
ServerAliveCountMax 3
TCPKeepAlive no
StrictHostKeyChecking ask

Le choix de TCPKeepAlive no n’est pas un oubli. Les keepalives TCP survivent à un chemin IP mort et mentent, là où les keepalives au niveau SSH, eux, disent la vérité et trois ratés de suite coupent proprement. J’ai développé ce volet tunneling côté usage dans tunneling SSH .

opencode et Claude Code routés par le gateway

L’intérêt pratique de tout ce montage, c’est que les deux agents que j’utilise, opencode comme Claude Code, lancés depuis ce répertoire, parlent à un gateway distant sans le savoir. Claude Code se contente des variables d’environnement qu’il lit nativement, exportées par le .envrc, sans le moindre wrapper.

export ANTHROPIC_BASE_URL="${gw_url}/proxy/anthropic"
export ANTHROPIC_API_KEY="${GATEWAY_API_KEY}"
export ANTHROPIC_AUTH_TOKEN="anyvalueworks"   # le gateway authentifie au reseau
export ANTHROPIC_DEFAULT_OPUS_MODEL="anthropic/claude-opus-latest"
export ANTHROPIC_DEFAULT_SONNET_MODEL="anthropic/claude-sonnet-latest"

opencode, de son côté, lit un opencode.json qui pointe le baseURL sur l’endpoint tunnelé et porte la clef dans un en-tête, référencée depuis l’environnement et jamais écrite en dur.

{
  "provider": {
    "anthropic": {
      "options": {
        "baseURL": "https://gateway.internal:4443/proxy/anthropic/v1",
        "headers": { "x-api-key": "{env:GATEWAY_API_KEY}" },
        "setCacheKey": true,
        "timeout": 600000
      }
    }
  }
}

La part honnête

Un point mérite d’être nommé plutôt que caché. J’exporte NODE_TLS_REJECT_UNAUTHORIZED=0 pour ce setup et c’est un choix assumé, pas un oubli. Le gateway vit derrière mon propre tunnel SSH et un pare-feu en amont, sa chaîne de certificats est validée hors bande et le chemin bout à bout est déjà chiffré par SSH. Ce serait inacceptable sur un endpoint exposé, cela se défend derrière un tunnel que je contrôle de bout en bout. Nommer le compromis vaut mieux que le maquiller.

Branché, planqué, tracé

  • direnv transforme un répertoire en contexte complet, l’environnement, les secrets et le tunnel montent ensemble à chaque cd et tombent à la sortie.
  • Un tunnel évalué de façon synchrone impose trois règles, sonde rapide, réparation détachée et exit 0 toujours, pour ne jamais geler le shell ni perdre les exports.
  • Le throttling par fichier d’horodatage évite qu’un gateway mort ne relance un ssh à chaque invite et le double fork empêche l’enfant d’arrière-plan de figer le prompt.
  • Un seul port SOCKS, déclaré une fois dans la config ssh, sert le navigateur comme les CLIs via proxychains, source de vérité unique.
  • Tout cela se pose sur la config ssh de myflec , c’est elle qui porte les identités, le rebond et le durcissement.

[edit: 2026] L’implémentation Ansible de tout ceci: le rôle ssh .