veilletech.fr
5 oct. Feed du jour
#02 DEVOPS Article

ngrok maison : OpenSSH, nginx et un lien signé

Le port ouvre la porte. La signature décide qui entre.

Vincent Bernat remplace ngrok par OpenSSH et nginx : un renvoi de port distant, un sous-domaine par port, un certificat joker. Le module secure_link de nginx ajoute un lien signé et daté, porté dans l'URL comme nom d'utilisateur, pour que le numéro de port ne soit pas le seul secret.

3 min de lectureintermédiairevidéo 1:16
Partager
Sommaire6 sections
  1. Ce qui se passe
  2. Le montage
  3. Le lien signé
  4. Une seule commande
  5. Les limites
  6. À retenir

Ce qui se passe

Partager un service local — l'aperçu d'un billet sur localhost:8080, par exemple — passe d'ordinaire par un service tiers (ngrok, Cloudflare Quick Tunnels, dont l'accès par e-mail a été traité ici le 3 octobre) ou par un client dédié (frp, localtunnel, sish). Vincent Bernat montre qu'un serveur qui fait déjà tourner OpenSSH et nginx suffit.

Le montage

Côté poste, un renvoi de port distant avec 0 comme port : le serveur en choisit un libre et l'annonce.

Terminal
$ ssh -N -R 0:localhost:8080 serveur.example.com
Allocated port 41535 for remote forward to localhost:8080

Côté serveur, nginx lit le port dans le sous-domaine p41535.ssh.example.com et relaie vers 127.0.0.1:41535. Il faut un CNAME joker *.ssh.example.com vers le serveur et un certificat joker Let's Encrypt, obtenu par défi DNS-01 — l'auteur délègue _acme-challenge à une zone Route 53 réservée à cet usage, et ajoute un enregistrement CAA issuewild pour Let's Encrypt.

Le lien signé

Sans rien de plus, le numéro de port est le seul secret, et il s'énumère. Le module ngx_http_secure_link_module compare un MD5 calculé sur « expiration, port, secret » à celui que présente le client. Ce hachage est en base64 ; or un nom de domaine ignore la casse. Il voyage donc dans la partie utilisateur de l'URL, avec sa date d'expiration :

TEXT
https://<hachage>--<expiration>@p41535.ssh.example.com/

Le navigateur l'envoie en authentification basique, nginx le lit dans $remote_user. Hachage absent ou faux : 401 avec un en-tête WWW-Authenticate. Lien expiré : 410.

Poste localsshdnginxVisiteurPoste localsshdnginxVisiteurURL signée1secure_link2401 ou 410 si refus3port 415354port 80805Réponse par le tunnel6Réponse7
nginx vérifie la signature avant que la requête n'entre dans le tunnel SSH

La configuration, adaptée avec un domaine et un secret fictifs (les directives ssl_certificate sont à ajouter) :

nginx
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\.example\.com$;
  location / {
    secure_link $httpssh_link;
    secure_link_md5 "$secure_link_expires $port MonSecret";
    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;
  }
}

L'en-tête Authorization est vidé avant de partir vers l'application, et les trois directives Upgrade / Connection laissent passer les WebSockets. Le lien se fabrique ainsi, ici pour 24 heures :

Terminal
expires=$(( $(date +%s) + 86400 ))
port=41535
secret='MonSecret'
printf '%s %s %s' "$expires" "$port" "$secret" \
  | openssl md5 -binary | openssl base64 | tr +/ -_ | tr -d =

Une seule commande

OpenSSH n'expose le port attribué dans aucune variable. Le script d'aide de l'auteur remonte donc aux processus sshd-session parents, liste leurs ports en écoute avec sudo -n ss --listening --numeric --tcp --processes, puis affiche l'URL signée et garde la session ouverte. Installé sur le serveur sous le nom http-over-ssh :

SSH-CONFIG
Host http-over-ssh
  Hostname serveur.example.com
  RemoteCommand http-over-ssh
  ControlPath none

Ensuite, ssh -R 0:localhost:8080 http-over-ssh suffit.

Les limites

Source : Self-hosted HTTP tunnels with SSH and nginx, Vincent Bernat, 3 octobre 2026. Script complet : http-over-ssh.sh, et module NixOS http-over-ssh.nix.