Skip to content

Instantly share code, notes, and snippets.

@rikka0w0
Last active March 1, 2025 12:29
Show Gist options
  • Save rikka0w0/9f49544ccbd86f4cb28cde7d292e4928 to your computer and use it in GitHub Desktop.
Save rikka0w0/9f49544ccbd86f4cb28cde7d292e4928 to your computer and use it in GitHub Desktop.
[nftables] IPv6 firewall with dynamic prefix update

It is very common that the ISP-provided modem only allows you to block all incoming IPv6 connections entirely or let all incoming requests through.

To publish your service on the internet, the only option is to tell the ISP-provided modem to let all incoming requests go through. However, this poses a security risk, which may accidentally expose private service on the internet (e.g. SMB sharing or remote desktop).

To enhance the security on such a network, each device needs to configure its firewall properly. A device needs to either block all IPv6 incoming requests or only allow connections from hosts with the same IPv6 prefix. For the sake of convenience, we obviously want the latter. But things can be complicated when you have a dynamic IPv6 prefix from your ISP.

Generally speaking, you must run a script to update the firewall rules each time your prefix changes.

So the automated firewall rule modification can be breakdown into two parts:

  1. Setup firewall to allow dynamic update of rule set
  2. Detect IPv6 prefix changes and update the rule set

1. Setup nftables

In the recent Linux, nftables is preferred over iptables.

Change your /etc/nftables.conf to be:

#!/sbin/nft -f

flush ruleset

table inet filter {
	set allowed_ipv6 {
		type ipv6_addr
		flags dynamic
		flags interval
	}

	chain input {
		type filter hook input priority 0; policy drop;

		# Accept all connections related to connections made by us
		ct state {established, related} counter accept

		# Enable loopback but drop connections to loopback not from lo
		iif lo accept comment "accept loopback"
		iif != lo ip daddr 127.0.0.1/8 counter drop
		iif != lo ip6 daddr ::1/128 counter drop

		# Accept all ICMP including ping
		ip protocol icmp counter accept
		meta l4proto ipv6-icmp counter accept

		# Accept all IPv4 incoming as we are behind NAT
		ip saddr 0.0.0.0/0 accept

		# Allow incoming IPv6 only if the source is in the set
		ip6 saddr @allowed_ipv6 accept

		tcp dport 22 counter accept comment "accept SSH"
		#counter comment "count dropped packets"
	}

	chain forward {
		type filter hook forward priority 0; policy drop;
		counter comment "count dropped packets"
	}

	chain output {
		type filter hook output priority 0; policy accept;
		counter comment "count accepted packets"
	}
}

This script drops all incoming connections except:

  1. ICMP, you need this for IPv6 access. Also covers ping.
  2. Port 22, for ssh. Assume you have enhanced the security of your ssh server. (e.g. pubkey-only login for public access)
  3. Allow all IPv4 incoming connections for convenience. This is safe as long as we are behind NAT.

To load the rule: sudo nft -f /etc/nftables.conf

On Debian, to load nftables rules on startup, see https://www.naturalborncoder.com/2024/10/installing-and-configuring-nftables-on-debian/#google_vignette

On Ubuntu, make sure you have disabled UFW first.

To list allowed IPv6 ranges:

sudo nft list set inet filter allowed_ipv6

To add an IPv6 range to the allowed set:

sudo nft add element inet filter allowed_ipv6 {2401:d000:114:514::/64}

To remove an IPv6 range from the allowed set:

sudo nft delete element inet filter allowed_ipv6 {2401:d000:114:514::/64}

To clear the allowed set:

sudo nft flush set inet filter allowed_ipv6

2. Auto detect IPv6 prefix change

#!/bin/bash

function process_route6_change() {
	local action="$1"
	local ipv6="$2"
	local flags="$3"

	local ipv6_netmask_size;
	local netmask;

	if [[ "$action" != 'add' && "$action" != 'delete' ]]; then
		return
	fi

	if [[ "$ipv6" == 'default' ]]; then
		return
	fi

	# Check if it's a ULA (fc00::/7) or Link-Local (fe80::/10), if so, return
	if [[ "$ipv6" =~ ^fc[0-9a-fA-F]{2}:|^fd[0-9a-fA-F]{2}:|^fe8[0-9a-bA-B]: ]]; then
		return
	fi

	# Extract the netmask size from CIDR notation
	if [[ "$ipv6" == */* ]]; then
		ipv6_netmask_size="${ipv6#*/}"
	else
		return
	fi

	[[ $ipv6_netmask_size -ne 64 ]] && return

	netmask=$(netmask "$ipv6")

	if [[ "$action" == 'add' ]]; then
		echo Add to allowed_ipv6: "$netmask"
		sudo nft add element inet filter allowed_ipv6 { "$netmask" }
	else
		echo Delete from allowed_ipv6: "$netmask"
		sudo nft delete element inet filter allowed_ipv6 { "$netmask" }
	fi
}

function foreach_route6_changes() {
	local line="$1";

	local action;
	local content;

	if [[ "$line" == local* || "$line" == "Deleted local"* ]]; then
		return
	fi

	if [[ "$line" == Deleted\ * ]]; then
		action='delete'
		content="${line#Deleted }"
	else
		action='add'
		content="$line"
	fi

	local ipv6="${content%% *}"

	if [[ "$ipv6" == 'multicast' ]]; then
		if [[ "$action" == 'delete' ]]; then
			action='delete-multicast'
		else
			action='add-multicast'
		fi
		content="${content#multicast }"
		ipv6="${content%% *}"
	fi

	local flags="${content#* }"

	process_route6_change "$action" "$ipv6" "$flags"
}

ip -6 route | while IFS= read -r line; do
	foreach_route6_changes "$line"
done

echo 'Start monitoring route changes'

ip -6 monitor route | while IFS= read -r line; do
	foreach_route6_changes "$line"
done

This script checks for IPv6 prefix change and print out a log message whenever it finds a change.

  1. Save this file to /opt/monitor-route6.sh
  2. Set up a systemd task (/etc/systemd/system/monitor-route6.service) to run this script at the background on boot:
[Unit]
Description=Monitor IPv6 route changes
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/opt/monitor-route6.sh
Restart=always
User=root
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

The script needs to be run as root to modify nftables set.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment