Post

VPS Security Hardening and Monitoring with Auditd, Fail2ban, and a One-Command Report

VPS Security Hardening and Monitoring with Auditd, Fail2ban, and a One-Command Report

This post presents a complete VPS security stack: kernel hardening via sysctl, file change auditing with auditd, brute-force protection with fail2ban, automated updates with conditional reboots, GeoIP whitelisting, and a single script that summarizes all security metrics into a readable report.

Problem Statement

A fresh VPS presents several security vulnerabilities:

  • SSH exposed to the internet with default settings
  • No visibility into authentication attempts
  • No alerting when critical files change
  • No automatic blocking of attackers

Multiple defensive layers are required:

LayerToolPurpose
Preventionsysctl, SSH configReduce attack surface
DetectionauditdLog file changes, sudo usage
Responsefail2banAuto-ban repeat offenders
MaintenanceAuto-updatesStay patched, reboot when needed
NetworkGeoIP blockingDrop traffic from unwanted regions
VisibilityReport scriptConsolidated status view

Architecture

graph TD
    subgraph SECURITY_STACK["SECURITY STACK"]
        subgraph PREVENT["PREVENT"]
            P1["SSH hardening"]
            P2["sysctl"]
            P3["firewall"]
        end

        subgraph DETECT["DETECT"]
            D1["auditd"]
            D2["journald"]
            D3["lastb/last"]
        end

        subgraph RESPOND["RESPOND"]
            R1["fail2ban"]
            R2["iptables"]
        end

        PREVENT --> REPORT["REPORT SCRIPT<br/>One command to<br/>see everything"]
        DETECT --> REPORT
        RESPOND --> REPORT
    end

Layer 1: Kernel Hardening (sysctl)

Create /etc/sysctl.d/90-security.conf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Log martian packets (spoofed source addresses)
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1

# Strict reverse path filtering (drop packets with unreachable source)
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# Disable ICMP redirects (MITM protection)
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0

# Disable IPv6 router advertisements (not needed on servers)
net.ipv6.conf.all.accept_ra = 0
net.ipv6.conf.default.accept_ra = 0

# Restrict unprivileged BPF (CVE mitigations)
kernel.unprivileged_bpf_disabled = 1

# Hide kernel pointers from unprivileged users
kernel.kptr_restrict = 2

# Restrict dmesg to root
kernel.dmesg_restrict = 1

# Disable core dumps for SUID binaries
fs.suid_dumpable = 0

Apply immediately:

1
sudo sysctl --system

Setting Descriptions

SettingAttack Mitigated
log_martiansDetect IP spoofing attempts
rp_filterBlock packets with forged source IPs
accept_redirects=0Prevent MITM route injection
accept_ra=0Prevent rogue IPv6 router attacks
unprivileged_bpf_disabledBlock BPF-based container escapes
kptr_restrictHide kernel addresses (exploit hardening)
dmesg_restrictPrevent info leaks via dmesg
suid_dumpable=0Prevent credential extraction from core dumps

Layer 2: SSH Hardening

Edit /etc/ssh/sshd_config:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Only IPv4 (disable if IPv6 is required)
AddressFamily inet

# No root login
PermitRootLogin no

# Fail fast
MaxAuthTries 3

# Keys only, no passwords
PasswordAuthentication no
PermitEmptyPasswords no

# Explicit authorized_keys location
AuthorizedKeysFile .ssh/authorized_keys

Restart SSH:

1
sudo systemctl restart sshd

Rationale

  • No root login: Attackers must guess both username AND key
  • MaxAuthTries 3: Fewer guesses before disconnect
  • No passwords: Eliminates brute-force attack vector entirely (keys only)

Layer 3: File Auditing (auditd)

Auditd monitors files and logs access/modifications. Create /etc/audit/rules.d/security.rules:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# Clear existing rules
-D

# Buffer size (increase for busy systems)
-b 8192

# Failure mode: 1=printk, 2=panic
-f 1

