Hardened hosts rejected clients that implement the very same key exchange. The
KEX list was assembled from version arithmetic and emitted only the
standardised spellings:
KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512
OpenSSH called that hybrid sntrup761x25519-sha512@openssh.com before the method
was standardised (8.5, in the default proposal from 8.9) and
sntrup761x25519-sha512 after (9.9), and KEXINIT matches names byte-exactly with
no alias resolution -- so every client older than the rename was refused with
"no matching key exchange method found" despite implementing the algorithm. The
same arithmetic was a latent server-side bug: on OpenSSH 9.0-9.8 it wrote the
post-standardisation name into sshd_config, which those builds do not know, and
sshd fatals on an unknown KexAlgorithms token rather than starting.
Ask the binary instead of guessing. oslib gains kex_supported(),
ssh_kex_pq_list(), ssh_kex_classic_list(), ssh_kex_list() and ssh_kex_has_pq(),
which filter candidates through `ssh -Q kex` and offer every spelling the host
actually has. Version thresholds are gone, and with them both failure modes --
including on distros whose backports make the version string meaningless.
SSH_ALLOW_CLASSIC_KEX=1 (off by default) additionally offers curve25519-sha256
and its @libssh.org spelling. Some clients have no PQ method at all: notably
Windows' in-box ssh.exe, which is not merely old -- Microsoft's fork compiles
sntrup761 out because it needs C99 VLAs that MSVC lacks, so even a fully patched
9.5p2 reports zero PQ methods. The knob is a real trade and says so in the
warning, the generated sshd_config comment, and the README: such a session is
safe against a classical attacker but has no store-now-decrypt-later protection.
Modern clients still negotiate PQ, since the client's preference order decides.
Three defects found reviewing the above, fixed here:
- the printed pre-reload verification command pinned the server's full list via
`-o KexAlgorithms=`, which ssh rejects at option-parse time when the client
lacks any one name. That made the one safety gate before a wholesale
sshd_config swap a false negative for exactly the clients this commit admits.
Dropped, matching harden-jumphost.sh.
- the no-PQ branch was unreachable: without the opt-in the classical names are
never collected, so a host with no PQ hybrid died reporting "no usable key
exchange method" instead of the actionable message written for it. The branch
now keys off a separate PQ probe, and the empty-list die is narrowed to a
genuinely empty `ssh -Q kex`.
- SSH_VER is cosmetic but its grep could abort the whole run under pipefail on
any banner that does not match (vendor forks, OpenSSH_for_Windows_9.5p2) --
silently, with no message. Guarded.
Wired through cloud-init/base.yml and jumphost.yml, since harden-ssh.sh rewrites
sshd_config wholesale on every run and a hand edit there does not survive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cloud-init/
Generic, distro-agnostic cloud-init templates for standing up a base host
or a bastion from scratch. They run on Alpine, Debian, or Alma — the
runcmd prelude detects the package manager and installs bash/git/curl
before invoking the scripts.
| Template | What it does |
|---|---|
base.yml |
Hostname (per the schema) + shared MOTD + seed root keys from globals/ + SSH hardening (harden-ssh.sh) + deny-by-default host firewall (harden-firewall.sh, ENABLE_FIREWALL=1). |
jumphost.yml |
Same base, but bastion hardening (harden-jumphost.sh) with an ssh-admins/ssh-jumpers split and a ProxyJump whitelist; same host firewall. |
These are for the host itself. To stand up a Docker stack on a host,
use the per-deployment cloud-init.yml under deployments/<name>/ instead
(those clone the repo and run the stack's deploy.sh).
Usage
- Copy the template, fill in
REPO_URL,HOST(<svc>-<n>, e.g.sto-1), and the other values at the top of theruncmdblock. - Paste it as the instance's user-data when creating the VM.
- On first boot the host names itself, installs the MOTD, seeds admin keys
from
globals/, hardens SSH, and brings up the deny-by-default firewall.
Hostnames follow ../globals/Network Domain Name Schema.md;
our VMs skip the region code and use srvno.de as the base.
The harden scripts print a generated root private key to stdout, which lands in the cloud provider's serial/console log. Capture it there, or rely on the keys seeded from
globals/authorized_keys(orSSH_KEYS_URL) and ignore it.