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.
$ ssh -N -R 0:localhost:8080 serveur.example.com
Allocated port 41535 for remote forward to localhost:8080Cô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 :
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.
La configuration, adaptée avec un domaine et un secret fictifs (les directives
ssl_certificate sont à ajouter) :
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 :
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 :
Host http-over-ssh
Hostname serveur.example.com
RemoteCommand http-over-ssh
ControlPath noneEnsuite, ssh -R 0:localhost:8080 http-over-ssh suffit.
Les limites
- MD5 est imposé par le module : la solidité repose entièrement sur le secret.
- L'URL est un jeton au porteur : collée dans un ticket ou un journal, elle ouvre l'accès jusqu'à l'expiration. Raccourcir la durée de vie limite le risque.
- Un secret unique signe tous les liens ; le changer les invalide tous.
- Le script d'aide a besoin de
sudopourss --processes: à restreindre dans sudoers.
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.