firewall: new plugin which allows a host interface to send/receive traffic

Distros often have additional rules in the their iptabvles 'filter' table
that do things like:

-A FORWARD -j REJECT --reject-with icmp-host-prohibited

docker, for example, gets around this by adding explicit rules to the filter
table's FORWARD chain to allow traffic from the docker0 interface.  Do that
for a given host interface too, as a chained plugin.
This commit is contained in:
Dan Williams 2017-09-29 16:34:18 -05:00 committed by Michael Cambria
parent e9e1d37309
commit d096a4df48
6 changed files with 815 additions and 0 deletions

12
plugins/linux_only.txt Normal file
View File

@ -0,0 +1,12 @@
plugins/ipam/dhcp
plugins/main/bridge
plugins/main/host-device
plugins/main/ipvlan
plugins/main/loopback
plugins/main/macvlan
plugins/main/ptp
plugins/main/vlan
plugins/meta/portmap
plugins/meta/tuning
plugins/meta/bandwidth
plugins/meta/firewall

View File

@ -0,0 +1,33 @@
# firewall plugin
## Overview
This plugin creates firewall rules to allow traffic to/from the host network interface given by "ifName".
It does not create any network interfaces and therefore does not set up connectivity by itself.
It is only useful when used in addition to other plugins.
## Operation
The following network configuration file
```
{
"name": "mynet",
"type": "firewall",
"ifName": "cni0"
}
```
will allow the given interface to send/receive traffic via the host.
A successful result would simply be an empty result, unless a previous plugin passed a previous result, in which case this plugin will return that verbatim.
## Backends
This plugin supports multiple firewall backends that implement the desired functionality.
Available backends include `iptables` and `firewalld` and may be selected with the `backend` key.
If no `backend` key is given, the plugin will use firewalld if the service exists on the D-Bus system bus.
If no firewalld service is found, it will fall back to iptables.
When the `iptables` backend is used, the above example will create two new iptables chains in the `filter` table and add rules that allow the given interface to send/receive traffic.
When the `firewalld` backend is used, the above example will place the `cni0` interface into firewalld's `trusted` zone, allowing it to send/receive traffic.

View File

@ -0,0 +1,162 @@
// Copyright 2016 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 "meta-plugin". It reads in its own netconf, it does not create
// any network interface but just changes the network sysctl.
package main
import (
"encoding/json"
"fmt"
"net"
"github.com/containernetworking/cni/pkg/skel"
"github.com/containernetworking/cni/pkg/types"
"github.com/containernetworking/cni/pkg/types/current"
"github.com/containernetworking/cni/pkg/version"
"github.com/containernetworking/plugins/pkg/ns"
)
// FirewallNetConf represents the firewall configuration.
type FirewallNetConf struct {
types.NetConf
// Backend is the firewall type to add rules to. Allowed values are
// 'iptables' and 'firewalld'.
Backend string `json:"backend"`
// IptablesAdminChainName is an optional name to use instead of the default
// admin rules override chain name that includes the interface name.
IptablesAdminChainName string `json:"iptablesAdminChainName,omitempty"`
// FirewalldZone is an optional firewalld zone to place the interface into. If
// the firewalld backend is used but the zone is not given, it defaults
// to 'trusted'
FirewalldZone string `json:"firewalldZone,omitempty"`
RawPrevResult map[string]interface{} `json:"prevResult,omitempty"`
PrevResult *current.Result `json:"-"`
}
type FirewallBackend interface {
Add(*FirewallNetConf) error
Del(*FirewallNetConf) error
}
func ipString(ip net.IPNet) string {
if ip.IP.To4() == nil {
return ip.IP.String() + "/128"
}
return ip.IP.String() + "/32"
}
func parseConf(data []byte) (*FirewallNetConf, error) {
conf := FirewallNetConf{}
if err := json.Unmarshal(data, &conf); err != nil {
return nil, fmt.Errorf("failed to load netconf: %v", err)
}
// Default the firewalld zone to trusted
if conf.FirewalldZone == "" {
conf.FirewalldZone = "trusted"
}
// Parse previous result.
if conf.RawPrevResult != nil {
resultBytes, err := json.Marshal(conf.RawPrevResult)
if err != nil {
return nil, fmt.Errorf("could not serialize prevResult: %v", err)
}
res, err := version.NewResult(conf.CNIVersion, resultBytes)
if err != nil {
return nil, fmt.Errorf("could not parse prevResult: %v", err)
}
conf.RawPrevResult = nil
conf.PrevResult, err = current.NewResultFromResult(res)
if err != nil {
return nil, fmt.Errorf("could not convert result to current version: %v", err)
}
}
return &conf, nil
}
func getBackend(conf *FirewallNetConf) (FirewallBackend, error) {
switch conf.Backend {
case "iptables":
return newIptablesBackend(conf)
case "firewalld":
return newFirewalldBackend(conf)
}
// Default to firewalld if it's running
if isFirewalldRunning() {
return newFirewalldBackend(conf)
}
// Otherwise iptables
return newIptablesBackend(conf)
}
func cmdAdd(args *skel.CmdArgs) error {
conf, err := parseConf(args.StdinData)
if err != nil {
return err
}
backend, err := getBackend(conf)
if err != nil {
return err
}
if err := backend.Add(conf); err != nil {
return err
}
result := conf.PrevResult
if result == nil {
result = &current.Result{}
}
return types.PrintResult(result, conf.CNIVersion)
}
func cmdDel(args *skel.CmdArgs) error {
conf, err := parseConf(args.StdinData)
if err != nil {
return err
}
backend, err := getBackend(conf)
if err != nil {
return err
}
// Tolerate errors if the container namespace has been torn down already
containerNS, err := ns.GetNS(args.Netns)
if err == nil {
defer containerNS.Close()
}
// Runtime errors are ignored
if err := backend.Del(conf); err != nil {
return err
}
return nil
}
func main() {
skel.PluginMain(cmdAdd, cmdDel, version.All)
}

