---
title: My CV is read over SSH
description: How I exposed an SSH server written in Rust on port 1337 to display my CV in a terminal, and why the interesting part is not the one you would expect.
publishedAt: 2026-09-07
tags:
  - rust
  - ssh
  - security
---

```
ssh -p 1337 gael-demette.fr
```

The CV appears in the terminal. One key switches version, another switches language. With no PTY, the same command produces a text dump you can redirect to a file or pipe into `grep`.

The idea is a wink. What kept me busy was not the idea: it is that opening an SSH port on the internet, accepting anyone with no authentication, is a serious security exercise. Here is what that actually involves.

## A single source of content

The starting point was not SSH, it was duplication. I had a CV as a PDF, an "about" page, and different versions depending on the role I was aiming at. Three places to update, so three chances to diverge.

Everything is now derived from a single set of JSON files. A pure selector applies the rules of a variant (which experiences, what level of detail, which sections) and produces a document. That document is then rendered as a web page, as a PDF, as Markdown, as plain text, and in JSON Resume format.

So the SSH server is not a reimplementation. It is one more client: it queries `/cv/<variant>.json` on the site, exactly as anyone else would, and does nothing but paint the result in a terminal. Adding a job updates all five outputs at once, without my having to think about it.

The SSH username is what selects the variant: `ssh cv-devops@…` opens the infrastructure-oriented version directly. It is the only channel available to a client with no PTY, which by definition has no key to press.

## russh and ratatui

The server is written in Rust, with [russh](https://github.com/Eugeny/russh) for the protocol and [ratatui](https://ratatui.rs/) for the interface. Nothing exotic: russh provides an async implementation of the server side of the SSH protocol, and ratatui draws text-mode interfaces.

Fitting the two together takes a little care. Ratatui writes into a synchronous `io::Write`, russh sends bytes over an async channel. Between the two I put a shared buffer that the session loop drains and then waits on before redrawing. That wait is the backpressure: a client that stops reading prevents the server from producing a new frame, instead of letting a queue grow without bound. Past one megabyte pending, the session is considered lost and closed.

## The real subject: refusing everything

A classic SSH server exists to give access to a machine. This one exists to display a document. Almost the whole protocol therefore has to be refused, and refused explicitly rather than left to a default.

Authentication is anonymous and public-key only. The visitor's key is checked against nothing: it merely gives the protocol an identity to work with. No password is offered, so there is nothing to brute-force with a dictionary.

Everything else is refused: command execution, subsystems (so `sftp` and `scp`), local and remote port forwarding, Unix sockets, X11, agent forwarding, and opening a second channel on the same connection. Environment variables sent by the client are accepted into the void, never reaching anything.

Writing that down is not enough. I verified it by starting the server and attacking it with a real OpenSSH client:

| Attempt | Result |
| --- | --- |
| `ssh cv@host 'id; cat /etc/passwd'` | `exec request failed`, exit code 255 |
| `sftp` | `subsystem request failed on channel 0` |
| `scp host:/etc/hostname` | failed, no file written |
| `ssh -L` (local tunnel) | `administratively prohibited`, 0 bytes transmitted |
| `ssh -R` (remote tunnel) | `remote port forwarding failed` |
| `ssh -A -X` | refused |
| Password authentication | `Permission denied (publickey)` |

The local tunnel case deserves a note, because it is misleading. The local port does open: it is the SSH client that opens it, on your machine, before even asking the server for anything. Seeing the port respond proves nothing. What counts is measuring how many bytes actually cross it. Here, zero.

## A port that will be scanned permanently

Port 1337 open on the internet receives automated traffic continuously. Two consequences I had not anticipated seriously enough.

First, logging becomes an attack surface. Writing one log line per connection on a scanned port is offering a disk fill to anyone who can open a loop. So the server writes nothing per connection. It aggregates counters and emits a single line every five minutes, and only if something happened. After thirty or so test connections, including attempts at execution, `scp`, tunnels and eleven refusals, the log held three lines, all emitted at startup.

Second, the username is attacker-chosen text. It must never land in a log, nor in a file path, nor in a URL. It is matched against an allowlist compiled into the binary, which returns a static string, and then it is thrown away.

Admission is bounded on three independent axes: sixty-four concurrent sessions in total, four per IP address, and twelve openings per minute per address. The table tracking those addresses is itself capped, because a rate limiter that grows without bound is simply a memory leak with good intentions. Each session holds a token whose release is carried by `Drop`, which guarantees that even a panic in a task gives its slot back.

Two timers close the rest: one hundred and twenty seconds of inactivity, ten minutes absolute. Nobody reads a CV for ten minutes.

## The details that do not show

**The host key has to survive redeployments.** A regenerated key triggers, for every returning visitor, the great `REMOTE HOST IDENTIFICATION HAS CHANGED` warning, which for a public service is indistinguishable from an attack. So it is persisted on a volume, created with mode 0600, and if the file exists but is unreadable, the server refuses to start rather than silently manufacturing a new one.

**No `panic = "abort"`.** This is counter-intuitive for a production binary, but a panic in a session's task must kill that session, not take the public server down with it.

**Thin LTO rather than full.** Full LTO with `codegen-units = 1` pushes rustc past 2 GiB on this dependency graph, and gets it killed by the OOM killer in a container. Verified the hard way. Thin LTO keeps most of the benefit for a fraction of the peak memory, and the build passes just as well on a CI runner as on a laptop.

**The site can go down without the CV disappearing.** Documents are cached for five minutes, and if a refresh fails, the stale version is served with a warning in the logs. A CV does not change often enough for a transient outage to deserve an error page.

## What I take away

The useful code fits in a few hundred lines. Security, on the other hand, lives mostly in a list of things you refuse, and that list has to be written by hand, one item at a time, because the defaults of an SSH library are designed for a server that gives access to a machine.

And above all: the difference between "the code refuses" and "the server refuses" is measured with a client, not with a re-read. I read this code several times believing it correct. It was by running `ssh -L` against it, and counting the bytes, that I knew.
