Skip to content

Commit 9287c4a

Browse files
authored
Merge pull request #88 from wiseflat/dev/ufw-blocklist
feat(ufw/blocklist): Block badips
2 parents 49b2ac1 + 15cc5c2 commit 9287c4a

7 files changed

Lines changed: 258 additions & 3 deletions

File tree

ansible/playbooks/paas/roles/ansible-ufw/defaults/main.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
11
---
2-
ufw_packages:
3-
- ufw
4-
52
ufw_ipv6: "yes"
63
ufw_default_input_policy: DROP
74
ufw_default_output_policy: ACCEPT
@@ -39,3 +36,7 @@ ufw_rules:
3936
ufw_custom_rules: []
4037

4138
ufw_applications: []
39+
40+
# Blocklist configuration
41+
ufw_blocklist_enabled: true
42+
ufw_blocklist_url: "https://cdn.jsdelivr.net/gh/duggytuxy/Data-Shield_IPv4_Blocklist@refs/heads/main/prod_data-shield_ipv4_blocklist.txt"

ansible/playbooks/paas/roles/ansible-ufw/handlers/main.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,7 @@
22
- name: Reload ufw
33
community.general.ufw:
44
state: reloaded
5+
6+
- name: Reload systemd
7+
ansible.builtin.systemd:
8+
daemon_reload: true
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
- name: Create blocklist working directory
3+
ansible.builtin.file:
4+
path: "{{ ufw_blocklist_tmp_dir }}"
5+
state: directory
6+
owner: root
7+
group: root
8+
mode: "0700"
9+
10+
- name: Deploy blocklist update script
11+
ansible.builtin.template:
12+
src: ufw-blocklist-update.sh.j2
13+
dest: "{{ ufw_blocklist_script_path }}"
14+
owner: root
15+
group: root
16+
mode: "0700"
17+
18+
- name: Create ipset for blocklist
19+
ansible.builtin.shell: |
20+
if ! ipset list {{ ufw_blocklist_ipset_name }} >/dev/null 2>&1; then
21+
ipset create {{ ufw_blocklist_ipset_name }} hash:ip maxelem 200000
22+
echo "CHANGED"
23+
else
24+
echo "OK"
25+
fi
26+
register: ipset_create
27+
changed_when: "'CHANGED' in ipset_create.stdout"
28+
29+
- name: Ensure ipset match rule is in before.rules
30+
ansible.builtin.lineinfile:
31+
path: /etc/ufw/before.rules
32+
insertafter: "^-A ufw-before-input -i lo -j ACCEPT"
33+
line: "-A ufw-before-input -m set --match-set {{ ufw_blocklist_ipset_name }} src -j DROP"
34+
state: present
35+
notify: Reload ufw
36+
37+
- name: Deploy ipset restore systemd service
38+
ansible.builtin.template:
39+
src: ufw-blocklist-restore.service.j2
40+
dest: /etc/systemd/system/ufw-blocklist-restore.service
41+
owner: root
42+
group: root
43+
mode: "0644"
44+
notify: Reload systemd
45+
46+
- name: Enable ipset restore service
47+
ansible.builtin.systemd:
48+
name: ufw-blocklist-restore.service
49+
enabled: true
50+
daemon_reload: true
51+
52+
- name: Reload ufw now if ipset rule was just added
53+
ansible.builtin.meta: flush_handlers
54+
55+
- name: Run initial blocklist update
56+
ansible.builtin.command: "{{ ufw_blocklist_script_path }}"
57+
register: initial_update
58+
changed_when: true
59+
when: ipset_create is changed
60+
61+
- name: Configure cron job for blocklist updates
62+
ansible.builtin.cron:
63+
name: "{{ ufw_blocklist_ipset_name }}-update"
64+
minute: "{{ ufw_blocklist_cron_minute }}"
65+
hour: "{{ ufw_blocklist_cron_hour }}"
66+
day: "{{ ufw_blocklist_cron_day }}"
67+
month: "{{ ufw_blocklist_cron_month }}"
68+
weekday: "{{ ufw_blocklist_cron_weekday }}"
69+
job: "{{ ufw_blocklist_script_path }} 2>&1 | logger -t ufw-blocklist-cron"
70+
user: root
71+
state: present

