diff --git a/pkg/utils/iptables.go b/pkg/utils/iptables.go index b38a2cd0a..d0172626c 100644 --- a/pkg/utils/iptables.go +++ b/pkg/utils/iptables.go @@ -119,3 +119,21 @@ func ClearChain(ipt *iptables.IPTables, table, chain string) error { return err } } + +// InsertUnique will add a rule to a chain if it does not already exist. +// By default the rule is appended, unless prepend is true. +func InsertUnique(ipt *iptables.IPTables, table, chain string, prepend bool, rule []string) error { + exists, err := ipt.Exists(table, chain, rule...) + if err != nil { + return err + } + if exists { + return nil + } + + if prepend { + return ipt.Insert(table, chain, 1, rule...) + } else { + return ipt.Append(table, chain, rule...) + } +} diff --git a/plugins/meta/firewall/firewall.go b/plugins/meta/firewall/firewall.go index b0569866b..35743f45e 100644 --- a/plugins/meta/firewall/firewall.go +++ b/plugins/meta/firewall/firewall.go @@ -46,8 +46,27 @@ type FirewallNetConf struct { // the firewalld backend is used but the zone is not given, it defaults // to 'trusted' FirewalldZone string `json:"firewalldZone,omitempty"` + + // IngressPolicy is an optional ingress policy. + // Defaults to "open". + IngressPolicy IngressPolicy `json:"ingressPolicy,omitempty"` } +// IngressPolicy is an ingress policy string. +type IngressPolicy = string + +const ( + // IngressPolicyOpen ("open"): all inbound connections to the container are accepted. + // IngressPolicyOpen is the default ingress policy. + IngressPolicyOpen IngressPolicy = "open" + + // IngressPolicySameBridge ("same-bridge"): connections from the same bridge are accepted, others are blocked. + // This is similar to how Docker libnetwork works. + // IngressPolicySameBridge executes `iptables` regardless to the value of `Backend`. + // IngressPolicySameBridge may not work as expected for non-bridge networks. + IngressPolicySameBridge IngressPolicy = "same-bridge" +) + type FirewallBackend interface { Add(*FirewallNetConf, *current.Result) error Del(*FirewallNetConf, *current.Result) error @@ -129,6 +148,10 @@ func cmdAdd(args *skel.CmdArgs) error { return err } + if err := setupIngressPolicy(conf, result); err != nil { + return err + } + if result == nil { result = ¤t.Result{ CNIVersion: current.ImplementedSpecVersion, @@ -153,6 +176,10 @@ func cmdDel(args *skel.CmdArgs) error { return err } + if err := teardownIngressPolicy(conf, result); err != nil { + return err + } + return nil } diff --git a/plugins/meta/firewall/firewall_integ_test.go b/plugins/meta/firewall/firewall_integ_test.go new file mode 100644 index 000000000..7a7c139d3 --- /dev/null +++ b/plugins/meta/firewall/firewall_integ_test.go @@ -0,0 +1,203 @@ +// Copyright 2022 CNI authors +// +// 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 ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/containernetworking/cni/libcni" + types100 "github.com/containernetworking/cni/pkg/types/100" + "github.com/containernetworking/plugins/pkg/ns" + "github.com/containernetworking/plugins/pkg/testutils" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +// The integration tests expect the "firewall" binary to be present in $PATH. +// To run test, e.g, : go test -exec "sudo -E PATH=$(pwd):/opt/cni/bin:$PATH" -v -ginkgo.v +var _ = Describe("firewall integration tests (ingressPolicy: same-bridge)", func() { + // ns0: foo (10.88.3.0/24) + // ns1: foo (10.88.3.0/24) + // ns2: bar (10.88.4.0/24) + // + // ns0@foo can talk to ns1@foo, but cannot talk to ns2@bar + const nsCount = 3 + var ( + configListFoo *libcni.NetworkConfigList // "foo", 10.88.3.0/24 + configListBar *libcni.NetworkConfigList // "bar", 10.88.4.0/24 + cniConf *libcni.CNIConfig + namespaces [nsCount]ns.NetNS + ) + + BeforeEach(func() { + var err error + rawConfigFoo := ` +{ + "cniVersion": "1.0.0", + "name": "foo", + "plugins": [ + { + "type": "bridge", + "bridge": "foo", + "isGateway": true, + "ipMasq": true, + "hairpinMode": true, + "ipam": { + "type": "host-local", + "routes": [ + { + "dst": "0.0.0.0/0" + } + ], + "ranges": [ + [ + { + "subnet": "10.88.3.0/24", + "gateway": "10.88.3.1" + } + ] + ] + } + }, + { + "type": "firewall", + "backend": "iptables", + "ingressPolicy": "same-bridge" + } + ] +} +` + configListFoo, err = libcni.ConfListFromBytes([]byte(rawConfigFoo)) + Expect(err).NotTo(HaveOccurred()) + + rawConfigBar := strings.ReplaceAll(rawConfigFoo, "foo", "bar") + rawConfigBar = strings.ReplaceAll(rawConfigBar, "10.88.3.", "10.88.4.") + + configListBar, err = libcni.ConfListFromBytes([]byte(rawConfigBar)) + Expect(err).NotTo(HaveOccurred()) + + // turn PATH in to CNI_PATH. + _, err = exec.LookPath("firewall") + Expect(err).NotTo(HaveOccurred()) + dirs := filepath.SplitList(os.Getenv("PATH")) + cniConf = &libcni.CNIConfig{Path: dirs} + + for i := 0; i < nsCount; i++ { + targetNS, err := testutils.NewNS() + Expect(err).NotTo(HaveOccurred()) + fmt.Fprintf(GinkgoWriter, "namespace %d:%s\n", i, targetNS.Path()) + namespaces[i] = targetNS + } + }) + + AfterEach(func() { + for _, targetNS := range namespaces { + if targetNS != nil { + targetNS.Close() + } + } + }) + + Describe("Testing with network foo and bar", func() { + It("should isolate foo from bar", func() { + var results [nsCount]*types100.Result + for i := 0; i < nsCount; i++ { + runtimeConfig := libcni.RuntimeConf{ + ContainerID: fmt.Sprintf("test-cni-firewall-%d", i), + NetNS: namespaces[i].Path(), + IfName: "eth0", + } + + configList := configListFoo + switch i { + case 0, 1: + // leave foo + default: + configList = configListBar + } + + // Clean up garbages produced during past failed executions + _ = cniConf.DelNetworkList(context.TODO(), configList, &runtimeConfig) + + // Make delete idempotent, so we can clean up on failure + netDeleted := false + deleteNetwork := func() error { + if netDeleted { + return nil + } + netDeleted = true + return cniConf.DelNetworkList(context.TODO(), configList, &runtimeConfig) + } + // Create the network + res, err := cniConf.AddNetworkList(context.TODO(), configList, &runtimeConfig) + Expect(err).NotTo(HaveOccurred()) + // nolint: errcheck + defer deleteNetwork() + + results[i], err = types100.NewResultFromResult(res) + Expect(err).NotTo(HaveOccurred()) + fmt.Fprintf(GinkgoWriter, "results[%d]: %+v\n", i, results[i]) + } + ping := func(src, dst int) error { + return namespaces[src].Do(func(ns.NetNS) error { + defer GinkgoRecover() + saddr := results[src].IPs[0].Address.IP.String() + daddr := results[dst].IPs[0].Address.IP.String() + srcNetName := results[src].Interfaces[0].Name + dstNetName := results[dst].Interfaces[0].Name + + fmt.Fprintf(GinkgoWriter, "ping %s (ns%d@%s) -> %s (ns%d@%s)...", + saddr, src, srcNetName, daddr, dst, dstNetName) + timeoutSec := 1 + if err := testutils.Ping(saddr, daddr, timeoutSec); err != nil { + fmt.Fprintln(GinkgoWriter, "unpingable") + return err + } + fmt.Fprintln(GinkgoWriter, "pingable") + return nil + }) + } + + // ns0@foo can ping to ns1@foo + err := ping(0, 1) + Expect(err).NotTo(HaveOccurred()) + + // ns1@foo can ping to ns0@foo + err = ping(1, 0) + Expect(err).NotTo(HaveOccurred()) + + // ns0@foo cannot ping to ns2@bar + err = ping(0, 2) + Expect(err).To(HaveOccurred()) + + // ns1@foo cannot ping to ns2@bar + err = ping(1, 2) + Expect(err).To(HaveOccurred()) + + // ns2@bar cannot ping to ns0@foo + err = ping(2, 0) + Expect(err).To(HaveOccurred()) + + // ns2@bar cannot ping to ns1@foo + err = ping(2, 1) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/plugins/meta/firewall/ingresspolicy.go b/plugins/meta/firewall/ingresspolicy.go new file mode 100644 index 000000000..ded877320 --- /dev/null +++ b/plugins/meta/firewall/ingresspolicy.go @@ -0,0 +1,175 @@ +// Copyright 2022 CNI authors +// +// 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. + +// This is a sample chained plugin that supports multiple CNI versions. It +// parses prevResult according to the cniVersion +package main + +import ( + "fmt" + + types100 "github.com/containernetworking/cni/pkg/types/100" + "github.com/containernetworking/plugins/pkg/utils" + "github.com/coreos/go-iptables/iptables" +) + +func setupIngressPolicy(conf *FirewallNetConf, prevResult *types100.Result) error { + switch conf.IngressPolicy { + case "", IngressPolicyOpen: + // NOP + return nil + case IngressPolicySameBridge: + return setupIngressPolicySameBridge(conf, prevResult) + default: + return fmt.Errorf("unknown ingress policy: %q", conf.IngressPolicy) + } +} + +func setupIngressPolicySameBridge(conf *FirewallNetConf, prevResult *types100.Result) error { + if len(prevResult.Interfaces) == 0 { + return fmt.Errorf("interface needs to be set for ingress policy %q, make sure to chain \"firewall\" plugin with \"bridge\"", + conf.IngressPolicy) + } + intf := prevResult.Interfaces[0] + if intf == nil { + return fmt.Errorf("got nil interface") + } + bridgeName := intf.Name + if bridgeName == "" { + return fmt.Errorf("got empty bridge name") + } + for _, iptProto := range findProtos(conf) { + ipt, err := iptables.NewWithProtocol(iptProto) + if err != nil { + return err + } + if err := setupIsolationChains(ipt, bridgeName); err != nil { + return err + } + } + return nil +} + +func teardownIngressPolicy(conf *FirewallNetConf, prevResult *types100.Result) error { + switch conf.IngressPolicy { + case "", IngressPolicyOpen: + // NOP + return nil + case IngressPolicySameBridge: + // NOP + // + // We can't be sure whether conf.bridgeName is still in use by other containers. + // So we do not remove the iptable rules that are created per bridge. + return nil + default: + return fmt.Errorf("unknown ingress policy: %q", conf.IngressPolicy) + } +} + +const ( + filterTableName = "filter" // built-in + forwardChainName = "FORWARD" // built-in +) + +// setupIsolationChains executes the following iptables commands for isolating networks: +// ``` +// iptables -N CNI-ISOLATION-STAGE-1 +// iptables -N CNI-ISOLATION-STAGE-2 +// # NOTE: "-j CNI-ISOLATION-STAGE-1" needs to be before "CNI-FORWARD" chain. So we use -I here. +// iptables -I FORWARD -j CNI-ISOLATION-STAGE-1 +// iptables -A CNI-ISOLATION-STAGE-1 -i ${bridgeName} ! -o ${bridgeName} -j CNI-ISOLATION-STAGE-2 +// iptables -A CNI-ISOLATION-STAGE-1 -j RETURN +// iptables -A CNI-ISOLATION-STAGE-2 -o ${bridgeName} -j DROP +// iptables -A CNI-ISOLATION-STAGE-2 -j RETURN +// ``` +func setupIsolationChains(ipt *iptables.IPTables, bridgeName string) error { + const ( + // Future version may support custom chain names + stage1Chain = "CNI-ISOLATION-STAGE-1" + stage2Chain = "CNI-ISOLATION-STAGE-2" + ) + // Commands: + // ``` + // iptables -N CNI-ISOLATION-STAGE-1 + // iptables -N CNI-ISOLATION-STAGE-2 + // ``` + for _, chain := range []string{stage1Chain, stage2Chain} { + if err := utils.EnsureChain(ipt, filterTableName, chain); err != nil { + return err + } + } + + // Commands: + // ``` + // iptables -I FORWARD -j CNI-ISOLATION-STAGE-1 + // ``` + jumpToStage1 := withDefaultComment([]string{"-j", stage1Chain}) + // NOTE: "-j CNI-ISOLATION-STAGE-1" needs to be before "CNI-FORWARD" created by CNI firewall plugin. + // So we specify prepend = true . + const jumpToStage1Prepend = true + if err := utils.InsertUnique(ipt, filterTableName, forwardChainName, jumpToStage1Prepend, jumpToStage1); err != nil { + return err + } + + // Commands: + // ``` + // iptables -A CNI-ISOLATION-STAGE-1 -i ${bridgeName} ! -o ${bridgeName} -j CNI-ISOLATION-STAGE-2 + // iptables -A CNI-ISOLATION-STAGE-1 -j RETURN + // ``` + stage1Bridge := withDefaultComment(isolationStage1BridgeRule(bridgeName, stage2Chain)) + // prepend = true because this needs to be before "-j RETURN" + const stage1BridgePrepend = true + if err := utils.InsertUnique(ipt, filterTableName, stage1Chain, stage1BridgePrepend, stage1Bridge); err != nil { + return err + } + stage1Return := withDefaultComment([]string{"-j", "RETURN"}) + if err := utils.InsertUnique(ipt, filterTableName, stage1Chain, false, stage1Return); err != nil { + return err + } + + // Commands: + // ``` + // iptables -A CNI-ISOLATION-STAGE-2 -o ${bridgeName} -j DROP + // iptables -A CNI-ISOLATION-STAGE-2 -j RETURN + // ``` + stage2Bridge := withDefaultComment(isolationStage2BridgeRule(bridgeName)) + // prepend = true because this needs to be before "-j RETURN" + const stage2BridgePrepend = true + if err := utils.InsertUnique(ipt, filterTableName, stage2Chain, stage2BridgePrepend, stage2Bridge); err != nil { + return err + } + stage2Return := withDefaultComment([]string{"-j", "RETURN"}) + if err := utils.InsertUnique(ipt, filterTableName, stage2Chain, false, stage2Return); err != nil { + return err + } + + return nil +} + +func isolationStage1BridgeRule(bridgeName, stage2Chain string) []string { + return []string{"-i", bridgeName, "!", "-o", bridgeName, "-j", stage2Chain} +} + +func isolationStage2BridgeRule(bridgeName string) []string { + return []string{"-o", bridgeName, "-j", "DROP"} +} + +func withDefaultComment(rule []string) []string { + defaultComment := fmt.Sprintf("CNI firewall plugin rules (ingressPolicy: same-bridge)") + return withComment(rule, defaultComment) +} + +func withComment(rule []string, comment string) []string { + return append(rule, []string{"-m", "comment", "--comment", comment}...) +} diff --git a/plugins/meta/portmap/chain.go b/plugins/meta/portmap/chain.go index 4875b5e27..adad1e70c 100644 --- a/plugins/meta/portmap/chain.go +++ b/plugins/meta/portmap/chain.go @@ -44,7 +44,7 @@ func (c *chain) setup(ipt *iptables.IPTables) error { // Add the rules to the chain for _, rule := range c.rules { - if err := insertUnique(ipt, c.table, c.name, false, rule); err != nil { + if err := utils.InsertUnique(ipt, c.table, c.name, false, rule); err != nil { return err } } @@ -55,7 +55,7 @@ func (c *chain) setup(ipt *iptables.IPTables) error { r := []string{} r = append(r, rule...) r = append(r, "-j", c.name) - if err := insertUnique(ipt, c.table, entryChain, c.prependEntry, r); err != nil { + if err := utils.InsertUnique(ipt, c.table, entryChain, c.prependEntry, r); err != nil { return err } } @@ -101,24 +101,6 @@ func (c *chain) teardown(ipt *iptables.IPTables) error { return utils.DeleteChain(ipt, c.table, c.name) } -// insertUnique will add a rule to a chain if it does not already exist. -// By default the rule is appended, unless prepend is true. -func insertUnique(ipt *iptables.IPTables, table, chain string, prepend bool, rule []string) error { - exists, err := ipt.Exists(table, chain, rule...) - if err != nil { - return err - } - if exists { - return nil - } - - if prepend { - return ipt.Insert(table, chain, 1, rule...) - } else { - return ipt.Append(table, chain, rule...) - } -} - // check the chain. func (c *chain) check(ipt *iptables.IPTables) error {