소스 검색

Initial commit

Shaun Arman 5 일 전
부모
커밋
f188c046ed
53개의 변경된 파일2222개의 추가작업 그리고 0개의 파일을 삭제
  1. 74 0
      tftsr_nginx-hardening/CLAUDE.md
  2. 4 0
      tftsr_nginx-hardening/ansible.cfg
  3. 4 0
      tftsr_nginx-hardening/inventory/hosts.yml
  4. 73 0
      tftsr_nginx-hardening/nginx-hardening/CLAUDE.md
  5. 179 0
      tftsr_nginx-hardening/nginx-hardening/README.md
  6. 4 0
      tftsr_nginx-hardening/nginx-hardening/ansible.cfg
  7. 7 0
      tftsr_nginx-hardening/nginx-hardening/inventory/hosts.yml
  8. 5 0
      tftsr_nginx-hardening/nginx-hardening/playbooks/fail2ban.yml
  9. 5 0
      tftsr_nginx-hardening/nginx-hardening/playbooks/geo_blocking.yml
  10. 5 0
      tftsr_nginx-hardening/nginx-hardening/playbooks/nginx_hardening.yml
  11. 5 0
      tftsr_nginx-hardening/nginx-hardening/playbooks/update_geo_blocks.yml
  12. 7 0
      tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/defaults/main.yml
  13. 5 0
      tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/handlers/main.yml
  14. 41 0
      tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/tasks/main.yml
  15. 22 0
      tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/templates/jail.local.j2
  16. 3 0
      tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/templates/nginx-4xx.conf.j2
  17. 3 0
      tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/templates/nginx-auth.conf.j2
  18. 509 0
      tftsr_nginx-hardening/nginx-hardening/roles/geo_blocking/defaults/main.yml
  19. 4 0
      tftsr_nginx-hardening/nginx-hardening/roles/geo_blocking/handlers/main.yml
  20. 103 0
      tftsr_nginx-hardening/nginx-hardening/roles/geo_blocking/tasks/main.yml
  21. 26 0
      tftsr_nginx-hardening/nginx-hardening/roles/geo_blocking/templates/geo-block.nft.j2
  22. 15 0
      tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/defaults/main.yml
  23. 5 0
      tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/handlers/main.yml
  24. 44 0
      tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/tasks/main.yml
  25. 8 0
      tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/templates/http_redirect.conf.j2
  26. 8 0
      tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/templates/proxy_params.conf.j2
  27. 17 0
      tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/templates/security_headers.conf.j2
  28. 10 0
      tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/templates/ssl_params.conf.j2
  29. 71 0
      tftsr_nginx-hardening/nginx-hardening/scripts/download-geo-zones.sh
  30. 7 0
      tftsr_nginx-hardening/nginx-hardening/site.yml
  31. 6 0
      tftsr_nginx-hardening/playbooks/fail2ban.yml
  32. 6 0
      tftsr_nginx-hardening/playbooks/geo_blocking.yml
  33. 6 0
      tftsr_nginx-hardening/playbooks/nginx_hardening.yml
  34. 6 0
      tftsr_nginx-hardening/playbooks/update_geo_blocks.yml
  35. 7 0
      tftsr_nginx-hardening/roles/fail2ban/defaults/main.yml
  36. 5 0
      tftsr_nginx-hardening/roles/fail2ban/handlers/main.yml
  37. 41 0
      tftsr_nginx-hardening/roles/fail2ban/tasks/main.yml
  38. 22 0
      tftsr_nginx-hardening/roles/fail2ban/templates/jail.local.j2
  39. 3 0
      tftsr_nginx-hardening/roles/fail2ban/templates/nginx-4xx.conf.j2
  40. 3 0
      tftsr_nginx-hardening/roles/fail2ban/templates/nginx-auth.conf.j2
  41. 509 0
      tftsr_nginx-hardening/roles/geo_blocking/defaults/main.yml
  42. 4 0
      tftsr_nginx-hardening/roles/geo_blocking/handlers/main.yml
  43. 103 0
      tftsr_nginx-hardening/roles/geo_blocking/tasks/main.yml
  44. 26 0
      tftsr_nginx-hardening/roles/geo_blocking/templates/geo-block.nft.j2
  45. 31 0
      tftsr_nginx-hardening/roles/nginx_hardening/defaults/main.yml
  46. 5 0
      tftsr_nginx-hardening/roles/nginx_hardening/handlers/main.yml
  47. 44 0
      tftsr_nginx-hardening/roles/nginx_hardening/tasks/main.yml
  48. 8 0
      tftsr_nginx-hardening/roles/nginx_hardening/templates/http_redirect.conf.j2
  49. 8 0
      tftsr_nginx-hardening/roles/nginx_hardening/templates/proxy_params.conf.j2
  50. 17 0
      tftsr_nginx-hardening/roles/nginx_hardening/templates/security_headers.conf.j2
  51. 10 0
      tftsr_nginx-hardening/roles/nginx_hardening/templates/ssl_params.conf.j2
  52. 71 0
      tftsr_nginx-hardening/scripts/download-geo-zones.sh
  53. 8 0
      tftsr_nginx-hardening/site.yml

+ 74 - 0
tftsr_nginx-hardening/CLAUDE.md

@@ -0,0 +1,74 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Target Environment
+
+- **OS:** RHEL 9.6, **NGINX:** 1.20.1 at `/etc/nginx/`, **Ansible:** `ansible_connection: local`
+- **TLS certs:** `/etc/letsencrypt/live/tftsr.com-0001/{fullchain,privkey}.pem`
+- **Services proxied:** 15 internal services on `*.tftsr.com` / `tftsr.com`
+- `sudo dnf install -y ansible-core` is required before first run (not managed by this project)
+
+## Run Commands
+
+```bash
+# Full hardening (all three roles)
+ansible-playbook -K site.yml
+
+# Individual roles
+ansible-playbook -K playbooks/nginx_hardening.yml
+ansible-playbook -K playbooks/fail2ban.yml
+ansible-playbook -K playbooks/geo_blocking.yml
+
+# Refresh country IP ranges from ipdeny.com (run periodically)
+ansible-playbook -K playbooks/update_geo_blocks.yml
+
+# Dry run — no changes applied
+ansible-playbook -K --check site.yml
+```
+
+## Architecture
+
+Three independent roles, each runnable standalone via `playbooks/`:
+
+### `nginx_hardening`
+Deploys four files to `/etc/nginx/conf.d/` prefixed `00-` so they sort before all service configs:
+- `00-security-headers.conf` — `server_tokens off`, HSTS, X-Frame-Options, rate-limit zone, client body size
+- `00-ssl-params.conf` — TLS 1.2/1.3 only, cipher suite, OCSP stapling, resolver
+- `00-proxy-params.conf` — strips `X-Powered-By`/`Server`, sets `X-Real-IP`/`X-Forwarded-*` headers
+- `00-http-redirects.conf` — port-80 301 redirect server blocks for the 11 services that lack them
+
+**Critical constraint:** Existing service configs in `/etc/nginx/conf.d/` are never modified. The 4 services that already have HTTP→HTTPS redirects (keycloak-proxy, vault, ollama-api, vaultwarden) are not in `nginx_redirect_services`. Do not add `ssl_session_cache` to `00-ssl-params.conf` — all service configs already declare `shared:SSL:1m` in their server blocks and a conflicting http-level declaration will break `nginx -t`.
+
+### `fail2ban`
+Installs fail2ban from EPEL, deploys filter definitions and `jail.local`. Three jails:
+- `sshd` → `/var/log/secure`
+- `nginx-4xx` → `/var/log/nginx/access.log` (regex: any 4xx)
+- `nginx-auth` → `/var/log/nginx/access.log` (regex: 401/403 only)
+
+### `geo_blocking`
+Downloads per-country CIDR files from `ipdeny.com/ipblocks/data/aggregated/{cc}-aggregated.zone` at runtime, assembles them into a single nftables set, and loads a standalone `table inet geo_block` (does not touch any existing nftables rules). The include line is appended to `/etc/sysconfig/nftables.conf`. Downloads use `ignore_errors: yes` — missing zone files are silently skipped.
+
+**To unblock a country:** set `blocked: false` for its entry in `roles/geo_blocking/defaults/main.yml` and re-run `update_geo_blocks.yml`.
+
+**ipdeny-absent territories** (no zone file exists — permanently `blocked: false`, no IPs to block): BV, CX, EH, GS, HM, PN, SH, SJ, TF, XK.
+
+**DMZ host has no outbound internet** — zone files must be pre-downloaded elsewhere and copied over:
+```bash
+# On a machine WITH internet access:
+./scripts/download-geo-zones.sh /tmp/geo_zones
+rsync -av /tmp/geo_zones/ sarman@dmz-host:/opt/geo_zones/
+
+# Then run with the local cache:
+ansible-playbook -K playbooks/geo_blocking.yml -e geo_zone_files_dir=/opt/geo_zones
+```
+The role does a fast 8-second HEAD check to ipdeny.com first; if it fails and `geo_zone_files_dir` is unset, the play fails immediately rather than timing out on all 238 countries.
+
+**YAML boolean trap:** `code: NO` (Norway) is parsed as boolean `false` by PyYAML (YAML 1.1). It must stay quoted as `code: "NO"`. Watch for this if adding new entries.
+
+## Key Design Decisions
+
+- All `template`/`copy`/`lineinfile` tasks use `backup: yes` — timestamped backups are created automatically on every run alongside the modified file.
+- The nft template opens with `add table inet geo_block` + `flush table inet geo_block` for idempotency (safe to re-run).
+- The `geo_blocking` role downloads zone files to a `tempfile` directory and cleans it up at the end of every run.
+- Handlers fire only when a task reports `changed` — NGINX reload and fail2ban restart are not triggered on idempotent re-runs.

+ 4 - 0
tftsr_nginx-hardening/ansible.cfg

@@ -0,0 +1,4 @@
+[defaults]
+inventory = inventory/hosts.yml
+roles_path = roles
+host_key_checking = False

+ 4 - 0
tftsr_nginx-hardening/inventory/hosts.yml

@@ -0,0 +1,4 @@
+all:
+  hosts:
+    localhost:
+      ansible_connection: local

+ 73 - 0
tftsr_nginx-hardening/nginx-hardening/CLAUDE.md