# === AUTHENTICATION ===
-w /var/log/lastlog -p wa -k logins
-w /var/run/faillock -p wa -k logins
-w /var/log/tallylog -p wa -k logins

# PAM config
-w /etc/pam.d -p wa -k auth_config
-w /etc/security -p wa -k auth_config

# === PRIVILEGE ESCALATION ===
-w /etc/sudoers -p wa -k sudo_config
-w /etc/sudoers.d -p wa -k sudo_config
-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k sudo_exec
-a always,exit -F arch=b64 -S execve -F path=/usr/bin/su -k sudo_exec

# === IDENTITY FILES ===
-w /etc/passwd -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/gshadow -p wa -k identity

# === SSH ===
-w /etc/ssh/sshd_config -p wa -k sshd_config
-w /etc/ssh/sshd_config.d -p wa -k sshd_config

# === SCHEDULED TASKS ===
-w /etc/crontab -p wa -k cron
-w /etc/cron.d -p wa -k cron
-w /var/spool/cron -p wa -k cron

# === SERVICES ===
-w /etc/systemd/system -p wa -k systemd
-w /usr/lib/systemd/system -p wa -k systemd

# === NETWORK ===
-w /etc/hosts -p wa -k network
-w /etc/resolv.conf -p wa -k network
-w /etc/iptables -p wa -k firewall
-w /etc/nftables.conf -p wa -k firewall

# Make rules immutable until reboot
-e 2

Load rules:

1
2
sudo augenrules --load
sudo systemctl enable --now auditd

Querying Audit Logs

1
2
3
4
5
6
7
8
9
10
11
# All sudo executions today
sudo ausearch -k sudo_exec -ts today

# Changes to passwd/shadow
sudo ausearch -k identity -ts today

# SSH config modifications
sudo ausearch -k sshd_config -ts today

# Recent cron changes
sudo ausearch -k cron -ts recent

Understanding Audit Output

1
2
3
type=SYSCALL ... key="identity"
type=PATH name="/etc/passwd"
type=PROCTITLE proctitle="useradd badguy"

The key (-k flag) enables filtering of related events. The PATH shows the file accessed, PROCTITLE shows the command.

Layer 4: Brute-Force Protection (fail2ban)

Base Configuration

Create /etc/fail2ban/jail.local:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[DEFAULT]
# Ban for 1 hour
bantime = 1h

# Detection window
findtime = 10m

# Strikes before ban
maxretry = 5

# Use systemd journal
backend = systemd

# Never ban these IPs (home IP, etc.)
ignoreip = 127.0.0.1/8 ::1

[sshd]
enabled = true
port = ssh
maxretry = 3

Nginx Jails

Protect the web server from scanners and bots:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[nginx-botsearch]
enabled = true
port = http,https
logpath = /var/log/nginx/access.log
maxretry = 2
findtime = 1m
bantime = 24h

[nginx-bad-request]
enabled = true
port = http,https
logpath = /var/log/nginx/access.log
maxretry = 3
bantime = 1h

[nginx-http-auth]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
maxretry = 3
bantime = 1h

[nginx-forbidden]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
maxretry = 5
bantime = 1h

Custom Jail: Rathole Tunnels

For rathole tunnels (see previous post), add protection.

Create /etc/fail2ban/filter.d/rathole-ssh.conf:

1
2
3
[Definition]
failregex = ^.*RATHOLE_BLOCKED: .*SRC=<HOST>.*DPT=(2222|2223).*$
ignoreregex =

Create /etc/fail2ban/jail.d/rathole-ssh.conf:

1
2
3
4
5
6
7
8
9
10
[rathole-ssh]
enabled = true
filter = rathole-ssh
backend = systemd
journalmatch = _TRANSPORT=kernel
port = 2222,2223
maxretry = 3
findtime = 10m
bantime = 1h
action = iptables-multiport[name=rathole-ssh, port="2222,2223", protocol=tcp]

Enable fail2ban

1
sudo systemctl enable --now fail2ban

Check Status

