Integration guide

Load an IP blocklist into nftables

nftables is the easiest of the firewalls to do this properly on, because it has the primitive the job needs: a whole file of commands applied as one atomic transaction. Either the new list is installed completely or the old one stays. There is no window in which your blocklist is empty.

This guide sets up a dedicated table, so a broken update can never damage the rest of your ruleset, and a refresh script driven by a systemd timer.

The table

Put the blocklist in its own table rather than adding a set to an existing one. A separate table can be flushed, reloaded or removed without touching the firewall policy you actually rely on.

# /etc/nftables.d/sciscope.nft
table inet sciscope {
    set blocklist4 {
        type ipv4_addr
        flags interval
        auto-merge
    }

    set blocklist6 {
        type ipv6_addr
        flags interval
        auto-merge
    }

    chain prerouting {
        type filter hook prerouting priority -150; policy accept;
        ip  saddr @blocklist4 drop
        ip6 saddr @blocklist6 drop
    }
}

flags interval lets the set hold CIDR ranges as well as single addresses, and auto-merge collapses overlapping entries so the set stays small. Hooking prerouting at a negative priority drops the traffic before conntrack and before your main filter chain ever sees it, which is what you want for a volume blocklist.

Load it once:

sudo nft -f /etc/nftables.d/sciscope.nft

The refresh script

#!/bin/bash
# /usr/local/sbin/sciscope-nft-update
# Refresh the nftables blocklist sets from the SciScope Scanner Feed.
set -euo pipefail

KEY_FILE=/etc/sciscope/key            # chmod 600, one line: the API key
URL=https://api.sciscope.ee/v1/feeds/blocklist.txt
MIN_ENTRIES=500                       # sanity floor; tune to your own baseline

tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT

curl -fsSL --max-time 60 --retry 3 --retry-delay 5 \
     -H "Authorization: Bearer $(cat "$KEY_FILE")" \
     -o "$tmp/blocklist.txt" "$URL"

# Strip comments and blank lines, split by family.
grep -Eo '^[^#[:space:]]+' "$tmp/blocklist.txt" | sort -u > "$tmp/all"
grep -F ':' "$tmp/all" > "$tmp/v6" || true
grep -vF ':' "$tmp/all" > "$tmp/v4" || true

count=$(wc -l < "$tmp/all")
if [ "$count" -lt "$MIN_ENTRIES" ]; then
    echo "sciscope: refusing to install $count entries (< $MIN_ENTRIES)" >&2
    exit 1
fi

# One transaction: flush and refill both sets, or change nothing at all.
{
    echo 'flush set inet sciscope blocklist4'
    echo 'flush set inet sciscope blocklist6'
    if [ -s "$tmp/v4" ]; then
        printf 'add element inet sciscope blocklist4 { %s }\n' \
               "$(paste -sd, "$tmp/v4")"
    fi
    if [ -s "$tmp/v6" ]; then
        printf 'add element inet sciscope blocklist6 { %s }\n' \
               "$(paste -sd, "$tmp/v6")"
    fi
} > "$tmp/update.nft"

nft -f "$tmp/update.nft"
echo "sciscope: installed $count entries"

Two details carry the whole thing. set -euo pipefail plus curl -f means a 401, a 429 or a truncated transfer aborts the script before it can touch the firewall. And the flush and the add element commands travel in one nft -f file, which nftables applies as a single transaction. This is the part that a naive "nft flush set, then loop over addresses" implementation gets wrong, and the reason that implementation drops its protection for as long as the loop takes.

The timer

# /etc/systemd/system/sciscope-blocklist.service
[Unit]
Description=Refresh the SciScope blocklist in nftables
After=network-online.target nftables.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/sciscope-nft-update
# /etc/systemd/system/sciscope-blocklist.timer
[Unit]
Description=Refresh the SciScope blocklist hourly

[Timer]
OnBootSec=3min
OnUnitActiveSec=1h
RandomizedDelaySec=5min
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl enable --now sciscope-blocklist.timer
sudo systemctl start sciscope-blocklist.service   # first run, now

RandomizedDelaySec matters more than it looks: without it every installation on earth fetches at the top of the hour. Persistent=true catches up after downtime instead of waiting a full interval.

Checking it

# how many entries are loaded
sudo nft list set inet sciscope blocklist4 | grep -c '^\s' 

# is a specific address in the set?
sudo nft get element inet sciscope blocklist4 { 192.0.2.10 }

# how much traffic is the rule actually dropping?
sudo nft -a list chain inet sciscope prerouting

To add counters to the drop rules (worth doing, because "is this feed earning its place?" should be a number, not a feeling) change the chain to:

chain prerouting {
    type filter hook prerouting priority -150; policy accept;
    ip  saddr @blocklist4 counter drop
    ip6 saddr @blocklist6 counter drop
}

Surviving a reboot

The sets live in kernel memory and are empty after a restart. Make sure /etc/nftables.d/sciscope.nft is loaded by your main nftables.conf (an include "/etc/nftables.d/*.nft" line), and the timer's OnBootSec=3min will refill the sets shortly after boot. Between boot and that first refresh the chain exists with empty sets, which fails open, for a reputation blocklist that is the correct direction to fail.

Or let the feed do it for you

Keeping these lists current is exactly the work the SciScope Scanner Feed takes off your hands: every crawler above is screened out of the feed continuously, and the opt-in crawler-identity list tells you which of them you are looking at.

How the feed works or request a trial