Repository: opencontainers/runc

Issue:
[ci] ssh: Could not resolve hostname localhost: Name or service not known
[test (actuated-arm64-6cpu-8gb, 1.24.x, rootless)](https://github.com/opencontainers/runc/actions/runs/15405759260/job/43348058943#logs)

integration test (systemd driver)

tests/rootless.sh
[01] run rootless tests ... ()
path: /home/runner/.local/share/bats/bin:/home/runner/go/bin:/home/runner/actions-runner/_work/_tool/go/1.24.3/arm64/bin:/home/runner/.arkade/bin:/home/runner/actions-runner/_work/_temp:/home/runner/.dotnet/tools:/home/runner/.cargo/bin:/home/runner/go/bin:/home/runner/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ssh: Could not resolve hostname localhost: Name or service not known

make: *** [Makefile:186: localrootlessintegration] Error 255
Script done.
Error: Process completed with exit code 2.

Discussion:

Solution 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 ]
+}