Repository: cilium/ebpf
Pull request #1163 (2023-10-10 13:04:30)

link: add TCX support
internal/sys: generate tcx wrappers

    Signed-off-by: Lorenz Bauer <lmb@isovalent.com>

link: add TCX support

    Add support for the new tcx link type. This supersedes netlink based 
    attachment to TC ingress and egress hooks. It is the first user of the
    bpf_mprog API in the kernel, which allows attaching multiple programs to the
    same interface. Semantically programs are put into a list and then executed
    in order. Earlier programs may prevent the execution of later programs.

    The most interesting part is a way to express where a new program should be
    inserted in the list. By default, a program attached via a new link will be
    appended to the list, which is equivalent to passing the BPF_F_AFTER flag.
    Passing the BPF_F_BEFORE flag prepends instead.

    BEFORE and AFTER can be combined with a reference to a program or a link to
    specify any position in the list. The BPF_F_LINK and BPF_F_ID flags indicate
    whether the reference is to a link or a program and whether it is in the
    form of an FD or a ID. The default is to refer to a program by FD. The other
    combinations are as follows:

    - ..._ID: Refer to a program by ID.
    - ..._LINK: Refer to a link by FD.
    - ..._LINK | ..._ID: Refer to a link by ID.

    There is a special case which allows replacing a program with another 
    program by specifying BPF_F_REPLACE. It's not possible to replace a link.

    The flag behaviour is pretty subtle, so the Go API doesn't expose it
    directly. Instead a new concept called an Anchor is introduced, which
    bundles a position with a reference to a program or link. A couple of
    constructors are used to create only valid combinations.

    bpf_mprog also introduces the concept of a revision which changes with each
    modification of the list of attached programs. User space can pass an
    expected revision when creating a new link. The kernel then rejects the
    update if the revision has changed.

    Signed-off-by: Lorenz Bauer <lmb@isovalent.com>

link: support querying bpf_mprog based links

    bpf_mprog based attachment allows specifying an expected revision when
    creating a link. The idea is that user space can first query the list of
    attached programs and the current revision. It then decides where to insert
    in the list and passes the current revision as the expected revision to the
    kernel.

    Return the current revision from QueryPrograms, and at the same time take
    the Target as an int instead of a path. This matches what we do for
    attachment and detachment.

    Signed-off-by: Lorenz Bauer <lmb@isovalent.com>


borkmann: Hi Lorenz :wave: the PR looks great to me, thanks a lot!

6.6 has now been officially released since yesterday, so this could probably also land now wrt ebpf go lib. :) 
lmb: @rgo3 can you take a look at the QueryPrograms changes? Those will break cilium and I wan to make sure the change is not too painful.
rgo3: Yes, it's on my radar. I'll get to it tonight or tomorrow morning.
lmb: > Do we document API breakages, e.g. the one for QueryPrograms only in the release notes or should the commit messages have that information as well?

Added something at the end of each commit message. I think the better approach overall is to use https://github.com/joelanford/go-apidiff when cutting a release.

Patch:
diff --git a/internal/cmd/gentypes/main.go b/internal/cmd/gentypes/main.go
index 07ff966..5e4897c 100644
--- a/internal/cmd/gentypes/main.go
+++ b/internal/cmd/gentypes/main.go
@@ -15,6 +15,8 @@ import (
 	"github.com/cilium/ebpf/btf"
 	"github.com/cilium/ebpf/internal"
 	"github.com/cilium/ebpf/internal/sys"
+
+	"golang.org/x/exp/slices"
 )
 
 type syscallRetval int
