Repository: opencontainers/runc
Pull request #4538 (2024-11-21 22:02:46)

Linux Network Devices
Implementation of https://github.com/opencontainers/runtime-spec/pull/1271

It implements the new proposal to the OCI spec to be able to specify Network Devices that get attached ~detached~ from the containers (updated to match the merged proposal https://github.com/opencontainers/runtime-spec/pull/1271)

rata: @aojea friendly reminder that this should be ready soon, we are cutting 1.3.0-rc.1 soon (maybe this week). The spec part is still not sure when it will be merged?
aojea: > @aojea friendly reminder that this should be ready soon, we are cutting 1.3.0-rc.1 soon (maybe this week). The spec part is still not sure when it will be merged?

@rata I'm not familiar with the processes in this ecosystem, should I do something? I'm waiting for the spec to merge
rata: @aojea I don't think it's much different from k8s or most projects. I think you need to drive alignment to merge the runtime-spec PR. But we can't merge this in runc if it depends on a spec change.
aojea: > @aojea I don't think it's much different from k8s or most projects. I think you need to drive alignment to merge the runtime-spec PR. But we can't merge this in runc if it depends on a spec change.

that is why I thought, thanks for confirming
aojea: > Not sure why CI is failing only in this PR. It seems to skip on rootless... :-/

but it seems it still tries to setup the interfaces and network namespaces ... I added a check to ignore those steps if is not root on the setup and teardown 
aojea: folks apologies for leaving this half way yesterdat, but we had a total blackout in spain and portugal and I lost connection until today :)
aojea: The rootless jobs fail when I move the network namespace logic to the `setup` block, it is not able to run the test despite is skipped ... I tried different things without success, so I encapsulated the create_netns logic in a function and moves it inside the test block, so is skipped correctly
aojea: kindly ping @kolyshkin @rata , please let me know what is missing
rata: Sorry, so far you are not missing anything, I'm quite overloaded with lot of stuff now and I didn't manage to get back to this. It was very close last time I check, I guess it's ready or almost. I can try to have a look next week.

Sorry again for the delay, it's quite hard with lot of deadlines in these weeks for me to find the time. But For sure I'll have the time for this is included in 1.4, don't worry about that :)
aojea: updated the code to handle the change on the vishvananda library and recover the previous behavior

ref https://github.com/vishvananda/netlink/pull/1018
lifubang: LGTM overall.
Just a question, it seems that there is no mechanism to attach network devices after `c/r` operation.
If we checkpointed a container with attached network devices, these network devices will disappeared after we restored from the dumped check point files.
aojea: > Just a question, it seems that there is no mechanism to attach network devices after `c/r` operation.

I'm not familiar with those operations but I'm happy to investigate more if you provide some guidance
lifubang: > * ci / test (actuated-arm64-6cpu-8gb, 1.24.x, rootless) (pull_request)

@alexellis May I ask your help here, is there some special changes cause we can't use `localhost` in `actuated-arm64-6cpu-8gb`? Could you have a suggestion to resolve this issue? 🙏 

It seems that the failure occurred from 4 days ago. Please see: https://github.com/opencontainers/runc/actions/runs/15232466902/job/42841958687

The other solution is to change `ssh rootless@localhost` to `ssh rootless@127.0.0.1`.
alexellis: Hi @lifubang happy to help but, we do not provide any support for actuated via GitHub - only by Slack. I've only just seen these mentions.

> The other solution is to change ssh rootless@localhost to ssh rootless@127.0.0.1.

That sounds like a better solution. I always prefer 127.0.0.1 over "localhost" - especially on systems with IPv6.. sometimes those resolutions will hang indefinitely.

I can't think of a reason why localhost wouldn't resolve off the top of my head, but you can explore the VM image in an SSH session and poke around. That's the best way - access it here - https://docs.actuated.com/tasks/debug-ssh/

You can also create a dummy repo and job and run a command like `cat /etc/hosts` to see if the entries you expect are present.

Support for rootless containers is built into the Kernel, are there any specific `CONFIG_` settings that you typically need?

Alex
alexellis: What is the context in which this command is being run? On the host directly, in a container?

There is no entry in `/etc/hosts` if that is being parsed? But I am seeing resolution work.

Can you try a test in your build of adding `127.0.0.1 localhost` to your `/etc/hosts` file for this job?

Perhaps conditionally if needed? Something like this should work

```yaml
       - name: Add entry to hosts
          if: ${{ runner.arch != 'ARM64' }}
          run: |
             echo "127.0.0.1 localhost" | sudo tee /etc/hosts
```

```
runner@87db36ae71e63a0c3a141cd9d597246cd6aaa628:~$ ping -c1 localhost
PING localhost(localhost (::1)) 56 data bytes
64 bytes from localhost (::1): icmp_seq=1 ttl=64 time=0.038 ms

--- localhost ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.038/0.038/0.038/0.000 ms
runner@87db36ae71e63a0c3a141cd9d597246cd6aaa628:~$ nslookup localhost
Server:		127.0.0.53
Address:	127.0.0.53#53

Name:	localhost
Address: 127.0.0.1
Name:	localhost
Address: ::1

runner@87db36ae71e63a0c3a141cd9d597246cd6aaa628:~$ cat /etc/hosts
runner@87db36ae71e63a0c3a141cd9d597246cd6aaa628:~$ cat /etc/hostname
87db36ae71e63a0c3a141cd9d597246cd6aaa628
runner@87db36ae71e63a0c3a141cd9d597246cd6aaa628:~$ curl localhost:80
curl: (7) Failed to connect to localhost port 80 after 0 ms: Connection refused
runner@87db36ae71e63a0c3a141cd9d597246cd6aaa628:~$ curl -4 localhost:80
curl: (7) Failed to connect to localhost port 80 after 0 ms: Connection refused
runner@87db36ae71e63a0c3a141cd9d597246cd6aaa628:~$ 

```

