@@ -30,6 +30,7 @@ import (
3030 "strings"
3131 "time"
3232
33+ cnilibrary "github.com/containernetworking/cni/libcni"
3334 types100 "github.com/containernetworking/cni/pkg/types/100"
3435 "github.com/opencontainers/runtime-spec/specs-go"
3536 b4nndclient "github.com/rootless-containers/bypass4netns/pkg/api/daemon/client"
@@ -185,13 +186,15 @@ func newHandlerOpts(state *specs.State, dataStore, cniPath, cniNetconfPath, brid
185186 cniOpts := []cni.Opt {
186187 cni .WithPluginDir ([]string {cniPath }),
187188 }
189+ o .cniPluginDir = cniPath
188190 var netw * netutil.NetworkConfig
189191 for _ , netstr := range networks {
190192 if netw , err = e .NetworkByNameOrID (netstr ); err != nil {
191193 return nil , err
192194 }
193195 cniOpts = append (cniOpts , cni .WithConfListBytes (netw .Bytes ))
194196 o .cniNames = append (o .cniNames , netstr )
197+ o .cniNetConfigs = append (o .cniNetConfigs , netw .Bytes )
195198 }
196199 o .cni , err = cni .New (cniOpts ... )
197200 if err != nil {
@@ -228,6 +231,15 @@ func newHandlerOpts(state *specs.State, dataStore, cniPath, cniNetconfPath, brid
228231 o .containerIP6 = ip6Address
229232 }
230233
234+ // Parse per-network IP map if present (for multi-network containers with per-network static IPs)
235+ if ipPerNetJSON , ok := o .state .Annotations [labels .IPAddressPerNetwork ]; ok && ipPerNetJSON != "" {
236+ var ipPerNetwork map [string ]string
237+ if err := json .Unmarshal ([]byte (ipPerNetJSON ), & ipPerNetwork ); err != nil {
238+ return nil , fmt .Errorf ("failed to unmarshal per-network IP map: %w" , err )
239+ }
240+ o .ipPerNetwork = ipPerNetwork
241+ }
242+
231243 if rootlessutil .IsRootlessChild () {
232244 o .rootlessKitClient , err = rootlessutil .NewRootlessKitClient ()
233245 if err != nil {
@@ -258,13 +270,16 @@ type handlerOpts struct {
258270 ports []cni.PortMapping
259271 cni cni.CNI
260272 cniNames []string
273+ cniPluginDir string
274+ cniNetConfigs [][]byte
261275 fullID string
262276 rootlessKitClient rlkclient.Client
263277 bypassClient b4nndclient.Client
264278 extraHosts map [string ]string // host:ip
265279 containerIP string
266280 containerMAC string
267281 containerIP6 string
282+ ipPerNetwork map [string ]string
268283}
269284
270285// hookSpec is from https://github.com/containerd/containerd/blob/v1.4.3/cmd/containerd/command/oci-hook.go#L59-L64
@@ -460,6 +475,64 @@ func portReserverPidFilePath(opts *handlerOpts) string {
460475 return filepath .Join ("/run/nerdctl/" , opts .state .Annotations [labels .Namespace ], opts .state .ID , "port-reserver.pid" )
461476}
462477
478+ // perNetworkIfName returns the container-side interface name for a given network index
479+ // (e.g., "eth0", "eth1", "eth2").
480+ func perNetworkIfName (index int ) string {
481+ return fmt .Sprintf ("eth%d" , index )
482+ }
483+
484+ // perNetworkAdd calls cnilibrary.AddNetworkList directly for a single network
485+ // with the correct interface name (ethN) and per-network args.
486+ func perNetworkAdd (ctx context.Context , opts * handlerOpts , networkIndex int , nsPath string , extraArgs [][2 ]string , portMappings []cni.PortMapping ) (* types100.Result , error ) {
487+ if networkIndex < 0 || networkIndex >= len (opts .cniNetConfigs ) {
488+ return nil , fmt .Errorf ("network index %d out of range (have %d networks)" , networkIndex , len (opts .cniNetConfigs ))
489+ }
490+ confList , err := cnilibrary .ConfListFromBytes (opts .cniNetConfigs [networkIndex ])
491+ if err != nil {
492+ return nil , fmt .Errorf ("failed to parse conflist for network %d: %w" , networkIndex , err )
493+ }
494+ cniConfig := cnilibrary .NewCNIConfig ([]string {opts .cniPluginDir }, nil )
495+ rt := & cnilibrary.RuntimeConf {
496+ ContainerID : opts .fullID ,
497+ NetNS : nsPath ,
498+ IfName : perNetworkIfName (networkIndex ),
499+ Args : extraArgs ,
500+ CapabilityArgs : make (map [string ]interface {}),
501+ }
502+ if len (portMappings ) > 0 {
503+ rt .CapabilityArgs ["portMappings" ] = portMappings
504+ }
505+ result , err := cniConfig .AddNetworkList (ctx , confList , rt )
506+ if err != nil {
507+ return nil , err
508+ }
509+ return types100 .NewResultFromResult (result )
510+ }
511+
512+ // perNetworkDel calls cnilibrary.DelNetworkList directly for a single network
513+ // with the correct interface name (ethN).
514+ func perNetworkDel (ctx context.Context , opts * handlerOpts , networkIndex int , nsPath string , extraArgs [][2 ]string , portMappings []cni.PortMapping ) error {
515+ if networkIndex < 0 || networkIndex >= len (opts .cniNetConfigs ) {
516+ return fmt .Errorf ("network index %d out of range (have %d networks)" , networkIndex , len (opts .cniNetConfigs ))
517+ }
518+ confList , err := cnilibrary .ConfListFromBytes (opts .cniNetConfigs [networkIndex ])
519+ if err != nil {
520+ return fmt .Errorf ("failed to parse conflist for network %d: %w" , networkIndex , err )
521+ }
522+ cniConfig := cnilibrary .NewCNIConfig ([]string {opts .cniPluginDir }, nil )
523+ rt := & cnilibrary.RuntimeConf {
524+ ContainerID : opts .fullID ,
525+ NetNS : nsPath ,
526+ IfName : perNetworkIfName (networkIndex ),
527+ Args : extraArgs ,
528+ CapabilityArgs : make (map [string ]interface {}),
529+ }
530+ if len (portMappings ) > 0 {
531+ rt .CapabilityArgs ["portMappings" ] = portMappings
532+ }
533+ return cniConfig .DelNetworkList (ctx , confList , rt )
534+ }
535+
463536func applyNetworkSettings (opts * handlerOpts ) (err error ) {
464537 portMapOpts , err := getPortMapOpts (opts )
465538 if err != nil {
@@ -530,17 +603,18 @@ func applyNetworkSettings(opts *handlerOpts) (err error) {
530603 if err != nil {
531604 return err
532605 }
533- var namespaceOpts []cni. NamespaceOpts
534- namespaceOpts = append ( namespaceOpts , portMapOpts ... )
535- namespaceOpts = append (namespaceOpts , ipAddressOpts ... )
536- namespaceOpts = append (namespaceOpts , macAddressOpts ... )
537- namespaceOpts = append (namespaceOpts , ip6AddressOpts ... )
538- namespaceOpts = append (namespaceOpts ,
606+
607+ commonOpts := []cni. NamespaceOpts {}
608+ commonOpts = append (commonOpts , portMapOpts ... )
609+ commonOpts = append (commonOpts , macAddressOpts ... )
610+ commonOpts = append (commonOpts , ip6AddressOpts ... )
611+ commonOpts = append (commonOpts ,
539612 cni .WithLabels (map [string ]string {
540613 "IgnoreUnknown" : "1" ,
541614 }),
542615 cni .WithArgs ("NERDCTL_CNI_DHCP_HOSTNAME" , opts .state .Annotations [labels .Hostname ]),
543616 )
617+
544618 hsMeta := hostsstore.Meta {
545619 ID : opts .state .ID ,
546620 Networks : make (map [string ]* types100.Result , len (opts .cniNames )),
@@ -550,33 +624,88 @@ func applyNetworkSettings(opts *handlerOpts) (err error) {
550624 Name : opts .state .Annotations [labels .Name ],
551625 }
552626
553- // When containerd gets bounced, containers that were previously running and that are restarted will go again
554- // through onCreateRuntime (*unlike* in a normal stop/start flow).
555- // As such, a container may very well have an ip already. The bridge plugin would thus refuse to loan a new one
556- // and error out, thus making the onCreateRuntime hook fail. In turn, runc (or containerd) will mis-interpret this,
557- // and subsequently call onPostStop (although the container will not get deleted), and we will release the name...
558- // leading to a bricked system where multiple containers may share the same name.
559- // Thus, we do pre-emptively clean things up - error is not checked, as in the majority of cases, that would
560- // legitimately error (and that does not matter)
561- // See https://github.com/containerd/nerdctl/issues/3355
562- _ = opts .cni .Remove (ctx , opts .fullID , "" , namespaceOpts ... )
627+ // When per-network IPs are specified (multi-network with different static IPs),
628+ // we must set up each network individually so each CNI plugin receives only its own IP.
629+ // We use cnilibrary directly (instead of go-cni's Setup) so that each network
630+ // gets the correct interface name (eth0, eth1, eth2, ...) rather than all getting eth0.
631+ if len (opts .ipPerNetwork ) > 0 {
632+ // Pre-emptively clean up (see comment below for rationale)
633+ for i := range opts .cniNames {
634+ _ = perNetworkDel (ctx , opts , i , "" , nil , nil )
635+ }
563636
564- // Defer CNI configuration removal to ensure idempotency of oci-hook.
565- defer func () {
566- if err != nil {
567- log .L .Warn ("Container failed starting. Removing allocated network configuration." )
568- _ = opts .cni .Remove (ctx , opts .fullID , nsPath , namespaceOpts ... )
637+ defer func () {
638+ if err != nil {
639+ log .L .Warn ("Container failed starting. Removing allocated network configuration." )
640+ for i , cniName := range opts .cniNames {
641+ if delErr := perNetworkDel (ctx , opts , i , nsPath , nil , nil ); delErr != nil {
642+ log .L .WithError (delErr ).Warnf ("failed to remove network %s during cleanup" , cniName )
643+ }
644+ }
645+ }
646+ }()
647+
648+ // Convert port mappings for cnilibrary RuntimeConf capability args
649+ var capPortMappings []cni.PortMapping
650+ if len (opts .ports ) > 0 {
651+ capPortMappings = opts .ports
569652 }
570- }()
571653
572- cniRes , err := opts .cni .Setup (ctx , opts .fullID , nsPath , namespaceOpts ... )
573- if err != nil {
574- return fmt .Errorf ("failed to call cni.Setup: %w" , err )
575- }
654+ for i , cniName := range opts .cniNames {
655+ // Build per-network CNI_ARGS
656+ extraArgs := [][2 ]string {
657+ {"IgnoreUnknown" , "1" },
658+ {"NERDCTL_CNI_DHCP_HOSTNAME" , opts .state .Annotations [labels .Hostname ]},
659+ }
660+ if ip , ok := opts .ipPerNetwork [cniName ]; ok && ip != "" {
661+ extraArgs = append (extraArgs , [2 ]string {"IP" , ip })
662+ }
663+ if opts .containerMAC != "" {
664+ extraArgs = append (extraArgs , [2 ]string {"MAC" , opts .containerMAC })
665+ }
666+
667+ cniRes , setupErr := perNetworkAdd (ctx , opts , i , nsPath , extraArgs , capPortMappings )
668+ if setupErr != nil {
669+ return fmt .Errorf ("failed to call cni.Setup for network %s: %w" , cniName , setupErr )
670+ }
671+ if cniRes != nil {
672+ hsMeta .Networks [cniName ] = cniRes
673+ }
674+ }
675+ } else {
676+ // Legacy path: single IP (or no IP) shared across all networks
677+ var namespaceOpts []cni.NamespaceOpts
678+ namespaceOpts = append (namespaceOpts , commonOpts ... )
679+ namespaceOpts = append (namespaceOpts , ipAddressOpts ... )
680+
681+ // When containerd gets bounced, containers that were previously running and that are restarted will go again
682+ // through onCreateRuntime (*unlike* in a normal stop/start flow).
683+ // As such, a container may very well have an ip already. The bridge plugin would thus refuse to loan a new one
684+ // and error out, thus making the onCreateRuntime hook fail. In turn, runc (or containerd) will mis-interpret this,
685+ // and subsequently call onPostStop (although the container will not get deleted), and we will release the name...
686+ // leading to a bricked system where multiple containers may share the same name.
687+ // Thus, we do pre-emptively clean things up - error is not checked, as in the majority of cases, that would
688+ // legitimately error (and that does not matter)
689+ // See https://github.com/containerd/nerdctl/issues/3355
690+ _ = opts .cni .Remove (ctx , opts .fullID , "" , namespaceOpts ... )
691+
692+ // Defer CNI configuration removal to ensure idempotency of oci-hook.
693+ defer func () {
694+ if err != nil {
695+ log .L .Warn ("Container failed starting. Removing allocated network configuration." )
696+ _ = opts .cni .Remove (ctx , opts .fullID , nsPath , namespaceOpts ... )
697+ }
698+ }()
576699
577- cniResRaw := cniRes .Raw ()
578- for i , cniName := range opts .cniNames {
579- hsMeta .Networks [cniName ] = cniResRaw [i ]
700+ cniRes , err := opts .cni .Setup (ctx , opts .fullID , nsPath , namespaceOpts ... )
701+ if err != nil {
702+ return fmt .Errorf ("failed to call cni.Setup: %w" , err )
703+ }
704+
705+ cniResRaw := cniRes .Raw ()
706+ for i , cniName := range opts .cniNames {
707+ hsMeta .Networks [cniName ] = cniResRaw [i ]
708+ }
580709 }
581710
582711 b4nnEnabled , b4nnBindEnabled , err := bypass4netnsutil .IsBypass4netnsEnabled (opts .state .Annotations )
@@ -708,14 +837,35 @@ func onPostStop(opts *handlerOpts) error {
708837 if err != nil {
709838 return err
710839 }
711- var namespaceOpts []cni.NamespaceOpts
712- namespaceOpts = append (namespaceOpts , portMapOpts ... )
713- namespaceOpts = append (namespaceOpts , ipAddressOpts ... )
714- namespaceOpts = append (namespaceOpts , macAddressOpts ... )
715- namespaceOpts = append (namespaceOpts , ip6AddressOpts ... )
716- if err := opts .cni .Remove (ctx , opts .fullID , "" , namespaceOpts ... ); err != nil {
717- log .L .WithError (err ).Errorf ("failed to call cni.Remove" )
718- return err
840+
841+ if len (opts .ipPerNetwork ) > 0 {
842+ // Per-network cleanup: remove each network individually with its own IP
843+ // and the correct interface name (ethN).
844+ var capPortMappings []cni.PortMapping
845+ if len (opts .ports ) > 0 {
846+ capPortMappings = opts .ports
847+ }
848+ for i , cniName := range opts .cniNames {
849+ extraArgs := [][2 ]string {
850+ {"IgnoreUnknown" , "1" },
851+ }
852+ if ip , ok := opts .ipPerNetwork [cniName ]; ok && ip != "" {
853+ extraArgs = append (extraArgs , [2 ]string {"IP" , ip })
854+ }
855+ if delErr := perNetworkDel (ctx , opts , i , "" , extraArgs , capPortMappings ); delErr != nil {
856+ log .L .WithError (delErr ).Errorf ("failed to call cni.Remove for network %s" , cniName )
857+ }
858+ }
859+ } else {
860+ var namespaceOpts []cni.NamespaceOpts
861+ namespaceOpts = append (namespaceOpts , portMapOpts ... )
862+ namespaceOpts = append (namespaceOpts , ipAddressOpts ... )
863+ namespaceOpts = append (namespaceOpts , macAddressOpts ... )
864+ namespaceOpts = append (namespaceOpts , ip6AddressOpts ... )
865+ if err := opts .cni .Remove (ctx , opts .fullID , "" , namespaceOpts ... ); err != nil {
866+ log .L .WithError (err ).Errorf ("failed to call cni.Remove" )
867+ return err
868+ }
719869 }
720870
721871 // opts.cni.Remove has trouble removing network configurations when netns is empty.
0 commit comments