From c25c1a2210ff37b9e7675413517cb703a7f5d22a Mon Sep 17 00:00:00 2001 From: MemorManeo <304054918+MemorManeo@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:41:07 +0200 Subject: [PATCH] chain: read the hook device attributes back AddChain marshals NFTA_HOOK_DEV, but hookFromMsg reads only the hook number and the priority, so a chain read back from the kernel never carries the device its hook is attached to. That makes an ingress or egress chain indistinguishable from one attached to every device. Read NFTA_HOOK_DEV and the NFTA_HOOK_DEVS list, and add Chain.Devices for the multi-device case that a single string cannot represent ("devices = { eth0, eth1 }"). AddChain marshals the list when one is set, so such a chain now round-trips. Setting Device alone is unchanged. Found while writing a tool that reads rulesets over netlink: without the device, an ingress chain has to be treated as seeing every packet, which makes every verdict about such a host unusable. --- chain.go | 63 ++++++++++++++++++++++++++++++++++---- chain_device_test.go | 73 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 chain_device_test.go diff --git a/chain.go b/chain.go index a0d18fd..8eba682 100644 --- a/chain.go +++ b/chain.go @@ -102,7 +102,14 @@ type Chain struct { Priority *ChainPriority Type ChainType Policy *ChainPolicy - Device string + // Device is the interface an ingress or egress chain is attached to. + // When a chain names several, this is the first of them and Devices + // holds all of them. + Device string + // Devices are all the interfaces an ingress or egress chain is + // attached to ("devices = { eth0, eth1 }"). Setting Device alone + // still works and is marshalled as before. + Devices []string } // AddChain adds the specified Chain. See also @@ -121,7 +128,21 @@ func (cc *Conn) AddChain(c *Chain) *Chain { {Type: unix.NFTA_HOOK_PRIORITY, Data: binaryutil.BigEndian.PutUint32(uint32(*c.Priority))}, } - if c.Device != "" { + switch { + case len(c.Devices) > 1: + var elems []netlink.Attribute + for _, d := range c.Devices { + elems = append(elems, netlink.Attribute{Type: nftaDeviceName, Data: []byte(d + "\x00")}) + } + devs, err := netlink.MarshalAttributes(elems) + if err != nil { + cc.setErr(err) + return c + } + hookAttr = append(hookAttr, netlink.Attribute{Type: unix.NLA_F_NESTED | nftaHookDevs, Data: devs}) + case len(c.Devices) == 1: + hookAttr = append(hookAttr, netlink.Attribute{Type: unix.NFTA_HOOK_DEV, Data: []byte(c.Devices[0] + "\x00")}) + case c.Device != "": hookAttr = append(hookAttr, netlink.Attribute{Type: unix.NFTA_HOOK_DEV, Data: []byte(c.Device + "\x00")}) } @@ -311,7 +332,12 @@ func chainFromMsg(msg netlink.Message) (*Chain, error) { c.Policy = &policy case unix.NFTA_CHAIN_HOOK: ad.Do(func(b []byte) error { - c.Hooknum, c.Priority, err = hookFromMsg(b) + var devices []string + c.Hooknum, c.Priority, devices, err = hookFromMsg(b) + if len(devices) > 0 { + c.Device = devices[0] + c.Devices = devices + } return err }) } @@ -320,16 +346,25 @@ func chainFromMsg(msg netlink.Message) (*Chain, error) { return &c, nil } -func hookFromMsg(b []byte) (*ChainHook, *ChainPriority, error) { +// nftaHookDevs and nftaDeviceName are NFTA_HOOK_DEVS and NFTA_DEVICE_NAME +// from linux/netfilter/nf_tables.h, which golang.org/x/sys/unix does not +// export. +const ( + nftaHookDevs = 4 + nftaDeviceName = 1 +) + +func hookFromMsg(b []byte) (*ChainHook, *ChainPriority, []string, error) { ad, err := netlink.NewAttributeDecoder(b) if err != nil { - return nil, nil, err + return nil, nil, nil, err } ad.ByteOrder = binary.BigEndian var hooknum ChainHook var prio ChainPriority + var devices []string for ad.Next() { switch ad.Type() { @@ -337,8 +372,24 @@ func hookFromMsg(b []byte) (*ChainHook, *ChainPriority, error) { hooknum = ChainHook(ad.Uint32()) case unix.NFTA_HOOK_PRIORITY: prio = ChainPriority(ad.Uint32()) + case unix.NFTA_HOOK_DEV: + devices = append(devices, ad.String()) + case nftaHookDevs: + ad.Do(func(b []byte) error { + nested, err := netlink.NewAttributeDecoder(b) + if err != nil { + return err + } + nested.ByteOrder = binary.BigEndian + for nested.Next() { + if nested.Type() == nftaDeviceName { + devices = append(devices, nested.String()) + } + } + return nested.Err() + }) } } - return &hooknum, &prio, nil + return &hooknum, &prio, devices, nil } diff --git a/chain_device_test.go b/chain_device_test.go new file mode 100644 index 0000000..ea88121 --- /dev/null +++ b/chain_device_test.go @@ -0,0 +1,73 @@ +package nftables + +import ( + "encoding/binary" + "testing" + + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +func hookAttrs(t *testing.T, extra ...netlink.Attribute) []byte { + t.Helper() + num := make([]byte, 4) + binary.BigEndian.PutUint32(num, uint32(*ChainHookIngress)) + prio := make([]byte, 4) + binary.BigEndian.PutUint32(prio, uint32(*ChainPriorityFilter)) + attrs := append([]netlink.Attribute{ + {Type: unix.NFTA_HOOK_HOOKNUM, Data: num}, + {Type: unix.NFTA_HOOK_PRIORITY, Data: prio}, + }, extra...) + b, err := netlink.MarshalAttributes(attrs) + if err != nil { + t.Fatal(err) + } + return b +} + +// A chain attached to one device: the kernel sends NFTA_HOOK_DEV, which +// was being read and discarded. +func TestHookFromMsgReadsASingleDevice(t *testing.T) { + _, _, devices, err := hookFromMsg(hookAttrs(t, + netlink.Attribute{Type: unix.NFTA_HOOK_DEV, Data: []byte("eth0\x00")})) + if err != nil { + t.Fatal(err) + } + if len(devices) != 1 || devices[0] != "eth0" { + t.Fatalf("devices = %v, want [eth0]", devices) + } +} + +// "devices = { eth0, eth1 }" arrives as a nested list instead. +func TestHookFromMsgReadsADeviceList(t *testing.T) { + list, err := netlink.MarshalAttributes([]netlink.Attribute{ + {Type: nftaDeviceName, Data: []byte("eth0\x00")}, + {Type: nftaDeviceName, Data: []byte("eth1\x00")}, + }) + if err != nil { + t.Fatal(err) + } + _, _, devices, err := hookFromMsg(hookAttrs(t, + netlink.Attribute{Type: unix.NLA_F_NESTED | nftaHookDevs, Data: list})) + if err != nil { + t.Fatal(err) + } + if len(devices) != 2 || devices[0] != "eth0" || devices[1] != "eth1" { + t.Fatalf("devices = %v, want [eth0 eth1]", devices) + } +} + +// A chain with no device, which is every base chain in the ip families, +// is unaffected. +func TestHookFromMsgWithoutADevice(t *testing.T) { + hooknum, prio, devices, err := hookFromMsg(hookAttrs(t)) + if err != nil { + t.Fatal(err) + } + if len(devices) != 0 { + t.Errorf("devices = %v, want none", devices) + } + if hooknum == nil || prio == nil { + t.Error("the hook number and priority must still be read") + } +}