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>
63 lines
2.9 KiB
YAML
63 lines
2.9 KiB
YAML
#cloud-config
|
|
#
|
|
# Generic base-host bootstrap -- Alpine, Debian, or Alma Linux.
|
|
#
|
|
# On first boot this:
|
|
# 1. Installs prerequisites (bash, git, curl) for whichever distro this is.
|
|
# 2. Clones this repo to /opt/automations.
|
|
# 3. Sets the hostname per the Network Domain Name Schema and installs the
|
|
# shared MOTD banner (scripts/setup-host.sh).
|
|
# 4. Seeds root's authorized_keys from globals/ (URL-preferred).
|
|
# 5. Applies SSH hardening: key-only auth, post-quantum KEX, sshguard
|
|
# (scripts/harden-ssh.sh).
|
|
#
|
|
# Fill in REPO_URL, HOST, and the other values, then paste as instance
|
|
# user-data. For a bastion host use jumphost.yml instead.
|
|
#
|
|
# NOTE: harden-ssh.sh prints a freshly generated root private key to stdout,
|
|
# which lands in the cloud provider's console/serial log. Either capture it
|
|
# from there, or rely on the keys seeded from globals/ and ignore it.
|
|
|
|
runcmd:
|
|
- |
|
|
set -e
|
|
# ===== config =====
|
|
REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git
|
|
REPO_BRANCH=main
|
|
HOST=sto-1 # <svc>-<n>; FQDN becomes HOST.BASE_DOMAIN
|
|
BASE_DOMAIN=srvno.de
|
|
DATACENTER="Globally Everywhere"
|
|
SSH_PORT=22
|
|
ALLOWED_IP= # optional: whitelist your client IP in sshguard
|
|
SSH_ALLOW_CLASSIC_KEX=0 # 1 = also offer curve25519-sha256 for clients
|
|
# with no post-quantum KEX (old Windows ssh.exe);
|
|
# costs store-now-decrypt-later protection
|
|
ENABLE_FIREWALL=1 # deny-by-default host firewall (0 to skip;
|
|
# always skipped on Proxmox -- pve-firewall owns it)
|
|
OPEN_PORTS="" # extra inbound ports, e.g. "80/tcp 443/tcp"
|
|
# ==================
|
|
|
|
# Prerequisites (OS-agnostic).
|
|
if command -v apk >/dev/null 2>&1; then apk add --no-cache bash git curl
|
|
elif command -v apt-get >/dev/null 2>&1; then apt-get update -qq && apt-get install -y -qq bash git curl
|
|
elif command -v dnf >/dev/null 2>&1; then dnf install -y -q bash git curl
|
|
fi
|
|
|
|
git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" /opt/automations
|
|
cd /opt/automations
|
|
|
|
# Hostname + shared MOTD.
|
|
HOST="$HOST" BASE_DOMAIN="$BASE_DOMAIN" DATACENTER="$DATACENTER" bash scripts/setup-host.sh
|
|
|
|
# Seed root's authorized_keys from globals/ (SSH_KEYS_URL or authorized_keys).
|
|
. scripts/lib.sh && load_globals \
|
|
&& install -d -m 700 /root/.ssh \
|
|
&& resolve_ssh_keys >> /root/.ssh/authorized_keys || true
|
|
sort -u /root/.ssh/authorized_keys -o /root/.ssh/authorized_keys 2>/dev/null || true
|
|
|
|
# SSH hardening (key-only, PQ KEX, sshguard) + deny-by-default host firewall.
|
|
SSH_PORT="$SSH_PORT" ALLOWED_IP="$ALLOWED_IP" \
|
|
SSH_ALLOW_CLASSIC_KEX="$SSH_ALLOW_CLASSIC_KEX" \
|
|
ENABLE_FIREWALL="$ENABLE_FIREWALL" OPEN_PORTS="$OPEN_PORTS" \
|
|
FORCE=1 bash scripts/harden-ssh.sh
|