dsc harden¶
Turn a fresh Ubuntu server reachable via ssh root@host into a hardened box ready for dsc install. WIP — currently ships stages 1 and 2; stage 3 is in development.
Usage¶
Starting from a fresh ssh root@host that you can already log in to (typical for a cloud-provisioned VM with your initial SSH key on creation), dsc harden runs stages 1 and 2 end-to-end. After it succeeds, the SSH entry-point on the box has moved to discourse@host:2227 (or whatever you configured); root login is disabled, password auth is disabled, and only the named user can SSH at all.
What stage 1 does¶
- Preflight — confirms the remote is Ubuntu, has ≥ 1 GB RAM (warns < 2 GB), has ≥ 5 GB free on
/var(warns < 30 GB), and that the SSH user is currently root. - Creates the new user with
adduser --disabled-password. Skipped if the user already exists. - Grants sudo NOPASSWD by dropping a single-file snippet into
/etc/sudoers.d/, validated throughvisudo -cfbefore being moved into place. Safer than editing the main sudoers, and idempotent. - Installs the supplied pubkey into the new user's
authorized_keyswith correct perms. Deduplicated, so re-running doesn't append duplicate lines. - Verifies new-user SSH actually works by opening a second SSH session as the new user and running
whoami. If this fails, the run errors out before stage 2 touches sshd — so a bad pubkey can never lock you out of the box. The originalssh root@hostis still usable for debugging.
What stage 2 does¶
- Writes
/etc/ssh/sshd_config.d/90-dsc-harden.confwith:Port <ssh-port>,PermitRootLogin no,PasswordAuthentication no,PubkeyAuthentication yes,MaxAuthTries 3,LoginGraceTime 30,AllowUsers <new-user>,X11Forwarding no,AllowAgentForwarding no,ClientAliveInterval 300,ClientAliveCountMax 2, plus modern cipher/KEX/MAC pins (chacha20-poly1305, curve25519-sha256, hmac-sha2-*-etm) — drops CBC ciphers and SHA-1 MACs. - Validates the file with
sshd -tbefore installing it. A typo can't break the daemon's next restart. - Patches
ssh.socketon Ubuntu's socket-activated systems via/etc/systemd/system/ssh.socket.d/90-dsc-harden.confso the systemd listener actually binds the new port. Without this step the sshd config knows about the new port but systemd is still listening on 22 — a subtle Ubuntu 22.04+ pitfall. - Verifies the new port by opening a third SSH session as
<new-user>@<host>:<ssh-port>. Same self-protection logic as stage 1 — if this fails, you get a clear error pointing at how to roll back from the still-open root session.
After a successful run: root@22 stops responding (port 22 isn't listened to anymore), and the only way in is <new-user>@<host>:<ssh-port> with the configured pubkey.
Idempotency caveat¶
Stages 1 and 2 are individually idempotent within the fresh-box flow — if you re-run dsc harden and stage 1 has already been done, step-by-step skip messages confirm this. However, after stage 2 has run successfully the box is no longer reachable as root@host:22, so a naive dsc harden <host> will fail at step 1 with Connection refused. To re-run after a successful harden you either need to update your ~/.ssh/config for the host so the alias resolves to <new-user>@<host>:<ssh-port>, or pass --ssh-user discourse explicitly (CLI doesn't yet expose --initial-port; tracked as a follow-up).
What stage 3 will add¶
Tracked in spec/commands/install.md. Briefly: UTC timezone, time-sync verified, swap file (2 GB by default), journald log cap, unattended security upgrades, fail2ban, rootless Docker (per the Bawmedical playbook — setcap cap_net_bind_service=ep on rootlesskit, loginctl enable-linger), and ufw opened for Discourse's standard ports.
Configuration ([harden] block)¶
Every flag has a sensible built-in default. You can override defaults globally in dsc.toml's [harden] block, and override that on a per-run basis with the CLI flag. Resolution: CLI flag → [harden] block → built-in default.
[harden]
new_user = "discourse"
ssh_port = 2227
docker_install_url = "https://get.docker.com"
docker_rootless = true
swap_size_gb = 2 # 0 to skip
journald_max_use = "500M"
timezone = "UTC"
unattended_security_upgrades = true
fail2ban = true
mosh = false # opt-in; opens UDP 60000-61000
# sshd_ciphers, sshd_kex, sshd_macs override dsc's policy overlays
# (not brittle full allow-lists)
# extra_ufw_allow = ["3000/tcp", "192.168.1.0/24"]
Read dsc.example.toml for the full annotated block.
Why publish a hardening routine?¶
A reasonable concern: doesn't publishing the exact steps dsc harden takes give attackers a blueprint? Short answer — yes a tiny bit, but no in any meaningful sense, and the auditability gain dwarfs the fingerprinting cost.
What an attacker actually learns:
- The defaults you'll likely run (port 2227, user
discourse, fail2ban tuning, ufw rule list). Mostly fingerprinting value, not exploit value. Port scanners find non-default SSH in under a minute anyway, andPasswordAuthentication nomakes the username irrelevant for brute force. - The list of packages and configs. Already implied by "this is a Discourse server"; nothing new.
- Bug surface in the hardening tool itself. This is the real risk — see SECURITY.md.
What you gain by publishing:
- Auditability. You can read
src/commands/harden.rsbefore running it as root on a fresh box. The alternative — closed binary with full root — is much worse for trust. - Crowd review. Security researchers can spot mistakes before attackers do. Every major hardening tool is open (ansible-hardening, OpenSCAP, dev-sec.io, CIS, lynis) for exactly this reason.
- Reproducibility. You can rebuild the same posture by hand from the published steps if you don't want the tool.
Mitigations dsc applies:
- Every "magic" default is configurable — port, username, swap size, packages, the lot. Power users who want extra obscurity can deviate; defaults stay readable for everyone else.
- No secrets, backdoor users, or default keys are ever shipped.
- Every non-obvious step has an inline
// why:comment so reviewers can reason quickly. - Vulnerabilities should be reported privately — see SECURITY.md.
The one genuinely new risk: once enough people use dsc harden, attackers can fingerprint dsc-hardened boxes (specific port + user + ufw rule combination + sshd algorithm list). For most users this is acceptable — being identifiably-hardened is still safer than being unhardened — but worth knowing so power users can opt out of any default they care about.
Testing the new user before going further¶
Stage 1's self-lockout guard catches the common failure mode (bad pubkey, wrong username, sshd quirks), but it's still good practice to test the new-user SSH yourself before invoking stage 2. From your laptop:
If that succeeds, you're safe to run subsequent stages.
Examples¶
# Bare-minimum first run on a fresh Hetzner box; uses every default.
dsc harden 192.0.2.1 --pubkey-file ~/.ssh/myserver.pub
# Dry-run first to inspect the exact commands.
dsc --dry-run harden 192.0.2.1 --pubkey-file ~/.ssh/myserver.pub
# Override a default on a per-run basis.
dsc harden 192.0.2.1 --pubkey-file ~/.ssh/myserver.pub --new-user ops --ssh-port 40022
Related¶
dsc install— declarative Discourse install on a hardened box. WIP, lands afterdsc hardenis feature-complete.dsc update— runs OS + Discourse rebuilds via SSH. Already shipped; complementsdsc hardenonce the box is provisioned.dsc config check— verifies API and SSH connectivity for every install indsc.toml. Good first thing to run afterdsc installadds your new box.