Security Hardening¶
Overview¶
Five hardening layers, all configured in security/ in the aether repo and
deployed via make security-deploy.
| Layer | Repo path | Installed at |
|---|---|---|
| SSH config | security/sshd-00-hardening.conf |
/etc/ssh/sshd_config.d/00-hardening.conf |
| Firewall rules | security/iptables-setup-docker.sh |
Run by systemd service |
| Firewall unit | security/iptables-firewall.service |
/etc/systemd/system/ |
| fail2ban jails | security/fail2ban-aletheia.conf |
/etc/fail2ban/jail.d/aletheia.conf |
| fail2ban paths | security/fail2ban-paths-overrides.local |
/etc/fail2ban/paths-overrides.local |
| Kernel tuning | security/sysctl-hardening.conf |
/etc/sysctl.d/99-hardening.conf |
The rationale for each setting lives in comments inside the config files — that's the authoritative source. The tables below are auto-extracted from those files.
SSH¶
| Setting | Value | Rationale |
|---|---|---|
Port |
57361 |
Non-standard port — not real security against targeted attacks, but massively reduces bot scan noise and gives fail2ban fewer events to process. |
PasswordAuthentication |
no |
Keys only. Combined with KbdInteractiveAuthentication, this blocks all interactive fallbacks (PAM, challenge-response). |
KbdInteractiveAuthentication |
no |
Keys only. Combined with KbdInteractiveAuthentication, this blocks all interactive fallbacks (PAM, challenge-response). |
PermitRootLogin |
no |
No direct root login. Use sudo after logging in as debian. |
MaxAuthTries |
3 |
Max attempts per connection before sshd drops it. Low value + rate-limit in iptables gives a tight brute-force envelope. |
X11Forwarding |
no |
X11 not needed on a headless server. Disabling reduces attack surface. |
AllowTcpForwarding |
yes |
TCP forwarding allowed for operational tunneling (e.g. ssh -L 5432:... for local db access). StreamLocal (Unix socket forwarding) not needed. |
AllowStreamLocalForwarding |
no |
TCP forwarding allowed for operational tunneling (e.g. ssh -L 5432:... for local db access). StreamLocal (Unix socket forwarding) not needed. |
AllowAgentForwarding |
no |
Agent forwarding disabled — if the bastion is compromised, attacker could use your keys to hop to other hosts. Use ProxyJump instead (doesn't forward the agent). |
GatewayPorts |
no |
GatewayPorts would let remote forwards bind to external interfaces — not needed. |
AllowUsers |
debian |
Only the debian user can log in. New operator? Add them here explicitly. |
LoginGraceTime |
30 |
Short login grace — slow or broken auth attempts should fail fast, not tie up slots for 2 minutes (the default). |
ClientAliveInterval |
120 |
Keep-alive probe every 2 minutes. Idle sessions eventually drop instead of hanging forever behind NAT/load balancer timeouts. |
Changing the SSH port¶
Three files reference the port and all must be updated:
security/sshd-00-hardening.conf—Portdirectivesecurity/iptables-setup-docker.sh— two--dportrulessecurity/fail2ban-aletheia.conf—[sshd]section
Then:
make security-deploy
sudo systemctl daemon-reload && sudo systemctl restart sshd fail2ban iptables-firewall
Verify before disconnecting
Test the new port from a second terminal before closing your current session. Locking yourself out requires console access to recover.
Firewall (iptables)¶
Default policies:
INPUT: DROP— reject inbound unless explicitly allowedOUTPUT: ACCEPT— all outbound allowedFORWARD: ACCEPT— required by Docker
Open inbound ports:
| Port | Protocol | Notes |
|---|---|---|
57361 |
TCP | SSH (rate-limited: 4 new conns per 60s) |
80 |
TCP | HTTP (redirects to HTTPS) |
443 |
TCP | HTTPS |
Trusted IP: 82.67.73.170 has unrestricted access (including Docker-published ports via the DOCKER-USER chain).
How the script stays Docker-safe¶
The script only flushes INPUT and OUTPUT — it deliberately does NOT touch FORWARD,
NAT, or the DOCKER chain, because Docker manages those. The systemd service runs
After=docker.service so Docker's chains exist before we add rules to DOCKER-USER.
See the rule-level comments in iptables-setup-docker.sh for the full logic.
Reloading firewall rules¶
# After editing iptables-setup-docker.sh:
sudo systemctl restart iptables-firewall
# Persist current rules across reboots (setup.sh does this automatically):
sudo iptables-save > /etc/iptables/rules.v4
Adding a new rule¶
Only edit security/iptables-setup-docker.sh in the repo, never the server copy directly.
Then make security-deploy and restart the service. Common changes:
- Open a new port: add
iptables -A INPUT -p tcp --dport <PORT> -j ACCEPT - Block a Docker-exposed port externally: add
iptables -I DOCKER-USER -p tcp --dport <PORT> -j DROP - Whitelist a new trusted IP: add
iptables -A INPUT -s <IP> -j ACCEPTnear the top
IPv6¶
Only IPv4 is managed. If the server has an IPv6 address, either block all IPv6 or mirror the rules — see initialization post-setup checklist for commands.
fail2ban¶
| Jail | Enabled | maxretry | bantime | findtime | Rationale |
|---|---|---|---|---|---|
sshd |
true | 3 | 1h | 10m | SSH brute-force protection. Explicit port needed because our sshd runs on 57361, not 22 — fail2ban wouldn't know otherwise. |
nginx-http-auth |
true | 3 | 1h | 10m | Failed basic auth on staging/dev htpasswd-protected vhosts. |
nginx-botsearch |
true | 2 | 1h | 10m | Scans for known exploits and common /wp-admin, /phpmyadmin, etc. paths. Lower maxretry (2) because these are almost always malicious — legitimate users don't probe for WordPress admin on a Django site. |
nginx-bad-request |
true | 3 | 1h | 10m | Malformed HTTP requests (often exploit attempts or port scanners). |
nginx-limit-req |
true | 5 | 1h | 10m | Triggers when nginx's limit_req module rejects a client. Higher maxretry (5) because rate limits can trip on legitimate bursts (AJAX polling, async loaders). |
Defaults (applied when jail doesn't override): bantime=1h, findtime=10m, maxretry=3, banaction=iptables-multiport
Log paths¶
Nginx logs live in a Docker named volume, not /var/log/nginx/. The file
security/fail2ban-paths-overrides.local points fail2ban at the actual location
(/var/lib/docker/volumes/nginx_nginx_logs/_data/).
Common operations¶
# List active jails
sudo fail2ban-client status
# Status of a specific jail (banned IPs, totals)
sudo fail2ban-client status sshd
# Manually ban/unban an IP
sudo fail2ban-client set sshd banip 1.2.3.4
sudo fail2ban-client set sshd unbanip 1.2.3.4
Tuning thresholds¶
To change a jail's maxretry, bantime, or findtime:
- Edit
security/fail2ban-aletheia.conf— update the rationale comment if you change a value - Deploy:
make security-deploy - Apply:
sudo systemctl restart fail2ban - Verify:
sudo fail2ban-client status <jail>
Kernel (sysctl)¶
| Parameter | Value | Purpose |
|---|---|---|
net.ipv4.conf.all.rp_filter |
1 |
Reverse path filtering — drop packets with spoofed source IPs |
net.ipv4.conf.default.rp_filter |
1 |
Reverse path filtering — drop packets with spoofed source IPs |
net.ipv4.conf.all.log_martians |
1 |
Log packets with impossible source addresses |
net.ipv4.conf.default.log_martians |
1 |
Log packets with impossible source addresses |
Martians show up in /var/log/kern.log prefixed with martian source. Useful for
detecting misconfigured routers and occasional attack probes, but not actionable on
their own.
Applying sysctl changes¶
Other layers¶
These aren't in security/ but contribute to the overall posture:
- Unattended upgrades:
setup.shenables automatic Debian security patches (/etc/apt/apt.conf.d/20auto-upgrades) - Nginx basic auth: staging and dev environments require htpasswd credentials
(
.htpasswdis encrypted via SOPS + age) - SSL/TLS: Let's Encrypt certs via certbot, auto-renewed every 12h
- Container isolation: apps only reachable via nginx; PostgreSQL bound to
127.0.0.1:5432(not externally accessible)
Verification¶
After any security change:
# SSH config syntax
sudo sshd -t
# Active iptables rules
sudo iptables -L INPUT -v -n --line-numbers
# fail2ban jails
sudo fail2ban-client status
# Applied sysctl values
sudo sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.all.log_martians
# Open listening ports (should show only 57361, 80, 443)
sudo ss -tlnp | grep LISTEN