Tunnels HTTP auto-hébergés avec SSH et nginx

Vincent Bernat

Un ami veut relire votre article de blog en cours de rédaction, mais sa prévisualisation ne tourne que sur localhost:8080. Plusieurs outils peuvent aider. Certains sont des services commerciaux, comme ngrok ou Cloudflare Quick Tunnels. D’autres peuvent être auto-hébergés, mais exigent un client dédié, comme frp ou localtunnel. D’autres encore se contentent d’un simple client SSH, mais reposent sur un serveur SSH spécifique, comme sish. Voyons comment mettre en place une solution auto-hébergée avec juste OpenSSH et nginx !

$ ssh -R 0:localhost:8080 http-over-ssh
Allocated port 41535 for remote forward to localhost:8080
https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/

Configuration de base#

Tout d’abord, nous redirigeons vers votre service local les connexions reçues sur un port d’un serveur distant :

$ ssh -N -R 0:localhost:8080 web02.luffy.cx
Allocated port 41535 for remote forward to localhost:8080

Lorsque nous indiquons 0 comme port distant, le serveur alloue un port libre. Ensuite, nous configurons nginx pour relayer les requêtes de https://p41535.ssh.luffy.cx vers http://127.0.0.1:41535 :

server {
  listen 0.0.0.0:443 ssl ;
  listen [::0]:443 ssl ;
  server_name ~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$;
  location / {
    proxy_pass http://127.0.0.1:$port;
  }
}

Il faut aussi ajouter des enregistrements DNS pour *.ssh.luffy.cx et obtenir un certificat générique auprès de Let’s Encrypt :

*.ssh.luffy.cx.               CNAME web02.luffy.cx.
ssh.luffy.cx.                 CAA   0 issuewild "letsencrypt.org"
_acme-challenge.ssh.luffy.cx  CNAME ssh.luffy.cx.acme.luffy.cx.

acme.luffy.cx est une zone hébergée sur Route 53. Je l’utilise pour les défis ACME DNS-01, aussi bien pour les certificats génériques que pour les domaines servis par plusieurs serveurs web. Dans mon cas, NixOS obtient les certificats automatiquement.

Contrôle d’accès#

Le port est le seul « secret1 » qui protège la confidentialité du contenu. D’autres solutions de redirection ajoutent une chaîne aléatoire au nom de domaine pour empêcher un intrus d’énumérer les valeurs possibles.

Grâce au module ngx_http_secure_link_module, nous pouvons renforcer un peu la sécurité de cette configuration. Ce module calcule une empreinte2 sur un ensemble de valeurs, dont un secret, et la compare à celle fournie dans la requête. L’empreinte est encodée en base64 : impossible donc de la placer dans le nom de domaine, qui est insensible à la casse. Nous l’insérons plutôt dans l’URL, comme nom d’utilisateur, avec sa date d’expiration3 :

https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/fr/blog
        ╰─────────┬──────────╯  ╰───┬────╯  ╰─┬─╯             ╰──┬───╯
              empreinte        expiration   port              chemin

Le client envoie le nom d’utilisateur au serveur avec l’authentification HTTP basique. Cela fonctionne avec la plupart des clients HTTP, dont curl. Nginx expose le nom d’utilisateur dans la variable $remote_user. Le module attend l’empreinte et la date d’expiration séparées par une virgule. À l’aide d’une directive map, nous extrayons les deux parties de $remote_user et les formatons comme attendu4. Nous fournissons aussi au module la chaîne à hacher. Elle contient la date d’expiration, le port et un secret :

map $remote_user $httpssh_link {
  "~^([-_A-Za-z0-9]{22})--([0-9]+)$" "$1,$2";
}
server {
  # […]
  location / {
    secure_link $httpssh_link;
    secure_link_md5 "$secure_link_expires $port ZuPerS3cr3!";
  }
}

Le module renvoie le résultat de la vérification dans la variable $secure_link :

  • vide si les empreintes ne correspondent pas ;
  • "0" si elles correspondent, mais que le lien a expiré ;
  • "1" sinon.

Si l’empreinte est incorrecte ou absente, nous renvoyons une erreur 401 avec un en-tête WWW-Authenticate pour réclamer des identifiants. Si le lien a expiré, nous renvoyons une erreur 410. Nous retirons l’en-tête Authorization avant de transmettre la requête et ajoutons quelques directives pour relayer les connexions WebSocket. Voici la configuration complète5 :

map $remote_user $httpssh_link {
  "~^([-_A-Za-z0-9]{22})--([0-9]+)$" "$1,$2";
}
server {
  listen 0.0.0.0:443 ssl ;
  listen [::0]:443 ssl ;
  server_name ~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$;
  location / {
    secure_link $httpssh_link;
    secure_link_md5 "$secure_link_expires $port ZuPerS3cr3!";
    if ($secure_link = "") {
      add_header WWW-Authenticate 'Basic realm="tunnel"' always;
      return 401;
    }
    if ($secure_link = "0") {
      return 410;
    }
    proxy_pass http://127.0.0.1:$port;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Authorization "";
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_buffering off;
    proxy_read_timeout 30m;
  }
}

Je pense que vous vous posez maintenant la question évidente : « Comment générer l’empreinte ? » Un jeu d’enfant !

$ expires=$(( $(date +%s) + 86400 ))
$ port=41535
$ secret='ZuPerS3cr3!'
$ printf '%s %s %s' "$expires" "$port" "$secret" \
>   | openssl md5 -binary \
>   | openssl base64 \
>   | tr +/ -_ | tr -d =
6J3jK1WmB15c6WmjW_X-Wg

Bon, je suppose que vous vous dites maintenant : « Vincent, ce n’est pas très pratique ! Si ça ne te dérange pas, je vais garder ngrok. » D’accord, d’accord. Écrivons un script pour fluidifier ça.

Script utilitaire#

La principale difficulté est de trouver le port éphémère alloué par OpenSSH, car il n’apparaît dans aucune variable d’environnement6. Pour contourner cet obstacle, nous cherchons parmi les ancêtres les processus sshd-session7 :

pids=$(
  pid=$$
  while [ "$pid" -gt 1 ]; do
    line=$(ps -o comm=,pid=,ppid= -p "$pid")
    echo "$line"
    pid=${line##* }
  done | awk '$1 == "sshd-session" { printf "pid=%s,\n", $2 }'
)
if [ -z "$pids" ]; then
  echo "not an ssh session" >&2
  exit 1
fi

Ensuite, nous récupérons les ports en écoute associés à ces processus sshd-session8 :

ports=$(sudo -n ss --listening --numeric --tcp --processes --no-header \
  | grep -F "$pids" \
  | awk '{ print $4 }' | awk -F: '{ print $NF }' \
  | sort -un)
if [ -z "$ports" ]; then
  echo "no forwarded port, use ssh -R 0:localhost:PORT" >&2
  exit 1
fi

Enfin, nous affichons les URL et gardons la session ouverte :

lifetime=86400
secret='ZuPerS3cr3!'
expires=$(( $(date +%s) + lifetime ))
for port in $ports; do
  token=$(printf '%s %s %s' "$expires" "$port" "$secret" \
            | openssl md5 -binary \
            | openssl base64 \
            | tr +/ -_ | tr -d =)
  echo "https://$token--$expires@p$port.ssh.luffy.cx/"
done
sleep infinity

J’installe ce script sous le nom http-over-ssh sur le serveur et j’ajoute cette entrée à mon ~/.ssh/config :

Host http-over-ssh
  Hostname web02.luffy.cx
  RemoteCommand http-over-ssh
  ControlPath none

Avec cette solution, je ne dépends que d’OpenSSH et de nginx, deux logiciels qui tournent déjà sur ce serveur. Une commande toute simple me fournit un tunnel auto-hébergé et une URL à partager. Pour l’essayer, récupérez le script utilitaire complet, qui contient quelques petites améliorations. Si vous utilisez NixOS, comme toute personne de goût, jetez aussi un œil à mon http-over-ssh.nix. ❄️