Patch:
diff --git a/features.go b/features.go
index b636466b..c5dff4a2 100644
--- a/features.go
+++ b/features.go
@@ -63,6 +63,9 @@ var featuresCommand = cli.Command{
 						Enabled: &t,
 					},
 				},
+				NetDevices: &features.NetDevices{
+					Enabled: &t,
+				},
 			},
 			PotentiallyUnsafeConfigAnnotations: []string{
 				"bundle",
diff --git a/go.mod b/go.mod
index f28a5dfc..3544a1b6 100644
--- a/go.mod
+++ b/go.mod
@@ -15,12 +15,13 @@ require (
 	github.com/moby/sys/userns v0.1.0
 	github.com/mrunalp/fileutils v0.5.1
 	github.com/opencontainers/cgroups v0.0.2
-	github.com/opencontainers/runtime-spec v1.2.1
+	github.com/opencontainers/runtime-spec v1.2.2-0.20250401095657-e935f995dd67
 	github.com/opencontainers/selinux v1.12.0
 	github.com/seccomp/libseccomp-golang v0.11.0
 	github.com/sirupsen/logrus v1.9.3
 	github.com/urfave/cli v1.22.17
 	github.com/vishvananda/netlink v1.3.1
+	github.com/vishvananda/netns v0.0.5
 	golang.org/x/net v0.41.0
 	golang.org/x/sys v0.33.0
 	google.golang.org/protobuf v1.36.6
@@ -30,5 +31,4 @@ require (
 	github.com/cilium/ebpf v0.17.3 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
-	github.com/vishvananda/netns v0.0.5 // indirect
 )
diff --git a/go.sum b/go.sum
index 2c7f5ba3..f09a27ac 100644
--- a/go.sum
+++ b/go.sum
@@ -47,8 +47,8 @@ github.com/mrunalp/fileutils v0.5.1 h1:F+S7ZlNKnrwHfSwdlgNSkKo67ReVf8o9fel6C3dkm
 github.com/mrunalp/fileutils v0.5.1/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ=
 github.com/opencontainers/cgroups v0.0.2 h1:A+mAPPMfgKNCEZUUtibESFx06uvhAmvo8sSz3Abwk7o=
 github.com/opencontainers/cgroups v0.0.2/go.mod h1:s8lktyhlGUqM7OSRL5P7eAW6Wb+kWPNvt4qvVfzA5vs=
-github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww=
-github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/runtime-spec v1.2.2-0.20250401095657-e935f995dd67 h1:Q+KewUGTMamIe6Q39xCD/T1NC1POmaTlWnhjikCrZHA=
+github.com/opencontainers/runtime-spec v1.2.2-0.20250401095657-e935f995dd67/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
 github.com/opencontainers/selinux v1.12.0 h1:6n5JV4Cf+4y0KNXW48TLj5DwfXpvWlxXplUkdTrmPb8=
 github.com/opencontainers/selinux v1.12.0/go.mod h1:BTPX+bjVbWGXw7ZZWUbdENt8w0htPSrlgOOysQaU62U=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
diff --git a/libcontainer/configs/config.go b/libcontainer/configs/config.go
index f88eb8b8..41101254 100644
--- a/libcontainer/configs/config.go
+++ b/libcontainer/configs/config.go
@@ -119,6 +119,9 @@ type Config struct {
 	// The device nodes that should be automatically created within the container upon container start.  Note, make sure that the node is marked as allowed in the cgroup as well!
 	Devices []*devices.Device `json:"devices"`
 
+	// NetDevices are key-value pairs, keyed by network device name, moved to the container's network namespace.
+	NetDevices map[string]*LinuxNetDevice `json:"netDevices,omitempty"`
+
 	MountLabel string `json:"mount_label,omitempty"`
 
 	// Hostname optionally sets the container's hostname if provided.
diff --git a/libcontainer/configs/doc.go b/libcontainer/configs/doc.go
new file mode 100644
index 00000000..2d3ae0b2
--- /dev/null
+++ b/libcontainer/configs/doc.go
@@ -0,0 +1,2 @@
+// Package configs defines the structures and constants used for configuring a container
+package configs
diff --git a/libcontainer/configs/netdevices.go b/libcontainer/configs/netdevices.go
new file mode 100644
index 00000000..09c38649
--- /dev/null
+++ b/libcontainer/configs/netdevices.go
@@ -0,0 +1,7 @@
+package configs
+
+// LinuxNetDevice represents a single network device to be added to the container's network namespace.
+type LinuxNetDevice struct {
+	// Name of the device in the container namespace.
+	Name string `json:"name,omitempty"`
+}
diff --git a/libcontainer/configs/validate/validator.go b/libcontainer/configs/validate/validator.go
index e0052900..a9d03a2e 100644
--- a/libcontainer/configs/validate/validator.go
+++ b/libcontainer/configs/validate/validator.go
@@ -24,6 +24,7 @@ func Validate(config *configs.Config) error {
 		cgroupsCheck,
 		rootfs,
 		network,
+		netdevices,
 		uts,
 		security,
 		namespaces,
@@ -70,6 +71,43 @@ func rootfs(config *configs.Config) error {
 	return nil
 }
 
+// https://elixir.bootlin.com/linux/v6.12/source/net/core/dev.c#L1066
+func devValidName(name string) bool {
+	if len(name) == 0 || len(name) > unix.IFNAMSIZ {
+		return false
+	}
+	if name == "." || name == ".." {
+		return false
+	}
+	if strings.ContainsAny(name, "/: ") {
+		return false
+	}
+	return true
+}
+
+func netdevices(config *configs.Config) error {
+	if len(config.NetDevices) == 0 {
+		return nil
+	}
+	if !config.Namespaces.Contains(configs.NEWNET) {
+		return errors.New("unable to move network devices without a NET namespace")
+	}
+
+	if config.RootlessEUID || config.RootlessCgroups {
+		return errors.New("network devices are not supported for rootless containers")
+	}
+
+	for name, netdev := range config.NetDevices {
+		if !devValidName(name) {
+			return fmt.Errorf("invalid network device name %q", name)
+		}
+		if netdev.Name != "" && !devValidName(netdev.Name) {
+			return fmt.Errorf("invalid network device name %q", netdev.Name)
+		}
+	}
+	return nil
+}
+
 func network(config *configs.Config) error {
 	if !config.Namespaces.Contains(configs.NEWNET) {
 		if len(config.Networks) > 0 || len(config.Routes) > 0 {
diff --git a/libcontainer/network_linux.go b/libcontainer/network_linux.go
index 8915548b..c645fbeb 100644
--- a/libcontainer/network_linux.go
+++ b/libcontainer/network_linux.go
@@ -2,6 +2,7 @@ package libcontainer
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -9,7 +10,12 @@ import (
 
 	"github.com/opencontainers/runc/libcontainer/configs"
 	"github.com/opencontainers/runc/types"
+	"github.com/sirupsen/logrus"
 	"github.com/vishvananda/netlink"
+	"github.com/vishvananda/netlink/nl"
+	"github.com/vishvananda/netns"
+
+	"golang.org/x/sys/unix"
 )
 
 var strategies = map[string]networkStrategy{
@@ -98,3 +104,129 @@ func (l *loopback) attach(n *configs.Network) (err error) {
 func (l *loopback) detach(n *configs.Network) (err error) {
 	return nil
 }
+
+// devChangeNetNamespace allows to move a device given by name to a network namespace given by nsPath
+// and optionally change the device name.
+// The device name will be kept the same if device.Name is the zero value.
+// This function ensures that the move and rename operations occur atomically.
+// It preserves existing interface attributes, including global IP addresses.
+func devChangeNetNamespace(name string, nsPath string, device configs.LinuxNetDevice) error {
+	logrus.Debugf("attaching network device %s with attrs %+v to network namespace %s", name, device, nsPath)
+	link, err := netlink.LinkByName(name)
+	// recover same behavior on vishvananda/netlink@1.2.1 and do not fail when the kernel returns NLM_F_DUMP_INTR.
+	if err != nil && !errors.Is(err, netlink.ErrDumpInterrupted) {
+		return fmt.Errorf("link not found for interface %s on runtime namespace: %w", name, err)
+	}
+
+	// Set the interface link state to DOWN before modifying attributes like namespace or name.
+	// This prevents potential conflicts or disruptions on the host network during the transition,
+	// particularly if other host components depend on this specific interface or its properties.
+	err = netlink.LinkSetDown(link)
+	if err != nil {
+		return fmt.Errorf("fail to set link down: %w", err)
+	}
+
+	// Get the existing IP addresses on the interface.
+	addresses, err := netlink.AddrList(link, netlink.FAMILY_ALL)
+	// recover same behavior on vishvananda/netlink@1.2.1 and do not fail when the kernel returns NLM_F_DUMP_INTR.
+	if err != nil && !errors.Is(err, netlink.ErrDumpInterrupted) {
+		return fmt.Errorf("fail to get ip addresses: %w", err)
+	}
+
+	// Do interface rename and namespace change in the same operation to avoid
+	// possible conflicts with the interface name.
+	// NLM_F_REQUEST: "It must be set on all request messages."
+	// NLM_F_ACK: "Request for an acknowledgment on success."
+	// netlink(7) man page: https://man7.org/linux/man-pages/man7/netlink.7.html
+	flags := unix.NLM_F_REQUEST | unix.NLM_F_ACK
+	req := nl.NewNetlinkRequest(unix.RTM_NEWLINK, flags)
+
+	// Get a netlink socket in current namespace
+	nlSock, err := nl.GetNetlinkSocketAt(netns.None(), netns.None(), unix.NETLINK_ROUTE)
+	if err != nil {
+		return fmt.Errorf("could not get network namespace handle: %w", err)
+	}
+	defer nlSock.Close()
+
+	req.Sockets = map[int]*nl.SocketHandle{
+		unix.NETLINK_ROUTE: {Socket: nlSock},
+	}
+
+	// Set the interface index.
+	msg := nl.NewIfInfomsg(unix.AF_UNSPEC)
+	msg.Index = int32(link.Attrs().Index)
+	req.AddData(msg)
+
+	// Set the interface name, also rename if requested.
+	newName := name
+	if device.Name != "" {
+		newName = device.Name
+	}
+	nameData := nl.NewRtAttr(unix.IFLA_IFNAME, nl.ZeroTerminated(newName))
+	req.AddData(nameData)
+
+	// Get the new network namespace.
+	ns, err := netns.GetFromPath(nsPath)
+	if err != nil {
+		return fmt.Errorf("could not get network namespace from path %s for network device %s : %w", nsPath, name, err)
+	}
+	defer ns.Close()
+
+	val := nl.Uint32Attr(uint32(ns))
+	attr := nl.NewRtAttr(unix.IFLA_NET_NS_FD, val)
+	req.AddData(attr)
+
+	_, err = req.Execute(unix.NETLINK_ROUTE, 0)
+	// recover same behavior on vishvananda/netlink@1.2.1 and do not fail when the kernel returns NLM_F_DUMP_INTR.
+	if err != nil && !errors.Is(err, netlink.ErrDumpInterrupted) {
+		return fmt.Errorf("fail to move network device %s to network namespace %s: %w", name, nsPath, err)
+	}
+
+	// To avoid us the husle with goroutines when joining a netns,
+	// we let the library create the socket in the namespace for us.
+	nhNs, err := netlink.NewHandleAt(ns)
+	if err != nil {
+		return err
+	}
+	defer nhNs.Close()
+
+	nsLink, err := nhNs.LinkByName(newName)
+	// recover same behavior on vishvananda/netlink@1.2.1 and do not fail when the kernel returns NLM_F_DUMP_INTR.
+	if err != nil && !errors.Is(err, netlink.ErrDumpInterrupted) {
+		return fmt.Errorf("link not found for interface %s on namespace %s : %w", newName, nsPath, err)
+	}
+
+	// Re-add the original IP addresses to the interface in the new namespace.
+	// The kernel removes IP addresses when an interface is moved between network namespaces.
+	for _, address := range addresses {
+		logrus.Debugf("processing address %s from network device %s", address.String(), name)
+		// Only move permanent IP addresses configured by the user, dynamic addresses are excluded because
+		// their validity may rely on the original network namespace's context and they may have limited
+		// lifetimes and are not guaranteed to be available in a new namespace.
+		// Ref: https://www.ietf.org/rfc/rfc3549.txt
+		if address.Flags&unix.IFA_F_PERMANENT == 0 {
+			logrus.Debugf("skipping address %s from network device %s: not a permanent address", address.String(), name)
+			continue
+		}
+		// Only move IP addresses with global scope because those are not host-specific, auto-configured,
+		// or have limited network scope, making them unsuitable inside the container namespace.
+		// Ref: https://www.ietf.org/rfc/rfc3549.txt
+		if address.Scope != unix.RT_SCOPE_UNIVERSE {
+			logrus.Debugf("skipping address %s from network device %s: not an address with global scope", address.String(), name)
+			continue
+		}
+		// Remove the interface attribute of the original address
+		// to avoid issues when the interface is renamed.
+		err = nhNs.AddrAdd(nsLink, &netlink.Addr{IPNet: address.IPNet})
+		if err != nil {
+			return fmt.Errorf("fail to set up address %s on namespace %s: %w", address.String(), nsPath, err)
+		}
+	}
+
+	err = nhNs.LinkSetUp(nsLink)
+	if err != nil {
+		return fmt.Errorf("fail to set up interface %s on namespace %s: %w", nsLink.Attrs().Name, nsPath, err)
+	}
+
+	return nil
+}
diff --git a/libcontainer/process_linux.go b/libcontainer/process_linux.go
index 3fcc79df..82f121fb 100644
--- a/libcontainer/process_linux.go
+++ b/libcontainer/process_linux.go
@@ -649,6 +649,10 @@ func (p *initProcess) start() (retErr error) {
 		return fmt.Errorf("error creating network interfaces: %w", err)
 	}
 
+	if err := p.setupNetworkDevices(); err != nil {
+		return fmt.Errorf("error creating network interfaces: %w", err)
+	}
+
 	// initConfig.SpecState is only needed to run hooks that are executed
 	// inside a container, i.e. CreateContainer and StartContainer.
 	if p.config.Config.HasHook(configs.CreateContainer, configs.StartContainer) {
@@ -836,6 +840,30 @@ func (p *initProcess) createNetworkInterfaces() error {
 	return nil
 }
 
+// setupNetworkDevices sets up and initializes any defined network interface inside the container.
+func (p *initProcess) setupNetworkDevices() error {
+	// host network pods does not move network devices.
+	if !p.config.Config.Namespaces.Contains(configs.NEWNET) {
+		return nil
+	}
+	// the container init process has already joined the provided net namespace,
+	// so we can use the process's net ns path directly.
+	nsPath := fmt.Sprintf("/proc/%d/ns/net", p.pid())
+
+	// If moving any of the network devices fails, we return an error immediately.
+	// The runtime spec requires that the kernel handles moving back any devices
+	// that were successfully moved before the failure occurred.
+	// See: https://github.com/opencontainers/runtime-spec/blob/27cb0027fd92ef81eda1ea3a8153b8337f56d94a/config-linux.md#namespace-lifecycle-and-container-termination
+	for name, netDevice := range p.config.Config.NetDevices {
+		err := devChangeNetNamespace(name, nsPath, *netDevice)
+		if err != nil {
+			return fmt.Errorf("move netDevice %s to namespace %s: %w", name, nsPath, err)
+		}
+	}
+
+	return nil
+}
+
 func pidGetFd(pid, srcFd int) (*os.File, error) {
 	pidFd, err := unix.PidfdOpen(pid, 0)
 	if err != nil {
diff --git a/libcontainer/specconv/spec_linux.go b/libcontainer/specconv/spec_linux.go
index c6bf02c9..ec417ed3 100644
--- a/libcontainer/specconv/spec_linux.go
+++ b/libcontainer/specconv/spec_linux.go
@@ -480,6 +480,14 @@ func CreateLibcontainerConfig(opts *CreateOpts) (*configs.Config, error) {
 			}
 		}
 
+		for name, netdev := range spec.Linux.NetDevices {
+			if config.NetDevices == nil {
+				config.NetDevices = make(map[string]*configs.LinuxNetDevice)
+			}
+			config.NetDevices[name] = &configs.LinuxNetDevice{
+				Name: netdev.Name,
+			}
+		}
 	}
 
 	// Set the host UID that should own the container's cgroup.
diff --git a/vendor/github.com/opencontainers/runtime-spec/specs-go/config.go b/vendor/github.com/opencontainers/runtime-spec/specs-go/config.go
index 1aa0693b..854290da 100644
--- a/vendor/github.com/opencontainers/runtime-spec/specs-go/config.go
+++ b/vendor/github.com/opencontainers/runtime-spec/specs-go/config.go
@@ -236,6 +236,8 @@ type Linux struct {
 	Namespaces []LinuxNamespace `json:"namespaces,omitempty"`
 	// Devices are a list of device nodes that are created for the container
 	Devices []LinuxDevice `json:"devices,omitempty"`
+	// NetDevices are key-value pairs, keyed by network device name on the host, moved to the container's network namespace.
+	NetDevices map[string]LinuxNetDevice `json:"netDevices,omitempty"`
 	// Seccomp specifies the seccomp security settings for the container.
 	Seccomp *LinuxSeccomp `json:"seccomp,omitempty"`
 	// RootfsPropagation is the rootfs mount propagation mode for the container.
@@ -491,6 +493,12 @@ type LinuxDevice struct {
 	GID *uint32 `json:"gid,omitempty"`
 }
 
+// LinuxNetDevice represents a single network device to be added to the container's network namespace
+type LinuxNetDevice struct {
+	// Name of the device in the container namespace
+	Name string `json:"name,omitempty"`
+}
+
 // LinuxDeviceCgroup represents a device rule for the devices specified to
 // the device controller
 type LinuxDeviceCgroup struct {
diff --git a/vendor/github.com/opencontainers/runtime-spec/specs-go/features/features.go b/vendor/github.com/opencontainers/runtime-spec/specs-go/features/features.go
index 949f532b..d8eb169d 100644
--- a/vendor/github.com/opencontainers/runtime-spec/specs-go/features/features.go
+++ b/vendor/github.com/opencontainers/runtime-spec/specs-go/features/features.go
@@ -48,6 +48,7 @@ type Linux struct {
 	Selinux         *Selinux         `json:"selinux,omitempty"`
 	IntelRdt        *IntelRdt        `json:"intelRdt,omitempty"`
 	MountExtensions *MountExtensions `json:"mountExtensions,omitempty"`
+	NetDevices      *NetDevices      `json:"netDevices,omitempty"`
 }
 
 // Cgroup represents the "cgroup" field.
@@ -143,3 +144,10 @@ type IDMap struct {
 	// Nil value means "unknown", not "false".
 	Enabled *bool `json:"enabled,omitempty"`
 }
+
+// NetDevices represents the "netDevices" field.
+type NetDevices struct {
+	// Enabled is true if network devices support is compiled in.
+	// Nil value means "unknown", not "false".
+	Enabled *bool `json:"enabled,omitempty"`
+}
diff --git a/vendor/github.com/opencontainers/runtime-spec/specs-go/version.go b/vendor/github.com/opencontainers/runtime-spec/specs-go/version.go
index 23234a9c..b0a00466 100644
--- a/vendor/github.com/opencontainers/runtime-spec/specs-go/version.go
+++ b/vendor/github.com/opencontainers/runtime-spec/specs-go/version.go
@@ -11,7 +11,7 @@ const (
 	VersionPatch = 1
 
 	// VersionDev indicates development branch. Releases will be empty string.
-	VersionDev = ""
+	VersionDev = "+dev"
 )
 
 // Version is the specification version that the package types support.
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 064ee9a8..a08b66fd 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -62,7 +62,7 @@ github.com/opencontainers/cgroups/fscommon
 github.com/opencontainers/cgroups/internal/path
 github.com/opencontainers/cgroups/manager
 github.com/opencontainers/cgroups/systemd
-# github.com/opencontainers/runtime-spec v1.2.1
+# github.com/opencontainers/runtime-spec v1.2.2-0.20250401095657-e935f995dd67
 ## explicit
 github.com/opencontainers/runtime-spec/specs-go
 github.com/opencontainers/runtime-spec/specs-go/features


Test patch:
diff --git a/libcontainer/configs/validate/validator_test.go b/libcontainer/configs/validate/validator_test.go
index d157feea..99e189c6 100644
--- a/libcontainer/configs/validate/validator_test.go
+++ b/libcontainer/configs/validate/validator_test.go
@@ -3,6 +3,7 @@ package validate
 import (
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/opencontainers/runc/libcontainer/configs"
@@ -877,3 +878,168 @@ func TestValidateIOPriority(t *testing.T) {
 		}
 	}
 }
+
+func TestValidateNetDevices(t *testing.T) {
+	testCases := []struct {
+		name   string
+		isErr  bool
+		config *configs.Config
+	}{
+		{
+			name: "network device with configured network namespace",
+			config: &configs.Config{
+				Namespaces: configs.Namespaces(
+					[]configs.Namespace{
+						{
+							Type: configs.NEWNET,
+							Path: "/var/run/netns/blue",
+						},
+					},
+				),
+				NetDevices: map[string]*configs.LinuxNetDevice{
+					"eth0": {},
+				},
+			},
+		},
+		{
+			name: "network device rename",
+			config: &configs.Config{
+				Namespaces: configs.Namespaces(
+					[]configs.Namespace{
+						{
+							Type: configs.NEWNET,
+							Path: "/var/run/netns/blue",
+						},
+					},
+				),
+				NetDevices: map[string]*configs.LinuxNetDevice{
+					"eth0": {
+						Name: "c0",
+					},
+				},
+			},
+		},
+		{
+			name: "network device network namespace created by runc",
+			config: &configs.Config{
+				Namespaces: configs.Namespaces(
+					[]configs.Namespace{
+						{
+							Type: configs.NEWNET,
+						},
+					},
+				),
+				NetDevices: map[string]*configs.LinuxNetDevice{
+					"eth0": {},
+				},
+			},
+		},
+		{
+			name:  "network device network namespace empty",
+			isErr: true,
+			config: &configs.Config{
+				Namespaces: configs.Namespaces(
+					[]configs.Namespace{},
+				),
+				NetDevices: map[string]*configs.LinuxNetDevice{
+					"eth0": {},
+				},
+			},
+		},
+		{
+			name:  "network device rootless EUID",
+			isErr: true,
+			config: &configs.Config{
+				Namespaces: configs.Namespaces(
+					[]configs.Namespace{
+						{
+							Type: configs.NEWNET,
+							Path: "/var/run/netns/blue",
+						},
+					},
+				),
+				RootlessEUID: true,
+				NetDevices: map[string]*configs.LinuxNetDevice{
+					"eth0": {},
+				},
+			},
+		},
+		{
+			name:  "network device rootless",
+			isErr: true,
+			config: &configs.Config{
+				Namespaces: configs.Namespaces(
+					[]configs.Namespace{
+						{
+							Type: configs.NEWNET,
+							Path: "/var/run/netns/blue",
+						},
+					},
+				),
+				RootlessCgroups: true,
+				NetDevices: map[string]*configs.LinuxNetDevice{
+					"eth0": {},
+				},
+			},
+		},
+		{
+			name:  "network device bad name",
+			isErr: true,
+			config: &configs.Config{
+				Namespaces: configs.Namespaces(
+					[]configs.Namespace{
+						{
+							Type: configs.NEWNET,
+							Path: "/var/run/netns/blue",
+						},
+					},
+				),
+				NetDevices: map[string]*configs.LinuxNetDevice{
+					"eth0": {
+						Name: "eth0/",
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			config := tc.config
+			config.Rootfs = "/var"
+
+			err := Validate(config)
+			if tc.isErr && err == nil {
+				t.Error("expected error, got nil")
+			}
+
+			if !tc.isErr && err != nil {
+				t.Error(err)
+			}
+		})
+	}
+}
+
+func TestDevValidName(t *testing.T) {
+	testCases := []struct {
+		name  string
+		valid bool
+	}{
+		{name: "", valid: false},
+		{name: "a", valid: true},
+		{name: strings.Repeat("a", unix.IFNAMSIZ), valid: true},
+		{name: strings.Repeat("a", unix.IFNAMSIZ+1), valid: false},
+		{name: ".", valid: false},
+		{name: "..", valid: false},
+		{name: "dev/null", valid: false},
+		{name: "valid:name", valid: false},
+		{name: "valid name", valid: false},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			if devValidName(tc.name) != tc.valid {
+				t.Fatalf("name %q, expected valid: %v", tc.name, tc.valid)
+			}
+		})
+	}
+}
diff --git a/libcontainer/specconv/spec_linux_test.go b/libcontainer/specconv/spec_linux_test.go
index 0f8d1c18..77219496 100644
--- a/libcontainer/specconv/spec_linux_test.go
+++ b/libcontainer/specconv/spec_linux_test.go
@@ -956,3 +956,64 @@ func TestCreateDevices(t *testing.T) {
 		t.Errorf("device /dev/ram0 not found in config devices; got %v", conf.Devices)
 	}
 }
+
+func TestCreateNetDevices(t *testing.T) {
+	testCases := []struct {
+		name       string
+		netDevices map[string]specs.LinuxNetDevice
+	}{
+		{
+			name: "no network devices",
+		},
+		{
+			name: "one network devices",
+			netDevices: map[string]specs.LinuxNetDevice{
+				"eth1": {},
+			},
+		},
+		{
+			name: "multiple network devices",
+			netDevices: map[string]specs.LinuxNetDevice{
+				"eth1": {},
+				"eth2": {},
+			},
+		},
+		{
+			name: "multiple network devices and rename",
+			netDevices: map[string]specs.LinuxNetDevice{
+				"eth1": {},
+				"eth2": {
+					Name: "ctr_eth2",
+				},
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			spec := Example()
+			spec.Linux.NetDevices = tc.netDevices
+			opts := &CreateOpts{
+				CgroupName:       "ContainerID",
+				UseSystemdCgroup: false,
+				Spec:             spec,
+			}
+			config, err := CreateLibcontainerConfig(opts)
+			if err != nil {
+				t.Errorf("Couldn't create libcontainer config: %v", err)
+			}
+			if len(config.NetDevices) != len(opts.Spec.Linux.NetDevices) {
+				t.Fatalf("expected %d network devices and got %d", len(config.NetDevices), len(opts.Spec.Linux.NetDevices))
+			}
+			for name, netdev := range config.NetDevices {
+				ctrNetDev, ok := config.NetDevices[name]
+				if !ok {
+					t.Fatalf("network device %s not found in the configuration", name)
+				}
+				if ctrNetDev.Name != netdev.Name {
+					t.Fatalf("expected %s got %s", ctrNetDev.Name, netdev.Name)
+				}
+			}
+		})
+	}
+}
diff --git a/tests/integration/checkpoint.bats b/tests/integration/checkpoint.bats
index 3db34061..eb7557f5 100644
--- a/tests/integration/checkpoint.bats
+++ b/tests/integration/checkpoint.bats
@@ -2,14 +2,34 @@
 
 load helpers
 
+function create_netns() {
+	# Create a temporary name for the test network namespace.
+	tmp=$(mktemp -u)
+	ns_name=$(basename "$tmp")
+
+	# Create the network namespace.
+	ip netns add "$ns_name"
+	ns_path=$(ip netns add "$ns_name" 2>&1 | sed -e 's/.*"\(.*\)".*/\1/')
+}
+
+function delete_netns() {
+	# Delete the namespace only if the ns_name variable is set.
+	[ -v ns_name ] && ip netns del "$ns_name"
+}
+
 function setup() {
 	# XXX: currently criu require root containers.
 	requires criu root
 
 	setup_busybox
+
+	# Create a dummy interface to move to the container.
+	ip link add dummy0 type dummy
 }
 
 function teardown() {
+	ip link del dev dummy0
+	delete_netns
 	teardown_bundle
 }
 
@@ -122,6 +142,52 @@ function simple_cr() {
 	done
 }
 
+function simple_cr_with_netdevice() {
+	# Set custom parameters to the netdevice to validate those are respected
+	mtu_value=1789
+	mac_address="00:11:22:33:44:55"
+	global_ip="169.254.169.77/32"
+
+	ip link set mtu "$mtu_value" dev dummy0
+	ip link set address "$mac_address" dev dummy0
+	ip address add "$global_ip" dev dummy0
+
+	# Tell runc which network namespace to use.
+	create_netns
+	update_config '(.. | select(.type? == "network")) .path |= "'"$ns_path"'"'
+	update_config ' .linux.netDevices |= {"dummy0": {} }'
+	runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox_netdevice
+	[ "$status" -eq 0 ]
+
+	testcontainer test_busybox_netdevice running
+	run runc exec test_busybox_netdevice ip address show dev dummy0
+	[ "$status" -eq 0 ]
+	[[ "$output" == *" $global_ip "* ]]
+	[[ "$output" == *"ether $mac_address "* ]]
+	[[ "$output" == *"mtu $mtu_value "* ]]
+
+	for _ in $(seq 2); do
+		# checkpoint the running container
+		runc "$@" checkpoint --work-path ./work-dir test_busybox_netdevice
+		[ "$status" -eq 0 ]
+
+		# after checkpoint busybox is no longer running
+		testcontainer test_busybox_netdevice checkpointed
+
+		# restore from checkpoint
+		runc "$@" restore -d --work-path ./work-dir --console-socket "$CONSOLE_SOCKET" test_busybox_netdevice
+		[ "$status" -eq 0 ]
+
+		# busybox should be back up and running
+		testcontainer test_busybox_netdevice running
+		run runc exec test_busybox_netdevice ip address show dev dummy0
+		[ "$status" -eq 0 ]
+		[[ "$output" == *" $global_ip "* ]]
+		[[ "$output" == *"ether $mac_address "* ]]
+		[[ "$output" == *"mtu $mtu_value "* ]]
+	done
+}
+
 @test "checkpoint and restore" {
 	simple_cr
 }
@@ -151,6 +217,35 @@ function simple_cr() {
 	simple_cr
 }
 
+@test "checkpoint and restore with netdevice" {
+	simple_cr_with_netdevice
+}
+
+@test "checkpoint and restore with netdevice (bind mount, destination is symlink)" {
+	mkdir -p rootfs/real/conf
+	ln -s /real/conf rootfs/conf
+	update_config '	  .mounts += [{
+					source: ".",
+					destination: "/conf",
+					options: ["bind"]
+				}]'
+	simple_cr_with_netdevice
+}
+
+@test "checkpoint and restore with netdevice (with --debug)" {
+	simple_cr_with_netdevice --debug
+}
+
+@test "checkpoint and restore with netdevice (cgroupns)" {
+	# cgroupv2 already enables cgroupns so this case was tested above already
+	requires cgroups_v1 cgroupns
+
+	# enable CGROUPNS
+	update_config '.linux.namespaces += [{"type": "cgroup"}]'
+
+	simple_cr_with_netdevice
+}
+
 @test "checkpoint --pre-dump (bad --parent-path)" {
 	runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
 	[ "$status" -eq 0 ]
diff --git a/tests/integration/netdev.bats b/tests/integration/netdev.bats
new file mode 100644
index 00000000..6eec8c88
--- /dev/null
+++ b/tests/integration/netdev.bats
@@ -0,0 +1,216 @@
+#!/usr/bin/env bats
+
+load helpers
+
+function create_netns() {
+	# Create a temporary name for the test network namespace.
+	tmp=$(mktemp -u)
+	ns_name=$(basename "$tmp")
+
+	# Create the network namespace.
+	ip netns add "$ns_name"
+	ns_path=$(ip netns add "$ns_name" 2>&1 | sed -e 's/.*"\(.*\)".*/\1/')
+}
+
+function delete_netns() {
+	# Delete the namespace only if the ns_name variable is set.
+	[ -v ns_name ] && ip netns del "$ns_name"
+}
+
+function setup() {
+	requires root
+	setup_busybox
+
+	# Create a dummy interface to move to the container.
+	ip link add dummy0 type dummy
+}
+
+function teardown() {
+	ip link del dev dummy0
+	delete_netns
+	teardown_bundle
+}
+
+@test "move network device to container network namespace" {
+	update_config ' .linux.netDevices |= {"dummy0": {} }
+      		| .process.args |= ["ip", "address", "show", "dev", "dummy0"]'
+
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+}
+
+@test "move network device to container network namespace and restore it back" {
+	# Tell runc which network namespace to use.
+	create_netns
+	update_config '(.. | select(.type? == "network")) .path |= "'"$ns_path"'"'
+
+	update_config ' .linux.netDevices |= {"dummy0": {} }'
+
+	runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
+	[ "$status" -eq 0 ]
+
+	# The network namespace owner controls the lifecycle of the interface.
+	# The interface should remain on the namespace after the container was killed.
+	runc delete --force test_busybox
+
+	# Move back the interface to the root namespace (pid 1).
+	ip netns exec "$ns_name" ip link set dev dummy0 netns 1
+
+	# Verify the interface is back in the root network namespace.
+	ip address show dev dummy0
+}
+
+@test "move network device to precreated container network namespace" {
+	update_config ' .linux.netDevices |= {"dummy0": {} }
+      		| .process.args |= ["ip", "address", "show", "dev", "dummy0"]'
+
+	# Tell runc which network namespace to use.
+	create_netns
+	update_config '(.. | select(.type? == "network")) .path |= "'"$ns_path"'"'
+
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+
+	# Verify the interface is still present in the network namespace.
+	ip netns exec "$ns_name" ip address show dev dummy0
+}
+
+@test "move network device to precreated container network namespace and set ip address with global scope" {
+	update_config ' .linux.netDevices |= {"dummy0": {} }
+      		| .process.args |= ["ip", "address", "show", "dev", "dummy0"]'
+
+	# Tell runc which network namespace to use.
+	create_netns
+	update_config '(.. | select(.type? == "network")) .path |= "'"$ns_path"'"'
+
+	global_ip="169.254.169.77/32"
+
+	# Set the interface down to avoid possible network problems.
+	# Set a custom address to the interface.
+	ip link set down dev dummy0
+	ip address add "$global_ip" dev dummy0
+
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+	[[ "$output" == *"$global_ip "* ]]
+
+	# Verify the interface is still present in the network namespace.
+	run ip netns exec "$ns_name" ip address show dev dummy0
+	[ "$status" -eq 0 ]
+	[[ "$output" == *"$global_ip "* ]]
+}
+
+@test "move network device to precreated container network namespace and set ip address without global scope" {
+	update_config ' .linux.netDevices |= {"dummy0": {} }
+      		| .process.args |= ["ip", "address", "show", "dev", "dummy0"]'
+
+	# Tell runc which network namespace to use.
+	create_netns
+	update_config '(.. | select(.type? == "network")) .path |= "'"$ns_path"'"'
+
+	non_global_ip="127.0.0.33"
+
+	# Set the interface down to avoid possible network problems.
+	# Set a custom address to the interface.
+	ip link set down dev dummy0
+	ip address add "$non_global_ip" dev dummy0
+
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+	[[ "$output" != *" $non_global_ip "* ]]
+
+	# Verify the interface is still present in the network namespace.
+	ip netns exec "$ns_name" ip address show dev dummy0
+}
+
+@test "move network device to precreated container network namespace and set mtu" {
+	update_config ' .linux.netDevices |= {"dummy0": {} }
+      		| .process.args |= ["ip", "address", "show", "dev", "dummy0"]'
+
+	# Tell runc which network namespace to use.
+	create_netns
+	update_config '(.. | select(.type? == "network")) .path |= "'"$ns_path"'"'
+
+	mtu_value=1789
+	# Cet a custom mtu to the interface.
+	ip link set mtu "$mtu_value" dev dummy0
+
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+	[[ "$output" == *"mtu $mtu_value "* ]]
+
+	# Verify the interface is still present in the network namespace.
+	run ip netns exec "$ns_name" ip address show dev dummy0
+	[ "$status" -eq 0 ]
+	[[ "$output" == *"mtu $mtu_value "* ]]
+}
+
+@test "move network device to precreated container network namespace and set mac address" {
+	update_config ' .linux.netDevices |= {"dummy0": {} }
+      		| .process.args |= ["ip", "address", "show", "dev", "dummy0"]'
+
+	# Tell runc which network namespace to use.
+	create_netns
+	update_config '(.. | select(.type? == "network")) .path |= "'"$ns_path"'"'
+
+	mac_address="00:11:22:33:44:55"
+	# set a custom mac address to the interface
+	ip link set address "$mac_address" dev dummy0
+
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+	[[ "$output" == *"ether $mac_address "* ]]
+
+	# Verify the interface is still present in the network namespace.
+	run ip netns exec "$ns_name" ip address show dev dummy0
+	[ "$status" -eq 0 ]
+	[[ "$output" == *"ether $mac_address "* ]]
+}
+
+@test "move network device to precreated container network namespace and rename" {
+	update_config ' .linux.netDevices |= { "dummy0": { "name" : "ctr_dummy0" } }
+      		| .process.args |= ["ip", "address", "show", "dev", "ctr_dummy0"]'
+
+	# Tell runc which network namespace to use.
+	create_netns
+	update_config '(.. | select(.type? == "network")) .path |= "'"$ns_path"'"'
+
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+
+	# Verify the interface is still present in the network namespace.
+	ip netns exec "$ns_name" ip address show dev ctr_dummy0
+}
+
+@test "move network device to precreated container network namespace and rename and set mtu and mac and ip address" {
+	update_config ' .linux.netDevices |= { "dummy0": { "name" : "ctr_dummy0" } }
+	    		| .process.args |= ["ip", "address", "show", "dev", "ctr_dummy0"]'
+
+	# Tell runc which network namespace to use.
+	create_netns
+	update_config '(.. | select(.type? == "network")) .path |= "'"$ns_path"'"'
+
+	mtu_value=1789
+	mac_address="00:11:22:33:44:55"
+	global_ip="169.254.169.77/32"
+
+	# Set a custom mtu to the interface.
+	ip link set mtu "$mtu_value" dev dummy0
+	# Set a custom mac address to the interface.
+	ip link set address "$mac_address" dev dummy0
+	# Set a custom ip address to the interface.
+	ip address add "$global_ip" dev dummy0
+
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+	[[ "$output" == *" $global_ip "* ]]
+	[[ "$output" == *"ether $mac_address "* ]]
+	[[ "$output" == *"mtu $mtu_value "* ]]
+
+	# Verify the interface is still present in the network namespace.
+	run ip netns exec "$ns_name" ip address show dev ctr_dummy0
+	[ "$status" -eq 0 ]
+	[[ "$output" == *" $global_ip "* ]]
+	[[ "$output" == *"ether $mac_address "* ]]
+	[[ "$output" == *"mtu $mtu_value "* ]]
+}
diff --git a/tests/integration/userns.bats b/tests/integration/userns.bats
index 1300dff9..16afa30f 100644
--- a/tests/integration/userns.bats
+++ b/tests/integration/userns.bats
@@ -246,3 +246,39 @@ function teardown() {
 	[ "$status" -eq 0 ]
 	[[ "$output" == "$netns_id" ]]
 }
+
+@test "userns with network interface" {
+	requires root
+
+	# Create a dummy interface to move to the container.
+	ip link add dummy0 type dummy
+
+	update_config ' .linux.netDevices |= {"dummy0": {} }
+		| .process.args |= ["ip", "address", "show", "dev", "dummy0"]'
+
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+
+	# The interface is virtual and should not exist because
+	# is deleted during the namespace cleanup.
+	run ip link del dummy0
+	[ "$status" -ne 0 ]
+}
+
+@test "userns with network interface renamed" {
+	requires root
+
+	# Create a dummy interface to move to the container.
+	ip link add dummy0 type dummy
+
+	update_config ' .linux.netDevices |= { "dummy0": { "name" : "ctr_dummy0" } }
+		| .process.args |= ["ip", "address", "show", "dev", "ctr_dummy0"]'
+
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+
+	# The interface is virtual and should not exist because
+	# is deleted during the namespace cleanup.
+	run ip link del dummy0
+	[ "$status" -ne 0 ]
+}