View File

@ -0,0 +1,368 @@
// Copyright 2017 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 (
"encoding/json"
"fmt"
"strings"
"github.com/containernetworking/cni/pkg/skel"
"github.com/containernetworking/cni/pkg/types"
"github.com/containernetworking/cni/pkg/types/current"
"github.com/containernetworking/cni/pkg/version"
"github.com/containernetworking/plugins/pkg/ns"
"github.com/containernetworking/plugins/pkg/testutils"
"github.com/vishvananda/netlink"
"github.com/coreos/go-iptables/iptables"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func findChains(chains []string) (bool, bool) {
var foundAdmin, foundPriv bool
for _, ch := range chains {
if ch == "CNI-ADMIN" {
foundAdmin = true
} else if ch == "CNI-FORWARD" {
foundPriv = true
}
}
return foundAdmin, foundPriv
}
func findForwardJumpRules(rules []string) (bool, bool) {
var foundAdmin, foundPriv bool
for _, rule := range rules {
if strings.Contains(rule, "-j CNI-ADMIN") {
foundAdmin = true
} else if strings.Contains(rule, "-j CNI-FORWARD") {
foundPriv = true
}
}
return foundAdmin, foundPriv
}
func findForwardAllowRules(rules []string, ip string) (bool, bool) {
var foundOne, foundTwo bool
for _, rule := range rules {
if !strings.HasSuffix(rule, "-j ACCEPT") {
continue
}
if strings.Contains(rule, fmt.Sprintf(" -s %s ", ip)) {
foundOne = true
} else if strings.Contains(rule, fmt.Sprintf(" -d %s ", ip)) && strings.Contains(rule, "RELATED,ESTABLISHED") {
foundTwo = true
}
}
return foundOne, foundTwo
}
func getPrevResult(bytes []byte) *current.Result {
type TmpConf struct {
types.NetConf
RawPrevResult map[string]interface{} `json:"prevResult,omitempty"`
PrevResult *current.Result `json:"-"`
}
conf := &TmpConf{}
err := json.Unmarshal(bytes, conf)
Expect(err).NotTo(HaveOccurred())
if conf.RawPrevResult == nil {
return nil
}
resultBytes, err := json.Marshal(conf.RawPrevResult)
Expect(err).NotTo(HaveOccurred())
res, err := version.NewResult(conf.CNIVersion, resultBytes)
Expect(err).NotTo(HaveOccurred())
prevResult, err := current.NewResultFromResult(res)
Expect(err).NotTo(HaveOccurred())
return prevResult
}
func validateFullRuleset(bytes []byte) {
prevResult := getPrevResult(bytes)
for _, ip := range prevResult.IPs {
ipt, err := iptables.NewWithProtocol(protoForIP(ip.Address))
Expect(err).NotTo(HaveOccurred())
// Ensure chains
chains, err := ipt.ListChains("filter")
Expect(err).NotTo(HaveOccurred())
foundAdmin, foundPriv := findChains(chains)
Expect(foundAdmin).To(Equal(true))
Expect(foundPriv).To(Equal(true))
// Look for the FORWARD chain jump rules to our custom chains
rules, err := ipt.List("filter", "FORWARD")
Expect(err).NotTo(HaveOccurred())
Expect(len(rules)).Should(BeNumerically(">", 1))
_, foundPriv = findForwardJumpRules(rules)
Expect(foundPriv).To(Equal(true))
// Look for the allow rules in our custom FORWARD chain
rules, err = ipt.List("filter", "CNI-FORWARD")
Expect(err).NotTo(HaveOccurred())
Expect(len(rules)).Should(BeNumerically(">", 1))
foundAdmin, _ = findForwardJumpRules(rules)
Expect(foundAdmin).To(Equal(true))
// Look for the IP allow rules
foundOne, foundTwo := findForwardAllowRules(rules, ipString(ip.Address))
Expect(foundOne).To(Equal(true))
Expect(foundTwo).To(Equal(true))
}
}
func validateCleanedUp(bytes []byte) {
prevResult := getPrevResult(bytes)
for _, ip := range prevResult.IPs {
ipt, err := iptables.NewWithProtocol(protoForIP(ip.Address))
Expect(err).NotTo(HaveOccurred())
// Our private and admin chains don't get cleaned up
chains, err := ipt.ListChains("filter")
Expect(err).NotTo(HaveOccurred())
foundAdmin, foundPriv := findChains(chains)
Expect(foundAdmin).To(Equal(true))
Expect(foundPriv).To(Equal(true))
// Look for the FORWARD chain jump rules to our custom chains
rules, err := ipt.List("filter", "FORWARD")
Expect(err).NotTo(HaveOccurred())
_, foundPriv = findForwardJumpRules(rules)
Expect(foundPriv).To(Equal(true))
// Look for the allow rules in our custom FORWARD chain
rules, err = ipt.List("filter", "CNI-FORWARD")
Expect(err).NotTo(HaveOccurred())
foundAdmin, _ = findForwardJumpRules(rules)
Expect(foundAdmin).To(Equal(true))
// Expect no IP address rules for this IP
foundOne, foundTwo := findForwardAllowRules(rules, ipString(ip.Address))
Expect(foundOne).To(Equal(false))
Expect(foundTwo).To(Equal(false))
}
}
var _ = Describe("firewall plugin iptables backend", func() {
var originalNS, targetNS ns.NetNS
const IFNAME string = "dummy0"
fullConf := []byte(`{
"name": "test",
"type": "firewall",
"backend": "iptables",
"ifName": "dummy0",
"cniVersion": "0.3.1",
"prevResult": {
"interfaces": [
{"name": "dummy0"}
],
"ips": [
{
"version": "4",
"address": "10.0.0.2/24",
"interface": 0
},
{
"version": "6",
"address": "2001:db8:1:2::1/64",
"interface": 0
}
]
}
}`)
BeforeEach(func() {
// Create a new NetNS so we don't modify the host
var err error
originalNS, err = testutils.NewNS()
Expect(err).NotTo(HaveOccurred())
err = originalNS.Do(func(ns.NetNS) error {
defer GinkgoRecover()
err = netlink.LinkAdd(&netlink.Dummy{
LinkAttrs: netlink.LinkAttrs{
Name: IFNAME,
},
})
Expect(err).NotTo(HaveOccurred())
_, err = netlink.LinkByName(IFNAME)
Expect(err).NotTo(HaveOccurred())
return nil
})
Expect(err).NotTo(HaveOccurred())
targetNS, err = testutils.NewNS()
Expect(err).NotTo(HaveOccurred())
})
AfterEach(func() {
Expect(originalNS.Close()).To(Succeed())
Expect(targetNS.Close()).To(Succeed())
})
It("passes prevResult through unchanged", func() {
args := &skel.CmdArgs{
ContainerID: "dummy",
Netns: targetNS.Path(),
IfName: IFNAME,
StdinData: fullConf,
}
err := originalNS.Do(func(ns.NetNS) error {
defer GinkgoRecover()
r, _, err := testutils.CmdAddWithResult(targetNS.Path(), IFNAME, fullConf, func() error {
return cmdAdd(args)
})
Expect(err).NotTo(HaveOccurred())
result, err := current.GetResult(r)
Expect(err).NotTo(HaveOccurred())
Expect(len(result.Interfaces)).To(Equal(1))
Expect(result.Interfaces[0].Name).To(Equal(IFNAME))
Expect(len(result.IPs)).To(Equal(2))
Expect(result.IPs[0].Address.String()).To(Equal("10.0.0.2/24"))
Expect(result.IPs[1].Address.String()).To(Equal("2001:db8:1:2::1/64"))
return nil
})
Expect(err).NotTo(HaveOccurred())
})
It("installs the right iptables rules on the host", func() {
args := &skel.CmdArgs{
ContainerID: "dummy",
Netns: targetNS.Path(),
IfName: IFNAME,
StdinData: fullConf,
}
err := originalNS.Do(func(ns.NetNS) error {
defer GinkgoRecover()
_, _, err := testutils.CmdAddWithResult(targetNS.Path(), IFNAME, fullConf, func() error {
return cmdAdd(args)
})
Expect(err).NotTo(HaveOccurred())
validateFullRuleset(fullConf)
return nil
})
Expect(err).NotTo(HaveOccurred())
})
It("correctly handles a custom IptablesAdminChainName", func() {
conf := []byte(`{
"name": "test",
"type": "firewall",
"backend": "iptables",
"ifName": "dummy0",
"cniVersion": "0.3.1",
"iptablesAdminChainName": "CNI-foobar",
"prevResult": {
"interfaces": [
{"name": "dummy0"}
],
"ips": [
{
"version": "4",
"address": "10.0.0.2/24",
"interface": 0
},
{
"version": "6",
"address": "2001:db8:1:2::1/64",
"interface": 0
}
]
}
}`)
args := &skel.CmdArgs{
ContainerID: "dummy",
Netns: targetNS.Path(),
IfName: IFNAME,
StdinData: conf,
}
err := originalNS.Do(func(ns.NetNS) error {
defer GinkgoRecover()
_, _, err := testutils.CmdAddWithResult(targetNS.Path(), IFNAME, conf, func() error {
return cmdAdd(args)
})
Expect(err).NotTo(HaveOccurred())
var ipt *iptables.IPTables
for _, proto := range []iptables.Protocol{iptables.ProtocolIPv4, iptables.ProtocolIPv6} {
ipt, err = iptables.NewWithProtocol(proto)
Expect(err).NotTo(HaveOccurred())
// Ensure custom admin chain name
chains, err := ipt.ListChains("filter")
Expect(err).NotTo(HaveOccurred())
var foundAdmin bool
for _, ch := range chains {
if ch == "CNI-foobar" {
foundAdmin = true
}
}
Expect(foundAdmin).To(Equal(true))
}
return nil
})
Expect(err).NotTo(HaveOccurred())
})
It("cleans up on delete", func() {
args := &skel.CmdArgs{
ContainerID: "dummy",
Netns: targetNS.Path(),
IfName: IFNAME,
StdinData: fullConf,
}
err := originalNS.Do(func(ns.NetNS) error {
defer GinkgoRecover()
_, _, err := testutils.CmdAddWithResult(targetNS.Path(), IFNAME, fullConf, func() error {
return cmdAdd(args)
})
Expect(err).NotTo(HaveOccurred())
validateFullRuleset(fullConf)
err = testutils.CmdDelWithResult(targetNS.Path(), IFNAME, func() error {
return cmdDel(args)
})
Expect(err).NotTo(HaveOccurred())
validateCleanedUp(fullConf)
return nil
})
Expect(err).NotTo(HaveOccurred())
})
})

