diff --git a/ansible/playbooks/paas/roles/ansible-ufw/defaults/main.yml b/ansible/playbooks/paas/roles/ansible-ufw/defaults/main.yml index 34cc3ab1..fb52137f 100644 --- a/ansible/playbooks/paas/roles/ansible-ufw/defaults/main.yml +++ b/ansible/playbooks/paas/roles/ansible-ufw/defaults/main.yml @@ -1,7 +1,4 @@ --- -ufw_packages: - - ufw - ufw_ipv6: "yes" ufw_default_input_policy: DROP ufw_default_output_policy: ACCEPT @@ -39,3 +36,7 @@ ufw_rules: ufw_custom_rules: [] ufw_applications: [] + +# Blocklist configuration +ufw_blocklist_enabled: true +ufw_blocklist_url: "https://cdn.jsdelivr.net/gh/duggytuxy/Data-Shield_IPv4_Blocklist@refs/heads/main/prod_data-shield_ipv4_blocklist.txt" diff --git a/ansible/playbooks/paas/roles/ansible-ufw/handlers/main.yml b/ansible/playbooks/paas/roles/ansible-ufw/handlers/main.yml index 6cb8fbc1..7cbb50f9 100644 --- a/ansible/playbooks/paas/roles/ansible-ufw/handlers/main.yml +++ b/ansible/playbooks/paas/roles/ansible-ufw/handlers/main.yml @@ -2,3 +2,7 @@ - name: Reload ufw community.general.ufw: state: reloaded + +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/ansible/playbooks/paas/roles/ansible-ufw/tasks/blocklist.yml b/ansible/playbooks/paas/roles/ansible-ufw/tasks/blocklist.yml new file mode 100644 index 00000000..ad01bfa2 --- /dev/null +++ b/ansible/playbooks/paas/roles/ansible-ufw/tasks/blocklist.yml @@ -0,0 +1,71 @@ +--- +- name: Create blocklist working directory + ansible.builtin.file: + path: "{{ ufw_blocklist_tmp_dir }}" + state: directory + owner: root + group: root + mode: "0700" + +- name: Deploy blocklist update script + ansible.builtin.template: + src: ufw-blocklist-update.sh.j2 + dest: "{{ ufw_blocklist_script_path }}" + owner: root + group: root + mode: "0700" + +- name: Create ipset for blocklist + ansible.builtin.shell: | + if ! ipset list {{ ufw_blocklist_ipset_name }} >/dev/null 2>&1; then + ipset create {{ ufw_blocklist_ipset_name }} hash:ip maxelem 200000 + echo "CHANGED" + else + echo "OK" + fi + register: ipset_create + changed_when: "'CHANGED' in ipset_create.stdout" + +- name: Ensure ipset match rule is in before.rules + ansible.builtin.lineinfile: + path: /etc/ufw/before.rules + insertafter: "^-A ufw-before-input -i lo -j ACCEPT" + line: "-A ufw-before-input -m set --match-set {{ ufw_blocklist_ipset_name }} src -j DROP" + state: present + notify: Reload ufw + +- name: Deploy ipset restore systemd service + ansible.builtin.template: + src: ufw-blocklist-restore.service.j2 + dest: /etc/systemd/system/ufw-blocklist-restore.service + owner: root + group: root + mode: "0644" + notify: Reload systemd + +- name: Enable ipset restore service + ansible.builtin.systemd: + name: ufw-blocklist-restore.service + enabled: true + daemon_reload: true + +- name: Reload ufw now if ipset rule was just added + ansible.builtin.meta: flush_handlers + +- name: Run initial blocklist update + ansible.builtin.command: "{{ ufw_blocklist_script_path }}" + register: initial_update + changed_when: true + when: ipset_create is changed + +- name: Configure cron job for blocklist updates + ansible.builtin.cron: + name: "{{ ufw_blocklist_ipset_name }}-update" + minute: "{{ ufw_blocklist_cron_minute }}" + hour: "{{ ufw_blocklist_cron_hour }}" + day: "{{ ufw_blocklist_cron_day }}" + month: "{{ ufw_blocklist_cron_month }}" + weekday: "{{ ufw_blocklist_cron_weekday }}" + job: "{{ ufw_blocklist_script_path }} 2>&1 | logger -t ufw-blocklist-cron" + user: root + state: present diff --git a/ansible/playbooks/paas/roles/ansible-ufw/tasks/main.yml b/ansible/playbooks/paas/roles/ansible-ufw/tasks/main.yml index 8470f35f..fff99fc6 100644 --- a/ansible/playbooks/paas/roles/ansible-ufw/tasks/main.yml +++ b/ansible/playbooks/paas/roles/ansible-ufw/tasks/main.yml @@ -23,3 +23,7 @@ community.general.ufw: state: "{{ ufw_state }}" logging: "{{ ufw_logging }}" + +- name: Configure blocklist + ansible.builtin.include_tasks: blocklist.yml + when: ufw_blocklist_enabled diff --git a/ansible/playbooks/paas/roles/ansible-ufw/templates/ufw-blocklist-restore.service.j2 b/ansible/playbooks/paas/roles/ansible-ufw/templates/ufw-blocklist-restore.service.j2 new file mode 100644 index 00000000..32aff8fb --- /dev/null +++ b/ansible/playbooks/paas/roles/ansible-ufw/templates/ufw-blocklist-restore.service.j2 @@ -0,0 +1,12 @@ +[Unit] +Description=Restore UFW blocklist ipset before UFW starts +Before=ufw.service +After=network-pre.target + +[Service] +Type=oneshot +ExecStart=/bin/bash -c 'if [ -f {{ ufw_blocklist_ipset_save_file }} ]; then ipset restore < {{ ufw_blocklist_ipset_save_file }}; fi' +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/ansible/playbooks/paas/roles/ansible-ufw/templates/ufw-blocklist-update.sh.j2 b/ansible/playbooks/paas/roles/ansible-ufw/templates/ufw-blocklist-update.sh.j2 new file mode 100644 index 00000000..580d73e3 --- /dev/null +++ b/ansible/playbooks/paas/roles/ansible-ufw/templates/ufw-blocklist-update.sh.j2 @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Managed by Ansible - Do not edit manually +# Updates the {{ ufw_blocklist_ipset_name }} ipset from a remote IPv4 blocklist +set -euo pipefail + +IPSET_NAME="{{ ufw_blocklist_ipset_name }}" +IPSET_TMP="${IPSET_NAME}-tmp" +BLOCKLIST_URL="{{ ufw_blocklist_url }}" +TMP_DIR="{{ ufw_blocklist_tmp_dir }}" +TMP_FILE="${TMP_DIR}/blocklist_new.txt" +CURRENT_FILE="${TMP_DIR}/blocklist_current.txt" +IPSET_SAVE_FILE="{{ ufw_blocklist_ipset_save_file }}" +LOG_TAG="ufw-blocklist" +LOCK_FILE="/var/run/ufw-blocklist-update.lock" +DRY_RUN=false +VERBOSE=false + +for arg in "$@"; do + case "$arg" in + --dry-run|-n) DRY_RUN=true ;; + -v|--verbose) VERBOSE=true ;; + esac +done + +log() { + if $DRY_RUN; then + echo "[DRY-RUN] $1" + elif $VERBOSE; then + echo "$1" + fi + logger -t "$LOG_TAG" "$1" +} + +cleanup() { + rm -f "$LOCK_FILE" "$TMP_FILE" "${TMP_FILE}.clean" + # Destroy tmp set if it exists + ipset destroy "$IPSET_TMP" 2>/dev/null || true +} + +trap cleanup EXIT + +# Prevent concurrent runs +if [ -f "$LOCK_FILE" ]; then + LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null || true) + if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then + log "Another instance is already running (PID $LOCK_PID). Exiting." + exit 0 + fi + log "Stale lock file found. Removing." + rm -f "$LOCK_FILE" +fi +echo $$ > "$LOCK_FILE" + +# Ensure temp directory exists +mkdir -p "$TMP_DIR" + +# Ensure the main ipset exists +if ! ipset list "$IPSET_NAME" >/dev/null 2>&1; then + if $DRY_RUN; then + log "Would create ipset: ipset create $IPSET_NAME hash:ip maxelem 200000" + else + log "ipset $IPSET_NAME does not exist. Creating." + ipset create "$IPSET_NAME" hash:ip maxelem 200000 + fi +fi + +# Download the blocklist +log "Downloading blocklist from $BLOCKLIST_URL" +if ! curl -fsSL --retry 3 --retry-delay 10 --max-time {{ ufw_blocklist_download_timeout }} \ + -o "$TMP_FILE" "$BLOCKLIST_URL"; then + log "ERROR: Failed to download blocklist. Aborting update." + exit 1 +fi + +# Validate and clean the downloaded file: keep only valid IPv4 addresses +grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?$' "$TMP_FILE" | sort -u > "${TMP_FILE}.clean" +mv "${TMP_FILE}.clean" "$TMP_FILE" + +NEW_COUNT=$(wc -l < "$TMP_FILE") +if [ "$NEW_COUNT" -eq 0 ]; then + log "ERROR: Downloaded blocklist is empty. Aborting update." + exit 1 +fi + +# Sanity check: reject suspiciously small lists (possible corruption) +if [ -f "$CURRENT_FILE" ]; then + CURRENT_COUNT=$(wc -l < "$CURRENT_FILE") + if [ "$CURRENT_COUNT" -gt 0 ]; then + MIN_EXPECTED=$(( CURRENT_COUNT * {{ ufw_blocklist_min_ratio }} / 100 )) + if [ "$NEW_COUNT" -lt "$MIN_EXPECTED" ]; then + log "WARNING: New list ($NEW_COUNT IPs) is less than {{ ufw_blocklist_min_ratio }}% of current ($CURRENT_COUNT IPs). Aborting." + exit 1 + fi + fi +fi + +log "Downloaded $NEW_COUNT IPs." + +if $DRY_RUN; then + # In dry-run, show what would change + if [ -f "$CURRENT_FILE" ]; then + ADD_COUNT=$(comm -23 "$TMP_FILE" "$CURRENT_FILE" | wc -l) + REMOVE_COUNT=$(comm -13 "$TMP_FILE" "$CURRENT_FILE" | wc -l) + else + ADD_COUNT="$NEW_COUNT" + REMOVE_COUNT=0 + fi + log "Would add: $ADD_COUNT IPs, Would remove: $REMOVE_COUNT IPs, Total: $NEW_COUNT IPs" + log "Would swap ipset $IPSET_TMP -> $IPSET_NAME" + log "Would save ipset to $IPSET_SAVE_FILE" + exit 0 +fi + +# Create temporary ipset and populate it +ipset create "$IPSET_TMP" hash:ip maxelem 200000 + +log "Populating temporary ipset $IPSET_TMP with $NEW_COUNT IPs..." +while IFS= read -r ip; do + [ -z "$ip" ] && continue + ipset add "$IPSET_TMP" "$ip" 2>/dev/null || true +done < "$TMP_FILE" + +# Atomic swap +ipset swap "$IPSET_TMP" "$IPSET_NAME" +log "Swapped $IPSET_TMP -> $IPSET_NAME" + +# Destroy the old set (now under the tmp name) +ipset destroy "$IPSET_TMP" 2>/dev/null || true + +# Save for persistence across reboots +ipset save "$IPSET_NAME" > "$IPSET_SAVE_FILE" +log "Saved ipset to $IPSET_SAVE_FILE" + +# Save the current list for next run's sanity check +cp "$TMP_FILE" "$CURRENT_FILE" + +TOTAL=$(ipset list "$IPSET_NAME" 2>/dev/null | grep -c "^[0-9]" || true) +log "Update complete. Total blocked: $TOTAL IPs" diff --git a/ansible/playbooks/paas/roles/ansible-ufw/vars/main.yml b/ansible/playbooks/paas/roles/ansible-ufw/vars/main.yml new file mode 100644 index 00000000..ba6a6888 --- /dev/null +++ b/ansible/playbooks/paas/roles/ansible-ufw/vars/main.yml @@ -0,0 +1,25 @@ +--- +ufw_packages: + - ufw + - ipset + +ufw_blocklist_ipset_name: ufw-blocklist-user + +ufw_blocklist_script_path: /usr/local/sbin/ufw-blocklist-update.sh + +ufw_blocklist_tmp_dir: /var/lib/ufw-blocklist + +ufw_blocklist_ipset_save_file: /var/lib/ufw-blocklist/ipset.save + +ufw_blocklist_download_timeout: 60 + +# Minimum ratio (%) of new list size vs current list to accept the update +# Protects against corrupted/empty downloads replacing a valid blocklist +ufw_blocklist_min_ratio: 50 + +# Cron schedule - default every 6 hours matching upstream refresh rate +ufw_blocklist_cron_minute: "15" +ufw_blocklist_cron_hour: "*/6" +ufw_blocklist_cron_day: "*" +ufw_blocklist_cron_month: "*" +ufw_blocklist_cron_weekday: "*"