diff --git a/README.md b/README.md index 30f38eb0f..6ffe5e294 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Read [CONTRIBUTING](CONTRIBUTING.md) for build and test instructions. * `ptp`: Creates a veth pair. * `vlan`: Allocates a vlan device. * `host-device`: Move an already-existing device into a container. +* `dummy`: Creates a new Dummy device in the container. #### Windows: Windows specific * `win-bridge`: Creates a bridge, adds the host and the container to it. * `win-overlay`: Creates an overlay interface to the container. diff --git a/plugins/linux_only.txt b/plugins/linux_only.txt index 3819a6963..586cf5952 100644 --- a/plugins/linux_only.txt +++ b/plugins/linux_only.txt @@ -6,6 +6,7 @@ plugins/main/loopback plugins/main/macvlan plugins/main/ptp plugins/main/vlan +plugins/main/dummy plugins/meta/portmap plugins/meta/tuning plugins/meta/bandwidth diff --git a/plugins/main/dummy/README.md b/plugins/main/dummy/README.md new file mode 100644 index 000000000..3368a95e9 --- /dev/null +++ b/plugins/main/dummy/README.md @@ -0,0 +1,39 @@ +--- +title: dummy plugin +description: "plugins/main/dummy/README.md" +date: 2022-05-12 +toc: true +draft: true +weight: 200 +--- + +## Overview + +dummy is a useful feature for routing packets through the Linux kernel without transmitting. + +Like loopback, it is a purely virtual interface that allows packets to be routed to a designated IP address. Unlike loopback, the IP address can be arbitrary and is not restricted to the `127.0.0.0/8` range. + +## Example configuration + +```json +{ + "name": "mynet", + "type": "dummy", + "ipam": { + "type": "host-local", + "subnet": "10.1.2.0/24" + } +} +``` + +## Network configuration reference + +* `name` (string, required): the name of the network. +* `type` (string, required): "dummy". +* `ipam` (dictionary, required): IPAM configuration to be used for this network. + +## Notes + +* `dummy` does not transmit packets. +Therefore the container will not be able to reach any external network. +This solution is designed to be used in conjunction with other CNI plugins (e.g., `bridge`) to provide an internal non-loopback address for applications to use. diff --git a/plugins/main/dummy/dummy.go b/plugins/main/dummy/dummy.go new file mode 100644 index 000000000..22edeafb2 --- /dev/null +++ b/plugins/main/dummy/dummy.go @@ -0,0 +1,300 @@ +// Copyright 2022 Arista Networks +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net" + + "github.com/vishvananda/netlink" + + "github.com/containernetworking/cni/pkg/skel" + "github.com/containernetworking/cni/pkg/types" + current "github.com/containernetworking/cni/pkg/types/100" + "github.com/containernetworking/cni/pkg/version" + + "github.com/containernetworking/plugins/pkg/ip" + "github.com/containernetworking/plugins/pkg/ipam" + "github.com/containernetworking/plugins/pkg/ns" + bv "github.com/containernetworking/plugins/pkg/utils/buildversion" +) + +func parseNetConf(bytes []byte) (*types.NetConf, error) { + conf := &types.NetConf{} + if err := json.Unmarshal(bytes, conf); err != nil { + return nil, fmt.Errorf("failed to parse network config: %v", err) + } + return conf, nil +} + +func createDummy(conf *types.NetConf, ifName string, netns ns.NetNS) (*current.Interface, error) { + + dummy := ¤t.Interface{} + + dm := &netlink.Dummy{ + LinkAttrs: netlink.LinkAttrs{ + Name: ifName, + Namespace: netlink.NsFd(int(netns.Fd())), + }, + } + + if err := netlink.LinkAdd(dm); err != nil { + return nil, fmt.Errorf("failed to create dummy: %v", err) + } + dummy.Name = ifName + + err := netns.Do(func(_ ns.NetNS) error { + // Re-fetch interface to get all properties/attributes + contDummy, err := netlink.LinkByName(ifName) + if err != nil { + return fmt.Errorf("failed to fetch dummy%q: %v", ifName, err) + } + + dummy.Mac = contDummy.Attrs().HardwareAddr.String() + dummy.Sandbox = netns.Path() + + return nil + }) + if err != nil { + return nil, err + } + + return dummy, nil +} + +func cmdAdd(args *skel.CmdArgs) error { + conf, err := parseNetConf(args.StdinData) + if err != nil { + return err + } + + if conf.IPAM.Type == "" { + return errors.New("dummy interface requires an IPAM configuration") + } + + netns, err := ns.GetNS(args.Netns) + if err != nil { + return fmt.Errorf("failed to open netns %q: %v", netns, err) + } + defer netns.Close() + + dummyInterface, err := createDummy(conf, args.IfName, netns) + if err != nil { + return err + } + + // Delete link if err to avoid link leak in this ns + defer func() { + if err != nil { + netns.Do(func(_ ns.NetNS) error { + return ip.DelLinkByName(args.IfName) + }) + } + }() + + r, err := ipam.ExecAdd(conf.IPAM.Type, args.StdinData) + if err != nil { + return err + } + + // defer ipam deletion to avoid ip leak + defer func() { + if err != nil { + ipam.ExecDel(conf.IPAM.Type, args.StdinData) + } + }() + + // convert IPAMResult to current Result type + result, err := current.NewResultFromResult(r) + if err != nil { + return err + } + + if len(result.IPs) == 0 { + return errors.New("IPAM plugin returned missing IP config") + } + + for _, ipc := range result.IPs { + // all addresses apply to the container dummy interface + ipc.Interface = current.Int(0) + } + + result.Interfaces = []*current.Interface{dummyInterface} + + err = netns.Do(func(_ ns.NetNS) error { + if err := ipam.ConfigureIface(args.IfName, result); err != nil { + return err + } + return nil + }) + + if err != nil { + return err + } + + return types.PrintResult(result, conf.CNIVersion) +} + +func cmdDel(args *skel.CmdArgs) error { + conf, err := parseNetConf(args.StdinData) + if err != nil { + return err + } + + if err = ipam.ExecDel(conf.IPAM.Type, args.StdinData); err != nil { + return err + } + + if args.Netns == "" { + return nil + } + + err = ns.WithNetNSPath(args.Netns, func(ns.NetNS) error { + err = ip.DelLinkByName(args.IfName) + if err != nil && err == ip.ErrLinkNotFound { + return nil + } + return err + }) + + if err != nil { + // if NetNs is passed down by the Cloud Orchestration Engine, or if it called multiple times + // so don't return an error if the device is already removed. + // https://github.com/kubernetes/kubernetes/issues/43014#issuecomment-287164444 + _, ok := err.(ns.NSPathNotExistErr) + if ok { + return nil + } + return err + } + + return nil +} + +func main() { + skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.All, bv.BuildString("dummy")) +} + +func cmdCheck(args *skel.CmdArgs) error { + conf, err := parseNetConf(args.StdinData) + if err != nil { + return err + } + + if conf.IPAM.Type == "" { + return errors.New("dummy interface requires an IPAM configuration") + } + + netns, err := ns.GetNS(args.Netns) + if err != nil { + return fmt.Errorf("failed to open netns %q: %v", args.Netns, err) + } + defer netns.Close() + + // run the IPAM plugin and get back the config to apply + err = ipam.ExecCheck(conf.IPAM.Type, args.StdinData) + if err != nil { + return err + } + + if conf.RawPrevResult == nil { + return fmt.Errorf("dummy: Required prevResult missing") + } + + if err := version.ParsePrevResult(conf); err != nil { + return err + } + + // Convert whatever the IPAM result was into the current Result type + result, err := current.NewResultFromResult(conf.PrevResult) + if err != nil { + return err + } + + var contMap current.Interface + // Find interfaces for name whe know, that of dummy device inside container + for _, intf := range result.Interfaces { + if args.IfName == intf.Name { + if args.Netns == intf.Sandbox { + contMap = *intf + continue + } + } + } + + // The namespace must be the same as what was configured + if args.Netns != contMap.Sandbox { + return fmt.Errorf("Sandbox in prevResult %s doesn't match configured netns: %s", + contMap.Sandbox, args.Netns) + } + + // + // Check prevResults for ips, routes and dns against values found in the container + if err := netns.Do(func(_ ns.NetNS) error { + + // Check interface against values found in the container + err := validateCniContainerInterface(contMap) + if err != nil { + return err + } + + err = ip.ValidateExpectedInterfaceIPs(args.IfName, result.IPs) + if err != nil { + return err + } + return nil + }); err != nil { + return err + } + + return nil + +} + +func validateCniContainerInterface(intf current.Interface) error { + + var link netlink.Link + var err error + + if intf.Name == "" { + return fmt.Errorf("Container interface name missing in prevResult: %v", intf.Name) + } + link, err = netlink.LinkByName(intf.Name) + if err != nil { + return fmt.Errorf("Container Interface name in prevResult: %s not found", intf.Name) + } + if intf.Sandbox == "" { + return fmt.Errorf("Error: Container interface %s should not be in host namespace", link.Attrs().Name) + } + + _, isDummy := link.(*netlink.Dummy) + if !isDummy { + return fmt.Errorf("Error: Container interface %s not of type dummy", link.Attrs().Name) + } + + if intf.Mac != "" { + if intf.Mac != link.Attrs().HardwareAddr.String() { + return fmt.Errorf("Interface %s Mac %s doesn't match container Mac: %s", intf.Name, intf.Mac, link.Attrs().HardwareAddr) + } + } + + if link.Attrs().Flags&net.FlagUp != net.FlagUp { + return fmt.Errorf("Interface %s is down", intf.Name) + } + + return nil +} diff --git a/plugins/main/dummy/dummy_suite_test.go b/plugins/main/dummy/dummy_suite_test.go new file mode 100644 index 000000000..098b59499 --- /dev/null +++ b/plugins/main/dummy/dummy_suite_test.go @@ -0,0 +1,41 @@ +// Copyright 2022 Arista Networks +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main_test + +import ( + "github.com/onsi/gomega/gexec" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +var pathToLoPlugin string + +func TestLoopback(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "plugins/main/dummy") +} + +var _ = BeforeSuite(func() { + var err error + pathToLoPlugin, err = gexec.Build("github.com/containernetworking/plugins/plugins/main/dummy") + Expect(err).NotTo(HaveOccurred()) +}) + +var _ = AfterSuite(func() { + gexec.CleanupBuildArtifacts() +}) diff --git a/plugins/main/dummy/dummy_test.go b/plugins/main/dummy/dummy_test.go new file mode 100644 index 000000000..8a1825d10 --- /dev/null +++ b/plugins/main/dummy/dummy_test.go @@ -0,0 +1,392 @@ +// Copyright 2022 Arista Networks +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net" + "os" + "strings" + "syscall" + + "github.com/containernetworking/cni/pkg/skel" + "github.com/containernetworking/cni/pkg/types" + types020 "github.com/containernetworking/cni/pkg/types/020" + types040 "github.com/containernetworking/cni/pkg/types/040" + types100 "github.com/containernetworking/cni/pkg/types/100" + "github.com/containernetworking/plugins/pkg/ns" + "github.com/containernetworking/plugins/pkg/testutils" + "github.com/containernetworking/plugins/plugins/ipam/host-local/backend/allocator" + + "github.com/vishvananda/netlink" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +const MASTER_NAME = "eth0" + +type Net struct { + Name string `json:"name"` + CNIVersion string `json:"cniVersion"` + Type string `json:"type,omitempty"` + IPAM *allocator.IPAMConfig `json:"ipam"` + RawPrevResult map[string]interface{} `json:"prevResult,omitempty"` + PrevResult types100.Result `json:"-"` +} + +func buildOneConfig(netName string, cniVersion string, orig *Net, prevResult types.Result) (*Net, error) { + var err error + + inject := map[string]interface{}{ + "name": netName, + "cniVersion": cniVersion, + } + // Add previous plugin result + if prevResult != nil { + inject["prevResult"] = prevResult + } + + // Ensure every config uses the same name and version + config := make(map[string]interface{}) + + confBytes, err := json.Marshal(orig) + if err != nil { + return nil, err + } + + err = json.Unmarshal(confBytes, &config) + if err != nil { + return nil, fmt.Errorf("unmarshal existing network bytes: %s", err) + } + + for key, value := range inject { + config[key] = value + } + + newBytes, err := json.Marshal(config) + if err != nil { + return nil, err + } + + conf := &Net{} + if err := json.Unmarshal(newBytes, &conf); err != nil { + return nil, fmt.Errorf("error parsing configuration: %s", err) + } + + return conf, nil + +} + +type tester interface { + // verifyResult minimally verifies the Result and returns the interface's MAC address + verifyResult(result types.Result, name string) string +} + +type testerBase struct{} + +type testerV10x testerBase +type testerV04x testerBase +type testerV03x testerBase +type testerV01xOr02x testerBase + +func newTesterByVersion(version string) tester { + switch { + case strings.HasPrefix(version, "1.0."): + return &testerV10x{} + case strings.HasPrefix(version, "0.4."): + return &testerV04x{} + case strings.HasPrefix(version, "0.3."): + return &testerV03x{} + default: + return &testerV01xOr02x{} + } +} + +// verifyResult minimally verifies the Result and returns the interface's MAC address +func (t *testerV10x) verifyResult(result types.Result, name string) string { + r, err := types100.GetResult(result) + Expect(err).NotTo(HaveOccurred()) + + Expect(len(r.Interfaces)).To(Equal(1)) + Expect(r.Interfaces[0].Name).To(Equal(name)) + Expect(len(r.IPs)).To(Equal(1)) + + return r.Interfaces[0].Mac +} + +func verify0403(result types.Result, name string) string { + r, err := types040.GetResult(result) + Expect(err).NotTo(HaveOccurred()) + + Expect(len(r.Interfaces)).To(Equal(1)) + Expect(r.Interfaces[0].Name).To(Equal(name)) + Expect(len(r.IPs)).To(Equal(1)) + + return r.Interfaces[0].Mac +} + +// verifyResult minimally verifies the Result and returns the interface's MAC address +func (t *testerV04x) verifyResult(result types.Result, name string) string { + return verify0403(result, name) +} + +// verifyResult minimally verifies the Result and returns the interface's MAC address +func (t *testerV03x) verifyResult(result types.Result, name string) string { + return verify0403(result, name) +} + +// verifyResult minimally verifies the Result and returns the interface's MAC address +func (t *testerV01xOr02x) verifyResult(result types.Result, name string) string { + r, err := types020.GetResult(result) + Expect(err).NotTo(HaveOccurred()) + + Expect(r.IP4.IP.IP).NotTo(BeNil()) + Expect(r.IP6).To(BeNil()) + + // 0.2 and earlier don't return MAC address + return "" +} + +var _ = Describe("dummy Operations", func() { + var originalNS, targetNS ns.NetNS + var dataDir string + + BeforeEach(func() { + // Create a new NetNS so we don't modify the host + var err error + originalNS, err = testutils.NewNS() + Expect(err).NotTo(HaveOccurred()) + targetNS, err = testutils.NewNS() + Expect(err).NotTo(HaveOccurred()) + + dataDir, err = ioutil.TempDir("", "dummy_test") + Expect(err).NotTo(HaveOccurred()) + + err = originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + // Add master + err = netlink.LinkAdd(&netlink.Dummy{ + LinkAttrs: netlink.LinkAttrs{ + Name: MASTER_NAME, + }, + }) + Expect(err).NotTo(HaveOccurred()) + m, err := netlink.LinkByName(MASTER_NAME) + Expect(err).NotTo(HaveOccurred()) + err = netlink.LinkSetUp(m) + Expect(err).NotTo(HaveOccurred()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + Expect(os.RemoveAll(dataDir)).To(Succeed()) + Expect(originalNS.Close()).To(Succeed()) + Expect(testutils.UnmountNS(originalNS)).To(Succeed()) + Expect(targetNS.Close()).To(Succeed()) + Expect(testutils.UnmountNS(targetNS)).To(Succeed()) + }) + + for _, ver := range testutils.AllSpecVersions { + // Redefine ver inside for scope so real value is picked up by each dynamically defined It() + // See Gingkgo's "Patterns for dynamically generating tests" documentation. + ver := ver + + It(fmt.Sprintf("[%s] creates an dummy link in a non-default namespace", ver), func() { + conf := &types.NetConf{ + CNIVersion: ver, + Name: "testConfig", + Type: "dummy", + } + + // Create dummy in other namespace + err := originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + _, err := createDummy(conf, "foobar0", targetNS) + Expect(err).NotTo(HaveOccurred()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + // Make sure dummy link exists in the target namespace + err = targetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + link, err := netlink.LinkByName("foobar0") + Expect(err).NotTo(HaveOccurred()) + Expect(link.Attrs().Name).To(Equal("foobar0")) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It(fmt.Sprintf("[%s] configures and deconfigures a dummy link with ADD/CHECK/DEL", ver), func() { + const IFNAME = "dummy0" + + conf := fmt.Sprintf(`{ + "cniVersion": "%s", + "name": "dummyTestv4", + "type": "dummy", + "ipam": { + "type": "host-local", + "subnet": "10.1.2.0/24", + "dataDir": "%s" + } + }`, ver, dataDir) + + args := &skel.CmdArgs{ + ContainerID: "contDummy", + Netns: targetNS.Path(), + IfName: IFNAME, + StdinData: []byte(conf), + } + + t := newTesterByVersion(ver) + + var result types.Result + var macAddress string + err := originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + var err error + result, _, err = testutils.CmdAddWithArgs(args, func() error { + return cmdAdd(args) + }) + Expect(err).NotTo(HaveOccurred()) + + macAddress = t.verifyResult(result, IFNAME) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + // Make sure dummy link exists in the target namespace + err = targetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + link, err := netlink.LinkByName(IFNAME) + Expect(err).NotTo(HaveOccurred()) + Expect(link.Attrs().Name).To(Equal(IFNAME)) + + if macAddress != "" { + hwaddr, err := net.ParseMAC(macAddress) + Expect(err).NotTo(HaveOccurred()) + Expect(link.Attrs().HardwareAddr).To(Equal(hwaddr)) + } + + addrs, err := netlink.AddrList(link, syscall.AF_INET) + Expect(err).NotTo(HaveOccurred()) + Expect(len(addrs)).To(Equal(1)) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + // call CmdCheck + n := &Net{} + err = json.Unmarshal([]byte(conf), &n) + Expect(err).NotTo(HaveOccurred()) + + n.IPAM, _, err = allocator.LoadIPAMConfig([]byte(conf), "") + Expect(err).NotTo(HaveOccurred()) + + newConf, err := buildOneConfig("dummyTestv4", ver, n, result) + Expect(err).NotTo(HaveOccurred()) + + confString, err := json.Marshal(newConf) + Expect(err).NotTo(HaveOccurred()) + + args.StdinData = confString + // CNI Check dummy in the target namespace + err = originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + return testutils.CmdCheckWithArgs(args, func() error { return cmdCheck(args) }) + }) + if testutils.SpecVersionHasCHECK(ver) { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(MatchError("config version does not allow CHECK")) + } + + args.StdinData = []byte(conf) + + err = originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + err = testutils.CmdDelWithArgs(args, func() error { + return cmdDel(args) + }) + Expect(err).NotTo(HaveOccurred()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + // Make sure dummy link has been deleted + err = targetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + link, err := netlink.LinkByName(IFNAME) + Expect(err).To(HaveOccurred()) + Expect(link).To(BeNil()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + // DEL can be called multiple times, make sure no error is returned + // if the device is already removed. + err = originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + err = testutils.CmdDelWithArgs(args, func() error { + return cmdDel(args) + }) + Expect(err).NotTo(HaveOccurred()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It(fmt.Sprintf("[%s] fails to create dummy link with no ipam", ver), func() { + var err error + + const confFmt = `{ + "cniVersion": "%s", + "name": "mynet", + "type": "dummy" + }` + + args := &skel.CmdArgs{ + ContainerID: "dummyCont", + Netns: "/var/run/netns/test", + IfName: "dummy0", + StdinData: []byte(fmt.Sprintf(confFmt, ver)), + } + + _ = originalNS.Do(func(netNS ns.NetNS) error { + defer GinkgoRecover() + + _, _, err = testutils.CmdAddWithArgs(args, func() error { + return cmdAdd(args) + }) + Expect(err).To(Equal(errors.New("dummy interface requires an IPAM configuration"))) + return nil + }) + }) + } +})