---
title: Mon CV se consulte en SSH
description: Comment j'ai exposé un serveur SSH écrit en Rust sur le port 1337 pour afficher mon CV dans un terminal, et pourquoi la partie intéressante n'est pas celle qu'on croit.
publishedAt: 2026-09-07
tags:
  - rust
  - ssh
  - securite
---

```
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](https://github.com/Eugeny/russh) pour le protocole et [ratatui](https://ratatui.rs/) 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.