1
2
3
4
5
6
7
8
# List all jails
sudo fail2ban-client status

# Check specific jail
sudo fail2ban-client status sshd

# Recent bans
sudo grep "Ban\|Unban" /var/log/fail2ban.log | tail -20

Layer 5: The Report Script

Consolidate all security information with a single script that shows:

  • Recent logins and failures
  • Sudo activity
  • Critical file changes
  • Fail2ban status and recent bans
  • Network connections
  • System health

Create /usr/local/bin/security-report:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
#!/bin/bash
# Security Report Script
# Usage: security-report [hours]
# Default: last 24 hours

HOURS="${1:-24}"
SINCE="$(date -d "${HOURS} hours ago" '+%Y-%m-%d %H:%M:%S' 2>/dev/null)"
DIVIDER="═══════════════════════════════════════════════════════════════════"

echo "$DIVIDER"
echo "  SECURITY REPORT - $(hostname)"
echo "  Generated: $(date)"
echo "  Period: Last ${HOURS} hours"
echo "$DIVIDER"

# === AUTHENTICATION ===
echo -e "\n[AUTHENTICATION]"
echo "─────────────────────────────────────────────────────────────────────"

echo -e "\nSuccessful logins:"
last -n 20 2>/dev/null | head -20

echo -e "\nFailed login attempts (last 20):"
sudo lastb 2>/dev/null | head -20 || echo "  (requires root)"

echo -e "\nCurrent sessions:"
who

# === SUDO ACTIVITY ===
echo -e "\n\n[SUDO ACTIVITY]"
echo "─────────────────────────────────────────────────────────────────────"
sudo ausearch -k sudo_exec -ts recent 2>/dev/null | \
    grep -E 'type=SYSCALL|exe=|comm=' | head -30 || \
    echo "  No recent sudo activity or auditd not running"

# === AUDIT: FILE CHANGES ===
echo -e "\n\n[CRITICAL FILE CHANGES]"
echo "─────────────────────────────────────────────────────────────────────"

echo -e "\nIdentity files (passwd, shadow, group):"
sudo ausearch -k identity -ts today 2>/dev/null | \
    grep -E 'name=|type=PATH' | head -20 || echo "  None"

echo -e "\nSSH config changes:"
sudo ausearch -k sshd_config -ts today 2>/dev/null | \
    grep -E 'name=|type=PATH' | head -10 || echo "  None"

echo -e "\nSystemd unit changes:"
sudo ausearch -k systemd -ts today 2>/dev/null | \
    grep -E 'name=|type=PATH' | head -10 || echo "  None"

echo -e "\nCron changes:"
sudo ausearch -k cron -ts today 2>/dev/null | \
    grep -E 'name=|type=PATH' | head -10 || echo "  None"

# === FAIL2BAN ===
echo -e "\n\n[FAIL2BAN STATUS]"
echo "─────────────────────────────────────────────────────────────────────"
if command -v fail2ban-client &>/dev/null; then
    sudo fail2ban-client status 2>/dev/null
    echo ""

    for jail in $(sudo fail2ban-client status 2>/dev/null | \
                  grep "Jail list" | sed 's/.*://;s/,//g'); do
        echo "--- $jail ---"
        sudo fail2ban-client status "$jail" 2>/dev/null | \
            grep -E 'Currently|Total'
    done

    echo -e "\nRecent bans (last ${HOURS}h):"
    sudo grep -E "Ban|Unban" /var/log/fail2ban.log 2>/dev/null | tail -20
else
    echo "  fail2ban not installed"
fi

# === NETWORK ===
echo -e "\n\n[NETWORK]"
echo "─────────────────────────────────────────────────────────────────────"

echo "Listening ports (external):"
ss -tlnp 2>/dev/null | grep -v "127.0.0" | grep LISTEN

echo -e "\nTop connections by IP:"
ss -tnp 2>/dev/null | grep ESTAB | \
    awk '{print $5}' | cut -d: -f1 | \
    sort | uniq -c | sort -rn | head -10

