{% set all_public_network_interfaces = ( ansible_interfaces | select('match', '^(eth|wlan|(en|wl|ww)[osxpP])[0-9]+') | list ) -%}
#!/usr/sbin/nft -f

# NOTE:
# Dynamic blacklisting:
# - https://wiki.nftables.org/wiki-nftables/index.php/Meters
# - https://wiki.archlinux.org/title/Nftables#Dynamic_blackhole
# - https://unix.stackexchange.com/questions/581964/create-dynamic-blacklist-with-nftables
#
# Early broken packages dropping in netdev:
# - https://wiki.gentoo.org/wiki/Nftables#Family_netdev_and_ingress_hook
# - https://serverfault.com/questions/772195/drop-fragmented-packets-in-nftables
#
# udev's network interface naming scheme:
# - https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/configuring_and_managing_networking/consistent-network-interface-device-naming_configuring-and-managing-networking
# - https://systemd.io/PREDICTABLE_INTERFACE_NAMES/
#
# UDP is stateless, but the kernel can still mark a few conntrack states on the packets:
# - https://blog.cloudflare.com/everything-you-ever-wanted-to-know-about-udp-sockets-but-were-afraid-to-ask-part-1/
# - https://www.rigacci.org/wiki/lib/exe/fetch.php/doc/appunti/linux/sa/iptables/conntrack.html
# - https://www.frozentux.net/iptables-tutorial/chunkyhtml/x1555.html
# - https://github.com/torvalds/linux/blob/master/net/ipv4/udp.c

# Clear all prior state
flush ruleset

table netdev filter {
    chain ingress {
        # Hook priority list: https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks
        # The priority needs to be lower than -400 (NF_IP_PRI_CONNTRACK_DEFRAG) to see fragments
        type filter hook ingress devices = { {{ all_public_network_interfaces | join(', ') }} } priority -500; policy accept;

        # Drop all fragments.
        ip frag-off & 0x1fff != 0 drop

        # Drop XMAS packets.
        tcp flags & (fin|syn|rst|psh|ack|urg) == fin|syn|rst|psh|ack|urg drop

        # Drop NULL packets.
        tcp flags & (fin|syn|rst|psh|ack|urg) == 0x0 drop

        # Drop uncommon MSS values.
        tcp flags syn tcp option maxseg size 1-535 drop
    }
}

# Basic IPv4/IPv6 stateful firewall for server/workstation.
table inet filter {
    set blackhole { type ipv4_addr; flags dynamic, timeout; size 65535; }
    set blackhole6 { type ipv6_addr; flags dynamic, timeout; size 65535; }

    # NOTE: connlimit shouldn't be used with timeout flag (same goes for "update" set statement)
    set connlimit { type ipv4_addr; flags dynamic; size 65535; }
    set connlimit6 { type ipv6_addr; flags dynamic; size 65535; }

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

        iif lo accept \
        comment "Accept any localhost traffic"

        ct state { established, related } accept \
        comment "Accept traffic originated from us"

        ct state invalid drop \
        comment "Drop invalid connections"

        tcp flags & (fin|syn|rst|ack) != syn ct state new drop \
        comment "Drop non-SYN packets"

        tcp dport 113 reject with icmpx type port-unreachable \
        comment "Reject AUTH to make it fail fast"

        iif != lo ip daddr 127.0.0.1/8 drop \
        comment "Block spoofing as localhost (IPv4)"
        iif != lo ip6 daddr ::1/128 drop \
        comment "Block spoofing as localhost (IPv6)"

        udp dport mdns ip daddr 224.0.0.251 accept \
        comment "Accept mDNS"
        udp dport mdns ip6 daddr ff02::fb accept \
        comment "Accept mDNS"

        jump input_dhcp_client
        jump input_icmp

        # Blacklisting should be done before stateful accept rules
        ip saddr @blackhole counter drop
        ip6 saddr @blackhole6 counter drop

        # Drop future attempts on opened ports if there are already 3 established connections
{% if opened_ports.tcp is sequence and opened_ports.tcp | length > 0 %}
        tcp dport { {{ opened_ports.tcp | join(', ') }} } ct state new \
            add @connlimit { ip saddr ct count over 3 } drop
        tcp dport { {{ opened_ports.tcp | join(', ') }} } ct state new \
            add @connlimit6 { ip6 saddr ct count over 3 } drop
{% endif %}
{% if opened_ports.udp is sequence and opened_ports.udp | length > 0 %}
        udp dport { {{ opened_ports.udp | join(', ') }} } ct state new \
            add @connlimit { ip saddr ct count over 3 } drop
        udp dport { {{ opened_ports.udp | join(', ') }} } ct state new \
            add @connlimit6 { ip6 saddr ct count over 3 } drop
{% endif %}

        # Allow opened ports but also dynamically add them to the blacklist
{% if opened_ports.tcp is sequence and opened_ports.tcp | length > 0 %}
        tcp dport { {{ opened_ports.tcp | join(', ') }} } ct state new \
            add @blackhole { ip saddr timeout 60s limit rate 10/second } accept
        tcp dport { {{ opened_ports.tcp | join(', ') }} } ct state new \
            add @blackhole6 { ip6 saddr timeout 60s limit rate 10/second } accept
{% endif %}
{% if opened_ports.udp is sequence and opened_ports.udp | length > 0 %}
        udp dport { {{ opened_ports.udp | join(', ') }} } ct state new \
            add @blackhole { ip saddr timeout 60s limit rate 10/second } accept
        udp dport { {{ opened_ports.udp | join(', ') }} } ct state new \
            add @blackhole6 { ip6 saddr timeout 60s limit rate 10/second } accept
{% endif %}
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
    }

    chain output {
        type filter hook output priority 0; policy accept;
    }

    chain input_dhcp_client {
        udp sport 67 udp dport 68 accept \
        comment "Accept DHCP client input traffic"

        ip6 saddr fe80::/10 udp sport 547 udp dport 546 accept \
        comment "Accept DHCPv6 replies from IPv6 link-local addresses"
    }

    chain input_icmp {
        ip protocol igmp accept \
        comment "Accept IGMP"

        ip protocol icmp icmp type {
            echo-reply,  # type 0
            destination-unreachable,  # type 3
            echo-request,  # type 8
            time-exceeded,  # type 11
            parameter-problem,  # type 12
        } limit rate 10/second burst 4 packets accept \
        comment "Accept ICMP"

        icmpv6 type {
            destination-unreachable,  # type 1
            packet-too-big,  # type 2
            time-exceeded,  # type 3
            parameter-problem,  # type 4
            echo-request,  # type 128
            echo-reply,  # type 129
        } limit rate 10/second burst 4 packets accept \
        comment "Accept basic IPv6 functionality"

        icmpv6 type {
            nd-router-solicit,  # type 133
            nd-router-advert,  # type 134
            nd-neighbor-solicit,  # type 135
            nd-neighbor-advert,  # type 136
        } ip6 hoplimit 255 accept \
        comment "Allow IPv6 SLAAC"

        icmpv6 type {
            mld-listener-query,  # type 130
            mld-listener-report,  # type 131
            mld-listener-reduction,  # type 132
            mld2-listener-report,  # type 143
        } ip6 saddr fe80::/10 accept \
        comment "Allow IPv6 multicast listener discovery on link-local"
    }
}

# The state of stateful objects saved on the nftables service stop.
include "/var/lib/nftables/*.nft"
