# /etc/systemd/system/china-ip-blocker.service [Unit] Description=Update and apply China IP blocklist using ipset After=network-online.target Wants=network-online.target
#!/bin/bash # ================================================================= # High-Performance, Low-Resource IP Blocklist Update Script # # Designed for execution via systemd timer or cron. # This script is non-persistent and relies on being run on boot # and periodically to maintain firewall rules. # =================================================================
set -o errexit # Exit immediately if a command exits with a non-zero status. set -o nounset # Exit immediately if it tries to use an undeclared variable. set -o pipefail # The return value of a pipeline is the status of the last command to exit with a non-zero status.
# --- Configuration --- readonly IPSET_NAME="chinablock" readonly IP_LIST_URL="https://www.ipdeny.com/ipblocks/data/countries/cn.zone" readonly TMP_FILE="/dev/shm/${IPSET_NAME}.zone" readonly LOCK_FILE="/var/run/${IPSET_NAME}.lock" readonly MIN_IP_COUNT=100 # Minimum expected number of IP ranges
# Efficient logging function using bash printf built-in (>= bash 4.2) log() { # %-1s prints a single space. Using it to get printf to evaluate the format string. printf'%(%Y-%m-%d %H:%M:%S)T - %s\n' -1 "$1" }
main() { # --- Prerequisite Checks --- if [[ ${EUID:-$(id -u)} -ne 0 ]]; then die "This script must be run as root." fi
for cmd in ipset wget iptables flock awk headtail; do if ! command -v "$cmd" &>/dev/null; then die "Required command '$cmd' is not found." fi done
# Ensure temporary file is cleaned up on any exit trap'rm -f "$TMP_FILE"' EXIT
log"Starting IP blocklist update for set '$IPSET_NAME'..."
# --- Download IP List --- log"Downloading IP list from $IP_LIST_URL..." if ! wget -q --timeout=60 --tries=3 -O "$TMP_FILE""$IP_LIST_URL"; then die "Download failed from $IP_LIST_URL." fi
# --- Create and Load Temporary IPSet --- local temp_ipset_name="${IPSET_NAME}_temp" ipset create "$temp_ipset_name"hash:net -exist ipset flush "$temp_ipset_name"
log"Validating list and preparing for bulk load..." # Use a single awk pass to validate and generate restore data. # It exits with an error code if the line count is too low. # The last line of its output is the total count. local awk_output awk_output=$(awk -v set_name="$temp_ipset_name" \ '{ print "add " set_name " " $1 } END { if (NR < '$MIN_IP_COUNT') exit 1; print NR }'"$TMP_FILE") \ || die "IP list validation failed (expected >$MIN_IP_COUNT lines, found $(wc -l < "$TMP_FILE" | awk '{print $1}'))."
# Pipe all but the last line (the count) to ipset restore for high-speed loading. echo"$awk_output" | head -n -1 | ipset restore || die "ipset restore command failed."
local final_count final_count=$(echo"$awk_output" | tail -n 1)
log"Loaded $final_count IP blocks into temporary set '$temp_ipset_name'."
# --- Atomically Activate the New IPSet --- ipset create "$IPSET_NAME"hash:net -exist ipset swap "$temp_ipset_name""$IPSET_NAME" ipset destroy "$temp_ipset_name" log"Successfully updated and activated ipset '$IPSET_NAME'."
# --- Ensure IPTables Rule Exists --- # This check is crucial because the rule is lost on reboot. if ! iptables -C INPUT -m set --match-set "$IPSET_NAME" src -j DROP &>/dev/null; then log"iptables rule not found. Inserting it at the top of the INPUT chain..." # -I INPUT 1 ensures it's one of the first rules evaluated, which is critical for performance. iptables -I INPUT 1 -m set --match-set "$IPSET_NAME" src -j DROP log"iptables rule for '$IPSET_NAME' added." else log"iptables rule for '$IPSET_NAME' already exists." fi
log"Update completed successfully." }
# --- Execution Wrapper --- # Use flock for robust concurrency control. The lock is held on file descriptor 200. ( flock -n 200 || die "Script is already running. Another instance holds the lock." main ) 200>"$LOCK_FILE"
Got a server for free from Google Cloud, but recently noticed it occasionally incurred small charges, a couple of dollars per month. Even though it’s not much, it defeats the purpose of getting it for free.
While there’s a monthly 200GB free traffic allowance, traffic from/to China is not included in the free tier. So, any access to/from Chinese IPs incurs charges. Therefore, I needed to find a way to block Chinese IPs.
Initially, I found many online scripts using ufw, like this:
1
#!/bin/bash for ip in $(cat cn.zone); do sudo ufw deny from $ip done
It uses a for loop to add each rule to ufw. When a network packet arrives, the kernel needs to check this long list of rules from top to bottom. The time complexity is O(n), leading to significant performance degradation when the rule count is high.
In contrast, ipset is specifically designed to handle large numbers of IP addresses. It uses a hash table to store IPs, offering near O(1) lookup time. Therefore, using ipset instead of ufw can significantly improve performance.
Initially, I tried using ipset-persistent and netfilter-persistent for persistence. However, these tools save all rules, which can easily lead to an accumulation of excessive ipset rules. To avoid this, I implemented a service-based approach to bypass persistent storage.
Solution
systemd Service
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
# /etc/systemd/system/china-ip-blocker.service [Unit] Description=Update and apply China IP blocklist using ipset After=network-online.target Wants=network-online.target
[Service] Type=oneshot # Absolute path to the script ExecStart=/usr/local/bin/update_china_blocklist.sh PrivateTmp=true ProtectSystem=strict ReadWritePaths=/var/run ProtectHome=true NoNewPrivileges=true SupplementaryGroups=systemd-resolve
systemd Timer
1 2 3 4 5 6 7 8 9
# /etc/systemd/system/china-ip-blocker.timer [Unit] Description=Run china-ip-blocker service on boot and daily [Timer] OnBootSec=2min OnUnitActiveSec=12h Unit=china-ip-blocker.service [Install] WantedBy=timers.target
#!/bin/bash # ================================================================= # High-Performance, Low-Resource IP Blocklist Update Script # # Designed for execution via systemd timer or cron. # This script is non-persistent and relies on being run on boot # and periodically to maintain firewall rules. # =================================================================
set -o errexit # Exit immediately if a command exits with a non-zero status. set -o nounset # Exit immediately if it tries to use an undeclared variable. set -o pipefail # The return value of a pipeline is the status of the last command to exit with a non-zero status.
# --- Configuration --- readonly IPSET_NAME="chinablock" readonly IP_LIST_URL="https://www.ipdeny.com/ipblocks/data/countries/cn.zone" readonly TMP_FILE="/dev/shm/${IPSET_NAME}.zone" readonly LOCK_FILE="/var/run/${IPSET_NAME}.lock" readonly MIN_IP_COUNT=100 # Minimum expected number of IP ranges
# Efficient logging function using bash printf built-in (>= bash 4.2) log() { # %-1s prints a single space. Using it to get printf to evaluate the format string. printf'%%(%Y-%m-%d %H:%M:%S)T - %s\n' -1 "$1" }
main() { # --- Prerequisite Checks --- if [[ ${EUID:-$(id -u)} -ne 0 ]]; then die "This script must be run as root." fi
for cmd in ipset wget iptables flock awk headtail; do if ! command -v "$cmd" &>/dev/null; then die "Required command '$cmd' is not found." fi done
# Ensure temporary file is cleaned up on any exit trap'rm -f "$TMP_FILE"' EXIT
log"Starting IP blocklist update for set '$IPSET_NAME'..."
# --- Download IP List --- log"Downloading IP list from $IP_LIST_URL..." if ! wget -q --timeout=60 --tries=3 -O "$TMP_FILE""$IP_LIST_URL"; then die "Download failed from $IP_LIST_URL." fi
# --- Create and Load Temporary IPSet --- local temp_ipset_name="${IPSET_NAME}_temp" ipset create "$temp_ipset_name"hash:net -exist ipset flush "$temp_ipset_name"
log"Validating list and preparing for bulk load..." # Use a single awk pass to validate and generate restore data. # It exits with an error code if the line count is too low. # The last line of its output is the total count. local awk_output awk_output=$(awk -v set_name="$temp_ipset_name" \ '{ print "add " set_name " " $1 } END { if (NR < '$MIN_IP_COUNT') exit 1; print NR }'"$TMP_FILE") \ || die "IP list validation failed (expected >$MIN_IP_COUNT lines, found $(wc -l < "$TMP_FILE" | awk '{print $1}'))."
# Pipe all but the last line (the count) to ipset restore for high-speed loading. echo"$awk_output" | head -n -1 | ipset restore || die "ipset restore command failed."
local final_count final_count=$(echo"$awk_output" | tail -n 1)
log"Loaded $final_count IP blocks into temporary set '$temp_ipset_name'."
# --- Atomically Activate the New IPSet --- ipset create "$IPSET_NAME"hash:net -exist ipset swap "$temp_ipset_name""$IPSET_NAME" ipset destroy "$temp_ipset_name" log"Successfully updated and activated ipset '$IPSET_NAME'."
# --- Ensure IPTables Rule Exists --- # This check is crucial because the rule is lost on reboot. if ! iptables -C INPUT -m set --match-set "$IPSET_NAME" src -j DROP &>/dev/null; then log"iptables rule not found. Inserting it at the top of the INPUT chain..." # -I INPUT 1 ensures it's one of the first rules evaluated, which is critical for performance. iptables -I INPUT 1 -m set --match-set "$IPSET_NAME" src -j DROP log"iptables rule for '$IPSET_NAME' added." else log"iptables rule for '$IPSET_NAME' already exists." fi
log"Update completed successfully." }
# --- Execution Wrapper --- # Use flock for robust concurrency control. The lock is held on file descriptor 200. ( flock -n 200 || die "Script is already running. Another instance holds the lock." main ) 200>"$LOCK_FILE"
exit 0
Usage
Create the systemd Service File
Create the file china-ip-blocker.service in the /etc/systemd/system/ directory.
Create the systemd Timer File
This file defines when and how often the above service is triggered.
Create a file named china-ip-blocker.timer (same name as the service file but with a different suffix) in the /etc/systemd/system/ directory.
Deployment and Activation
Place the script: Put the script (e.g., update_china_blocklist.sh) in a suitable location, such as /usr/local/bin/, and ensure it has execute permissions.