@@ -120,6 +122,7 @@ import (
 		{"HdrStartOff", "bpf_hdr_start_off"},
 		{"RetCode", "bpf_ret_code"},
 		{"XdpAction", "xdp_action"},
+		{"TcxActionBase", "tcx_action_base"},
 	}
 
 	sort.Slice(enums, func(i, j int) bool {
@@ -319,11 +322,21 @@ import (
 		},
 		{
 			"ProgAttach", retError, "prog_attach", "BPF_PROG_ATTACH",
-			nil,
+			[]patch{
+				flattenAnon,
+				rename("target_fd", "target_fd_or_ifindex"),
+				rename("relative_fd", "relative_fd_or_id"),
+			},
 		},
 		{
 			"ProgDetach", retError, "prog_attach", "BPF_PROG_DETACH",
-			[]patch{truncateAfter("attach_type")},
+			[]patch{
+				flattenAnon,
+				rename("target_fd", "target_fd_or_ifindex"),
+				truncateAfter("expected_revision"),
+				rename("relative_fd", "relative_fd_or_id"),
+				remove("replace_bpf_fd"),
+			},
 		},
 		{
 			"ProgRun", retError, "prog_run", "BPF_PROG_TEST_RUN",
@@ -426,6 +439,17 @@ import (
 				replace(btfID, "target_btf_id"),
 			},
 		},
+		{
+			"LinkCreateTcx", retFd, "link_create", "BPF_LINK_CREATE",
+			[]patch{
+				choose(1, "target_ifindex"),
+				choose(4, "tcx"),
+				replace(enumTypes["AttachType"], "attach_type"),
+				flattenAnon,
+				flattenAnon, // flatten tcx member
+				rename("relative_fd", "relative_fd_or_id"),
+			},
+		},
 		{
 			"LinkUpdate", retError, "link_update", "BPF_LINK_UPDATE",
 			nil,
@@ -442,9 +466,11 @@ import (
 			"ProgQuery", retError, "prog_query", "BPF_PROG_QUERY",
 			[]patch{
 				replace(enumTypes["AttachType"], "attach_type"),
-				replace(pointer, "prog_ids", "prog_attach_flags", "link_ids", "link_attach_flags"),
-				choose(5, "prog_cnt"),
-				rename("prog_cnt", "prog_count"),
+				replace(pointer, "prog_ids", "prog_attach_flags"),
+				replace(pointer, "link_ids", "link_attach_flags"),
+				flattenAnon,
+				rename("prog_cnt", "count"),
+				rename("target_fd", "target_fd_or_ifindex"),
 			},
 		},
 	}
@@ -519,6 +545,9 @@ import (
 			replace(typeID, "target_btf_id")},
 		},
 		{"XDPLinkInfo", "xdp", nil},
+		{"TcxLinkInfo", "tcx", []patch{
+			replace(enumTypes["AttachType"], "attach_type"),
+		}},
 	}
 
 	sort.Slice(linkInfoExtraTypes, func(i, j int) bool {
@@ -543,6 +572,11 @@ import (
 		{"iter", "iter"},
 		{"netns", "netns"},
 		{"xdp", "xdp"},
+		{"struct_ops", "struct_ops"},
+		{"netfilter", "netfilter"},
+		{"kprobe_multi", "kprobe_multi"},
+		{"perf_event", "perf_event"},
+		{"tcx", "tcx"},
 	})
 	if err != nil {
 		return nil, fmt.Errorf("splitting linkInfo: %w", err)
@@ -550,6 +584,9 @@ import (
 
 	for _, s := range linkInfoExtraTypes {
 		t := linkInfoTypes[s.cType]
+		if t == nil {
+			return nil, fmt.Errorf("link info: no C type named %q", s.cType)
+		}
 		if err := outputPatchedStruct(gf, w, s.goType, t, s.patches); err != nil {
 			return nil, fmt.Errorf("output %q: %w", s.goType, err)
 		}
@@ -716,21 +753,27 @@ func flattenAnon(s *btf.Struct) error {
 	for i := range s.Members {
 		m := &s.Members[i]
 
-		cs, ok := m.Type.(*btf.Struct)
-		if !ok || cs.TypeName() != "" {
+		if m.Type.TypeName() != "" {
 			continue
 		}
 
-		for j := range cs.Members {
-			cs.Members[j].Offset += m.Offset
-		}
+		var newMembers []btf.Member
+		switch cs := m.Type.(type) {
+		case *btf.Struct:
+			for j := range cs.Members {
+				cs.Members[j].Offset += m.Offset
+			}
+			newMembers = cs.Members
 
-		newMembers := make([]btf.Member, 0, len(s.Members)+len(cs.Members)-1)
-		newMembers = append(newMembers, s.Members[:i]...)
-		newMembers = append(newMembers, cs.Members...)
-		newMembers = append(newMembers, s.Members[i+1:]...)
+		case *btf.Union:
+			cs.Members[0].Offset += m.Offset
+			newMembers = []btf.Member{cs.Members[0]}
 
-		s.Members = newMembers
+		default:
+			continue
+		}
+
+		s.Members = slices.Replace(s.Members, i, i+1, newMembers...)
 	}
 
 	return nil
@@ -799,3 +842,15 @@ func replaceWithBytes(members ...string) patch {
 		return nil
 	}, members...)
 }
+
+func remove(member string) patch {
+	return func(s *btf.Struct) error {
+		for i, m := range s.Members {
+			if m.Name == member {
+				s.Members = slices.Delete(s.Members, i, i+1)
+				return nil
+			}
+		}
+		return fmt.Errorf("member %q not found", member)
+	}
+}
diff --git a/internal/sys/mapflags_string.go b/internal/sys/mapflags_string.go
index c80744a..d9fe217 100644
--- a/internal/sys/mapflags_string.go
+++ b/internal/sys/mapflags_string.go
@@ -21,24 +21,28 @@ func _() {
 	_ = x[BPF_F_MMAPABLE-1024]
 	_ = x[BPF_F_PRESERVE_ELEMS-2048]
 	_ = x[BPF_F_INNER_MAP-4096]
+	_ = x[BPF_F_LINK-8192]
+	_ = x[BPF_F_PATH_FD-16384]
 }
 
-const _MapFlags_name = "BPF_F_NO_PREALLOCBPF_F_NO_COMMON_LRUBPF_F_NUMA_NODEBPF_F_RDONLYBPF_F_WRONLYBPF_F_STACK_BUILD_IDBPF_F_ZERO_SEEDBPF_F_RDONLY_PROGBPF_F_WRONLY_PROGBPF_F_CLONEBPF_F_MMAPABLEBPF_F_PRESERVE_ELEMSBPF_F_INNER_MAP"
+const _MapFlags_name = "BPF_F_NO_PREALLOCBPF_F_NO_COMMON_LRUBPF_F_NUMA_NODEBPF_F_RDONLYBPF_F_WRONLYBPF_F_STACK_BUILD_IDBPF_F_ZERO_SEEDBPF_F_RDONLY_PROGBPF_F_WRONLY_PROGBPF_F_CLONEBPF_F_MMAPABLEBPF_F_PRESERVE_ELEMSBPF_F_INNER_MAPBPF_F_LINKBPF_F_PATH_FD"
 
 var _MapFlags_map = map[MapFlags]string{
-	1:    _MapFlags_name[0:17],
-	2:    _MapFlags_name[17:36],
-	4:    _MapFlags_name[36:51],
-	8:    _MapFlags_name[51:63],
-	16:   _MapFlags_name[63:75],
-	32:   _MapFlags_name[75:95],
-	64:   _MapFlags_name[95:110],
-	128:  _MapFlags_name[110:127],
-	256:  _MapFlags_name[127:144],
-	512:  _MapFlags_name[144:155],
-	1024: _MapFlags_name[155:169],
-	2048: _MapFlags_name[169:189],
-	4096: _MapFlags_name[189:204],
+	1:     _MapFlags_name[0:17],
+	2:     _MapFlags_name[17:36],
+	4:     _MapFlags_name[36:51],
+	8:     _MapFlags_name[51:63],
+	16:    _MapFlags_name[63:75],
+	32:    _MapFlags_name[75:95],
+	64:    _MapFlags_name[95:110],
+	128:   _MapFlags_name[110:127],
+	256:   _MapFlags_name[127:144],
+	512:   _MapFlags_name[144:155],
+	1024:  _MapFlags_name[155:169],
+	2048:  _MapFlags_name[169:189],
+	4096:  _MapFlags_name[189:204],
+	8192:  _MapFlags_name[204:214],
+	16384: _MapFlags_name[214:227],
 }
 
 func (i MapFlags) String() string {
diff --git a/internal/sys/syscall.go b/internal/sys/syscall.go
index 088e82e..b1d49b8 100644
--- a/internal/sys/syscall.go
+++ b/internal/sys/syscall.go
@@ -139,6 +139,17 @@ const (
 	BPF_F_MMAPABLE
 	BPF_F_PRESERVE_ELEMS
 	BPF_F_INNER_MAP
+	BPF_F_LINK
+	BPF_F_PATH_FD
+)
+
+// Flags used by bpf_mprog.
+const (
+	BPF_F_REPLACE = 1 << (iota + 2)
+	BPF_F_BEFORE
+	BPF_F_AFTER
+	BPF_F_ID
+	BPF_F_LINK_MPROG = 1 << 13 // aka BPF_F_LINK
 )
 
 // wrappedErrno wraps syscall.Errno to prevent direct comparisons with
diff --git a/internal/sys/types.go b/internal/sys/types.go
index 51698e0..0feffae 100644
--- a/internal/sys/types.go
+++ b/internal/sys/types.go
@@ -462,6 +462,15 @@ const (
 	BPF_STATS_RUN_TIME StatsType = 0
 )
 
+type TcxActionBase int32
+
+const (
+	TCX_NEXT     TcxActionBase = -1
+	TCX_PASS     TcxActionBase = 0
+	TCX_DROP     TcxActionBase = 2
+	TCX_REDIRECT TcxActionBase = 7
+)
+
 type XdpAction uint32
 
 const (
@@ -719,6 +728,25 @@ func LinkCreatePerfEvent(attr *LinkCreatePerfEventAttr) (*FD, error) {
 	return NewFD(int(fd))
 }
 
+type LinkCreateTcxAttr struct {
+	ProgFd           uint32
+	TargetIfindex    uint32
+	AttachType       AttachType
+	Flags            uint32
+	RelativeFdOrId   uint32
+	_                [4]byte
+	ExpectedRevision uint64
+	_                [32]byte
+}
+
+func LinkCreateTcx(attr *LinkCreateTcxAttr) (*FD, error) {
+	fd, err := BPF(BPF_LINK_CREATE, unsafe.Pointer(attr), unsafe.Sizeof(*attr))
+	if err != nil {
+		return nil, err
+	}
+	return NewFD(int(fd))
+}
+
 type LinkCreateTracingAttr struct {
 	ProgFd      uint32
 	TargetFd    uint32
@@ -971,13 +999,13 @@ func ObjPin(attr *ObjPinAttr) error {
 }
 
 type ProgAttachAttr struct {
-	TargetFd         uint32
-	AttachBpfFd      uint32
-	AttachType       uint32
-	AttachFlags      uint32
-	ReplaceBpfFd     uint32
-	RelativeFd       uint32
-	ExpectedRevision uint64
+	TargetFdOrIfindex uint32
+	AttachBpfFd       uint32
+	AttachType        uint32
+	AttachFlags       uint32
+	ReplaceBpfFd      uint32
+	RelativeFdOrId    uint32
+	ExpectedRevision  uint64
 }
 
 func ProgAttach(attr *ProgAttachAttr) error {
@@ -997,9 +1025,13 @@ func ProgBindMap(attr *ProgBindMapAttr) error {
 }
 
 type ProgDetachAttr struct {
-	TargetFd    uint32
-	AttachBpfFd uint32
-	AttachType  uint32
+	TargetFdOrIfindex uint32
+	AttachBpfFd       uint32
+	AttachType        uint32
+	AttachFlags       uint32
+	_                 [4]byte
+	RelativeFdOrId    uint32
+	ExpectedRevision  uint64
 }
 
 func ProgDetach(attr *ProgDetachAttr) error {
@@ -1065,17 +1097,17 @@ func ProgLoad(attr *ProgLoadAttr) (*FD, error) {
 }
 
 type ProgQueryAttr struct {
-	TargetFd        uint32
-	AttachType      AttachType
-	QueryFlags      uint32
-	AttachFlags     uint32
-	ProgIds         Pointer
-	ProgCount       uint32
-	_               [4]byte
-	ProgAttachFlags Pointer
-	LinkIds         Pointer
-	LinkAttachFlags Pointer
-	Revision        uint64
+	TargetFdOrIfindex uint32
+	AttachType        AttachType
+	QueryFlags        uint32
+	AttachFlags       uint32
+	ProgIds           Pointer
+	Count             uint32
+	_                 [4]byte
+	ProgAttachFlags   Pointer
+	LinkIds           Pointer
+	LinkAttachFlags   Pointer
+	Revision          uint64
 }
 
 func ProgQuery(attr *ProgQueryAttr) error {
@@ -1143,6 +1175,11 @@ type RawTracepointLinkInfo struct {
 	_         [4]byte
 }
 
+type TcxLinkInfo struct {
+	Ifindex    uint32
+	AttachType AttachType
+}
+
 type TracingLinkInfo struct {
 	AttachType  AttachType
 	TargetObjId uint32
diff --git a/internal/unix/types_linux.go b/internal/unix/types_linux.go
index 51ed7d0..bc63724 100644
--- a/internal/unix/types_linux.go
+++ b/internal/unix/types_linux.go
@@ -25,6 +25,7 @@ const (
 	EACCES     = linux.EACCES
 	EILSEQ     = linux.EILSEQ
 	EOPNOTSUPP = linux.EOPNOTSUPP
+	ESTALE     = linux.ESTALE
 )
 
 const (
diff --git a/internal/unix/types_other.go b/internal/unix/types_other.go
index 1760e9e..3a0f79c 100644
--- a/internal/unix/types_other.go
+++ b/internal/unix/types_other.go
@@ -27,6 +27,7 @@ const (
 	EACCES
 	EILSEQ
 	EOPNOTSUPP
+	ESTALE
 )
 
 // Constants are distinct to avoid breaking switch statements.
diff --git a/link/anchor.go b/link/anchor.go
new file mode 100644
index 0000000..983296d
--- /dev/null
+++ b/link/anchor.go
@@ -0,0 +1,135 @@
+package link
+
+import (
+	"fmt"
+
+	"github.com/cilium/ebpf"
+	"github.com/cilium/ebpf/internal/sys"
+)
+
+const anchorFlags = sys.BPF_F_REPLACE |
+	sys.BPF_F_BEFORE |
+	sys.BPF_F_AFTER |
+	sys.BPF_F_ID |
+	sys.BPF_F_LINK_MPROG
+
+// Anchor is a reference to a link or program.
+//
+// It is used to describe where an attachment or detachment should take place
+// for link types which support multiple attachment.
+type Anchor interface {
+	// anchor returns an fd or ID and a set of flags.
+	//
+	// By default fdOrID is taken to reference a program, but BPF_F_LINK_MPROG
+	// changes this to refer to a link instead.
+	//
+	// BPF_F_BEFORE, BPF_F_AFTER, BPF_F_REPLACE modify where a link or program
+	// is attached. The default behaviour if none of these flags is specified
+	// matches BPF_F_AFTER.
+	anchor() (fdOrID, flags uint32, _ error)
+}
+
+type firstAnchor struct{}
+
+func (firstAnchor) anchor() (fdOrID, flags uint32, _ error) {
+	return 0, sys.BPF_F_BEFORE, nil
+}
+
+func First() Anchor {
+	return firstAnchor{}
+}
+
+type lastAnchor struct{}
+
+func (lastAnchor) anchor() (fdOrID, flags uint32, _ error) {
+	return 0, sys.BPF_F_AFTER, nil
+}
+
+func Last() Anchor {
+	return lastAnchor{}
+}
+
+// Before is the position just in front of target.
+func BeforeLink(target Link) Anchor {
+	return anchor{target, sys.BPF_F_BEFORE}
+}
+
+// After is the position just after target.
+func AfterLink(target Link) Anchor {
+	return anchor{target, sys.BPF_F_AFTER}
+}
+
+// Before is the position just in front of target.
+func BeforeLinkByID(target ID) Anchor {
+	return anchor{target, sys.BPF_F_BEFORE}
+}
+
+// After is the position just after target.
+func AfterLinkByID(target ID) Anchor {
+	return anchor{target, sys.BPF_F_AFTER}
+}
+
+// Before is the position just in front of target.
+func BeforeProgram(target *ebpf.Program) Anchor {
+	return anchor{target, sys.BPF_F_BEFORE}
+}
+
+// After is the position just after target.
+func AfterProgram(target *ebpf.Program) Anchor {
+	return anchor{target, sys.BPF_F_AFTER}
+}
+
+// Replace the target itself.
+func ReplaceProgram(target *ebpf.Program) Anchor {
+	return anchor{target, sys.BPF_F_REPLACE}
+}
+
+// Before is the position just in front of target.
+func BeforeProgramByID(target ebpf.ProgramID) Anchor {
+	return anchor{target, sys.BPF_F_BEFORE}
+}
+
+// After is the position just after target.
+func AfterProgramByID(target ebpf.ProgramID) Anchor {
+	return anchor{target, sys.BPF_F_AFTER}
+}
+
+// Replace the target itself.
+func ReplaceProgramByID(target ebpf.ProgramID) Anchor {
+	return anchor{target, sys.BPF_F_REPLACE}
+}
+
+type anchor struct {
+	target   any
+	position uint32
+}
+
+func (ap anchor) anchor() (fdOrID, flags uint32, _ error) {
+	var typeFlag uint32
+	switch target := ap.target.(type) {
+	case *ebpf.Program:
+		fd := target.FD()
+		if fd < 0 {
+			return 0, 0, sys.ErrClosedFd
+		}
+		fdOrID = uint32(fd)
+		typeFlag = 0
+	case ebpf.ProgramID:
+		fdOrID = uint32(target)
+		typeFlag = sys.BPF_F_ID
+	case interface{ FD() int }:
+		fd := target.FD()
+		if fd < 0 {
+			return 0, 0, sys.ErrClosedFd
+		}
+		fdOrID = uint32(fd)
+		typeFlag = sys.BPF_F_LINK_MPROG
+	case ID:
+		fdOrID = uint32(target)
+		typeFlag = sys.BPF_F_LINK_MPROG | sys.BPF_F_ID
+	default:
+		return 0, 0, fmt.Errorf("invalid target %T", ap.target)
+	}
+
+	return fdOrID, ap.position | typeFlag, nil
+}
diff --git a/link/cgroup.go b/link/cgroup.go
index 58e85fe..79f3d2b 100644
--- a/link/cgroup.go
+++ b/link/cgroup.go
@@ -143,8 +143,7 @@ func (cg *progAttachCgroup) Update(prog *ebpf.Program) error {
 		// Atomically replacing multiple programs requires at least
 		// 5.5 (commit 7dd68b3279f17921 "bpf: Support replacing cgroup-bpf
 		// program in MULTI mode")
-		args.Flags |= uint32(flagReplace)
-		args.Replace = cg.current
+		args.Anchor = ReplaceProgram(cg.current)
 	}
 
 	if err := RawAttachProgram(args); err != nil {
diff --git a/link/link.go b/link/link.go
index 36acd6e..590ea3a 100644
--- a/link/link.go
+++ b/link/link.go
@@ -98,6 +98,8 @@ func wrapRawLink(raw *RawLink) (_ Link, err error) {
 		return &kprobeMultiLink{*raw}, nil
 	case PerfEventType:
 		return nil, fmt.Errorf("recovering perf event fd: %w", ErrNotSupported)
+	case TCXType:
+		return &tcxLink{*raw}, nil
 	default:
 		return raw, nil
 	}
@@ -132,6 +134,7 @@ type TracingInfo sys.TracingLinkInfo
 type CgroupInfo sys.CgroupLinkInfo
 type NetNsInfo sys.NetNsLinkInfo
 type XDPInfo sys.XDPLinkInfo
+type TCXInfo sys.TcxLinkInfo
 
 // Tracing returns tracing type-specific link info.
 //
@@ -315,6 +318,8 @@ func (l *RawLink) Info() (*Info, error) {
 	case RawTracepointType, IterType,
 		PerfEventType, KprobeMultiType:
 		// Extra metadata not supported.
+	case TCXType:
+		extra = &TCXInfo{}
 	default:
 		return nil, fmt.Errorf("unknown link info type: %d", info.Type)
 	}
diff --git a/link/program.go b/link/program.go
index 053735a..d8a2a15 100644
--- a/link/program.go
+++ b/link/program.go
@@ -2,22 +2,27 @@ package link
 
 import (
 	"fmt"
+	"runtime"
 
 	"github.com/cilium/ebpf"
 	"github.com/cilium/ebpf/internal/sys"
 )
 
 type RawAttachProgramOptions struct {
-	// File descriptor to attach to. This differs for each attach type.
+	// Target to query. This is usually a file descriptor but may refer to
+	// something else based on the attach type.
 	Target int
 	// Program to attach.
 	Program *ebpf.Program
-	// Program to replace (cgroups).
-	Replace *ebpf.Program
-	// Attach must match the attach type of Program (and Replace).
+	// Attach must match the attach type of Program.
 	Attach ebpf.AttachType
-	// Flags control the attach behaviour. This differs for each attach type.
+	// Attach relative to an anchor. Optional.
+	Anchor Anchor
+	// Flags control the attach behaviour. Specify an Anchor instead of
+	// F_LINK, F_ID, F_BEFORE, F_AFTER and F_REPLACE. Optional.
 	Flags uint32
+	// Only attach if the internal revision matches the given value.
+	ExpectedRevision uint64
 }
 
 // RawAttachProgram is a low level wrapper around BPF_PROG_ATTACH.
@@ -25,45 +30,72 @@ type RawAttachProgramOptions struct {
 // You should use one of the higher level abstractions available in this
 // package if possible.
 func RawAttachProgram(opts RawAttachProgramOptions) error {
-	var replaceFd uint32
-	if opts.Replace != nil {
-		replaceFd = uint32(opts.Replace.FD())
+	if opts.Flags&anchorFlags != 0 {
+		return fmt.Errorf("disallowed flags: use Anchor to specify attach target")
 	}
 
 	attr := sys.ProgAttachAttr{
-		TargetFd:     uint32(opts.Target),
-		AttachBpfFd:  uint32(opts.Program.FD()),
-		ReplaceBpfFd: replaceFd,
-		AttachType:   uint32(opts.Attach),
-		AttachFlags:  uint32(opts.Flags),
+		TargetFdOrIfindex: uint32(opts.Target),
+		AttachBpfFd:       uint32(opts.Program.FD()),
+		AttachType:        uint32(opts.Attach),
+		AttachFlags:       uint32(opts.Flags),
+		ExpectedRevision:  opts.ExpectedRevision,
+	}
+
+	if opts.Anchor != nil {
+		fdOrID, flags, err := opts.Anchor.anchor()
+		if err != nil {
+			return fmt.Errorf("attach program: %w", err)
+		}
+
+		if flags == sys.BPF_F_REPLACE {
+			// Ensure that replacing a program works on old kernels.
+			attr.ReplaceBpfFd = fdOrID
+		} else {
+			attr.RelativeFdOrId = fdOrID
+			attr.AttachFlags |= flags
+		}
 	}
 
 	if err := sys.ProgAttach(&attr); err != nil {
 		if haveFeatErr := haveProgAttach(); haveFeatErr != nil {
 			return haveFeatErr
 		}
-		return fmt.Errorf("can't attach program: %w", err)
+		return fmt.Errorf("attach program: %w", err)
 	}
+	runtime.KeepAlive(opts.Program)
 
 	return nil
 }
 
-type RawDetachProgramOptions struct {
-	Target  int
-	Program *ebpf.Program
-	Attach  ebpf.AttachType
-}
+type RawDetachProgramOptions RawAttachProgramOptions
 
 // RawDetachProgram is a low level wrapper around BPF_PROG_DETACH.
 //
 // You should use one of the higher level abstractions available in this
 // package if possible.
 func RawDetachProgram(opts RawDetachProgramOptions) error {
+	if opts.Flags&anchorFlags != 0 {
+		return fmt.Errorf("disallowed flags: use Anchor to specify attach target")
+	}
+
 	attr := sys.ProgDetachAttr{
-		TargetFd:    uint32(opts.Target),
-		AttachBpfFd: uint32(opts.Program.FD()),
-		AttachType:  uint32(opts.Attach),
+		TargetFdOrIfindex: uint32(opts.Target),
+		AttachBpfFd:       uint32(opts.Program.FD()),
+		AttachType:        uint32(opts.Attach),
+		ExpectedRevision:  opts.ExpectedRevision,
 	}
+
+	if opts.Anchor != nil {
+		fdOrID, flags, err := opts.Anchor.anchor()
+		if err != nil {
+			return fmt.Errorf("detach program: %w", err)
+		}
+
+		attr.RelativeFdOrId = fdOrID
+		attr.AttachFlags |= flags
+	}
+
 	if err := sys.ProgDetach(&attr); err != nil {
 		if haveFeatErr := haveProgAttach(); haveFeatErr != nil {
 			return haveFeatErr
diff --git a/link/query.go b/link/query.go
index c056565..fe534f8 100644
--- a/link/query.go
+++ b/link/query.go
@@ -2,7 +2,6 @@ package link
 
 import (
 	"fmt"
-	"os"
 	"unsafe"
 
 	"github.com/cilium/ebpf"
@@ -11,53 +10,102 @@ import (
 
 // QueryOptions defines additional parameters when querying for programs.
 type QueryOptions struct {
-	// Path can be a path to a cgroup, netns or LIRC2 device
-	Path string
+	// Target to query. This is usually a file descriptor but may refer to
+	// something else based on the attach type.
+	Target int
 	// Attach specifies the AttachType of the programs queried for
 	Attach ebpf.AttachType
 	// QueryFlags are flags for BPF_PROG_QUERY, e.g. BPF_F_QUERY_EFFECTIVE
 	QueryFlags uint32
 }
 
-// QueryPrograms retrieves ProgramIDs associated with the AttachType.
-//
-// Returns (nil, nil) if there are no programs attached to the queried kernel
-// resource. Calling QueryPrograms on a kernel missing PROG_QUERY will result in
-// ErrNotSupported.
-func QueryPrograms(opts QueryOptions) ([]ebpf.ProgramID, error) {
-	if haveProgQuery() != nil {
-		return nil, fmt.Errorf("can't query program IDs: %w", ErrNotSupported)
-	}
+// QueryResult describes which programs and links are active.
+type QueryResult struct {
+	// List of attached programs.
+	Programs []AttachedProgram
 
-	f, err := os.Open(opts.Path)
-	if err != nil {
-		return nil, fmt.Errorf("can't open file: %s", err)
-	}
-	defer f.Close()
+	// Incremented by one every time the set of attached programs changes.
+	// May be zero if not supported by the [ebpf.AttachType].
+	Revision uint64
+}
+
+// HaveLinkInfo returns true if the kernel supports querying link information
+// for a particular [ebpf.AttachType].
+func (qr *QueryResult) HaveLinkInfo() bool {
+	return qr.Revision > 0
+}
+
+type AttachedProgram struct {
+	ID     ebpf.ProgramID
+	linkID ID
+}
+
+// LinkID returns the ID associated with the program.
+//
+// Returns 0, false if the kernel doesn't support retrieving the ID or if the
+// program wasn't attached via a link. See [QueryResult.HaveLinkInfo] if you
+// need to tell the two apart.
+func (ap *AttachedProgram) LinkID() (ID, bool) {
+	return ap.linkID, ap.linkID != 0
+}
 
+// QueryPrograms retrieves a list of programs for the given AttachType.
+//
+// Returns a slice of attached programs, which may be empty.
+// revision counts how many times the set of attached programs has changed and
+// may be zero if not supported by the [ebpf.AttachType].
+// Returns ErrNotSupportd on a kernel without BPF_PROG_QUERY
+func QueryPrograms(opts QueryOptions) (*QueryResult, error) {
 	// query the number of programs to allocate correct slice size
 	attr := sys.ProgQueryAttr{
-		TargetFd:   uint32(f.Fd()),
-		AttachType: sys.AttachType(opts.Attach),
-		QueryFlags: opts.QueryFlags,
+		TargetFdOrIfindex: uint32(opts.Target),
+		AttachType:        sys.AttachType(opts.Attach),
+		QueryFlags:        opts.QueryFlags,
 	}
-	if err := sys.ProgQuery(&attr); err != nil {
-		return nil, fmt.Errorf("can't query program count: %w", err)
+	err := sys.ProgQuery(&attr)
+	if err != nil {
+		if haveFeatErr := haveProgQuery(); haveFeatErr != nil {
+			return nil, fmt.Errorf("query programs: %w", haveFeatErr)
+		}
+		return nil, fmt.Errorf("query programs: %w", err)
 	}
+	if attr.Count == 0 {
+		return &QueryResult{Revision: attr.Revision}, nil
+	}
+
+	// The minimum bpf_mprog revision is 1, so we can use the field to detect
+	// whether the attach type supports link ids.
+	haveLinkIDs := attr.Revision != 0
 
-	// return nil if no progs are attached
-	if attr.ProgCount == 0 {
-		return nil, nil
+	count := attr.Count
+	progIds := make([]ebpf.ProgramID, count)
+	attr = sys.ProgQueryAttr{
+		TargetFdOrIfindex: uint32(opts.Target),
+		AttachType:        sys.AttachType(opts.Attach),
+		QueryFlags:        opts.QueryFlags,
+		Count:             count,
+		ProgIds:           sys.NewPointer(unsafe.Pointer(&progIds[0])),
+	}
+
+	var linkIds []ID
+	if haveLinkIDs {
+		linkIds = make([]ID, count)
+		attr.LinkIds = sys.NewPointer(unsafe.Pointer(&linkIds[0]))
 	}
 
-	// we have at least one prog, so we query again
-	progIds := make([]ebpf.ProgramID, attr.ProgCount)
-	attr.ProgIds = sys.NewPointer(unsafe.Pointer(&progIds[0]))
-	attr.ProgCount = uint32(len(progIds))
 	if err := sys.ProgQuery(&attr); err != nil {
-		return nil, fmt.Errorf("can't query program IDs: %w", err)
+		return nil, fmt.Errorf("query programs: %w", err)
 	}
 
-	return progIds, nil
+	// NB: attr.Count might have changed between the two syscalls.
+	var programs []AttachedProgram
+	for i, id := range progIds[:attr.Count] {
+		ap := AttachedProgram{ID: id}
+		if haveLinkIDs {
+			ap.linkID = linkIds[i]
+		}
+		programs = append(programs, ap)
+	}
 
+	return &QueryResult{programs, attr.Revision}, nil
 }
diff --git a/link/syscalls.go b/link/syscalls.go
index 012970e..96d6c7b 100644
--- a/link/syscalls.go
+++ b/link/syscalls.go
@@ -24,6 +24,7 @@ const (
 	XDPType           = sys.BPF_LINK_TYPE_XDP
 	PerfEventType     = sys.BPF_LINK_TYPE_PERF_EVENT
 	KprobeMultiType   = sys.BPF_LINK_TYPE_KPROBE_MULTI
+	TCXType           = sys.BPF_LINK_TYPE_TCX
 )
 
 var haveProgAttach = internal.NewFeatureTest("BPF_PROG_ATTACH", "4.10", func() error {
@@ -72,10 +73,10 @@ var haveProgAttachReplace = internal.NewFeatureTest("BPF_PROG_ATTACH atomic repl
 	// present.
 	attr := sys.ProgAttachAttr{
 		// We rely on this being checked after attachFlags.
-		TargetFd:    ^uint32(0),
-		AttachBpfFd: uint32(prog.FD()),
-		AttachType:  uint32(ebpf.AttachCGroupInetIngress),
-		AttachFlags: uint32(flagReplace),
+		TargetFdOrIfindex: ^uint32(0),
+		AttachBpfFd:       uint32(prog.FD()),
+		AttachType:        uint32(ebpf.AttachCGroupInetIngress),
+		AttachFlags:       uint32(flagReplace),
 	}
 
 	err = sys.ProgAttach(&attr)
@@ -110,8 +111,8 @@ var haveProgQuery = internal.NewFeatureTest("BPF_PROG_QUERY", "4.15", func() err
 		// We rely on this being checked during the syscall.
 		// With an otherwise correct payload we expect EBADF here
 		// as an indication that the feature is present.
-		TargetFd:   ^uint32(0),
-		AttachType: sys.AttachType(ebpf.AttachCGroupInetIngress),
+		TargetFdOrIfindex: ^uint32(0),
+		AttachType:        sys.AttachType(ebpf.AttachCGroupInetIngress),
 	}
 
 	err := sys.ProgQuery(&attr)
diff --git a/link/tcx.go b/link/tcx.go
new file mode 100644
index 0000000..6989af8
--- /dev/null
+++ b/link/tcx.go
@@ -0,0 +1,68 @@
+package link
+
+import (
+	"fmt"
+	"runtime"
+
+	"github.com/cilium/ebpf"
+	"github.com/cilium/ebpf/internal/sys"
+)
+
+type TCXOptions struct {
+	// Index of the interface to attach to.
+	Interface int
+	// Program to attach.
+	Program *ebpf.Program
+	// One of the AttachTCX* constants.
+	Attach ebpf.AttachType
+	// Attach relative to an anchor. Optional.
+	Anchor Anchor
+	// Only attach if the expected revision matches.
+	ExpectedRevision uint64
+	// Flags control the attach behaviour. Specify an Anchor instead of
+	// F_LINK, F_ID, F_BEFORE, F_AFTER and R_REPLACE. Optional.
+	Flags uint32
+}
+
+func AttachTCX(opts TCXOptions) (Link, error) {
+	if opts.Interface < 0 {
+		return nil, fmt.Errorf("interface %d is out of bounds", opts.Interface)
+	}
+
+	if opts.Flags&anchorFlags != 0 {
+		return nil, fmt.Errorf("disallowed flags: use Anchor to specify attach target")
+	}
+
+	attr := sys.LinkCreateTcxAttr{
+		ProgFd:           uint32(opts.Program.FD()),
+		AttachType:       sys.AttachType(opts.Attach),
+		TargetIfindex:    uint32(opts.Interface),
+		ExpectedRevision: opts.ExpectedRevision,
+		Flags:            opts.Flags,
+	}
+
+	if opts.Anchor != nil {
+		fdOrID, flags, err := opts.Anchor.anchor()
+		if err != nil {
+			return nil, fmt.Errorf("attach tcx link: %w", err)
+		}
+
+		attr.RelativeFdOrId = fdOrID
+		attr.Flags |= flags
+	}
+
+	fd, err := sys.LinkCreateTcx(&attr)
+	runtime.KeepAlive(opts.Program)
+	runtime.KeepAlive(opts.Anchor)
+	if err != nil {
+		return nil, fmt.Errorf("attach tcx link: %w", err)
+	}
+
+	return &tcxLink{RawLink{fd, ""}}, nil
+}
+
+type tcxLink struct {
+	RawLink
+}
+
+var _ Link = (*tcxLink)(nil)


Test patch:
diff --git a/link/program_test.go b/link/program_test.go
index 0e32f33..2d56212 100644
--- a/link/program_test.go
+++ b/link/program_test.go
@@ -1,10 +1,14 @@
 package link
 
 import (
+	"fmt"
+	"net"
 	"testing"
 
 	"github.com/cilium/ebpf"
 	"github.com/cilium/ebpf/internal/testutils"
+
+	qt "github.com/frankban/quicktest"
 )
 
 func TestProgramAlter(t *testing.T) {
@@ -42,3 +46,90 @@ func TestProgramAlter(t *testing.T) {
 		t.Fatal(err)
 	}
 }
+
+func TestRawAttachProgramAnchor(t *testing.T) {
+	testutils.SkipOnOldKernel(t, "6.6", "attach anchor")
+
+	iface, err := net.InterfaceByName("lo")
+	qt.Assert(t, err, qt.IsNil)
+
+	a := mustLoadProgram(t, ebpf.SchedCLS, 0, "")
+	info, err := a.Info()
+	qt.Assert(t, err, qt.IsNil)
+	aID, _ := info.ID()
+
+	err = RawAttachProgram(RawAttachProgramOptions{
+		Target:  iface.Index,
+		Program: a,
+		Attach:  ebpf.AttachTCXIngress,
+	})
+	qt.Assert(t, err, qt.IsNil)
+	defer RawDetachProgram(RawDetachProgramOptions{
+		Target:  iface.Index,
+		Program: a,
+		Attach:  ebpf.AttachTCXIngress,
+	})
+
+	link, err := AttachTCX(TCXOptions{
+		Interface: iface.Index,
+		Program:   mustLoadProgram(t, ebpf.SchedCLS, 0, ""),
+		Attach:    ebpf.AttachTCXIngress,
+	})
+	qt.Assert(t, err, qt.IsNil)
+	defer link.Close()
+
+	linkInfo, err := link.Info()
+	qt.Assert(t, err, qt.IsNil)
+
+	b := mustLoadProgram(t, ebpf.SchedCLS, 0, "")
+
+	for _, anchor := range []Anchor{
+		First(),
+		Last(),
+		AfterProgram(a),
+		AfterProgramByID(aID),
+		AfterLink(link),
+		AfterLinkByID(linkInfo.ID),
+	} {
+		t.Run(fmt.Sprintf("%T", anchor), func(t *testing.T) {
+			err := RawAttachProgram(RawAttachProgramOptions{
+				Target:  iface.Index,
+				Program: b,
+				Attach:  ebpf.AttachTCXIngress,
+				Anchor:  anchor,
+			})
+			qt.Assert(t, err, qt.IsNil)
+
+			// Detach doesn't allow first or last anchor.
+			if _, ok := anchor.(firstAnchor); ok {
+				anchor = nil
+			} else if _, ok := anchor.(lastAnchor); ok {
+				anchor = nil
+			}
+
+			err = RawDetachProgram(RawDetachProgramOptions{
+				Target:  iface.Index,
+				Program: b,
+				Attach:  ebpf.AttachTCXIngress,
+				Anchor:  anchor,
+			})
+			qt.Assert(t, err, qt.IsNil)
+		})
+	}
+
+	// Check that legacy replacement with a program works.
+	err = RawAttachProgram(RawAttachProgramOptions{
+		Target:  iface.Index,
+		Program: b,
+		Attach:  ebpf.AttachTCXIngress,
+		Anchor:  ReplaceProgram(a),
+	})
+	qt.Assert(t, err, qt.IsNil)
+
+	err = RawDetachProgram(RawDetachProgramOptions{
+		Target:  iface.Index,
+		Program: b,
+		Attach:  ebpf.AttachTCXIngress,
+	})
+	qt.Assert(t, err, qt.IsNil)
+}
diff --git a/link/query_test.go b/link/query_test.go
index c52a4de..c02a21c 100644
--- a/link/query_test.go
+++ b/link/query_test.go
@@ -1,57 +1,91 @@
 package link
 
 import (
+	"os"
 	"testing"
 
 	"github.com/cilium/ebpf"
 	"github.com/cilium/ebpf/internal/testutils"
+
+	qt "github.com/frankban/quicktest"
+	"golang.org/x/exp/slices"
 )
 
 func TestQueryPrograms(t *testing.T) {
-	for name, fn := range map[string]func(*testing.T) (*ebpf.Program, QueryOptions){
-		"cgroup": queryCgroupFixtures,
-		"netns":  queryNetNSFixtures,
+	for name, fn := range map[string]func(*testing.T) (*ebpf.Program, Link, QueryOptions){
+		"cgroup":      queryCgroupProgAttachFixtures,
+		"cgroup link": queryCgroupLinkFixtures,
+		"netns":       queryNetNSFixtures,
+		"tcx":         queryTCXFixtures,
 	} {
 		t.Run(name, func(t *testing.T) {
-			prog, opts := fn(t)
-			ids, err := QueryPrograms(opts)
+			prog, link, opts := fn(t)
+			result, err := QueryPrograms(opts)
 			testutils.SkipIfNotSupported(t, err)
-			if err != nil {
-				t.Fatal("Can't query programs:", err)
-			}
+			qt.Assert(t, err, qt.IsNil)
 
 			progInfo, err := prog.Info()
-			if err != nil {
-				t.Fatal("Can't get program info:", err)
+			qt.Assert(t, err, qt.IsNil)
+			progID, _ := progInfo.ID()
+
+			i := slices.IndexFunc(result.Programs, func(ap AttachedProgram) bool {
+				return ap.ID == progID
+			})
+			qt.Assert(t, i, qt.Not(qt.Equals), -1)
+
+			if name == "tcx" {
+				qt.Assert(t, result.Revision, qt.Not(qt.Equals), uint64(0))
 			}
 
-			progId, _ := progInfo.ID()
+			if result.HaveLinkInfo() {
+				ap := result.Programs[i]
+				linkInfo, err := link.Info()
+				qt.Assert(t, err, qt.IsNil)
 
-			for _, id := range ids {
-				if id == progId {
-					return
-				}
+				linkID, ok := ap.LinkID()
+				qt.Assert(t, ok, qt.IsTrue)
+				qt.Assert(t, linkID, qt.Equals, linkInfo.ID)
 			}
-			t.Fatalf("Can't find program ID %d in query result: %v", progId, ids)
 		})
 	}
 }
 
-func queryCgroupFixtures(t *testing.T) (*ebpf.Program, QueryOptions) {
+func queryCgroupProgAttachFixtures(t *testing.T) (*ebpf.Program, Link, QueryOptions) {
+	cgroup, prog := mustCgroupFixtures(t)
+
+	link, err := newProgAttachCgroup(cgroup, ebpf.AttachCGroupInetEgress, prog, flagAllowOverride)
+	if err != nil {
+		t.Fatal("Can't create link:", err)
+	}
+	t.Cleanup(func() {
+		qt.Assert(t, link.Close(), qt.IsNil)
+	})
+
+	return prog, nil, QueryOptions{
+		Target: int(cgroup.Fd()),
+		Attach: ebpf.AttachCGroupInetEgress,
+	}
+}
+
+func queryCgroupLinkFixtures(t *testing.T) (*ebpf.Program, Link, QueryOptions) {
 	cgroup, prog := mustCgroupFixtures(t)
 
-	link, err := newProgAttachCgroup(cgroup, ebpf.AttachCGroupInetEgress, prog, 0)
+	link, err := newLinkCgroup(cgroup, ebpf.AttachCGroupInetEgress, prog)
+	testutils.SkipIfNotSupported(t, err)
 	if err != nil {
 		t.Fatal("Can't create link:", err)
 	}
 	t.Cleanup(func() {
-		link.Close()
+		qt.Assert(t, link.Close(), qt.IsNil)
 	})
 
-	return prog, QueryOptions{Path: cgroup.Name(), Attach: ebpf.AttachCGroupInetEgress}
+	return prog, nil, QueryOptions{
+		Target: int(cgroup.Fd()),
+		Attach: ebpf.AttachCGroupInetEgress,
+	}
 }
 
-func queryNetNSFixtures(t *testing.T) (*ebpf.Program, QueryOptions) {
+func queryNetNSFixtures(t *testing.T) (*ebpf.Program, Link, QueryOptions) {
 	testutils.SkipOnOldKernel(t, "4.20", "flow_dissector program")
 
 	prog := mustLoadProgram(t, ebpf.FlowDissector, ebpf.AttachFlowDissector, "")
@@ -77,5 +111,25 @@ func queryNetNSFixtures(t *testing.T) (*ebpf.Program, QueryOptions) {
 		}
 	})
 
-	return prog, QueryOptions{Path: "/proc/self/ns/net", Attach: ebpf.AttachFlowDissector}
+	netns, err := os.Open("/proc/self/ns/net")
+	qt.Assert(t, err, qt.IsNil)
+	t.Cleanup(func() { netns.Close() })
+
+	return prog, nil, QueryOptions{
+		Target: int(netns.Fd()),
+		Attach: ebpf.AttachFlowDissector,
+	}
+}
+
+func queryTCXFixtures(t *testing.T) (*ebpf.Program, Link, QueryOptions) {
+	testutils.SkipOnOldKernel(t, "6.6", "TCX link")
+
+	prog := mustLoadProgram(t, ebpf.SchedCLS, ebpf.AttachTCXIngress, "")
+
+	link, iface := mustAttachTCX(t, prog, ebpf.AttachTCXIngress)
+
+	return prog, link, QueryOptions{
+		Target: iface,
+		Attach: ebpf.AttachTCXIngress,
+	}
 }
diff --git a/link/syscalls_test.go b/link/syscalls_test.go
index 361dc47..70fd983 100644
--- a/link/syscalls_test.go
+++ b/link/syscalls_test.go
@@ -17,3 +17,7 @@ func TestHaveProgAttachReplace(t *testing.T) {
 func TestHaveBPFLink(t *testing.T) {
 	testutils.CheckFeatureTest(t, haveBPFLink)
 }
+
+func TestHaveProgQuery(t *testing.T) {
+	testutils.CheckFeatureTest(t, haveProgQuery)
+}
diff --git a/link/tcx_test.go b/link/tcx_test.go
new file mode 100644
index 0000000..77cde74
--- /dev/null
+++ b/link/tcx_test.go
@@ -0,0 +1,90 @@
+package link
+
+import (
+	"fmt"
+	"math"
+	"net"
+	"testing"
+
+	qt "github.com/frankban/quicktest"
+
+	"github.com/cilium/ebpf"
+	"github.com/cilium/ebpf/internal/testutils"
+	"github.com/cilium/ebpf/internal/unix"
+)
+
+func TestAttachTCX(t *testing.T) {
+	testutils.SkipOnOldKernel(t, "6.6", "TCX link")
+
+	prog := mustLoadProgram(t, ebpf.SchedCLS, ebpf.AttachNone, "")
+	link, _ := mustAttachTCX(t, prog, ebpf.AttachTCXIngress)
+
+	testLink(t, link, prog)
+}
+
+func TestTCXAnchor(t *testing.T) {
+	testutils.SkipOnOldKernel(t, "6.6", "TCX link")
+
+	a := mustLoadProgram(t, ebpf.SchedCLS, ebpf.AttachNone, "")
+	b := mustLoadProgram(t, ebpf.SchedCLS, ebpf.AttachNone, "")
+
+	linkA, iface := mustAttachTCX(t, a, ebpf.AttachTCXEgress)
+
+	programInfo, err := a.Info()
+	qt.Assert(t, err, qt.IsNil)
+	programID, _ := programInfo.ID()
+
+	linkInfo, err := linkA.Info()
+	qt.Assert(t, err, qt.IsNil)
+	linkID := linkInfo.ID
+
+	for _, anchor := range []Anchor{
+		First(),
+		Last(),
+		BeforeProgram(a),
+		BeforeProgramByID(programID),
+		AfterLink(linkA),
+		AfterLinkByID(linkID),
+	} {
+		t.Run(fmt.Sprintf("%T", anchor), func(t *testing.T) {
+			linkB, err := AttachTCX(TCXOptions{
+				Program:   b,
+				Attach:    ebpf.AttachTCXEgress,
+				Interface: iface,
+				Anchor:    anchor,
+			})
+			qt.Assert(t, err, qt.IsNil)
+			qt.Assert(t, linkB.Close(), qt.IsNil)
+		})
+	}
+}
+
+func TestTCXExpectedRevision(t *testing.T) {
+	testutils.SkipOnOldKernel(t, "6.6", "TCX link")
+
+	iface, err := net.InterfaceByName("lo")
+	qt.Assert(t, err, qt.IsNil)
+
+	_, err = AttachTCX(TCXOptions{
+		Program:          mustLoadProgram(t, ebpf.SchedCLS, ebpf.AttachNone, ""),
+		Attach:           ebpf.AttachTCXEgress,
+		Interface:        iface.Index,
+		ExpectedRevision: math.MaxUint64,
+	})
+	qt.Assert(t, err, qt.ErrorIs, unix.ESTALE)
+}
+
+func mustAttachTCX(tb testing.TB, prog *ebpf.Program, attachType ebpf.AttachType) (Link, int) {
+	iface, err := net.InterfaceByName("lo")
+	qt.Assert(tb, err, qt.IsNil)
+
+	link, err := AttachTCX(TCXOptions{
+		Program:   prog,
+		Attach:    attachType,
+		Interface: iface.Index,
+	})
+	qt.Assert(tb, err, qt.IsNil)
+	tb.Cleanup(func() { qt.Assert(tb, link.Close(), qt.IsNil) })
+
+	return link, iface.Index
+}
diff --git a/map_test.go b/map_test.go
index 3d71f4b..8c9710d 100644
--- a/map_test.go
+++ b/map_test.go
@@ -1407,11 +1407,11 @@ func TestCgroupPerCPUStorageMarshaling(t *testing.T) {
 	defer prog.Close()
 
 	progAttachAttrs := sys.ProgAttachAttr{
-		TargetFd:     uint32(cgroup.Fd()),
-		AttachBpfFd:  uint32(prog.FD()),
-		AttachType:   uint32(AttachCGroupInetEgress),
-		AttachFlags:  0,
-		ReplaceBpfFd: 0,
+		TargetFdOrIfindex: uint32(cgroup.Fd()),
+		AttachBpfFd:       uint32(prog.FD()),
+		AttachType:        uint32(AttachCGroupInetEgress),
+		AttachFlags:       0,
+		ReplaceBpfFd:      0,
 	}
 	err = sys.ProgAttach(&progAttachAttrs)
 	if err != nil {
@@ -1419,9 +1419,9 @@ func TestCgroupPerCPUStorageMarshaling(t *testing.T) {
 	}
 	defer func() {
 		attr := sys.ProgDetachAttr{
-			TargetFd:    uint32(cgroup.Fd()),
-			AttachBpfFd: uint32(prog.FD()),
-			AttachType:  uint32(AttachCGroupInetEgress),
+			TargetFdOrIfindex: uint32(cgroup.Fd()),
+			AttachBpfFd:       uint32(prog.FD()),
+			AttachType:        uint32(AttachCGroupInetEgress),
 		}
 		if err := sys.ProgDetach(&attr); err != nil {
 			t.Fatal(err)