Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

linux-vps-hardening-script

One command to take a freshly provisioned Debian/Ubuntu VPS from root + password + port 22 to a hardened baseline: SSH on a non-default port with key-only login, default-deny firewall, fail2ban, kernel hardening, audit trail.

Written because "I configured the server by hand" is how people ship servers with port 22 open, root login enabled and no firewall. This is the same baseline applied the same way on every host — and it can prove it afterwards.

  • Idempotent — running it twice changes nothing the second time and exits 0.
  • Fail-closed — if it cannot find a non-root account with a working SSH key it refuses to disable password authentication (exit 3) instead of locking you out.
  • Auditable — --check reports PASS/FAIL/WARN per control and exits 9 if anything is still open. It is the same control list it just implemented, so it is not a rubber stamp.
$ sudo ./harden.sh --port 22022 --user deploy -y
[i] platform: Debian GNU/Linux 12 (bookworm)
[i] sshd currently listening on port 22 -> target 22022
...
[ok] done in 41s — log /var/log/harden-20260921-214634.log

What it changes

Area Action
Packages apt update && apt upgrade; installs openssh-server ca-certificates curl + ufw + fail2ban python3-systemd
SSH port Moves the listener (default 22022), keeps the old port open until the new one is verified, then closes it with --close-old-port
SSH auth PermitRootLogin no, PasswordAuthentication no, PermitEmptyPasswords no, MaxAuthTries 3, KbdInteractiveAuthentication no — in a drop-in, never by editing the vendor file
SSH crypto Modern ciphers/KEX/MACs, AllowTcpForwarding no, X11Forwarding no, AllowAgentForwarding no (--legacy-crypto keeps the defaults for old clients)
Firewall ufw default deny incoming / allow outgoing, then only 80, 443 and the new SSH port (optionally restricted to a CIDR with --allow-cidr)
Brute force fail2ban with an sshd jail (maxretry 4, bantime 1h) wired to the journal via python3-systemd
Kernel sysctl drop-in: rp_filter, no ICMP redirects/accepts, TCP syncookies, randomize_va_space=2, kptr_restrict=2, fs.suid_dumpable=0
Evidence Every run writes /var/log/harden-<timestamp>.log and backs up every file it touches to /var/backups/harden-<timestamp>/

Every change is written to a new drop-in file (/etc/ssh/sshd_config.d/00-hardening.conf, /etc/sysctl.d/99-hardening.conf, /etc/fail2ban/jail.d/sshd-hardening.local, /etc/apt/apt.conf.d/99-hardening-upgrades). Nothing is edited in place, so the diff is reviewable and dpkg upgrades never fight you over a vendor file.

Quick start

git clone https://github.com/<you>/linux-vps-hardening-script
cd linux-vps-hardening-script

# 1. Look before you leap: prints every action, changes nothing
sudo ./harden.sh --port 22022 --user deploy --dry-run

# 2. Audit the host as it is now
sudo ./harden.sh --check        # exit 9 = controls still failing

# 3. Apply
sudo ./harden.sh --port 22022 --user deploy -y

# 4. Re-audit and keep the output as evidence
sudo ./harden.sh --check | tee reports/audit-$(hostname)-$(date +%F).txt

Run it straight from cloud-init so the box is never reachable in its default state — see examples/cloud-init.yaml.

Usage

--port N              new SSH port                          (default: 22022)
--user NAME           non-root account that keeps SSH access
--allow-cidr CIDR     restrict the SSH port to this CIDR (repeatable)
--check               audit only: report PASS/FAIL, change nothing
--dry-run             print what would be done, change nothing
-y, --yes             do not ask for confirmation
--no-fail2ban         skip fail2ban
--no-ufw              skip the firewall step
--close-old-port      close the previous SSH port in the firewall
--auto-updates        install and configure unattended-upgrades
--legacy-crypto       keep sshd's default ciphers/KEX (old clients, e.g. WinSCP)
--skip-upgrade        do not run apt upgrade (only install missing packages)

Exit codes: 0 ok · 1 fatal/runtime error · 2 bad usage · 3 refused (would lock you out) · 4 unsupported platform · 9 audit found FAIL controls. The distinct codes are deliberate: cron, Ansible or a pipeline can tell "the host is insecure" (9) apart from "the script broke" (1).

Verification

tests/smoke.sh boots a privileged systemd container on Debian 12, installs the script and exercises it end to end — no mocks, no fake sshd -T parser. Real evidence from a run on this repo:

$ make test
== 1) audit BEFORE hardening (expect FAILs, exit 9)      -> 6 PASS / 6 FAIL, rc=9
== 3) apply hardening                                    -> rc=0
== 5) the running sshd answers on the new port           -> TCP open, ssh deploy@22022 works
== 6) idempotency: second run is a no-op                 -> rc=0, "unchanged: ...", 0 FAIL
== 7) audit AFTER hardening                              -> 15 PASS / 0 FAIL / 1 WARN, rc=0
== 8) fail-closed: refuses to disable passwords with no admin key -> rc=3, port untouched
== 9) invalid CIDR / invalid port rejected               -> rc=2

The single WARN is honest: kernel.kptr_restrict and kernel.randomize_va_space are host-global, so inside a container they cannot be written at all — the audit reports the config as present but flags the live value instead of pretending otherwise.

make test     # full end-to-end suite in a throwaway container
make lint     # shellcheck (if installed)
make dry-run  # local dry run
make help

Design decisions

  • Fail-closed over convenient. Disabling password authentication when the admin account has no key is a one-way ticket to a locked-out server. The script checks for a non-empty authorized_keys and a root-prohibited account before writing the config; if it finds none, it aborts with 3.
  • Never touch the vendor config. Drop-ins only. sshd_config.d/00-hardening.conf sorts first, so the effective config is the hardening baseline plus whatever the distro ships for everything else.
  • Verify the new listener before closing the old port. The port change is the classic way to brick remote access; the script checks the new port is actually listening, and only --close-old-port removes the old rule.
  • No lock-in, no agent. Bash + the distro's own tooling. It runs from cloud-init, from Ansible, from a Makefile, or by hand — this same script is what the playbook/template calls, so there is one implementation of the baseline instead of two that drift.
  • Bash, not Ansible, because a 650-line script that a junior can read and that runs before any config-management agent is installed is the shortest path to a hardened box; Ansible earns its place when you have many hosts and want inventory, idempotency across playbooks, and drift detection. Both work here — examples/ shows the cloud-init path.

Requirements

Debian 11/12 or Ubuntu 20.04+ (systemd, apt). --check needs only bash; applying needs root. Tested on Debian 12 in a privileged container and used on VPS instances behind Cloudflare-proxied DNS.

Docs

MIT licensed. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages