From 0b6eea6113308b7ddd849a4a985ce7a52727e5bf Mon Sep 17 00:00:00 2001 From: Joakim Date: Wed, 12 Nov 2025 20:48:28 +0100 Subject: [PATCH] Initial commit --- README.md | 21 +++ docs/security-hardening.md | 7 + docs/setup-guide.md | 16 ++ inventory/hosts.yml | 11 ++ playbooks/security.yml | 337 +++++++++++++++++++++++++++++++++++++ roles/caddy/README.md | 2 + site.yml | 5 + 7 files changed, 399 insertions(+) create mode 100644 README.md create mode 100644 docs/security-hardening.md create mode 100644 docs/setup-guide.md create mode 100644 inventory/hosts.yml create mode 100644 playbooks/security.yml create mode 100644 roles/caddy/README.md create mode 100644 site.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..c028ac9 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Rick's Infra +## Arch Linux VPS +### Ansible +Infrastructure as code for setting up new instance. +- [ ] Security + - [ ] SSH + - [ ] Firewall + - [ ] Fail2ban + - [ ] Kernel hardening +- [ ] Base packages +- [ ] Monitoring/Logging +- [ ] Backup + +### Services +Services are managed by serviced + +#### Caddy +Reverse proxy. + +### Containers +Containers are managed by rootless Podman. diff --git a/docs/security-hardening.md b/docs/security-hardening.md new file mode 100644 index 0000000..374379e --- /dev/null +++ b/docs/security-hardening.md @@ -0,0 +1,7 @@ +# Securing the VPS +## Network Security +- **SSH Hardening**: Password authentication disabled, root login disabled, key-only authentication +- **Firewall Configuration**: UFW with deny-all incoming, allow-all outgoing defaults +- **Fail2ban**: SSH brute-force protection with configurable ban times +- **Kernel Network Hardening**: IP forwarding disabled, source routing blocked, ICMP redirects disabled + diff --git a/docs/setup-guide.md b/docs/setup-guide.md new file mode 100644 index 0000000..d618590 --- /dev/null +++ b/docs/setup-guide.md @@ -0,0 +1,16 @@ +# Setup guide + +## Get a VPS with Arch Linux OS +- We are using [Hostinger](https://hostinger.com) +- Find it's IP in the Hostinger Dashboard + +## Initial Setup +- Test manual sign in: ```ssh root@``` +- Copy SSH key: +```bash +# From Workstation +ssh-copy-id -i ~/.ssh/id_ed25519.pub root@ +``` +- Add host to Ansible inventory +- Test connection `ansible -i inventory/hosts.yml arch-vps -m ping` +- ```ansible-playbook -i inventory/hosts/yml site.yml``` diff --git a/inventory/hosts.yml b/inventory/hosts.yml new file mode 100644 index 0000000..6c9b73d --- /dev/null +++ b/inventory/hosts.yml @@ -0,0 +1,11 @@ +--- +all: + children: + production: + hosts: + arch-vps: + ansible_host: 69.62.119.31 + ansible_user: root + + vars: + ansible_python_interpreter: /usr/bin/python3 diff --git a/playbooks/security.yml b/playbooks/security.yml new file mode 100644 index 0000000..2d34787 --- /dev/null +++ b/playbooks/security.yml @@ -0,0 +1,337 @@ +--- +- name: Security Hardening + hosts: arch-vps + become: yes + gather_facts: yes + + tasks: + # ============================================ + # System Updates and Package Security + # ============================================ + + - name: Update package database + pacman: + update_cache: yes + + - name: Upgrade all packages to latest versions + pacman: + upgrade: yes + register: package_upgrade + + - name: Display upgrade results + debug: + msg: "{{ package_upgrade.packages | length }} packages were upgraded" + when: package_upgrade.packages is defined + + # ============================================ + # Kernel/Module Version Check and Reboot + # ============================================ + + - name: Get current running kernel version + command: uname -r + register: current_kernel + changed_when: false + + - name: Get latest available kernel modules version + shell: ls /lib/modules/ | sort -V | tail -1 + register: latest_modules + changed_when: false + + - name: Display kernel versions for debugging + debug: + msg: + - "Running kernel: {{ current_kernel.stdout }}" + - "Latest modules: {{ latest_modules.stdout }}" + + - name: Test if nftables modules are available + command: nft list ruleset + register: nft_test_prereq + failed_when: false + changed_when: false + + - name: Determine if reboot is needed + set_fact: + reboot_needed: "{{ current_kernel.stdout != latest_modules.stdout or nft_test_prereq.rc != 0 }}" + + - name: Reboot system if kernel/module mismatch detected + reboot: + reboot_timeout: 60 + test_command: uptime + when: reboot_needed | bool + + - name: Wait for system to be fully ready after reboot + wait_for_connection: + delay: 15 + timeout: 300 + when: reboot_needed | bool + + - name: Verify nftables is now available after reboot + command: nft list ruleset + register: nft_post_reboot + failed_when: false + changed_when: false + + - name: Display post-reboot nftables status + debug: + msg: "nftables availability after reboot: {{ 'Working' if nft_post_reboot.rc == 0 else 'Failed' }}" + + # ============================================ + # SSH Hardening + # ============================================ + + - name: Harden SSH configuration + lineinfile: + path: /etc/ssh/sshd_config + regexp: "{{ item.regexp }}" + line: "{{ item.line }}" + backup: no + loop: + - { regexp: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' } + - { regexp: '^#?PermitRootLogin', line: 'PermitRootLogin prohibit-password' } + - { regexp: '^#?PubkeyAuthentication', line: 'PubkeyAuthentication yes' } + - { regexp: '^#?AuthorizedKeysFile', line: 'AuthorizedKeysFile .ssh/authorized_keys' } + - { regexp: '^#?PermitEmptyPasswords', line: 'PermitEmptyPasswords no' } + - { regexp: '^#?ChallengeResponseAuthentication', line: 'ChallengeResponseAuthentication no' } + - { regexp: '^#?UsePAM', line: 'UsePAM no' } + - { regexp: '^#?X11Forwarding', line: 'X11Forwarding no' } + - { regexp: '^#?MaxAuthTries', line: 'MaxAuthTries 3' } + - { regexp: '^#?ClientAliveInterval', line: 'ClientAliveInterval 300' } + - { regexp: '^#?ClientAliveCountMax', line: 'ClientAliveCountMax 2' } + register: ssh_config_changed + + - name: Test SSH configuration syntax + command: sshd -t + changed_when: false + failed_when: false + register: ssh_test + + - name: Fail if SSH configuration is invalid + fail: + msg: "SSH configuration test failed: {{ ssh_test.stderr }}" + when: ssh_test.rc != 0 + + - name: Restart SSH service if configuration changed + systemd: + name: sshd + state: restarted + when: ssh_config_changed.changed + + - name: Wait for SSH service to be available + wait_for: + port: 22 + host: "{{ ansible_host }}" + delay: 2 + timeout: 30 + delegate_to: localhost + become: no + when: ssh_config_changed.changed + + - name: Test SSH connection with new configuration + ping: + when: ssh_config_changed.changed + + # ============================================ + # nftables Firewall Configuration + # ============================================ + + - name: Install nftables + pacman: + name: nftables + state: present + + - name: Create nftables configuration + copy: + content: | + #!/usr/sbin/nft -f + + # Main firewall table + table inet filter { + chain input { + type filter hook input priority 0; policy drop; + + # Allow loopback interface + iif "lo" accept + + # Allow established and related connections + ct state established,related accept + + # Allow SSH (port 22) + tcp dport 22 ct state new accept + + # Allow HTTP and HTTPS for Caddy reverse proxy + tcp dport { 80, 443 } ct state new accept + + # Allow ping with rate limiting + icmp type echo-request limit rate 1/second accept + icmpv6 type echo-request limit rate 1/second accept + + # Log and drop everything else + counter drop + } + + chain forward { + type filter hook forward priority 0; policy drop; + } + + chain output { + type filter hook output priority 0; policy accept; + } + } + dest: /etc/nftables.conf + mode: '0755' + backup: yes + register: nft_config_changed + + - name: Test nftables configuration syntax + command: nft -c -f /etc/nftables.conf + changed_when: false + failed_when: false + register: nft_test + + - name: Fail if nftables configuration is invalid + fail: + msg: "nftables configuration test failed: {{ nft_test.stderr }}" + when: nft_test.rc != 0 + + - name: Flush existing nftables rules before applying new configuration + command: nft flush ruleset + failed_when: false + changed_when: false + when: nft_config_changed.changed + + - name: Create firewall rollback safety script + copy: + content: | + #!/bin/bash + # Safety rollback script - automatically disables firewall after 5 minutes + echo "$(date): Starting 5-minute firewall safety timer" + sleep 300 + echo "$(date): Safety timer expired, disabling firewall" + nft flush ruleset + systemctl stop nftables + rm -f /tmp/nft-rollback.sh + dest: /tmp/nft-rollback.sh + mode: '0755' + when: nft_config_changed.changed + + - name: Start rollback safety timer in background + shell: nohup /tmp/nft-rollback.sh >> /tmp/nft-rollback.log 2>&1 & + when: nft_config_changed.changed + + - name: Enable and start nftables service + systemd: + name: nftables + enabled: yes + state: restarted + when: nft_config_changed.changed + + - name: Wait for nftables to be active + pause: + seconds: 3 + when: nft_config_changed.changed + + - name: Test SSH connection after firewall activation + wait_for: + port: 22 + host: "{{ ansible_host }}" + timeout: 15 + delegate_to: localhost + become: no + when: nft_config_changed.changed + + - name: Cancel rollback timer if SSH connection works + shell: pkill -f nft-rollback.sh || true + when: nft_config_changed.changed + + - name: Verify nftables rules are loaded + command: nft list ruleset + register: nft_rules + changed_when: false + + - name: Display active firewall rules + debug: + var: nft_rules.stdout_lines + + # ============================================ + # Fail2ban Setup + # ============================================ + + - name: Install fail2ban + pacman: + name: fail2ban + state: present + + - name: Create fail2ban local configuration + copy: + content: | + [DEFAULT] + # Ban hosts for 1 hour (3600 seconds) + bantime = 3600 + banaction = nftables + banaction_allports = nftables[type=allports] + + # A host is banned if it has generated "maxretry" failures during "findtime" + findtime = 600 + maxretry = 3 + + [sshd] + enabled = true + port = ssh + filter = sshd + logpath = /var/log/auth.log + maxretry = 3 + bantime = 3600 + dest: /etc/fail2ban/jail.local + backup: yes + notify: restart fail2ban + + - name: Enable and start fail2ban service + systemd: + name: fail2ban + enabled: yes + state: started + + # ============================================ + # Kernel Network Hardening + # ============================================ + + - name: Apply kernel network security settings + sysctl: + name: "{{ item.name }}" + value: "{{ item.value }}" + state: present + sysctl_file: /etc/sysctl.d/99-security.conf + reload: yes + loop: + # Disable IP forwarding + - { name: 'net.ipv4.ip_forward', value: '0' } + - { name: 'net.ipv6.conf.all.forwarding', value: '0' } + + # Disable source routing + - { name: 'net.ipv4.conf.all.accept_source_route', value: '0' } + - { name: 'net.ipv6.conf.all.accept_source_route', value: '0' } + + # Disable ICMP redirects + - { name: 'net.ipv4.conf.all.accept_redirects', value: '0' } + - { name: 'net.ipv6.conf.all.accept_redirects', value: '0' } + - { name: 'net.ipv4.conf.all.send_redirects', value: '0' } + + # Enable syn flood protection + - { name: 'net.ipv4.tcp_syncookies', value: '1' } + + # Ignore ping requests + - { name: 'net.ipv4.icmp_echo_ignore_all', value: '0' } + + # Log suspicious packets + - { name: 'net.ipv4.conf.all.log_martians', value: '1' } + + # Disable IPv6 if not needed + - { name: 'net.ipv6.conf.all.disable_ipv6', value: '0' } + - { name: 'net.ipv6.conf.default.disable_ipv6', value: '0' } + + handlers: + + - name: restart fail2ban + systemd: + name: fail2ban + state: restarted diff --git a/roles/caddy/README.md b/roles/caddy/README.md new file mode 100644 index 0000000..a62f3fe --- /dev/null +++ b/roles/caddy/README.md @@ -0,0 +1,2 @@ +# Install and configure caddy +Installs and configures caddy. diff --git a/site.yml b/site.yml new file mode 100644 index 0000000..86009f8 --- /dev/null +++ b/site.yml @@ -0,0 +1,5 @@ +--- +- name: Setting up VPS + hosts: arch-vps + become: yes + gather_facts: yes