nftables: do some fancy stuff with sets

- Fix the incorrect use of rate limit on ICMP rule ('over' keyword
  matched over the rate limit)
- Use dynamic sets to limit connections on opened ports
- Naively whitelist all libvirt bridges. This includes the whole
  192.168.0.0/16 subnet, so it probably will clash with the internal LAN
  network. I control my own router :) so I don't mind (just use
  a different private IPv4 address space).
This commit is contained in:
Hoang Nguyen 2022-11-05 11:21:19 +07:00
parent 4d1dd6cd7a
commit 385332e312
No known key found for this signature in database
GPG Key ID: 813CF484F4993419
5 changed files with 80 additions and 47 deletions

View File

@ -13,7 +13,7 @@ This is an Ansible playbook to deploy my system configurations for desktop usage
- Install needed external modules (e.g. `apk`, `pamd`, `mount`):
```bash
ansible-galaxy collection install -r requirements/collections.yml
ansible-galaxy install -r requirements/collections.yml
```
- Create an encrypted file to store your user password:

View File

@ -13,7 +13,7 @@ Stuff that are planned to be changed.
- [ ] Option for other bootloaders (refind / efistub / limine / zfsbootmenu)
- [ ] Refactor grub role
- [ ] Option for other dhcp clients (connman / networkmanager)
- [ ] Option for other initramfs generators (booster)
- [ ] Option for other initramfs generators (booster, dracut)
## Cosmetic

View File

@ -103,13 +103,8 @@ libvirt_daemons:
- virtqemud
- virtstoraged
# For libvirt's NAT firewall rules
# IPv6 is optional (https://wiki.gentoo.org/wiki/QEMU/KVM_IPv6_Support)
libvirt_bridges:
- name: virbr0
ip4: 192.168.122.0/24
# Public facing network interfaces
# Public facing network interfaces to configured
# (don't include wireless interfaces here as they should use dhcp with iwctl)
# https://wiki.alpinelinux.org/wiki/Configure_Networking
network_interfaces:
- name: eth0

View File

@ -55,6 +55,7 @@ crond_provider:
syslog_provider:
- busybox
- rsyslog
- sysklogd
ntp_client:
- ntpsec

View File

@ -1,16 +1,34 @@
{% set all_public_network_interfaces = ( ansible_interfaces | select('match', '^(eth|wlan|(en|wl|ww)[osxpP])[0-9]+') | list ) -%}
#!/usr/sbin/nft -f
# You can find examples in /usr/share/nftables/.
# 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
# https://wiki.gentoo.org/wiki/Nftables#Family_netdev_and_ingress_hook
table netdev filter {
chain ingress {
# https://serverfault.com/questions/772195/drop-fragmented-packets-in-nftables
# 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 = { {{ network_interfaces | map(attribute='name') | join(', ') }} } priority -500; policy accept;
type filter hook ingress devices = { {{ all_public_network_interfaces | join(', ') }} } priority -500; policy accept;
# Drop all fragments.
ip frag-off & 0x1fff != 0 drop
@ -28,6 +46,12 @@ table netdev filter {
# 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;
@ -50,21 +74,42 @@ table inet filter {
comment "Block spoofing as localhost (IPv4)"
iif != lo ip6 daddr ::1/128 drop \
comment "Block spoofing as localhost (IPv6)"
{% for interface in network_interfaces %}
{% if opened_ports.tcp is sequence and opened_ports.tcp | length > 0 %}
iif {{ interface. name }} tcp dport { {{ opened_ports.tcp | join(', ') }} } ct state new accept \
comment "Open specified TCP ports"
{% endif %}
{% if opened_ports.udp is sequence and opened_ports.udp | length > 0 %}
iif {{ interface.name }} udp dport { {{ opened_ports.udp | join(', ') }} } ct state new accept \
comment "Open specified UDP ports"
{% endif %}
{% endfor %}
# Allow stuff first before the dynamic blacklisting
jump input_icmp
jump input_libvirt
# 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 {
@ -85,7 +130,7 @@ table inet filter {
echo-request, # type 8
time-exceeded, # type 11
parameter-problem, # type 12
} limit rate over 1/second burst 4 packets accept \
} limit rate 10/second burst 4 packets accept \
comment "Accept ICMP"
# ICMPv6
@ -97,7 +142,7 @@ table inet filter {
parameter-problem, # type 4
echo-request, # type 128
echo-reply, # type 129
} limit rate over 1/second burst 4 packets accept \
} limit rate 10/second burst 4 packets accept \
comment "Accept basic IPv6 functionality"
ip6 nexthdr icmpv6 icmpv6 type {
@ -121,26 +166,19 @@ table inet filter {
}
chain input_libvirt {
{% for bridge in libvirt_bridges %}
# Open DHCP and DNS on {{ bridge.name }}
iifname "{{ bridge.name }}" udp dport { 53, 67 } accept
iifname "{{ bridge.name }}" tcp dport { 53, 67 } accept
{% endfor %}
# Open DHCP and DNS on libvirt's bridges
iifname "virbr*" udp dport { 53, 67 } accept
iifname "virbr*" tcp dport { 53, 67 } accept
}
chain forward_libvirt {
{% for bridge in libvirt_bridges %}
# Allow forwarding on {{ bridge.name }}
oifname "{{ bridge.name }}" ip daddr {{ bridge.ip4 }} ct state { established, related } accept
iifname "{{ bridge.name }}" ip saddr {{ bridge.ip4 }} accept
{% if bridge.ip6 is defined %}
oifname "{{ bridge.name }}" ip6 daddr {{ bridge.ip6 }} ct state { established, related } accept
iifname "{{ bridge.name }}" ip6 saddr {{ bridge.ip6 }} accept
{% endif %}
iifname "{{ bridge.name }}" oifname "{{ bridge.name }}" accept
oifname "{{ bridge.name }}" reject with icmpx type port-unreachable
iifname "{{ bridge.name }}" reject with icmpx type port-unreachable
{% endfor %}
# NOTE: use 10.0.0.0/8 or 172.16.0.0/12 subnets for the LAN network instead
# These rules naively defeat the purpose of having multiple separated subnets but I'm fine with it being on my desktops
oifname "virbr*" ip daddr 192.168.0.0/16 ct state { established, related } accept
iifname "virbr*" ip saddr 192.168.0.0/16 accept
iifname "virbr*" oifname "virbr*" accept
oifname "virbr*" reject with icmpx type port-unreachable
iifname "virbr*" reject with icmpx type port-unreachable
}
}
@ -151,8 +189,7 @@ table inet nat {
}
chain postrouting_libvirt {
{% for bridge in libvirt_bridges %}
ip saddr {{ bridge.ip4 }} ip daddr != {{ bridge.ip4 }} masquerade
{% endfor %}
# IPv6 shouldn't be NAT
meta nfproto ipv4 iifname "virbr*" oif { {{ all_public_network_interfaces | join(', ') }} } masquerade
}
}