ansible/playbooks/paas/roles/ansible-ufw/tasks/main.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@
2323
community.general.ufw:
2424
state: "{{ ufw_state }}"
2525
logging: "{{ ufw_logging }}"
26+
27+
- name: Configure blocklist
28+
ansible.builtin.include_tasks: blocklist.yml
29+
when: ufw_blocklist_enabled
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[Unit]
2+
Description=Restore UFW blocklist ipset before UFW starts
3+
Before=ufw.service
4+
After=network-pre.target
5+
6+
[Service]
7+
Type=oneshot
8+
ExecStart=/bin/bash -c 'if [ -f {{ ufw_blocklist_ipset_save_file }} ]; then ipset restore < {{ ufw_blocklist_ipset_save_file }}; fi'
9+
RemainAfterExit=yes
10+
11+
[Install]
12+
WantedBy=multi-user.target
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/usr/bin/env bash
2+
# Managed by Ansible - Do not edit manually
3+
# Updates the {{ ufw_blocklist_ipset_name }} ipset from a remote IPv4 blocklist
4+
set -euo pipefail
5+
6+
IPSET_NAME="{{ ufw_blocklist_ipset_name }}"
7+
IPSET_TMP="${IPSET_NAME}-tmp"
8+
BLOCKLIST_URL="{{ ufw_blocklist_url }}"
9+
TMP_DIR="{{ ufw_blocklist_tmp_dir }}"
10+
TMP_FILE="${TMP_DIR}/blocklist_new.txt"
11+
CURRENT_FILE="${TMP_DIR}/blocklist_current.txt"
12+
IPSET_SAVE_FILE="{{ ufw_blocklist_ipset_save_file }}"
13+
LOG_TAG="ufw-blocklist"
14+
LOCK_FILE="/var/run/ufw-blocklist-update.lock"
15+
DRY_RUN=false
16+
VERBOSE=false
17+
18+
for arg in "$@"; do
19+
case "$arg" in
20+
--dry-run|-n) DRY_RUN=true ;;
21+
-v|--verbose) VERBOSE=true ;;
22+
esac
23+
done
24+
25+
log() {
26+
if $DRY_RUN; then
27+
echo "[DRY-RUN] $1"
28+
elif $VERBOSE; then
29+
echo "$1"
30+
fi
31+
logger -t "$LOG_TAG" "$1"
32+
}
33+
34+
cleanup() {
35+
rm -f "$LOCK_FILE" "$TMP_FILE" "${TMP_FILE}.clean"
36+
# Destroy tmp set if it exists
37+
ipset destroy "$IPSET_TMP" 2>/dev/null || true
38+
}
39+
40+
trap cleanup EXIT
41+
42+
# Prevent concurrent runs
43+
if [ -f "$LOCK_FILE" ]; then
44+
LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null || true)
45+
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
46+
log "Another instance is already running (PID $LOCK_PID). Exiting."
47+
exit 0
48+
fi
49+
log "Stale lock file found. Removing."
50+
rm -f "$LOCK_FILE"
51+
fi
52+
echo $$ > "$LOCK_FILE"
53+
54+
# Ensure temp directory exists
55+
mkdir -p "$TMP_DIR"
56+
57+
# Ensure the main ipset exists
58+
if ! ipset list "$IPSET_NAME" >/dev/null 2>&1; then
59+
if $DRY_RUN; then
60+
log "Would create ipset: ipset create $IPSET_NAME hash:ip maxelem 200000"
61+
else
62+
log "ipset $IPSET_NAME does not exist. Creating."
63+
ipset create "$IPSET_NAME" hash:ip maxelem 200000
64+
fi
65+
fi
66+
67+
# Download the blocklist
68+
log "Downloading blocklist from $BLOCKLIST_URL"
69+
if ! curl -fsSL --retry 3 --retry-delay 10 --max-time {{ ufw_blocklist_download_timeout }} \
70+
-o "$TMP_FILE" "$BLOCKLIST_URL"; then
71+
log "ERROR: Failed to download blocklist. Aborting update."
72+
exit 1
73+
fi
74+
75+
# Validate and clean the downloaded file: keep only valid IPv4 addresses
76+
grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?$' "$TMP_FILE" | sort -u > "${TMP_FILE}.clean"
77+
mv "${TMP_FILE}.clean" "$TMP_FILE"
78+
79+
NEW_COUNT=$(wc -l < "$TMP_FILE")
80+
if [ "$NEW_COUNT" -eq 0 ]; then
81+
log "ERROR: Downloaded blocklist is empty. Aborting update."
82+
exit 1
83+
fi
84+
85+
# Sanity check: reject suspiciously small lists (possible corruption)
86+
if [ -f "$CURRENT_FILE" ]; then
87+
CURRENT_COUNT=$(wc -l < "$CURRENT_FILE")
88+
if [ "$CURRENT_COUNT" -gt 0 ]; then
89+
MIN_EXPECTED=$(( CURRENT_COUNT * {{ ufw_blocklist_min_ratio }} / 100 ))
90+
if [ "$NEW_COUNT" -lt "$MIN_EXPECTED" ]; then
91+
log "WARNING: New list ($NEW_COUNT IPs) is less than {{ ufw_blocklist_min_ratio }}% of current ($CURRENT_COUNT IPs). Aborting."
92+
exit 1
93+
fi
94+
fi
95+
fi
96+
97+
log "Downloaded $NEW_COUNT IPs."
98+
99+
if $DRY_RUN; then
100+
# In dry-run, show what would change
101+
if [ -f "$CURRENT_FILE" ]; then
102+
ADD_COUNT=$(comm -23 "$TMP_FILE" "$CURRENT_FILE" | wc -l)
103+
REMOVE_COUNT=$(comm -13 "$TMP_FILE" "$CURRENT_FILE" | wc -l)
104+
else
105+
ADD_COUNT="$NEW_COUNT"
106+
REMOVE_COUNT=0
107+
fi
108+
log "Would add: $ADD_COUNT IPs, Would remove: $REMOVE_COUNT IPs, Total: $NEW_COUNT IPs"
109+
log "Would swap ipset $IPSET_TMP -> $IPSET_NAME"
110+
log "Would save ipset to $IPSET_SAVE_FILE"
111+
exit 0
112+
fi
113+
114+
# Create temporary ipset and populate it
115+
ipset create "$IPSET_TMP" hash:ip maxelem 200000
116+
117+
log "Populating temporary ipset $IPSET_TMP with $NEW_COUNT IPs..."
118+
while IFS= read -r ip; do
119+
[ -z "$ip" ] && continue
120+
ipset add "$IPSET_TMP" "$ip" 2>/dev/null || true
121+
done < "$TMP_FILE"
122+
123+
# Atomic swap
124+
ipset swap "$IPSET_TMP" "$IPSET_NAME"
125+
log "Swapped $IPSET_TMP -> $IPSET_NAME"
126+
127+
# Destroy the old set (now under the tmp name)
128+
ipset destroy "$IPSET_TMP" 2>/dev/null || true
129+
130+
# Save for persistence across reboots
131+
ipset save "$IPSET_NAME" > "$IPSET_SAVE_FILE"
132+
log "Saved ipset to $IPSET_SAVE_FILE"
133+
134+
# Save the current list for next run's sanity check
135+
cp "$TMP_FILE" "$CURRENT_FILE"
136+
137+
TOTAL=$(ipset list "$IPSET_NAME" 2>/dev/null | grep -c "^[0-9]" || true)
138+
log "Update complete. Total blocked: $TOTAL IPs"
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
ufw_packages:
3+
- ufw
4+
- ipset
5+
6+
ufw_blocklist_ipset_name: ufw-blocklist-user
7+
8+
ufw_blocklist_script_path: /usr/local/sbin/ufw-blocklist-update.sh
9+
10+
ufw_blocklist_tmp_dir: /var/lib/ufw-blocklist
11+
12+
ufw_blocklist_ipset_save_file: /var/lib/ufw-blocklist/ipset.save
13+
14+
ufw_blocklist_download_timeout: 60
15+
16+
# Minimum ratio (%) of new list size vs current list to accept the update
17+
# Protects against corrupted/empty downloads replacing a valid blocklist
18+
ufw_blocklist_min_ratio: 50
19+
20+
# Cron schedule - default every 6 hours matching upstream refresh rate
21+
ufw_blocklist_cron_minute: "15"
22+
ufw_blocklist_cron_hour: "*/6"
23+
ufw_blocklist_cron_day: "*"
24+
ufw_blocklist_cron_month: "*"
25+
ufw_blocklist_cron_weekday: "*"

0 commit comments

Comments
 (0)