View File

@ -0,0 +1,27 @@
// Copyright 2017 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 (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"testing"
)
func TestFirewall(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "firewall Suite")
}

View File

@ -0,0 +1,213 @@
// Copyright 2016 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 "meta-plugin". It reads in its own netconf, it does not create
// any network interface but just changes the network sysctl.
package main
import (
"fmt"
"net"
"github.com/coreos/go-iptables/iptables"
)
func getPrivChainRules(ip string) [][]string {
var rules [][]string
rules = append(rules, []string{"-d", ip, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"})
rules = append(rules, []string{"-s", ip, "-j", "ACCEPT"})
return rules
}
func ensureChain(ipt *iptables.IPTables, table, chain string) error {
chains, err := ipt.ListChains(table)
if err != nil {
return fmt.Errorf("failed to list iptables chains: %v", err)
}
for _, ch := range chains {
if ch == chain {
return nil
}
}
return ipt.NewChain(table, chain)
}
func generateFilterRule(privChainName string) []string {
return []string{"-m", "comment", "--comment", "CNI firewall plugin rules", "-j", privChainName}
}
func generateAdminRule(adminChainName string) []string {
return []string{"-m", "comment", "--comment", "CNI firewall plugin admin overrides", "-j", adminChainName}
}
func cleanupRules(ipt *iptables.IPTables, privChainName string, rules [][]string) {
for _, rule := range rules {
ipt.Delete("filter", privChainName, rule...)
}
}
func ensureFirstChainRule(ipt *iptables.IPTables, chain string, rule []string) error {
exists, err := ipt.Exists("filter", chain, rule...)
if !exists && err == nil {
err = ipt.Insert("filter", chain, 1, rule...)
}
return err
}
func (ib *iptablesBackend) setupChains(ipt *iptables.IPTables) error {
privRule := generateFilterRule(ib.privChainName)
adminRule := generateFilterRule(ib.adminChainName)
// Ensure our private chains exist
if err := ensureChain(ipt, "filter", ib.privChainName); err != nil {
return err
}
if err := ensureChain(ipt, "filter", ib.adminChainName); err != nil {
return err
}
// Ensure our filter rule exists in the forward chain
if err := ensureFirstChainRule(ipt, "FORWARD", privRule); err != nil {
return err
}
// Ensure our admin override chain rule exists in our private chain
if err := ensureFirstChainRule(ipt, ib.privChainName, adminRule); err != nil {
return err
}
return nil
}
func protoForIP(ip net.IPNet) iptables.Protocol {
if ip.IP.To4() != nil {
return iptables.ProtocolIPv4
}
return iptables.ProtocolIPv6
}
func (ib *iptablesBackend) addRules(conf *FirewallNetConf, ipt *iptables.IPTables, proto iptables.Protocol) error {
rules := make([][]string, 0)
for _, ip := range conf.PrevResult.IPs {
if protoForIP(ip.Address) == proto {
rules = append(rules, getPrivChainRules(ipString(ip.Address))...)
}
}
if len(rules) > 0 {
if err := ib.setupChains(ipt); err != nil {
return err
}
// Clean up on any errors
var err error
defer func() {
if err != nil {
cleanupRules(ipt, ib.privChainName, rules)
}
}()
for _, rule := range rules {
err = ipt.AppendUnique("filter", ib.privChainName, rule...)
if err != nil {
return err
}
}
}
return nil
}
func (ib *iptablesBackend) delRules(conf *FirewallNetConf, ipt *iptables.IPTables, proto iptables.Protocol) error {
rules := make([][]string, 0)
for _, ip := range conf.PrevResult.IPs {
if protoForIP(ip.Address) == proto {
rules = append(rules, getPrivChainRules(ipString(ip.Address))...)
}
}
if len(rules) > 0 {
cleanupRules(ipt, ib.privChainName, rules)
}
return nil
}
func findProtos(conf *FirewallNetConf) []iptables.Protocol {
protos := []iptables.Protocol{iptables.ProtocolIPv4, iptables.ProtocolIPv6}
if conf.PrevResult != nil {
// If PrevResult is given, scan all IP addresses to figure out
// which IP versions to use
protos = []iptables.Protocol{}
for _, addr := range conf.PrevResult.IPs {
if addr.Address.IP.To4() != nil {
protos = append(protos, iptables.ProtocolIPv4)
} else {
protos = append(protos, iptables.ProtocolIPv6)
}
}
}
return protos
}
type iptablesBackend struct {
protos map[iptables.Protocol]*iptables.IPTables
privChainName string
adminChainName string
ifName string
}
// iptablesBackend implements the FirewallBackend interface
var _ FirewallBackend = &iptablesBackend{}
func newIptablesBackend(conf *FirewallNetConf) (FirewallBackend, error) {
adminChainName := conf.IptablesAdminChainName
if adminChainName == "" {
adminChainName = "CNI-ADMIN"
}
backend := &iptablesBackend{
privChainName: "CNI-FORWARD",
adminChainName: adminChainName,
protos: make(map[iptables.Protocol]*iptables.IPTables),
}
for _, proto := range []iptables.Protocol{iptables.ProtocolIPv4, iptables.ProtocolIPv6} {
ipt, err := iptables.NewWithProtocol(proto)
if err != nil {
return nil, fmt.Errorf("could not initialize iptables protocol %v: %v", proto, err)
}
backend.protos[proto] = ipt
}
return backend, nil
}
func (ib *iptablesBackend) Add(conf *FirewallNetConf) error {
for proto, ipt := range ib.protos {
if err := ib.addRules(conf, ipt, proto); err != nil {
return err
}
}
return nil
}
func (ib *iptablesBackend) Del(conf *FirewallNetConf) error {
for proto, ipt := range ib.protos {
ib.delRules(conf, ipt, proto)
}
return nil
}