# === SYSTEM ===
echo -e "\n\n[SYSTEM]"
echo "─────────────────────────────────────────────────────────────────────"

echo "Uptime:"
uptime

echo -e "\nDisk usage:"
df -h / | tail -1

echo -e "\nMemory:"
free -h | grep Mem

echo -e "\nRunning services:"
systemctl list-units --type=service --state=running --no-pager 2>/dev/null | \
    grep -c running | xargs echo "Total:"

# === RECENT WARNINGS ===
echo -e "\n\n[RECENT SECURITY EVENTS]"
echo "─────────────────────────────────────────────────────────────────────"
sudo journalctl -p warning --since "${HOURS} hours ago" --no-pager 2>/dev/null | \
    tail -30

echo -e "\n$DIVIDER"
echo "  END OF REPORT"
echo "$DIVIDER"

Make executable:

1
sudo chmod +x /usr/local/bin/security-report

Usage

1
2
3
4
5
6
7
8
# Last 24 hours (default)
security-report

# Last 6 hours
security-report 6

# Last week
security-report 168

Sample Output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
═══════════════════════════════════════════════════════════════════
  SECURITY REPORT - myvps
  Generated: Sat Feb 28 14:30:00 MST 2026
  Period: Last 24 hours
═══════════════════════════════════════════════════════════════════

[AUTHENTICATION]
─────────────────────────────────────────────────────────────────────

Successful logins:
youruser  pts/0   your.home.ip     Sat Feb 28 14:25   still logged in
youruser  pts/0   your.home.ip     Sat Feb 28 10:12 - 12:45  (02:33)

Failed login attempts (last 20):
root     ssh:notty    45.227.253.130   Sat Feb 28 13:42 - 13:42  (00:00)
admin    ssh:notty    103.145.12.88    Sat Feb 28 11:15 - 11:15  (00:00)

Current sessions:
youruser  pts/0        2026-02-28 14:25 (your.home.ip)


[FAIL2BAN STATUS]
─────────────────────────────────────────────────────────────────────
Status
|- Number of jail:      5
`- Jail list:   nginx-botsearch, nginx-forbidden, nginx-http-auth, rathole-ssh, sshd

--- sshd ---
   |- Currently banned: 2
   `- Total banned:     47
