ssh -p 1337 gael-demette.fr
Le CV s'affiche dans le terminal. On change de version avec une touche, de langue avec une autre. Sans PTY, la même commande produit un dump texte que l'on peut rediriger vers un fichier ou un grep.
L'idée est un clin d'œil. Ce qui m'a occupé, ce n'est pas l'idée : c'est qu'ouvrir un port SSH sur internet, en acceptant n'importe qui sans authentification, est un exercice de sécurité sérieux. Voici ce que ça implique réellement.
Une seule source de contenu
Le point de départ n'était pas SSH, c'était la duplication. J'avais un CV en PDF, une page « à propos », et des versions différentes selon le poste visé. Trois endroits à mettre à jour, donc trois occasions de diverger.
Tout est maintenant dérivé d'un jeu de fichiers JSON unique. Un sélecteur pur applique les règles d'une variante (quelles expériences, quel niveau de détail, quelles sections) et produit un document. Ce document est ensuite rendu en page web, en PDF, en Markdown, en texte brut, et au format JSON Resume.
Le serveur SSH n'est donc pas une réimplémentation. C'est un client de plus : il interroge /cv/<variante>.json sur le site, exactement comme le ferait n'importe qui, et se contente de peindre le résultat dans un terminal. Ajouter une expérience professionnelle met à jour les cinq sorties d'un coup, sans que j'aie à y penser.
Le nom d'utilisateur SSH sert à choisir la variante : ssh cv-devops@… ouvre directement la version orientée infrastructure. C'est le seul canal disponible pour un client sans PTY, qui n'a par définition aucune touche à presser.
russh et ratatui
Le serveur est écrit en Rust, avec russh pour le protocole et ratatui pour l'interface. Rien d'exotique : russh fournit une implémentation asynchrone du protocole SSH côté serveur, et ratatui dessine des interfaces en mode texte.
L'adaptation entre les deux demande un peu de soin. Ratatui écrit dans un io::Write synchrone, russh envoie des octets sur un canal asynchrone. J'ai mis entre les deux un tampon partagé que la boucle de session vide puis attend avant de redessiner. Cette attente est la contre-pression : un client qui cesse de lire empêche le serveur de produire une nouvelle image, au lieu de laisser une file grossir indéfiniment. Au-delà d'un mégaoctet en attente, la session est considérée perdue et fermée.
Le vrai sujet : tout refuser
Un serveur SSH classique existe pour donner accès à une machine. Celui-ci existe pour afficher un document. La quasi-totalité du protocole doit donc être refusée, et refusée explicitement plutôt que laissée à un défaut.
L'authentification est anonyme et par clé publique uniquement. La clé du visiteur n'est vérifiée contre rien : elle sert seulement au protocole à disposer d'une identité. Aucun mot de passe n'est proposé, donc il n'y a rien à forcer par dictionnaire.
Tout le reste est refusé : exécution de commande, sous-systèmes (donc sftp et scp), redirection de ports locale et distante, sockets Unix, X11, agent forwarding, et l'ouverture d'un second canal sur la même connexion. Les variables d'environnement envoyées par le client sont acceptées dans le vide, sans jamais atteindre quoi que ce soit.
Il ne suffit pas de l'écrire. Je l'ai vérifié en lançant le serveur et en l'attaquant avec un vrai client OpenSSH :
| Tentative | Résultat |
|---|---|
ssh cv@host 'id; cat /etc/passwd' | exec request failed, code 255 |
sftp | subsystem request failed on channel 0 |
scp host:/etc/hostname | échec, aucun fichier écrit |
ssh -L (tunnel local) | administratively prohibited, 0 octet transmis |
ssh -R (tunnel distant) | remote port forwarding failed |
ssh -A -X | refusé |
| Authentification par mot de passe | Permission denied (publickey) |
Le cas du tunnel local mérite une note, parce qu'il est trompeur. Le port local s'ouvre toujours : c'est le client SSH qui l'ouvre, sur votre machine, avant même de demander quoi que ce soit au serveur. Voir le port répondre ne prouve rien. Ce qui compte est de mesurer combien d'octets le traversent réellement. Ici, zéro.
Un port qui sera scanné en permanence
Le port 1337 ouvert sur internet reçoit du trafic automatisé en continu. Deux conséquences que je n'avais pas anticipées avec assez de sérieux.
D'abord, la journalisation devient une surface d'attaque. Écrire une ligne de log par connexion sur un port scanné, c'est offrir un remplissage de disque à quiconque sait ouvrir une boucle. Le serveur n'écrit donc rien par connexion. Il agrège des compteurs et émet une seule ligne toutes les cinq minutes, et seulement s'il s'est passé quelque chose. Après une trentaine de connexions de test, dont des tentatives d'exécution, de scp, de tunnels et onze refus, le journal contenait trois lignes, toutes émises au démarrage.
Ensuite, le nom d'utilisateur est un texte choisi par l'attaquant. Il ne doit jamais atterrir dans un journal, ni dans un chemin de fichier, ni dans une URL. Il est confronté à une liste blanche compilée dans le binaire, qui renvoie une chaîne statique, puis il est jeté.
L'admission est bornée sur trois axes indépendants : soixante-quatre sessions simultanées au total, quatre par adresse IP, et douze ouvertures par minute et par adresse. La table qui suit ces adresses est elle-même plafonnée, parce qu'un limiteur de débit qui grossit sans limite est simplement une fuite de mémoire avec une bonne intention. Chaque session détient un jeton dont la libération est portée par Drop, ce qui garantit que même une panique dans une tâche rend sa place.
Deux minuteries ferment le reste : cent vingt secondes d'inactivité, dix minutes en absolu. Personne ne lit un CV pendant dix minutes.
Les détails qui ne se voient pas
La clé d'hôte doit survivre aux redéploiements. Une clé régénérée déclenche chez tout visiteur déjà venu le grand avertissement REMOTE HOST IDENTIFICATION HAS CHANGED, qui pour un service public est indiscernable d'une attaque. Elle est donc persistée sur un volume, créée en 0600, et si le fichier existe mais est illisible, le serveur refuse de démarrer plutôt que d'en fabriquer une nouvelle en silence.
Pas de panic = "abort". C'est contre-intuitif pour un binaire de production, mais une panique dans la tâche d'une session doit tuer cette session, pas emporter le serveur public avec elle.
LTO partiel plutôt que complet. Le LTO complet avec codegen-units = 1 fait dépasser les 2 Gio à rustc sur ce graphe de dépendances, et se fait tuer par l'OOM killer dans un conteneur. Vérifié à mes dépens. Le LTO « thin » garde l'essentiel du bénéfice pour une fraction du pic mémoire, et le build passe aussi bien sur un runner de CI que sur un portable.
Le site peut tomber sans que le CV disparaisse. Les documents sont mis en cache cinq minutes, et en cas d'échec de rafraîchissement, la version périmée est servie avec un avertissement dans les logs. Un CV ne change pas assez souvent pour qu'une panne passagère mérite une page d'erreur.
Ce que j'en retiens
Le code utile tient en quelques centaines de lignes. La sécurité, elle, tient surtout dans une liste de choses qu'on refuse, et cette liste doit être écrite à la main, une par une, parce que les valeurs par défaut d'une bibliothèque SSH sont pensées pour un serveur qui donne accès à une machine.
Et surtout : la différence entre « le code refuse » et « le serveur refuse » se mesure avec un client, pas avec une relecture. J'ai lu ce code plusieurs fois en le croyant correct. C'est en lançant ssh -L contre lui, et en comptant les octets, que j'ai su.