Thanks again for all inputs.
Another answer that was posted (and disappeared?) was this one:
There's also a really exotic method using tc and its vlan action,
completely ignoring the network (routing) layer.
Usual preparation: add the simplest classful qdisc (prio) only used
here for allowing to attach filters for egress, and add the ingress
qdisc:
sudo tc qdisc add dev enP2p1s0 root handle 1: prio
sudo tc qdisc add dev enP2p1s0 ingress
Add filter rules, both ways: tagging for egress, untagging for
ingress. u8 at 9 layer network eq 17 means UDP, u16 at 2 layer
transport means destination port:
sudo tc filter add dev enP2p1s0 parent 1: protocol ip basic match 'cmp(u8 at 9 layer network eq 17) and cmp(u16 at 2 layer transport eq 15004)' action vlan push id 151
sudo tc filter add dev enP2p1s0 parent 1: protocol ip basic match 'cmp(u8 at 9 layer network eq 17) and cmp(u16 at 2 layer transport eq 14002)' action vlan push id 150
sudo tc filter add dev enP2p1s0 ingress basic match 'meta(vlan eq 150) or meta(vlan eq 151)' action vlan pop
The rest of the network stack will never even be aware of VLANs,
because it happens really early (for ingress) and late (for egress).
This approach proved simpler and more robust for my purpose.
I had to rebuild the Linux Kernel to support the prio, vlan action, cmp and ingress features (sch_prio, sch_ingress, cls_basic, act_vlan, em_cmp) but so far the solution looks promising.
One note: NICs tend to strip off VLAN tags. That's a real bummer for testing and is a subject for a whole different post/forum