How to tag IPv4 and IPv6 packets with different VLAN tags on a Linux box?

I want to tag incoming IPv4 and IPv6 packets from a dual stack enabled connection with different VLAN tags, e.g. IPv4 packets should go to VLAN4 and IPv6 packets should go to VLAN6. To be more general, I want to split the dual stack ip stream with mixed IPv4 and IPv6 packets into two clean single stack networks so you will not find any IPv4 packet on a IPv6 network and vise versa. I need this to test and support an IPv6 only network. And of course I still need the IPv4 data. It cannot simply be dropped.

                          Linux Box
                       Debian Bullseye
       untagged         ┏━━━━━━━━━━━┓         tagged (trunk)
════════════════════════┫eth0  vlan4┣═╦══════════════════════
     IPv4 and IPv6      ┃      vlan6┣═╝eth1  IPv4 with VLAN4 tag
      dual stack        ┗━━━━━━━━━━━┛        IPv6 with VLAN6 tag

I had a look at the Linux bridge and at nftables but wasn't able to find a solution. How can I achieve this selective tagging?


While your answer apparantly works for you, it does seem overly complicated. I'm doubtful though that it does what you want, as it lacks any IPv4 addresses in the given output (apart from lo).

Creating 2 tagged interfaces (named e.g. vlan4 and vlan6), assigning them IPv4 and IPv6 addresses + gateways, and disabling SLAAC with sysctl for the IPv4 one should be sufficient.

There's neither need for bridging nor messing with nftables apart from what you would need to enable flow between eth0 and eth1.


I have found a solution. Because I want to manipulate VLANs, I have to use a bridge. VLAN is working on OSI layer 2 and a bridge is the device that can handle layer 2 protocols. So first I added two VLAN interfaces to the physical interface eth1. Then added all interfaces eth0, vlan4 and vlan6 to the bridge. The rest is done by nftables.

IPv4 and IPv6 are defined on layer3 and have no different meaning on layer 2. So nftables can handle them just as packets with different "marks", which is the protocol type in the IP header. Fortunately nftables can select them with meta protocol {}. It directs the incoming untagged IPv4 and IPv6 packets to the corresponding VLAN interface. Tagging is done automagically by the interface as usual. I use systemd-networkd and here is the configuration in detail.

First create the network devices vlan4, vlan6 and the bridge br0:

~$ sudo -Es
~# cat > /etc/systemd/network/01-vlan4.netdev <<EOF
[NetDev]
Name=vlan4
Kind=vlan
[VLAN]
Id=4
EOF

~# cat > /etc/systemd/network/02-vlan6.netdev <<EOF
[NetDev]
Name=vlan6
Kind=vlan
[VLAN]
Id=6
EOF

~# cat > /etc/systemd/network/03-br0.netdev <<EOF
[NetDev]
Name=br0
Kind=bridge
[Bridge]
DefaultPVID=6
VLANProtocol=802.1q
STP=no
EOF

Then attach the interfaces to eth1 and to the bridge:

~# cat > /etc/systemd/network/12-eth1_attach-vlans.network <<EOF
[Match]
Name=eth1
[Network]
LLMNR=no
LinkLocalAddressing=no
VLAN=vlan4
VLAN=vlan6
EOF

~# cat > /etc/systemd/network/16-ifs_add_to_br0.network <<EOF
[Match]
Name=eth0 vlan4 vlan6
[Network]
Bridge=br0
LLMNR=no
LinkLocalAddressing=no
EOF

Now just bring up the bridge:

~# cat > /etc/systemd/network/20-br0-up.network <<EOF
[Match]
Name=br0
[Network]
LLMNR=no
MulticastDNS=yes
EOF

After a reboot this will give you:

~$ ip -brief address
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP
br0              UP             2003:d5:2721:900:9012:fdff:fef0:ea7f/64 fe80::9012:fdff:fef0:ea7f/64
vlan6@eth1       UP
vlan4@eth1       UP
eth1             UP

# these are the slave interfaces of the bridge
~$ sudo bridge link show
4: vlan6@eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br0 state forwarding priority 32 cost 4
5: vlan4@eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br0 state forwarding priority 32 cost 4
6: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br0 state forwarding priority 32 cost 4

It is also worth to check with resolvectl. Now we have to do the final step and redirect the packets with nftables using these rules:

~$ cat /etc/nftables.conf
#!/usr/sbin/nft -f

flush ruleset

table bridge filter {
    chain forward {
        type filter hook forward priority 0; policy accept;
        meta protocol { ip6 } iifname "vlan4" drop
        meta protocol { ip6 } oifname "vlan4" drop
        meta protocol { ip6 } iifname "vlan6" accept
        meta protocol { ip6 } oifname "vlan6" accept
        iifname "vlan6" drop
        oifname "vlan6" drop
    }

    chain output {
        type filter hook output priority 0; policy drop;
        meta protocol { ip6 } iifname "vlan6" accept
        meta protocol { ip6 } oifname "vlan6" accept
    }
}

On the forward chain this will drop all IPv6 to/from interface vlan4 and only allow it on interface vlan6. All other stuff is dropped on interface vlan6, but by the chains default policy accepted on interface vlan4. This ensures that all old stuff like ARP and other broadcasts go also to interface vlan4.

The output chain is only to avoid that the bridge itself sends packets to the wrong VLAN. On my configuration it uses only IPv6 (single stack) so everything will be dropped by the chains default policy, except IPv6 to vlan6.