--- nginx-botsearch ---
   |- Currently banned: 5
   `- Total banned:     312

Recent bans (last 24h):
2026-02-28 13:42:15 fail2ban.actions: NOTICE  [sshd] Ban 45.227.253.130
2026-02-28 11:15:03 fail2ban.actions: NOTICE  [sshd] Ban 103.145.12.88
2026-02-28 09:22:41 fail2ban.actions: NOTICE  [nginx-botsearch] Ban 185.220.101.34

Scheduled Reports

Daily Email

Create /etc/cron.d/security-report:

1
0 8 * * * root /usr/local/bin/security-report 24 | mail -s "Daily Security Report - $(hostname)" you@example.com

Weekly Summary

1
0 8 * * 1 root /usr/local/bin/security-report 168 | mail -s "Weekly Security Report - $(hostname)" you@example.com

Alerting on Critical Events

For real-time alerts on critical file changes, add a systemd path unit.

/etc/systemd/system/passwd-alert.path:

1
2
3
4
5
6
7
8
[Unit]
Description=Monitor /etc/passwd for changes

[Path]
PathModified=/etc/passwd

[Install]
WantedBy=multi-user.target

/etc/systemd/system/passwd-alert.service:

1
2
3
4
5
6
[Unit]
Description=Alert on /etc/passwd change

[Service]
Type=oneshot
ExecStart=/bin/bash -c 'echo "ALERT: /etc/passwd modified on $(hostname) at $(date)" | mail -s "CRITICAL: passwd changed" you@example.com'

Enable:

1
sudo systemctl enable --now passwd-alert.path

Quick Reference

Check Security Status

1
2
3
4
5
6
7
8
9
10
11
# One-command overview
security-report

# Currently banned IPs
sudo fail2ban-client status sshd

# Recent file changes
sudo ausearch -k identity -ts today

# Current logged-in users
who

Respond to Incidents

1
2
3
4
5
6
7
8
9
10
11
# Manually ban an IP
sudo fail2ban-client set sshd banip 1.2.3.4

# Unban an IP
sudo fail2ban-client set sshd unbanip 1.2.3.4

# View full audit log for today
sudo ausearch -ts today | less

# Check what a specific user did
sudo ausearch -ua username -ts today

Maintenance

1
2
3
4
5
6
7
8
9
10
11
# Rotate audit logs
sudo service auditd rotate

# Check fail2ban is running
sudo fail2ban-client ping

# Reload fail2ban after config change
sudo fail2ban-client reload

# Verify sysctl settings
sysctl -a | grep -E "rp_filter|log_martians|kptr_restrict"

Automated Updates with Conditional Reboot

Security patches should be applied promptly, but manual updates are often neglected. Automate them with intelligent reboot handling.

The Update Script

Create /usr/local/bin/auto-update.sh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/bin/bash
# Auto-update with conditional reboot
set -euo pipefail

LOG="/var/log/auto-update.log"

# Packages that require a reboot when updated
REBOOT_PATTERNS="^linux$|^linux-lts|^linux-zen|^linux-hardened|^nvidia|^mesa|^vulkan|^amdgpu|^xf86-video"

touch "$LOG"
chmod 640 "$LOG"

echo "=== Update started: $(date) ===" >> "$LOG"

# Check for available updates (Arch Linux)
UPDATES=$(checkupdates 2>/dev/null || true)

if [ -z "$UPDATES" ]; then
    echo "No updates available" >> "$LOG"
    exit 0
fi

echo "Updates available:" >> "$LOG"
echo "$UPDATES" >> "$LOG"

# Check if any kernel/driver updates require reboot
NEEDS_REBOOT=$(echo "$UPDATES" | cut -d' ' -f1 | grep -E "$REBOOT_PATTERNS" || true)

# Perform update
if ! pacman -Syu --noconfirm >> "$LOG" 2>&1; then
    echo "ERROR: Update failed" >> "$LOG"
    exit 1
fi

echo "Update completed successfully" >> "$LOG"

# Reboot if kernel or graphics drivers were updated
if [ -n "$NEEDS_REBOOT" ]; then
    echo "Kernel/driver updates detected, scheduling reboot:" >> "$LOG"
    echo "$NEEDS_REBOOT" >> "$LOG"
    echo "Rebooting in 1 minute..." >> "$LOG"
    shutdown -r +1 "System reboot for kernel/driver updates"
fi

echo "=== Update finished: $(date) ===" >> "$LOG"

For Debian/Ubuntu, replace the update commands:

1
2
3
4
5
6
# Debian/Ubuntu version
UPDATES=$(apt list --upgradable 2>/dev/null | grep -v "^Listing" || true)
REBOOT_PATTERNS="linux-image|linux-headers|nvidia|mesa"

# Perform update
apt update && apt upgrade -y >> "$LOG" 2>&1

Systemd Timer

/etc/systemd/system/auto-update.service:

1
2
3
4
5
6
7
8
[Unit]
Description=Automatic system update with conditional reboot
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/auto-update.sh

/etc/systemd/system/auto-update.timer:

1
2
3
4
5
6
7
8
9
10
[Unit]
Description=Run auto-update daily at 3am

[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target

Enable:

1
2
sudo chmod +x /usr/local/bin/auto-update.sh
sudo systemctl enable --now auto-update.timer

Conditional Reboot Rationale

Not all updates require a reboot. The script only reboots when:

Package PatternReboot Reason
linux*Kernel updates require reboot to load
nvidia, amdgpuGPU drivers loaded at boot
mesa, vulkanGraphics stack, safer to reboot

Regular package updates (nginx, fail2ban, etc.) apply immediately without disruption.

Check Update History

1
2
3
4
5
# Recent update activity
tail -50 /var/log/auto-update.log

# Next scheduled run
systemctl list-timers auto-update.timer

GeoIP Whitelisting

Block entire countries at the kernel level with ipset. Only allow traffic from the desired country.

The Update Script

Create /etc/geoblock/update-blocklist.sh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#!/bin/bash
# GeoIP whitelist - only allow US traffic
set -euo pipefail

IPSET_NAME="geoallow"
ZONE_DIR="/etc/geoblock/zones"
MIN_ENTRIES=15000  # US zone should have ~17k entries

mkdir -p "$ZONE_DIR"

echo "Downloading US IP zones..."
curl -sSL "https://www.ipdeny.com/ipblocks/data/countries/us.zone" \
    -o "$ZONE_DIR/us.zone.new"

# Validate download
new_count=$(grep -cE '^[0-9]' "$ZONE_DIR/us.zone.new" 2>/dev/null || echo 0)
if [[ "$new_count" -lt "$MIN_ENTRIES" ]]; then
    echo "ERROR: Zone file only has $new_count entries (min $MIN_ENTRIES)"
    rm -f "$ZONE_DIR/us.zone.new"
    exit 1
fi

mv "$ZONE_DIR/us.zone.new" "$ZONE_DIR/us.zone"
echo "Zone validated: $new_count entries"

# Build new set atomically (no downtime)
TEMP_SET="${IPSET_NAME}_tmp"
ipset create "$TEMP_SET" hash:net maxelem 100000 2>/dev/null || ipset flush "$TEMP_SET"

# Always allow localhost
ipset add "$TEMP_SET" 127.0.0.0/8

# Load country IPs
while read -r cidr; do
    [[ -z "$cidr" || "$cidr" =~ ^# ]] && continue
    ipset add "$TEMP_SET" "$cidr" 2>/dev/null
done < "$ZONE_DIR/us.zone"

# Atomic swap
ipset swap "$TEMP_SET" "$IPSET_NAME"
ipset destroy "$TEMP_SET"

echo "GeoIP whitelist updated"

IPTables Rule

Add to the firewall setup:

1
2
3
4
5
# Create the ipset if it does not exist
ipset create geoallow hash:net maxelem 100000 2>/dev/null || true

# Drop traffic not in whitelist (apply to INPUT chain)
iptables -I INPUT -m set ! --match-set geoallow src -j DROP

Weekly Updates

/etc/systemd/system/geoblock-update.timer:

1
2
3
4
5
6
7
8
9
[Unit]
Description=Weekly GeoIP update

[Timer]
OnCalendar=weekly
Persistent=true

[Install]
WantedBy=timers.target

/etc/systemd/system/geoblock-update.service:

1
2
3
4
5
6
[Unit]
Description=Update GeoIP blocklist

[Service]
Type=oneshot
ExecStart=/etc/geoblock/update-blocklist.sh

Enable:

1
sudo systemctl enable --now geoblock-update.timer

Adding More Countries

For multiple countries, download and merge zones:

1
2
3
4
for country in us ca gb; do
    curl -sSL "https://www.ipdeny.com/ipblocks/data/countries/${country}.zone" \
        >> "$ZONE_DIR/allowed.zone"
done

Conclusion

A layered security approach provides comprehensive protection:

  1. sysctl: Kernel-level hardening (prevent classes of attacks)
  2. SSH config: Reduce attack surface (keys only, no root)
  3. auditd: Record changes and access events (detection)
  4. fail2ban: Automatic response to attacks (block repeat offenders)
  5. Auto-updates: Stay patched with intelligent reboots
  6. GeoIP blocking: Reduce attack surface by country
  7. Report script: Single command for consolidated status view

The report script is the force multiplier—instead of requiring multiple commands, security-report provides the complete picture in seconds.

This stack addresses the majority of VPS security requirements with minimal maintenance. The tools are standard, well-documented, and available in every major distribution’s package manager.

This post is licensed under CC BY 4.0 by the author.