@@ -0,0 +1,73 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Target Environment
+
+- **OS:** RHEL 9, **NGINX:** 1.20+ at `/etc/nginx/`
+- Playbooks target `hosts: all` — configure the target in `inventory/hosts.yml`
+- `sudo dnf install -y ansible-core` is required on the control node before first run
+
+## Run Commands
+
+```bash
+# Full hardening (all three roles)
+ansible-playbook -K site.yml
+
+# Individual roles
+ansible-playbook -K playbooks/nginx_hardening.yml
+ansible-playbook -K playbooks/fail2ban.yml
+ansible-playbook -K playbooks/geo_blocking.yml
+
+# Refresh country IP ranges from ipdeny.com (run periodically)
+ansible-playbook -K playbooks/update_geo_blocks.yml
+
+# Dry run — no changes applied
+ansible-playbook -K --check site.yml
+```
+
+## Architecture
+
+Three independent roles, each runnable standalone via `playbooks/`:
+
+### `nginx_hardening`
+Deploys four files to `/etc/nginx/conf.d/` prefixed `00-` so they sort before all service configs:
+- `00-security-headers.conf` — `server_tokens off`, HSTS, X-Frame-Options, rate-limit zone, client body size
+- `00-ssl-params.conf` — TLS 1.2/1.3 only, cipher suite, OCSP stapling, resolver
+- `00-proxy-params.conf` — strips `X-Powered-By`/`Server`, sets `X-Real-IP`/`X-Forwarded-*` headers
+- `00-http-redirects.conf` — port-80 301 redirect server blocks for the 11 services that lack them
+
+**Critical constraint:** Existing service configs in `/etc/nginx/conf.d/` are never modified. Only list services in `nginx_redirect_services` that are **missing** a port-80 redirect — services that already have one must be excluded or NGINX will have duplicate `server_name` entries. Do not add `ssl_session_cache` to `00-ssl-params.conf` — if any existing service configs already declare `shared:SSL:Xm` in their server blocks, a conflicting http-level declaration with a different size will break `nginx -t`.
+
+### `fail2ban`
+Installs fail2ban from EPEL, deploys filter definitions and `jail.local`. Three jails:
+- `sshd` → `/var/log/secure`
+- `nginx-4xx` → `/var/log/nginx/access.log` (regex: any 4xx)
+- `nginx-auth` → `/var/log/nginx/access.log` (regex: 401/403 only)
+
+### `geo_blocking`
+Downloads per-country CIDR files from `ipdeny.com/ipblocks/data/aggregated/{cc}-aggregated.zone` at runtime, assembles them into a single nftables set, and loads a standalone `table inet geo_block` (does not touch any existing nftables rules). The include line is appended to `/etc/sysconfig/nftables.conf`. Downloads use `ignore_errors: yes` — missing zone files are silently skipped.
+
+**To unblock a country:** set `blocked: false` for its entry in `roles/geo_blocking/defaults/main.yml` and re-run `update_geo_blocks.yml`.
+
+**ipdeny-absent territories** (no zone file exists — permanently `blocked: false`, no IPs to block): BV, CX, EH, GS, HM, PN, SH, SJ, TF, XK.
+
+**DMZ host has no outbound internet** — zone files must be pre-downloaded elsewhere and copied over:
+```bash
+# On a machine WITH internet access:
+./scripts/download-geo-zones.sh /tmp/geo_zones
+rsync -av --no-group /tmp/geo_zones/ user@your-host:/opt/geo_zones/
+
+# Then run with the local cache:
+ansible-playbook -K playbooks/geo_blocking.yml -e geo_zone_files_dir=/opt/geo_zones
+```
+The role does a fast 8-second HEAD check to ipdeny.com first; if it fails and `geo_zone_files_dir` is unset, the play fails immediately rather than timing out on all 238 countries.
+
+**YAML boolean trap:** `code: NO` (Norway) is parsed as boolean `false` by PyYAML (YAML 1.1). It must stay quoted as `code: "NO"`. Watch for this if adding new entries.
+
+## Key Design Decisions
+
+- All `template`/`copy`/`lineinfile` tasks use `backup: yes` — timestamped backups are created automatically on every run alongside the modified file.
+- The nft template opens with `add table inet geo_block` + `flush table inet geo_block` for idempotency (safe to re-run).
+- The `geo_blocking` role downloads zone files to a `tempfile` directory and cleans it up at the end of every run.
+- Handlers fire only when a task reports `changed` — NGINX reload and fail2ban restart are not triggered on idempotent re-runs.

+ 179 - 0
tftsr_nginx-hardening/nginx-hardening/README.md

