Preface
I got a free server from Google Cloud, but recently I kept finding that it was quietly racking up charges — one or two dollars a month. It’s not much, but it conflicts with the whole point of getting something for free.
Because although I get 200G of free traffic per month, China is not covered by the free traffic allowance, so every time traffic comes from a Chinese IP, money gets deducted. So I set out to block Chinese IPs.
At first I came across many ufw scripts online, like this one:
#!/bin/bash for ip in $(cat cn.zone); do sudo ufw deny from $ip doneThey stuff a bunch of for loops into ufw. When a network packet arrives, the kernel has to check this long rule list one by one from top to bottom. The time complexity is O(n), and when there are many rules, performance drops significantly.
ipset, on the other hand, is specifically designed for handling large numbers of IP addresses. It uses hash tables to store IP addresses, giving query time complexity close to O(1). So using ipset instead of ufw can significantly improve performance.
Initially I used ipset-persistent and netfilter-persistent for persistence, but these two tools save all rules, which can easily cause ipset rules to accumulate too much. So I switched to a service-based approach to bypass persistence.
Solution
systemd Service
[Unit]Description=Update and apply China IP blocklist using ipsetAfter=network-online.targetWants=network-online.target
[Service]Type=oneshot# Absolute path to the scriptExecStart=/usr/local/bin/update_china_blocklist.shPrivateTmp=trueProtectSystem=strictReadWritePaths=/var/runProtectHome=trueNoNewPrivileges=trueSupplementaryGroups=systemd-resolvesystemd Timer
[Unit]Description=Run china-ip-blocker service on boot and daily[Timer]OnBootSec=2minOnUnitActiveSec=12hUnit=china-ip-blocker.service[Install]WantedBy=timers.targetMain script
#!/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"}
# Centralized error-handling functiondie() { log "FATAL: $1" >&2 exit 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 head tail; 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 0Usage
Create the systemd Service file
Create 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.
In the /etc/systemd/system/ directory, create a china-ip-blocker.timer file with the same name as the service file (but with a different suffix).
Deploy and enable
-
Place the script: Put the script (e.g.
update_china_blocklist.sh) in a suitable location, such as/usr/local/bin/, and make sure it has executable permissions.Terminal window sudo chmod +x /usr/local/bin/update_china_blocklist.sh -
Reload the
systemdconfiguration: Letsystemdknow that you have created new unit files.Terminal window sudo systemctl daemon-reload -
Enable and start the timer:
Terminal window sudo systemctl enable china-ip-blocker.timersudo systemctl start china-ip-blocker.timerenablemakes the timer start automatically at boot.startimmediately activates the timer so it begins counting down.- Note: you only need to
enableandstartthe.timerfile; it will automatically manage the.servicefile.
Management and debugging
-
Check the timer status:
Terminal window systemctl status china-ip-blocker.timerThe output shows
NEXT(the next run time). -
View the service run logs:
Terminal window journalctl -u china-ip-blocker.serviceThis shows all output and error messages from the script, which is more convenient than traditional log file management.
-
Manually trigger the task once:
Terminal window sudo systemctl start china-ip-blocker.service
Verify it works
root@nagasaki-soyo:/home/tokisaki# sudo ipset list chinablockName: chinablockType: hash:netRevision: 7Header: family inet hashsize 2048 maxelem 65536 bucketsize 12 initval 0xc19a419fSize in memory: 233664References: 1Number of entries: 8711Members:58.240.0.0/15103.3.100.0/22...root@nagasaki-soyo:/home/tokisaki# sudo iptables -L INPUT -n -vChain INPUT (policy DROP 619 packets, 41086 bytes) pkts bytes target prot opt in out source destination 5345 916K DROP 0 -- * * 0.0.0.0/0 0.0.0.0/0 match-set chinablock src 492K 168M ts-input 0 -- * * 0.0.0.0/0 0.0.0.0/0 355K 138M ufw-before-logging-input 0 -- * * 0.0.0.0/0 0.0.0.0/0 355K 138M ufw-before-input 0 -- * * 0.0.0.0/0 0.0.0.0/0 627 41710 ufw-after-input 0 -- * * 0.0.0.0/0 0.0.0.0/0 619 41086 ufw-after-logging-input 0 -- * * 0.0.0.0/0 0.0.0.0/0 619 41086 ufw-reject-input 0 -- * * 0.0.0.0/0 0.0.0.0/0 619 41086 ufw-track-input 0 -- * * 0.0.0.0/0 0.0.0.0/0