@@ -0,0 +1,179 @@
+# nginx-hardening
+
+Ansible project to harden an NGINX reverse proxy to a production security posture. Applies security headers, TLS hardening, HTTP→HTTPS redirects, fail2ban jails, and nftables-based country geo-blocking — without modifying any existing service configurations.
+
+## Target environment
+
+- **OS:** RHEL 9 / Rocky Linux 9 / AlmaLinux 9
+- **NGINX:** 1.20+ with existing service configs in `/etc/nginx/conf.d/`
+- **EPEL:** Must be installed before running (`dnf install -y epel-release`)
+- **nftables:** Installed but not required to be running (managed by this project)
+- **firewalld:** Should be inactive to avoid nftables coexistence issues
+
+## What it does
+
+### Role: `nginx_hardening`
+Deploys four files to `/etc/nginx/conf.d/` prefixed `00-` so they load before all service configs:
+
+| File | Purpose |
+|------|---------|
+| `00-security-headers.conf` | `server_tokens off`, HSTS, X-Frame-Options, X-Content-Type-Options, CSP, rate-limit zone |
+| `00-ssl-params.conf` | TLS 1.2/1.3 only, hardened cipher suite, OCSP stapling, session timeout |
+| `00-proxy-params.conf` | Strips `X-Powered-By`/`Server`, sets `X-Real-IP` and `X-Forwarded-*` headers |
+| `00-http-redirects.conf` | Port-80 → HTTPS 301 redirects for services listed in `nginx_redirect_services` |
+
+**No existing service configs are modified.**
+
+### Role: `fail2ban`
+Installs fail2ban from EPEL and configures three jails:
+
+| Jail | Log | Trigger |
+|------|-----|---------|
+| `sshd` | `/var/log/secure` | Failed SSH logins |
+| `nginx-4xx` | `/var/log/nginx/access.log` | Repeated 4xx responses |
+| `nginx-auth` | `/var/log/nginx/access.log` | Repeated 401/403 responses |
+
+### Role: `geo_blocking`
+Builds a standalone `table inet geo_block` nftables ruleset populated with CIDRs for every country except the US, downloaded from [ipdeny.com](https://www.ipdeny.com). The table is loaded at boot via `/etc/sysconfig/nftables.conf`.
+
+## Prerequisites
+
+On the **Ansible control node** (the machine you run `ansible-playbook` from):
+```bash
+# Ansible itself
+pip install ansible-core
+# or
+dnf install -y ansible-core
+```
+
+On the **target host** (applied automatically by the playbooks):
+- EPEL repo must already be installed
+- SSH access with a user that can `sudo`
+
+## Setup
+
+### 1. Configure your inventory
+
+Edit `inventory/hosts.yml`:
+```yaml
+all:
+  hosts:
+    nginx-proxy:
+      ansible_host: 192.168.1.10          # your server's IP or hostname
+      ansible_user: your_ssh_user
+      # ansible_ssh_private_key_file: ~/.ssh/id_rsa
+```
+
+### 2. Configure HTTP→HTTPS redirects
+
+Edit `roles/nginx_hardening/defaults/main.yml` and populate `nginx_redirect_services` with any services that are **missing** a port-80 redirect in their existing NGINX config:
+
+```yaml
+nginx_redirect_services:
+  - name: myapp
+    server_name: myapp.example.com
+  - name: dashboard
+    server_name: dashboard.example.com
+```
+
+Services that already have a redirect in their existing `conf.d/` file should **not** be listed here.
+
+### 3. (Optional) Tune defaults
+
+All tunable variables live in each role's `defaults/main.yml`:
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `nginx_hsts_max_age` | `31536000` | HSTS max-age in seconds |
+| `nginx_rate_limit_req_zone` | `30r/m` | Rate limit zone definition |
+| `nginx_client_max_body_size` | `10m` | Max upload body size |
+| `fail2ban_bantime` | `3600` | Ban duration (seconds) |
+| `fail2ban_maxretry_ssh` | `5` | SSH failures before ban |
+| `fail2ban_maxretry_nginx_auth` | `5` | 401/403 failures before ban |
+
+## Running
+
+```bash
+# Full hardening (all roles)
+ansible-playbook -K site.yml
+
+# Individual roles
+ansible-playbook -K playbooks/nginx_hardening.yml
+ansible-playbook -K playbooks/fail2ban.yml
+ansible-playbook -K playbooks/geo_blocking.yml
+
+# Refresh country IP ranges (run periodically — ipdeny.com updates regularly)
+ansible-playbook -K playbooks/update_geo_blocks.yml
+
+# Dry run — no changes applied
+ansible-playbook -K --check site.yml
+```
+
+`-K` prompts for the sudo password. Omit it if your user has passwordless sudo.
+
+## Geo-blocking: servers without direct internet access
+
+If your target server cannot reach `ipdeny.com`, pre-download the zone files on a machine that can and copy them over:
+
+```bash
+# On a machine WITH unrestricted internet access:
+./scripts/download-geo-zones.sh /tmp/geo_zones
+
+# Copy to the target server:
+rsync -av --no-group /tmp/geo_zones/ user@your-server:/opt/geo_zones/
+
+# Run the playbook pointing at the local cache:
+ansible-playbook -K playbooks/geo_blocking.yml -e geo_zone_files_dir=/opt/geo_zones
+```
+
+To make the cache path permanent, add it to your inventory:
+```yaml
+all:
+  hosts:
+    nginx-proxy:
+      ansible_host: 192.168.1.10
+      ansible_user: your_ssh_user
+      geo_zone_files_dir: /opt/geo_zones
+```
+
+### Unblocking a country
+
+Set `blocked: false` for the desired country code in `roles/geo_blocking/defaults/main.yml`, then re-run `update_geo_blocks.yml`.
+
+## Verification
+
+After a successful run:
+
+```bash
+# NGINX config is valid
+sudo nginx -t
+
+# Security headers are present
+curl -sI https://your-domain.com | grep -i 'strict\|x-frame\|x-content'
+
+# HTTP redirects to HTTPS
+curl -I http://your-domain.com   # expect: 301 Moved Permanently
+
+# fail2ban jails are active
+sudo fail2ban-client status
+sudo fail2ban-client status nginx-4xx
+
+# nftables geo-block table is loaded
+sudo nft list table inet geo_block
+```
+
+## Files written to the target host
+
+| Path | Action |
+|------|--------|
+| `/etc/nginx/conf.d/00-security-headers.conf` | Created |
+| `/etc/nginx/conf.d/00-ssl-params.conf` | Created |
+| `/etc/nginx/conf.d/00-proxy-params.conf` | Created |
+| `/etc/nginx/conf.d/00-http-redirects.conf` | Created |
+| `/etc/fail2ban/jail.local` | Created |
+| `/etc/fail2ban/filter.d/nginx-4xx.conf` | Created |
+| `/etc/fail2ban/filter.d/nginx-auth.conf` | Created |
+| `/etc/nftables.d/geo-block.nft` | Created |
+| `/etc/sysconfig/nftables.conf` | Appended (include line) |
+
+All tasks that write files use `backup: yes` — a timestamped copy is created automatically before each overwrite.

+ 4 - 0
tftsr_nginx-hardening/nginx-hardening/ansible.cfg

@@ -0,0 +1,4 @@
+[defaults]
+inventory = inventory/hosts.yml
+roles_path = roles
+host_key_checking = False

+ 7 - 0
tftsr_nginx-hardening/nginx-hardening/inventory/hosts.yml

@@ -0,0 +1,7 @@
+all:
+  hosts:
+    nginx-proxy:
+      ansible_host: YOUR_SERVER_IP
+      ansible_user: YOUR_SSH_USER
+      # ansible_ssh_private_key_file: ~/.ssh/id_rsa
+      # geo_zone_files_dir: /opt/geo_zones   # set if server cannot reach ipdeny.com

+ 5 - 0
tftsr_nginx-hardening/nginx-hardening/playbooks/fail2ban.yml

@@ -0,0 +1,5 @@
+---
+- hosts: all
+  become: true
+  roles:
+    - fail2ban

+ 5 - 0
tftsr_nginx-hardening/nginx-hardening/playbooks/geo_blocking.yml

@@ -0,0 +1,5 @@
+---
+- hosts: all
+  become: true
+  roles:
+    - geo_blocking

+ 5 - 0
tftsr_nginx-hardening/nginx-hardening/playbooks/nginx_hardening.yml

@@ -0,0 +1,5 @@
+---
+- hosts: all
+  become: true
+  roles:
+    - nginx_hardening

+ 5 - 0
tftsr_nginx-hardening/nginx-hardening/playbooks/update_geo_blocks.yml

@@ -0,0 +1,5 @@
+---
+- hosts: all
+  become: true
+  roles:
+    - geo_blocking

+ 7 - 0
tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/defaults/main.yml

@@ -0,0 +1,7 @@
+---
+fail2ban_bantime: 3600
+fail2ban_findtime: 600
+fail2ban_maxretry_ssh: 5
+fail2ban_maxretry_nginx_4xx: 20
+fail2ban_maxretry_nginx_auth: 5
+fail2ban_ignoreip: "127.0.0.1/8 ::1"

+ 5 - 0
tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/handlers/main.yml

@@ -0,0 +1,5 @@
+---
+- name: restart fail2ban
+  ansible.builtin.service:
+    name: fail2ban
+    state: restarted

+ 41 - 0
tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/tasks/main.yml

@@ -0,0 +1,41 @@
+---
+- name: Install fail2ban
+  ansible.builtin.dnf:
+    name: fail2ban
+    state: present
+
+- name: Deploy nginx-4xx filter
+  ansible.builtin.template:
+    src: nginx-4xx.conf.j2
+    dest: /etc/fail2ban/filter.d/nginx-4xx.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: restart fail2ban
+
+- name: Deploy nginx-auth filter
+  ansible.builtin.template:
+    src: nginx-auth.conf.j2
+    dest: /etc/fail2ban/filter.d/nginx-auth.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: restart fail2ban
+
+- name: Deploy jail.local configuration
+  ansible.builtin.template:
+    src: jail.local.j2
+    dest: /etc/fail2ban/jail.local
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: restart fail2ban
+
+- name: Enable and start fail2ban service
+  ansible.builtin.service:
+    name: fail2ban
+    state: started
+    enabled: yes

+ 22 - 0
tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/templates/jail.local.j2

@@ -0,0 +1,22 @@
+[DEFAULT]
+ignoreip = {{ fail2ban_ignoreip }}
+bantime  = {{ fail2ban_bantime }}
+findtime = {{ fail2ban_findtime }}
+
+[sshd]
+enabled  = true
+port     = ssh
+logpath  = /var/log/secure
+maxretry = {{ fail2ban_maxretry_ssh }}
+
+[nginx-4xx]
+enabled  = true
+filter   = nginx-4xx
+logpath  = /var/log/nginx/access.log
+maxretry = {{ fail2ban_maxretry_nginx_4xx }}
+
+[nginx-auth]
+enabled  = true
+filter   = nginx-auth
+logpath  = /var/log/nginx/access.log
+maxretry = {{ fail2ban_maxretry_nginx_auth }}

+ 3 - 0
tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/templates/nginx-4xx.conf.j2

@@ -0,0 +1,3 @@
+[Definition]
+failregex = ^<HOST> - \S+ \S+ \[.*\] "(GET|POST|HEAD|PUT|DELETE|PATCH|OPTIONS) \S+ HTTP/[0-9.]+" (4[0-9]{2}) \d+
+ignoreregex =

+ 3 - 0
tftsr_nginx-hardening/nginx-hardening/roles/fail2ban/templates/nginx-auth.conf.j2

@@ -0,0 +1,3 @@
+[Definition]
+failregex = ^<HOST> - \S+ \S+ \[.*\] "(GET|POST|HEAD|PUT|DELETE|PATCH|OPTIONS) \S+ HTTP/[0-9.]+" (401|403) \d+
+ignoreregex =

+ 509 - 0
tftsr_nginx-hardening/nginx-hardening/roles/geo_blocking/defaults/main.yml

@@ -0,0 +1,509 @@
+---
+geo_ipdeny_base_url: "https://www.ipdeny.com/ipblocks/data/aggregated"
+geo_nft_table_dir: "/etc/nftables.d"
+geo_nft_file: "/etc/nftables.d/geo-block.nft"
+# Set this to a directory containing pre-downloaded {cc}.zone files when the
+# target host has no outbound internet access. Leave empty to download live.
+geo_zone_files_dir: ""
+
+geo_countries:
+  - code: AD   # Andorra
+    blocked: true
+  - code: AE   # United Arab Emirates
+    blocked: true
+  - code: AF   # Afghanistan
+    blocked: true
+  - code: AG   # Antigua and Barbuda
+    blocked: true
+  - code: AI   # Anguilla
+    blocked: true
+  - code: AL   # Albania
+    blocked: true
+  - code: AM   # Armenia
+    blocked: true
+  - code: AO   # Angola
+    blocked: true
+  - code: AQ   # Antarctica
+    blocked: true
+  - code: AR   # Argentina
+    blocked: true
+  - code: AS   # American Samoa
+    blocked: true
+  - code: AT   # Austria
+    blocked: true
+  - code: AU   # Australia
+    blocked: true
+  - code: AW   # Aruba
+    blocked: true
+  - code: AX   # Aland Islands
+    blocked: true
+  - code: AZ   # Azerbaijan
+    blocked: true
+  - code: BA   # Bosnia and Herzegovina
+    blocked: true
+  - code: BB   # Barbados
+    blocked: true
+  - code: BD   # Bangladesh
+    blocked: true
+  - code: BE   # Belgium
+    blocked: true
+  - code: BF   # Burkina Faso
+    blocked: true
+  - code: BG   # Bulgaria
+    blocked: true
+  - code: BH   # Bahrain
+    blocked: true
+  - code: BI   # Burundi
+    blocked: true
+  - code: BJ   # Benin
+    blocked: true
+  - code: BL   # Saint Barthelemy
+    blocked: true
+  - code: BM   # Bermuda
+    blocked: true
+  - code: BN   # Brunei Darussalam
+    blocked: true
+  - code: BO   # Bolivia
+    blocked: true
+  - code: BQ   # Bonaire
+    blocked: true
+  - code: BR   # Brazil
+    blocked: true
+  - code: BS   # Bahamas
+    blocked: true
+  - code: BT   # Bhutan
+    blocked: true
+  - code: BV   # Bouvet Island — no ipdeny zone file
+    blocked: false
+  - code: BW   # Botswana
+    blocked: true
+  - code: BY   # Belarus
+    blocked: true
+  - code: BZ   # Belize
+    blocked: true
+  - code: CA   # Canada
+    blocked: true
+  - code: CC   # Cocos Islands
+    blocked: true
+  - code: CD   # Dem. Rep. Congo
+    blocked: true
+  - code: CF   # Central African Republic
+    blocked: true
+  - code: CG   # Congo
+    blocked: true
+  - code: CH   # Switzerland
+    blocked: true
+  - code: CI   # Cote d'Ivoire
+    blocked: true
+  - code: CK   # Cook Islands
+    blocked: true
+  - code: CL   # Chile
+    blocked: true
+  - code: CM   # Cameroon
+    blocked: true
+  - code: CN   # China
+    blocked: true
+  - code: CO   # Colombia
+    blocked: true
+  - code: CR   # Costa Rica
+    blocked: true
+  - code: CU   # Cuba
+    blocked: true
+  - code: CV   # Cabo Verde
+    blocked: true
+  - code: CW   # Curacao
+    blocked: true
+  - code: CX   # Christmas Island — no ipdeny zone file
+    blocked: false
+  - code: CY   # Cyprus
+    blocked: true
+  - code: CZ   # Czechia
+    blocked: true
+  - code: DE   # Germany
+    blocked: true
+  - code: DJ   # Djibouti
+    blocked: true
+  - code: DK   # Denmark
+    blocked: true
+  - code: DM   # Dominica
+    blocked: true
+  - code: DO   # Dominican Republic
+    blocked: true
+  - code: DZ   # Algeria
+    blocked: true
+  - code: EC   # Ecuador
+    blocked: true
+  - code: EE   # Estonia
+    blocked: true
+  - code: EG   # Egypt
+    blocked: true
+  - code: EH   # Western Sahara — no ipdeny zone file
+    blocked: false
+  - code: ER   # Eritrea
+    blocked: true
+  - code: ES   # Spain
+    blocked: true
+  - code: ET   # Ethiopia
+    blocked: true
+  - code: FI   # Finland
+    blocked: true
+  - code: FJ   # Fiji
+    blocked: true
+  - code: FK   # Falkland Islands
+    blocked: true
+  - code: FM   # Micronesia
+    blocked: true
+  - code: FO   # Faroe Islands
+    blocked: true
+  - code: FR   # France
+    blocked: true
+  - code: GA   # Gabon
+    blocked: true
+  - code: GB   # United Kingdom
+    blocked: true
+  - code: GD   # Grenada
+    blocked: true
+  - code: GE   # Georgia
+    blocked: true
+  - code: GF   # French Guiana
+    blocked: true
+  - code: GG   # Guernsey
+    blocked: true
+  - code: GH   # Ghana
+    blocked: true
+  - code: GI   # Gibraltar
+    blocked: true
+  - code: GL   # Greenland
+    blocked: true
+  - code: GM   # Gambia
+    blocked: true
+  - code: GN   # Guinea
+    blocked: true
+  - code: GP   # Guadeloupe
+    blocked: true
+  - code: GQ   # Equatorial Guinea
+    blocked: true
+  - code: GR   # Greece
+    blocked: true
+  - code: GS   # South Georgia — no ipdeny zone file
+    blocked: false
+  - code: GT   # Guatemala
+    blocked: true
+  - code: GU   # Guam
+    blocked: true
+  - code: GW   # Guinea-Bissau
+    blocked: true
+  - code: GY   # Guyana
+    blocked: true
+  - code: HK   # Hong Kong
+    blocked: true
+  - code: HM   # Heard Island — no ipdeny zone file
+    blocked: false
+  - code: HN   # Honduras
+    blocked: true
+  - code: HR   # Croatia
+    blocked: true
+  - code: HT   # Haiti
+    blocked: true
+  - code: HU   # Hungary
+    blocked: true
+  - code: ID   # Indonesia
+    blocked: true
+  - code: IE   # Ireland
+    blocked: true
+  - code: IL   # Israel
+    blocked: true
+  - code: IM   # Isle of Man
+    blocked: true
+  - code: IN   # India
+    blocked: true
+  - code: IO   # British Indian Ocean Territory
+    blocked: true
+  - code: IQ   # Iraq
+    blocked: true
+  - code: IR   # Iran
+    blocked: true
+  - code: IS   # Iceland
+    blocked: true
+  - code: IT   # Italy
+    blocked: true
+  - code: JE   # Jersey
+    blocked: true
+  - code: JM   # Jamaica
+    blocked: true
+  - code: JO   # Jordan
+    blocked: true
+  - code: JP   # Japan
+    blocked: true
+  - code: KE   # Kenya
+    blocked: true
+  - code: KG   # Kyrgyzstan
+    blocked: true
+  - code: KH   # Cambodia
+    blocked: true
+  - code: KI   # Kiribati
+    blocked: true
+  - code: KM   # Comoros
+    blocked: true
+  - code: KN   # Saint Kitts and Nevis
+    blocked: true
+  - code: KP   # North Korea
+    blocked: true
+  - code: KR   # South Korea
+    blocked: true
+  - code: KW   # Kuwait
+    blocked: true
+  - code: KY   # Cayman Islands
+    blocked: true
+  - code: KZ   # Kazakhstan
+    blocked: true
+  - code: LA   # Laos
+    blocked: true
+  - code: LB   # Lebanon
+    blocked: true
+  - code: LC   # Saint Lucia
+    blocked: true
+  - code: LI   # Liechtenstein
+    blocked: true
+  - code: LK   # Sri Lanka
+    blocked: true
+  - code: LR   # Liberia
+    blocked: true
+  - code: LS   # Lesotho
+    blocked: true
+  - code: LT   # Lithuania
+    blocked: true
+  - code: LU   # Luxembourg
+    blocked: true
+  - code: LV   # Latvia
+    blocked: true
+  - code: LY   # Libya
+    blocked: true
+  - code: MA   # Morocco
+    blocked: true
+  - code: MC   # Monaco
+    blocked: true
+  - code: MD   # Moldova
+    blocked: true
+  - code: ME   # Montenegro
+    blocked: true
+  - code: MF   # Saint Martin
+    blocked: true
+  - code: MG   # Madagascar
+    blocked: true
+  - code: MH   # Marshall Islands
+    blocked: true
+  - code: MK   # North Macedonia
+    blocked: true
+  - code: ML   # Mali
+    blocked: true
+  - code: MM   # Myanmar
+    blocked: true
+  - code: MN   # Mongolia
+    blocked: true
+  - code: MO   # Macao
+    blocked: true
+  - code: MP   # Northern Mariana Islands
+    blocked: true
+  - code: MQ   # Martinique
+    blocked: true
+  - code: MR   # Mauritania
+    blocked: true
+  - code: MS   # Montserrat
+    blocked: true
+  - code: MT   # Malta
+    blocked: true
+  - code: MU   # Mauritius
+    blocked: true
+  - code: MV   # Maldives
+    blocked: true
+  - code: MW   # Malawi
+    blocked: true
+  - code: MX   # Mexico
+    blocked: true
+  - code: MY   # Malaysia
+    blocked: true
+  - code: MZ   # Mozambique
+    blocked: true
+  - code: NA   # Namibia
+    blocked: true
+  - code: NC   # New Caledonia
+    blocked: true
+  - code: NE   # Niger
+    blocked: true
+  - code: NF   # Norfolk Island
+    blocked: true
+  - code: NG   # Nigeria
+    blocked: true
+  - code: NI   # Nicaragua
+    blocked: true
+  - code: NL   # Netherlands
+    blocked: true
+  - code: "NO"  # Norway
+    blocked: true
+  - code: NP   # Nepal
+    blocked: true
+  - code: NR   # Nauru
+    blocked: true
+  - code: NU   # Niue
+    blocked: true
+  - code: NZ   # New Zealand
+    blocked: true
+  - code: OM   # Oman
+    blocked: true
+  - code: PA   # Panama
+    blocked: true
+  - code: PE   # Peru
+    blocked: true
+  - code: PF   # French Polynesia
+    blocked: true
+  - code: PG   # Papua New Guinea
+    blocked: true
+  - code: PH   # Philippines
+    blocked: true
+  - code: PK   # Pakistan
+    blocked: true
+  - code: PL   # Poland
+    blocked: true
+  - code: PM   # Saint Pierre and Miquelon
+    blocked: true
+  - code: PN   # Pitcairn — no ipdeny zone file
+    blocked: false
+  - code: PR   # Puerto Rico
+    blocked: true
+  - code: PS   # Palestine
+    blocked: true
+  - code: PT   # Portugal
+    blocked: true
+  - code: PW   # Palau
+    blocked: true
+  - code: PY   # Paraguay
+    blocked: true
+  - code: QA   # Qatar
+    blocked: true
+  - code: RE   # Reunion
+    blocked: true
+  - code: RO   # Romania
+    blocked: true
+  - code: RS   # Serbia
+    blocked: true
+  - code: RU   # Russia
+    blocked: true
+  - code: RW   # Rwanda
+    blocked: true
+  - code: SA   # Saudi Arabia
+    blocked: true
+  - code: SB   # Solomon Islands
+    blocked: true
+  - code: SC   # Seychelles
+    blocked: true
+  - code: SD   # Sudan
+    blocked: true
+  - code: SE   # Sweden
+    blocked: true
+  - code: SG   # Singapore
+    blocked: true
+  - code: SH   # Saint Helena — no ipdeny zone file
+    blocked: false
+  - code: SI   # Slovenia
+    blocked: true
+  - code: SJ   # Svalbard and Jan Mayen — no ipdeny zone file
+    blocked: false
+  - code: SK   # Slovakia
+    blocked: true
+  - code: SL   # Sierra Leone
+    blocked: true
+  - code: SM   # San Marino
+    blocked: true
+  - code: SN   # Senegal
+    blocked: true
+  - code: SO   # Somalia
+    blocked: true
+  - code: SR   # Suriname
+    blocked: true
+  - code: SS   # South Sudan
+    blocked: true
+  - code: ST   # Sao Tome and Principe
+    blocked: true
+  - code: SV   # El Salvador
+    blocked: true
+  - code: SX   # Sint Maarten
+    blocked: true
+  - code: SY   # Syria
+    blocked: true
+  - code: SZ   # Eswatini
+    blocked: true
+  - code: TC   # Turks and Caicos Islands
+    blocked: true
+  - code: TD   # Chad
+    blocked: true
+  - code: TF   # French Southern Territories — no ipdeny zone file
+    blocked: false
+  - code: TG   # Togo
+    blocked: true
+  - code: TH   # Thailand
+    blocked: true
+  - code: TJ   # Tajikistan
+    blocked: true
+  - code: TK   # Tokelau
+    blocked: true
+  - code: TL   # Timor-Leste
+    blocked: true
+  - code: TM   # Turkmenistan
+    blocked: true
+  - code: TN   # Tunisia
+    blocked: true
+  - code: TO   # Tonga
+    blocked: true
+  - code: TR   # Turkey
+    blocked: true
+  - code: TT   # Trinidad and Tobago
+    blocked: true
+  - code: TV   # Tuvalu
+    blocked: true
+  - code: TW   # Taiwan
+    blocked: true
+  - code: TZ   # Tanzania
+    blocked: true
+  - code: UA   # Ukraine
+    blocked: true
+  - code: UG   # Uganda
+    blocked: true
+  - code: UM   # US Minor Outlying Islands
+    blocked: true
+  - code: US   # United States
+    blocked: false
+  - code: UY   # Uruguay
+    blocked: true
+  - code: UZ   # Uzbekistan
+    blocked: true
+  - code: VA   # Vatican City
+    blocked: true
+  - code: VC   # Saint Vincent and the Grenadines
+    blocked: true
+  - code: VE   # Venezuela
+    blocked: true
+  - code: VG   # British Virgin Islands
+    blocked: true
+  - code: VI   # US Virgin Islands
+    blocked: true
+  - code: VN   # Vietnam
+    blocked: true
+  - code: VU   # Vanuatu
+    blocked: true
+  - code: WF   # Wallis and Futuna
+    blocked: true
+  - code: WS   # Samoa
+    blocked: true
+  - code: XK   # Kosovo — no ipdeny zone file
+    blocked: false
+  - code: YE   # Yemen
+    blocked: true
+  - code: YT   # Mayotte
+    blocked: true
+  - code: ZA   # South Africa
+    blocked: true
+  - code: ZM   # Zambia
+    blocked: true
+  - code: ZW   # Zimbabwe
+    blocked: true

+ 4 - 0
tftsr_nginx-hardening/nginx-hardening/roles/geo_blocking/handlers/main.yml

@@ -0,0 +1,4 @@
+---
+- name: reload nftables
+  ansible.builtin.command: nft -f {{ geo_nft_file }}
+  changed_when: true

+ 103 - 0
tftsr_nginx-hardening/nginx-hardening/roles/geo_blocking/tasks/main.yml

@@ -0,0 +1,103 @@
+---
+- name: Ensure nftables.d directory exists
+  ansible.builtin.file:
+    path: "{{ geo_nft_table_dir }}"
+    state: directory
+    owner: root
+    group: root
+    mode: '0755'
+
+- name: Create temp directory for zone files
+  ansible.builtin.tempfile:
+    state: directory
+    suffix: geo_zones
+  register: geo_temp_dir
+
+# --- Source: live download ---
+
+- name: Test connectivity to ipdeny.com (fast pre-check)
+  ansible.builtin.uri:
+    url: "{{ geo_ipdeny_base_url }}/us-aggregated.zone"
+    method: HEAD
+    timeout: 8
+  register: geo_connectivity_check
+  ignore_errors: yes
+  when: geo_zone_files_dir | length == 0
+
+- name: Fail fast if ipdeny.com is unreachable and no local cache configured
+  ansible.builtin.fail:
+    msg: >-
+      Cannot reach ipdeny.com (connection timed out or refused) and
+      geo_zone_files_dir is not set. Pre-download zone files on a machine
+      with internet access using scripts/download-geo-zones.sh, copy them
+      to this host, then set geo_zone_files_dir in inventory or with -e.
+  when:
+    - geo_zone_files_dir | length == 0
+    - geo_connectivity_check is failed
+
+- name: Download zone files for blocked countries
+  ansible.builtin.get_url:
+    url: "{{ geo_ipdeny_base_url }}/{{ item.code | lower }}-aggregated.zone"
+    dest: "{{ geo_temp_dir.path }}/{{ item.code | lower }}.zone"
+    timeout: 30
+  loop: "{{ geo_countries | selectattr('blocked', 'equalto', true) | list }}"
+  loop_control:
+    label: "{{ item.code }}"
+  ignore_errors: yes
+  when:
+    - geo_zone_files_dir | length == 0
+    - geo_connectivity_check is succeeded
+
+# --- Source: local pre-downloaded cache ---
+
+- name: Copy zone files from local cache directory
+  ansible.builtin.copy:
+    src: "{{ geo_zone_files_dir }}/{{ item.code | lower }}.zone"
+    dest: "{{ geo_temp_dir.path }}/{{ item.code | lower }}.zone"
+    remote_src: yes
+  loop: "{{ geo_countries | selectattr('blocked', 'equalto', true) | list }}"
+  loop_control:
+    label: "{{ item.code }}"
+  ignore_errors: yes
+  when: geo_zone_files_dir | length > 0
+
+# --- Assemble and deploy ---
+
+- name: Assemble all CIDRs from downloaded zone files
+  ansible.builtin.shell: >
+    cat {{ geo_temp_dir.path }}/*.zone 2>/dev/null |
+    grep -v '^#' | grep -v '^$' | sort -u
+  register: geo_cidrs_raw
+  changed_when: false
+
+- name: Set geo_blocked_cidrs fact
+  ansible.builtin.set_fact:
+    geo_blocked_cidrs: "{{ geo_cidrs_raw.stdout_lines }}"
+
+- name: Deploy geo-block nftables ruleset
+  ansible.builtin.template:
+    src: geo-block.nft.j2
+    dest: "{{ geo_nft_file }}"
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: reload nftables
+
+- name: Ensure nftables.conf includes geo-block.nft
+  ansible.builtin.lineinfile:
+    path: /etc/sysconfig/nftables.conf
+    line: 'include "{{ geo_nft_file }}"'
+    state: present
+    backup: yes
+
+- name: Enable and start nftables service
+  ansible.builtin.service:
+    name: nftables
+    state: started
+    enabled: yes
+
+- name: Clean up temp directory
+  ansible.builtin.file:
+    path: "{{ geo_temp_dir.path }}"
+    state: absent

+ 26 - 0
tftsr_nginx-hardening/nginx-hardening/roles/geo_blocking/templates/geo-block.nft.j2

@@ -0,0 +1,26 @@
+#!/usr/sbin/nft -f
+# Managed by Ansible — do not edit manually
+
+# Ensure table exists, then flush for idempotency
+add table inet geo_block
+flush table inet geo_block
+
+table inet geo_block {
+    set blocked_countries {
+        type ipv4_addr
+        flags interval
+{% if geo_blocked_cidrs | length > 0 %}
+        elements = {
+{% for cidr in geo_blocked_cidrs %}
+            {{ cidr }}{% if not loop.last %},{% endif %}
+
+{% endfor %}
+        }
+{% endif %}
+    }
+
+    chain prerouting {
+        type filter hook prerouting priority -100; policy accept;
+        ip saddr @blocked_countries drop
+    }
+}

+ 15 - 0
tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/defaults/main.yml

@@ -0,0 +1,15 @@
+---
+nginx_ssl_protocols: "TLSv1.2 TLSv1.3"
+nginx_ssl_ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256"
+nginx_hsts_max_age: 31536000
+nginx_rate_limit_req_zone: "$binary_remote_addr zone=general:10m rate=30r/m"
+nginx_client_max_body_size: "10m"
+nginx_proxy_read_timeout: 60
+
+# Services that need a port-80 → HTTPS redirect added.
+# List only services that do NOT already have a redirect in their existing config.
+nginx_redirect_services:
+  - name: service1
+    server_name: service1.example.com
+  - name: service2
+    server_name: service2.example.com

+ 5 - 0
tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/handlers/main.yml

@@ -0,0 +1,5 @@
+---
+- name: reload nginx
+  ansible.builtin.service:
+    name: nginx
+    state: reloaded

+ 44 - 0
tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/tasks/main.yml

@@ -0,0 +1,44 @@
+---
+- name: Deploy security headers configuration
+  ansible.builtin.template:
+    src: security_headers.conf.j2
+    dest: /etc/nginx/conf.d/00-security-headers.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: reload nginx
+
+- name: Deploy SSL parameters configuration
+  ansible.builtin.template:
+    src: ssl_params.conf.j2
+    dest: /etc/nginx/conf.d/00-ssl-params.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: reload nginx
+
+- name: Deploy proxy parameters configuration
+  ansible.builtin.template:
+    src: proxy_params.conf.j2
+    dest: /etc/nginx/conf.d/00-proxy-params.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: reload nginx
+
+- name: Deploy HTTP to HTTPS redirect configuration
+  ansible.builtin.template:
+    src: http_redirect.conf.j2
+    dest: /etc/nginx/conf.d/00-http-redirects.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: reload nginx
+
+- name: Validate NGINX configuration
+  ansible.builtin.command: nginx -t
+  changed_when: false

+ 8 - 0
tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/templates/http_redirect.conf.j2

@@ -0,0 +1,8 @@
+# Managed by Ansible — do not edit manually
+{% for svc in nginx_redirect_services %}
+server {
+    listen 80;
+    server_name {{ svc.server_name }};
+    return 301 https://$host$request_uri;
+}
+{% endfor %}

+ 8 - 0
tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/templates/proxy_params.conf.j2

@@ -0,0 +1,8 @@
+# Managed by Ansible — do not edit manually
+
+proxy_hide_header X-Powered-By;
+proxy_hide_header Server;
+proxy_set_header X-Real-IP $remote_addr;
+proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+proxy_set_header X-Forwarded-Proto $scheme;
+proxy_read_timeout {{ nginx_proxy_read_timeout }};

+ 17 - 0
tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/templates/security_headers.conf.j2

@@ -0,0 +1,17 @@
+# Managed by Ansible — do not edit manually
+
+server_tokens off;
+
+# Rate limiting zone definition
+limit_req_zone {{ nginx_rate_limit_req_zone }};
+
+# Client body size limit
+client_max_body_size {{ nginx_client_max_body_size }};
+
+# Security headers
+add_header Strict-Transport-Security "max-age={{ nginx_hsts_max_age }}; includeSubDomains; preload" always;
+add_header X-Frame-Options SAMEORIGIN always;
+add_header X-Content-Type-Options nosniff always;
+add_header Referrer-Policy strict-origin-when-cross-origin always;
+add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
+add_header X-XSS-Protection "1; mode=block" always;

+ 10 - 0
tftsr_nginx-hardening/nginx-hardening/roles/nginx_hardening/templates/ssl_params.conf.j2

@@ -0,0 +1,10 @@
+# Managed by Ansible — do not edit manually
+
+ssl_protocols {{ nginx_ssl_protocols }};
+ssl_ciphers {{ nginx_ssl_ciphers }};
+ssl_prefer_server_ciphers off;
+ssl_session_timeout 1d;
+ssl_stapling on;
+ssl_stapling_verify on;
+resolver 8.8.8.8 8.8.4.4 valid=300s;
+resolver_timeout 5s;

+ 71 - 0
tftsr_nginx-hardening/nginx-hardening/scripts/download-geo-zones.sh

@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# Download ipdeny.com aggregated zone files for all blocked countries.
+# Run this on a machine WITH internet access, then rsync the output
+# directory to the DMZ host and set geo_zone_files_dir in your inventory.
+#
+# Usage:
+#   ./scripts/download-geo-zones.sh [output-dir]
+#
+# Example workflow:
+#   # On your workstation:
+#   ./scripts/download-geo-zones.sh /tmp/geo_zones
+#   rsync -av /tmp/geo_zones/ sarman@dmz-host:/opt/geo_zones/
+#
+#   # Then run the playbook pointing at the cache:
+#   ansible-playbook -K playbooks/geo_blocking.yml -e geo_zone_files_dir=/opt/geo_zones
+
+set -euo pipefail
+
+BASE_URL="https://www.ipdeny.com/ipblocks/data/aggregated"
+OUT_DIR="${1:-/tmp/geo_zones}"
+
+# All blocked country codes (excludes US and ipdeny-absent territories)
+COUNTRIES=(
+  AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
+  BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BW BY BZ
+  CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CY CZ
+  DE DJ DK DM DO DZ
+  EC EE EG ER ES ET
+  FI FJ FK FM FO FR
+  GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GT GU GW GY
+  HK HN HR HT HU
+  ID IE IL IM IN IO IQ IR IS IT
+  JE JM JO JP
+  KE KG KH KI KM KN KP KR KW KY KZ
+  LA LB LC LI LK LR LS LT LU LV LY
+  MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
+  NA NC NE NF NG NI NL NO NP NR NU NZ
+  OM
+  PA PE PF PG PH PK PL PM PR PS PT PW PY
+  QA
+  RE RO RS RU RW
+  SA SB SC SD SE SG SI SK SL SM SN SO SR SS ST SV SX SY SZ
+  TC TD TG TH TJ TK TL TM TN TO TR TT TV TW TZ
+  UA UG UM UY UZ
+  VA VC VE VG VI VN VU
+  WF WS
+  YE YT
+  ZA ZM ZW
+)
+
+mkdir -p "$OUT_DIR"
+echo "Downloading ${#COUNTRIES[@]} zone files to $OUT_DIR ..."
+
+ok=0; fail=0
+for cc in "${COUNTRIES[@]}"; do
+  url="${BASE_URL}/${cc,,}-aggregated.zone"
+  dest="${OUT_DIR}/${cc,,}.zone"
+  if curl -fsSL --connect-timeout 10 --max-time 30 -o "$dest" "$url"; then
+    (( ++ok ))
+  else
+    echo "  SKIP $cc (no zone file at ipdeny.com)"
+    rm -f "$dest"
+    (( ++fail ))
+  fi
+done
+
+echo "Done: $ok downloaded, $fail skipped."
+echo ""
+echo "Next steps:"
+echo "  rsync -av ${OUT_DIR}/ USER@DMZ_HOST:/opt/geo_zones/"
+echo "  ansible-playbook -K playbooks/geo_blocking.yml -e geo_zone_files_dir=/opt/geo_zones"

+ 7 - 0
tftsr_nginx-hardening/nginx-hardening/site.yml

@@ -0,0 +1,7 @@
+---
+- hosts: all
+  become: true
+  roles:
+    - nginx_hardening
+    - fail2ban
+    - geo_blocking

+ 6 - 0
tftsr_nginx-hardening/playbooks/fail2ban.yml

@@ -0,0 +1,6 @@
+---
+- hosts: localhost
+  connection: local
+  become: true
+  roles:
+    - fail2ban

+ 6 - 0
tftsr_nginx-hardening/playbooks/geo_blocking.yml

@@ -0,0 +1,6 @@
+---
+- hosts: localhost
+  connection: local
+  become: true
+  roles:
+    - geo_blocking

+ 6 - 0
tftsr_nginx-hardening/playbooks/nginx_hardening.yml

@@ -0,0 +1,6 @@
+---
+- hosts: localhost
+  connection: local
+  become: true
+  roles:
+    - nginx_hardening

+ 6 - 0
tftsr_nginx-hardening/playbooks/update_geo_blocks.yml

@@ -0,0 +1,6 @@
+---
+- hosts: localhost
+  connection: local
+  become: true
+  roles:
+    - geo_blocking

+ 7 - 0
tftsr_nginx-hardening/roles/fail2ban/defaults/main.yml

@@ -0,0 +1,7 @@
+---
+fail2ban_bantime: 3600
+fail2ban_findtime: 600
+fail2ban_maxretry_ssh: 5
+fail2ban_maxretry_nginx_4xx: 20
+fail2ban_maxretry_nginx_auth: 5
+fail2ban_ignoreip: "127.0.0.1/8 ::1"

+ 5 - 0
tftsr_nginx-hardening/roles/fail2ban/handlers/main.yml

@@ -0,0 +1,5 @@
+---
+- name: restart fail2ban
+  ansible.builtin.service:
+    name: fail2ban
+    state: restarted

+ 41 - 0
tftsr_nginx-hardening/roles/fail2ban/tasks/main.yml

@@ -0,0 +1,41 @@
+---
+- name: Install fail2ban
+  ansible.builtin.dnf:
+    name: fail2ban
+    state: present
+
+- name: Deploy nginx-4xx filter
+  ansible.builtin.template:
+    src: nginx-4xx.conf.j2
+    dest: /etc/fail2ban/filter.d/nginx-4xx.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: restart fail2ban
+
+- name: Deploy nginx-auth filter
+  ansible.builtin.template:
+    src: nginx-auth.conf.j2
+    dest: /etc/fail2ban/filter.d/nginx-auth.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: restart fail2ban
+
+- name: Deploy jail.local configuration
+  ansible.builtin.template:
+    src: jail.local.j2
+    dest: /etc/fail2ban/jail.local
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: restart fail2ban
+
+- name: Enable and start fail2ban service
+  ansible.builtin.service:
+    name: fail2ban
+    state: started
+    enabled: yes

+ 22 - 0
tftsr_nginx-hardening/roles/fail2ban/templates/jail.local.j2

@@ -0,0 +1,22 @@
+[DEFAULT]
+ignoreip = {{ fail2ban_ignoreip }}
+bantime  = {{ fail2ban_bantime }}
+findtime = {{ fail2ban_findtime }}
+
+[sshd]
+enabled  = true
+port     = ssh
+logpath  = /var/log/secure
+maxretry = {{ fail2ban_maxretry_ssh }}
+
+[nginx-4xx]
+enabled  = true
+filter   = nginx-4xx
+logpath  = /var/log/nginx/access.log
+maxretry = {{ fail2ban_maxretry_nginx_4xx }}
+
+[nginx-auth]
+enabled  = true
+filter   = nginx-auth
+logpath  = /var/log/nginx/access.log
+maxretry = {{ fail2ban_maxretry_nginx_auth }}

+ 3 - 0
tftsr_nginx-hardening/roles/fail2ban/templates/nginx-4xx.conf.j2

@@ -0,0 +1,3 @@
+[Definition]
+failregex = ^<HOST> - \S+ \S+ \[.*\] "(GET|POST|HEAD|PUT|DELETE|PATCH|OPTIONS) \S+ HTTP/[0-9.]+" (4[0-9]{2}) \d+
+ignoreregex =

+ 3 - 0
tftsr_nginx-hardening/roles/fail2ban/templates/nginx-auth.conf.j2

@@ -0,0 +1,3 @@
+[Definition]
+failregex = ^<HOST> - \S+ \S+ \[.*\] "(GET|POST|HEAD|PUT|DELETE|PATCH|OPTIONS) \S+ HTTP/[0-9.]+" (401|403) \d+
+ignoreregex =

+ 509 - 0
tftsr_nginx-hardening/roles/geo_blocking/defaults/main.yml

@@ -0,0 +1,509 @@
+---
+geo_ipdeny_base_url: "https://www.ipdeny.com/ipblocks/data/aggregated"
+geo_nft_table_dir: "/etc/nftables.d"
+geo_nft_file: "/etc/nftables.d/geo-block.nft"
+# Set this to a directory containing pre-downloaded {cc}.zone files when the
+# target host has no outbound internet access. Leave empty to download live.
+geo_zone_files_dir: ""
+
+geo_countries:
+  - code: AD   # Andorra
+    blocked: true
+  - code: AE   # United Arab Emirates
+    blocked: true
+  - code: AF   # Afghanistan
+    blocked: true
+  - code: AG   # Antigua and Barbuda
+    blocked: true
+  - code: AI   # Anguilla
+    blocked: true
+  - code: AL   # Albania
+    blocked: true
+  - code: AM   # Armenia
+    blocked: true
+  - code: AO   # Angola
+    blocked: true
+  - code: AQ   # Antarctica
+    blocked: true
+  - code: AR   # Argentina
+    blocked: true
+  - code: AS   # American Samoa
+    blocked: true
+  - code: AT   # Austria
+    blocked: true
+  - code: AU   # Australia
+    blocked: true
+  - code: AW   # Aruba
+    blocked: true
+  - code: AX   # Aland Islands
+    blocked: true
+  - code: AZ   # Azerbaijan
+    blocked: true
+  - code: BA   # Bosnia and Herzegovina
+    blocked: true
+  - code: BB   # Barbados
+    blocked: true
+  - code: BD   # Bangladesh
+    blocked: true
+  - code: BE   # Belgium
+    blocked: true
+  - code: BF   # Burkina Faso
+    blocked: true
+  - code: BG   # Bulgaria
+    blocked: true
+  - code: BH   # Bahrain
+    blocked: true
+  - code: BI   # Burundi
+    blocked: true
+  - code: BJ   # Benin
+    blocked: true
+  - code: BL   # Saint Barthelemy
+    blocked: true
+  - code: BM   # Bermuda
+    blocked: true
+  - code: BN   # Brunei Darussalam
+    blocked: true
+  - code: BO   # Bolivia
+    blocked: true
+  - code: BQ   # Bonaire
+    blocked: true
+  - code: BR   # Brazil
+    blocked: true
+  - code: BS   # Bahamas
+    blocked: true
+  - code: BT   # Bhutan
+    blocked: true
+  - code: BV   # Bouvet Island — no ipdeny zone file
+    blocked: false
+  - code: BW   # Botswana
+    blocked: true
+  - code: BY   # Belarus
+    blocked: true
+  - code: BZ   # Belize
+    blocked: true
+  - code: CA   # Canada
+    blocked: true
+  - code: CC   # Cocos Islands
+    blocked: true
+  - code: CD   # Dem. Rep. Congo
+    blocked: true
+  - code: CF   # Central African Republic
+    blocked: true
+  - code: CG   # Congo
+    blocked: true
+  - code: CH   # Switzerland
+    blocked: true
+  - code: CI   # Cote d'Ivoire
+    blocked: true
+  - code: CK   # Cook Islands
+    blocked: true
+  - code: CL   # Chile
+    blocked: true
+  - code: CM   # Cameroon
+    blocked: true
+  - code: CN   # China
+    blocked: true
+  - code: CO   # Colombia
+    blocked: true
+  - code: CR   # Costa Rica
+    blocked: true
+  - code: CU   # Cuba
+    blocked: true
+  - code: CV   # Cabo Verde
+    blocked: true
+  - code: CW   # Curacao
+    blocked: true
+  - code: CX   # Christmas Island — no ipdeny zone file
+    blocked: false
+  - code: CY   # Cyprus
+    blocked: true
+  - code: CZ   # Czechia
+    blocked: true
+  - code: DE   # Germany
+    blocked: true
+  - code: DJ   # Djibouti
+    blocked: true
+  - code: DK   # Denmark
+    blocked: true
+  - code: DM   # Dominica
+    blocked: true
+  - code: DO   # Dominican Republic
+    blocked: true
+  - code: DZ   # Algeria
+    blocked: true
+  - code: EC   # Ecuador
+    blocked: true
+  - code: EE   # Estonia
+    blocked: true
+  - code: EG   # Egypt
+    blocked: true
+  - code: EH   # Western Sahara — no ipdeny zone file
+    blocked: false
+  - code: ER   # Eritrea
+    blocked: true
+  - code: ES   # Spain
+    blocked: true
+  - code: ET   # Ethiopia
+    blocked: true
+  - code: FI   # Finland
+    blocked: true
+  - code: FJ   # Fiji
+    blocked: true
+  - code: FK   # Falkland Islands
+    blocked: true
+  - code: FM   # Micronesia
+    blocked: true
+  - code: FO   # Faroe Islands
+    blocked: true
+  - code: FR   # France
+    blocked: true
+  - code: GA   # Gabon
+    blocked: true
+  - code: GB   # United Kingdom
+    blocked: true
+  - code: GD   # Grenada
+    blocked: true
+  - code: GE   # Georgia
+    blocked: true
+  - code: GF   # French Guiana
+    blocked: true
+  - code: GG   # Guernsey
+    blocked: true
+  - code: GH   # Ghana
+    blocked: true
+  - code: GI   # Gibraltar
+    blocked: true
+  - code: GL   # Greenland
+    blocked: true
+  - code: GM   # Gambia
+    blocked: true
+  - code: GN   # Guinea
+    blocked: true
+  - code: GP   # Guadeloupe
+    blocked: true
+  - code: GQ   # Equatorial Guinea
+    blocked: true
+  - code: GR   # Greece
+    blocked: true
+  - code: GS   # South Georgia — no ipdeny zone file
+    blocked: false
+  - code: GT   # Guatemala
+    blocked: true
+  - code: GU   # Guam
+    blocked: true
+  - code: GW   # Guinea-Bissau
+    blocked: true
+  - code: GY   # Guyana
+    blocked: true
+  - code: HK   # Hong Kong
+    blocked: true
+  - code: HM   # Heard Island — no ipdeny zone file
+    blocked: false
+  - code: HN   # Honduras
+    blocked: true
+  - code: HR   # Croatia
+    blocked: true
+  - code: HT   # Haiti
+    blocked: true
+  - code: HU   # Hungary
+    blocked: true
+  - code: ID   # Indonesia
+    blocked: true
+  - code: IE   # Ireland
+    blocked: true
+  - code: IL   # Israel
+    blocked: true
+  - code: IM   # Isle of Man
+    blocked: true
+  - code: IN   # India
+    blocked: true
+  - code: IO   # British Indian Ocean Territory
+    blocked: true
+  - code: IQ   # Iraq
+    blocked: true
+  - code: IR   # Iran
+    blocked: true
+  - code: IS   # Iceland
+    blocked: true
+  - code: IT   # Italy
+    blocked: true
+  - code: JE   # Jersey
+    blocked: true
+  - code: JM   # Jamaica
+    blocked: true
+  - code: JO   # Jordan
+    blocked: true
+  - code: JP   # Japan
+    blocked: true
+  - code: KE   # Kenya
+    blocked: true
+  - code: KG   # Kyrgyzstan
+    blocked: true
+  - code: KH   # Cambodia
+    blocked: true
+  - code: KI   # Kiribati
+    blocked: true
+  - code: KM   # Comoros
+    blocked: true
+  - code: KN   # Saint Kitts and Nevis
+    blocked: true
+  - code: KP   # North Korea
+    blocked: true
+  - code: KR   # South Korea
+    blocked: true
+  - code: KW   # Kuwait
+    blocked: true
+  - code: KY   # Cayman Islands
+    blocked: true
+  - code: KZ   # Kazakhstan
+    blocked: true
+  - code: LA   # Laos
+    blocked: true
+  - code: LB   # Lebanon
+    blocked: true
+  - code: LC   # Saint Lucia
+    blocked: true
+  - code: LI   # Liechtenstein
+    blocked: true
+  - code: LK   # Sri Lanka
+    blocked: true
+  - code: LR   # Liberia
+    blocked: true
+  - code: LS   # Lesotho
+    blocked: true
+  - code: LT   # Lithuania
+    blocked: true
+  - code: LU   # Luxembourg
+    blocked: true
+  - code: LV   # Latvia
+    blocked: true
+  - code: LY   # Libya
+    blocked: true
+  - code: MA   # Morocco
+    blocked: true
+  - code: MC   # Monaco
+    blocked: true
+  - code: MD   # Moldova
+    blocked: true
+  - code: ME   # Montenegro
+    blocked: true
+  - code: MF   # Saint Martin
+    blocked: true
+  - code: MG   # Madagascar
+    blocked: true
+  - code: MH   # Marshall Islands
+    blocked: true
+  - code: MK   # North Macedonia
+    blocked: true
+  - code: ML   # Mali
+    blocked: true
+  - code: MM   # Myanmar
+    blocked: true
+  - code: MN   # Mongolia
+    blocked: true
+  - code: MO   # Macao
+    blocked: true
+  - code: MP   # Northern Mariana Islands
+    blocked: true
+  - code: MQ   # Martinique
+    blocked: true
+  - code: MR   # Mauritania
+    blocked: true
+  - code: MS   # Montserrat
+    blocked: true
+  - code: MT   # Malta
+    blocked: true
+  - code: MU   # Mauritius
+    blocked: true
+  - code: MV   # Maldives
+    blocked: true
+  - code: MW   # Malawi
+    blocked: true
+  - code: MX   # Mexico
+    blocked: true
+  - code: MY   # Malaysia
+    blocked: true
+  - code: MZ   # Mozambique
+    blocked: true
+  - code: NA   # Namibia
+    blocked: true
+  - code: NC   # New Caledonia
+    blocked: true
+  - code: NE   # Niger
+    blocked: true
+  - code: NF   # Norfolk Island
+    blocked: true
+  - code: NG   # Nigeria
+    blocked: true
+  - code: NI   # Nicaragua
+    blocked: true
+  - code: NL   # Netherlands
+    blocked: true
+  - code: "NO"  # Norway
+    blocked: true
+  - code: NP   # Nepal
+    blocked: true
+  - code: NR   # Nauru
+    blocked: true
+  - code: NU   # Niue
+    blocked: true
+  - code: NZ   # New Zealand
+    blocked: true
+  - code: OM   # Oman
+    blocked: true
+  - code: PA   # Panama
+    blocked: true
+  - code: PE   # Peru
+    blocked: true
+  - code: PF   # French Polynesia
+    blocked: true
+  - code: PG   # Papua New Guinea
+    blocked: true
+  - code: PH   # Philippines
+    blocked: true
+  - code: PK   # Pakistan
+    blocked: true
+  - code: PL   # Poland
+    blocked: true
+  - code: PM   # Saint Pierre and Miquelon
+    blocked: true
+  - code: PN   # Pitcairn — no ipdeny zone file
+    blocked: false
+  - code: PR   # Puerto Rico
+    blocked: true
+  - code: PS   # Palestine
+    blocked: true
+  - code: PT   # Portugal
+    blocked: true
+  - code: PW   # Palau
+    blocked: true
+  - code: PY   # Paraguay
+    blocked: true
+  - code: QA   # Qatar
+    blocked: true
+  - code: RE   # Reunion
+    blocked: true
+  - code: RO   # Romania
+    blocked: true
+  - code: RS   # Serbia
+    blocked: true
+  - code: RU   # Russia
+    blocked: true
+  - code: RW   # Rwanda
+    blocked: true
+  - code: SA   # Saudi Arabia
+    blocked: true
+  - code: SB   # Solomon Islands
+    blocked: true
+  - code: SC   # Seychelles
+    blocked: true
+  - code: SD   # Sudan
+    blocked: true
+  - code: SE   # Sweden
+    blocked: true
+  - code: SG   # Singapore
+    blocked: true
+  - code: SH   # Saint Helena — no ipdeny zone file
+    blocked: false
+  - code: SI   # Slovenia
+    blocked: true
+  - code: SJ   # Svalbard and Jan Mayen — no ipdeny zone file
+    blocked: false
+  - code: SK   # Slovakia
+    blocked: true
+  - code: SL   # Sierra Leone
+    blocked: true
+  - code: SM   # San Marino
+    blocked: true
+  - code: SN   # Senegal
+    blocked: true
+  - code: SO   # Somalia
+    blocked: true
+  - code: SR   # Suriname
+    blocked: true
+  - code: SS   # South Sudan
+    blocked: true
+  - code: ST   # Sao Tome and Principe
+    blocked: true
+  - code: SV   # El Salvador
+    blocked: true
+  - code: SX   # Sint Maarten
+    blocked: true
+  - code: SY   # Syria
+    blocked: true
+  - code: SZ   # Eswatini
+    blocked: true
+  - code: TC   # Turks and Caicos Islands
+    blocked: true
+  - code: TD   # Chad
+    blocked: true
+  - code: TF   # French Southern Territories — no ipdeny zone file
+    blocked: false
+  - code: TG   # Togo
+    blocked: true
+  - code: TH   # Thailand
+    blocked: true
+  - code: TJ   # Tajikistan
+    blocked: true
+  - code: TK   # Tokelau
+    blocked: true
+  - code: TL   # Timor-Leste
+    blocked: true
+  - code: TM   # Turkmenistan
+    blocked: true
+  - code: TN   # Tunisia
+    blocked: true
+  - code: TO   # Tonga
+    blocked: true
+  - code: TR   # Turkey
+    blocked: true
+  - code: TT   # Trinidad and Tobago
+    blocked: true
+  - code: TV   # Tuvalu
+    blocked: true
+  - code: TW   # Taiwan
+    blocked: true
+  - code: TZ   # Tanzania
+    blocked: true
+  - code: UA   # Ukraine
+    blocked: true
+  - code: UG   # Uganda
+    blocked: true
+  - code: UM   # US Minor Outlying Islands
+    blocked: true
+  - code: US   # United States
+    blocked: false
+  - code: UY   # Uruguay
+    blocked: true
+  - code: UZ   # Uzbekistan
+    blocked: true
+  - code: VA   # Vatican City
+    blocked: true
+  - code: VC   # Saint Vincent and the Grenadines
+    blocked: true
+  - code: VE   # Venezuela
+    blocked: true
+  - code: VG   # British Virgin Islands
+    blocked: true
+  - code: VI   # US Virgin Islands
+    blocked: true
+  - code: VN   # Vietnam
+    blocked: true
+  - code: VU   # Vanuatu
+    blocked: true
+  - code: WF   # Wallis and Futuna
+    blocked: true
+  - code: WS   # Samoa
+    blocked: true
+  - code: XK   # Kosovo — no ipdeny zone file
+    blocked: false
+  - code: YE   # Yemen
+    blocked: true
+  - code: YT   # Mayotte
+    blocked: true
+  - code: ZA   # South Africa
+    blocked: true
+  - code: ZM   # Zambia
+    blocked: true
+  - code: ZW   # Zimbabwe
+    blocked: true

+ 4 - 0
tftsr_nginx-hardening/roles/geo_blocking/handlers/main.yml

@@ -0,0 +1,4 @@
+---
+- name: reload nftables
+  ansible.builtin.command: nft -f {{ geo_nft_file }}
+  changed_when: true

+ 103 - 0
tftsr_nginx-hardening/roles/geo_blocking/tasks/main.yml

@@ -0,0 +1,103 @@
+---
+- name: Ensure nftables.d directory exists
+  ansible.builtin.file:
+    path: "{{ geo_nft_table_dir }}"
+    state: directory
+    owner: root
+    group: root
+    mode: '0755'
+
+- name: Create temp directory for zone files
+  ansible.builtin.tempfile:
+    state: directory
+    suffix: geo_zones
+  register: geo_temp_dir
+
+# --- Source: live download ---
+
+- name: Test connectivity to ipdeny.com (fast pre-check)
+  ansible.builtin.uri:
+    url: "{{ geo_ipdeny_base_url }}/us-aggregated.zone"
+    method: HEAD
+    timeout: 8
+  register: geo_connectivity_check
+  ignore_errors: yes
+  when: geo_zone_files_dir | length == 0
+
+- name: Fail fast if ipdeny.com is unreachable and no local cache configured
+  ansible.builtin.fail:
+    msg: >-
+      Cannot reach ipdeny.com (connection timed out or refused) and
+      geo_zone_files_dir is not set. Pre-download zone files on a machine
+      with internet access using scripts/download-geo-zones.sh, copy them
+      to this host, then set geo_zone_files_dir in inventory or with -e.
+  when:
+    - geo_zone_files_dir | length == 0
+    - geo_connectivity_check is failed
+
+- name: Download zone files for blocked countries
+  ansible.builtin.get_url:
+    url: "{{ geo_ipdeny_base_url }}/{{ item.code | lower }}-aggregated.zone"
+    dest: "{{ geo_temp_dir.path }}/{{ item.code | lower }}.zone"
+    timeout: 30
+  loop: "{{ geo_countries | selectattr('blocked', 'equalto', true) | list }}"
+  loop_control:
+    label: "{{ item.code }}"
+  ignore_errors: yes
+  when:
+    - geo_zone_files_dir | length == 0
+    - geo_connectivity_check is succeeded
+
+# --- Source: local pre-downloaded cache ---
+
+- name: Copy zone files from local cache directory
+  ansible.builtin.copy:
+    src: "{{ geo_zone_files_dir }}/{{ item.code | lower }}.zone"
+    dest: "{{ geo_temp_dir.path }}/{{ item.code | lower }}.zone"
+    remote_src: yes
+  loop: "{{ geo_countries | selectattr('blocked', 'equalto', true) | list }}"
+  loop_control:
+    label: "{{ item.code }}"
+  ignore_errors: yes
+  when: geo_zone_files_dir | length > 0
+
+# --- Assemble and deploy ---
+
+- name: Assemble all CIDRs from downloaded zone files
+  ansible.builtin.shell: >
+    cat {{ geo_temp_dir.path }}/*.zone 2>/dev/null |
+    grep -v '^#' | grep -v '^$' | sort -u
+  register: geo_cidrs_raw
+  changed_when: false
+
+- name: Set geo_blocked_cidrs fact
+  ansible.builtin.set_fact:
+    geo_blocked_cidrs: "{{ geo_cidrs_raw.stdout_lines }}"
+
+- name: Deploy geo-block nftables ruleset
+  ansible.builtin.template:
+    src: geo-block.nft.j2
+    dest: "{{ geo_nft_file }}"
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: reload nftables
+
+- name: Ensure nftables.conf includes geo-block.nft
+  ansible.builtin.lineinfile:
+    path: /etc/sysconfig/nftables.conf
+    line: 'include "{{ geo_nft_file }}"'
+    state: present
+    backup: yes
+
+- name: Enable and start nftables service
+  ansible.builtin.service:
+    name: nftables
+    state: started
+    enabled: yes
+
+- name: Clean up temp directory
+  ansible.builtin.file:
+    path: "{{ geo_temp_dir.path }}"
+    state: absent

+ 26 - 0
tftsr_nginx-hardening/roles/geo_blocking/templates/geo-block.nft.j2

@@ -0,0 +1,26 @@
+#!/usr/sbin/nft -f
+# Managed by Ansible — do not edit manually
+
+# Ensure table exists, then flush for idempotency
+add table inet geo_block
+flush table inet geo_block
+
+table inet geo_block {
+    set blocked_countries {
+        type ipv4_addr
+        flags interval
+{% if geo_blocked_cidrs | length > 0 %}
+        elements = {
+{% for cidr in geo_blocked_cidrs %}
+            {{ cidr }}{% if not loop.last %},{% endif %}
+
+{% endfor %}
+        }
+{% endif %}
+    }
+
+    chain prerouting {
+        type filter hook prerouting priority -100; policy accept;
+        ip saddr @blocked_countries drop
+    }
+}

+ 31 - 0
tftsr_nginx-hardening/roles/nginx_hardening/defaults/main.yml

@@ -0,0 +1,31 @@
+---
+nginx_ssl_protocols: "TLSv1.2 TLSv1.3"
+nginx_ssl_ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256"
+nginx_hsts_max_age: 31536000
+nginx_rate_limit_req_zone: "$binary_remote_addr zone=general:10m rate=30r/m"
+nginx_client_max_body_size: "10m"
+nginx_proxy_read_timeout: 60
+
+nginx_redirect_services:
+  - name: gogs
+    server_name: gogs.tftsr.com
+  - name: homeassist
+    server_name: homeassist.tftsr.com
+  - name: kimai
+    server_name: kimai.tftsr.com
+  - name: ollama-ui
+    server_name: ollama-ui.tftsr.com
+  - name: overseerr
+    server_name: overseerr.tftsr.com
+  - name: plex
+    server_name: plex.tftsr.com
+  - name: portainer
+    server_name: portainer.tftsr.com
+  - name: radarr
+    server_name: radarr.tftsr.com
+  - name: retro
+    server_name: retro.tftsr.com
+  - name: sonarr
+    server_name: sonarr.tftsr.com
+  - name: trilium
+    server_name: trilium.tftsr.com

+ 5 - 0
tftsr_nginx-hardening/roles/nginx_hardening/handlers/main.yml

@@ -0,0 +1,5 @@
+---
+- name: reload nginx
+  ansible.builtin.service:
+    name: nginx
+    state: reloaded

+ 44 - 0
tftsr_nginx-hardening/roles/nginx_hardening/tasks/main.yml

@@ -0,0 +1,44 @@
+---
+- name: Deploy security headers configuration
+  ansible.builtin.template:
+    src: security_headers.conf.j2
+    dest: /etc/nginx/conf.d/00-security-headers.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: reload nginx
+
+- name: Deploy SSL parameters configuration
+  ansible.builtin.template:
+    src: ssl_params.conf.j2
+    dest: /etc/nginx/conf.d/00-ssl-params.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: reload nginx
+
+- name: Deploy proxy parameters configuration
+  ansible.builtin.template:
+    src: proxy_params.conf.j2
+    dest: /etc/nginx/conf.d/00-proxy-params.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: reload nginx
+
+- name: Deploy HTTP to HTTPS redirect configuration
+  ansible.builtin.template:
+    src: http_redirect.conf.j2
+    dest: /etc/nginx/conf.d/00-http-redirects.conf
+    owner: root
+    group: root
+    mode: '0644'
+    backup: yes
+  notify: reload nginx
+
+- name: Validate NGINX configuration
+  ansible.builtin.command: nginx -t
+  changed_when: false

+ 8 - 0
tftsr_nginx-hardening/roles/nginx_hardening/templates/http_redirect.conf.j2

@@ -0,0 +1,8 @@
+# Managed by Ansible — do not edit manually
+{% for svc in nginx_redirect_services %}
+server {
+    listen 80;
+    server_name {{ svc.server_name }};
+    return 301 https://$host$request_uri;
+}
+{% endfor %}

+ 8 - 0
tftsr_nginx-hardening/roles/nginx_hardening/templates/proxy_params.conf.j2

@@ -0,0 +1,8 @@
+# Managed by Ansible — do not edit manually
+
+proxy_hide_header X-Powered-By;
+proxy_hide_header Server;
+proxy_set_header X-Real-IP $remote_addr;
+proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+proxy_set_header X-Forwarded-Proto $scheme;
+proxy_read_timeout {{ nginx_proxy_read_timeout }};

+ 17 - 0
tftsr_nginx-hardening/roles/nginx_hardening/templates/security_headers.conf.j2

@@ -0,0 +1,17 @@
+# Managed by Ansible — do not edit manually
+
+server_tokens off;
+
+# Rate limiting zone definition
+limit_req_zone {{ nginx_rate_limit_req_zone }};
+
+# Client body size limit
+client_max_body_size {{ nginx_client_max_body_size }};
+
+# Security headers
+add_header Strict-Transport-Security "max-age={{ nginx_hsts_max_age }}; includeSubDomains; preload" always;
+add_header X-Frame-Options SAMEORIGIN always;
+add_header X-Content-Type-Options nosniff always;
+add_header Referrer-Policy strict-origin-when-cross-origin always;
+add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
+add_header X-XSS-Protection "1; mode=block" always;

+ 10 - 0
tftsr_nginx-hardening/roles/nginx_hardening/templates/ssl_params.conf.j2

@@ -0,0 +1,10 @@
+# Managed by Ansible — do not edit manually
+
+ssl_protocols {{ nginx_ssl_protocols }};
+ssl_ciphers {{ nginx_ssl_ciphers }};
+ssl_prefer_server_ciphers off;
+ssl_session_timeout 1d;
+ssl_stapling on;
+ssl_stapling_verify on;
+resolver 8.8.8.8 8.8.4.4 valid=300s;
+resolver_timeout 5s;

+ 71 - 0
tftsr_nginx-hardening/scripts/download-geo-zones.sh

@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# Download ipdeny.com aggregated zone files for all blocked countries.
+# Run this on a machine WITH internet access, then rsync the output
+# directory to the DMZ host and set geo_zone_files_dir in your inventory.
+#
+# Usage:
+#   ./scripts/download-geo-zones.sh [output-dir]
+#
+# Example workflow:
+#   # On your workstation:
+#   ./scripts/download-geo-zones.sh /tmp/geo_zones
+#   rsync -av /tmp/geo_zones/ sarman@dmz-host:/opt/geo_zones/
+#
+#   # Then run the playbook pointing at the cache:
+#   ansible-playbook -K playbooks/geo_blocking.yml -e geo_zone_files_dir=/opt/geo_zones
+
+set -euo pipefail
+
+BASE_URL="https://www.ipdeny.com/ipblocks/data/aggregated"
+OUT_DIR="${1:-/tmp/geo_zones}"
+
+# All blocked country codes (excludes US and ipdeny-absent territories)
+COUNTRIES=(
+  AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
+  BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BW BY BZ
+  CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CY CZ
+  DE DJ DK DM DO DZ
+  EC EE EG ER ES ET
+  FI FJ FK FM FO FR
+  GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GT GU GW GY
+  HK HN HR HT HU
+  ID IE IL IM IN IO IQ IR IS IT
+  JE JM JO JP
+  KE KG KH KI KM KN KP KR KW KY KZ
+  LA LB LC LI LK LR LS LT LU LV LY
+  MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
+  NA NC NE NF NG NI NL NO NP NR NU NZ
+  OM
+  PA PE PF PG PH PK PL PM PR PS PT PW PY
+  QA
+  RE RO RS RU RW
+  SA SB SC SD SE SG SI SK SL SM SN SO SR SS ST SV SX SY SZ
+  TC TD TG TH TJ TK TL TM TN TO TR TT TV TW TZ
+  UA UG UM UY UZ
+  VA VC VE VG VI VN VU
+  WF WS
+  YE YT
+  ZA ZM ZW
+)
+
+mkdir -p "$OUT_DIR"
+echo "Downloading ${#COUNTRIES[@]} zone files to $OUT_DIR ..."
+
+ok=0; fail=0
+for cc in "${COUNTRIES[@]}"; do
+  url="${BASE_URL}/${cc,,}-aggregated.zone"
+  dest="${OUT_DIR}/${cc,,}.zone"
+  if curl -fsSL --connect-timeout 10 --max-time 30 -o "$dest" "$url"; then
+    (( ++ok ))
+  else
+    echo "  SKIP $cc (no zone file at ipdeny.com)"
+    rm -f "$dest"
+    (( ++fail ))
+  fi
+done
+
+echo "Done: $ok downloaded, $fail skipped."
+echo ""
+echo "Next steps:"
+echo "  rsync -av ${OUT_DIR}/ USER@DMZ_HOST:/opt/geo_zones/"
+echo "  ansible-playbook -K playbooks/geo_blocking.yml -e geo_zone_files_dir=/opt/geo_zones"

+ 8 - 0
tftsr_nginx-hardening/site.yml

@@ -0,0 +1,8 @@
+---
+- hosts: localhost
+  connection: local
+  become: true
+  roles:
+    - nginx_hardening
+    - fail2ban
+    - geo_blocking