From e7653d0bf95af7023e58db3b17f9f9f52f6393fc Mon Sep 17 00:00:00 2001 From: Paul Greenberg Date: Sun, 15 Mar 2020 22:38:35 -0400 Subject: [PATCH] firewall: add nftables backend Resolves: #461 Signed-off-by: Paul Greenberg --- plugins/meta/firewall/README.md | 96 ++++++++ plugins/meta/firewall/firewall.go | 2 + plugins/meta/firewall/nftables.go | 350 ++++++++++++++++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 plugins/meta/firewall/nftables.go diff --git a/plugins/meta/firewall/README.md b/plugins/meta/firewall/README.md index f73a41213..b5cd1831b 100644 --- a/plugins/meta/firewall/README.md +++ b/plugins/meta/firewall/README.md @@ -133,3 +133,99 @@ of the container as shown: - `-s 10.88.0.2 -m conntrack --ctstate RELATED,ESTABLISHED -j CNI-FORWARD` - `-d 10.88.0.2 -j CNI-FORWARD` +## nftables backend rule structure + +The prerequisite for the backend is the existence of `filter` table and +the existence of `FORWARD` chain in the table. + +A sample standalone config list (with the file extension `.conflist`) using +`nftables` backend might look like: + +```json +{ + "cniVersion": "0.4.0", + "name": "podman", + "plugins": [ + { + "type": "bridge", + "bridge": "cni-podman0", + "isGateway": true, + "ipMasq": true, + "ipam": { + "type": "host-local", + "routes": [ + { + "dst": "0.0.0.0/0" + } + ], + "ranges": [ + [ + { + "subnet": "192.168.124.0/24", + "gateway": "192.168.124.1" + } + ] + ] + } + }, + { + "type": "portmap", + "capabilities": { + "portMappings": true + } + }, + { + "type": "firewall", + "backend": "nftables" + } + ] +} +``` + +Prior to the invocation of CNI `firewall` plugin, the `FORWARD` chain in `filter` table is: + +``` +$ nft list chain ip filter FORWARD -a +table ip filter { + chain FORWARD { # handle 2 + type filter hook forward priority 0; policy drop; + oifname "virbr0" ip daddr 192.168.122.0/24 ct state established,related counter packets 0 bytes 0 accept # handle 51 + iifname "virbr0" ip saddr 192.168.122.0/24 counter packets 0 bytes 0 accept # handle 52 + iifname "virbr0" oifname "virbr0" counter packets 0 bytes 0 accept # handle 53 + log prefix "IPv4 FORWARD drop: " flags all # handle 54 + counter packets 10 bytes 630 drop # handle 55 + } +} +``` + +After starting a container, the plugin executes the following commands based +on the configuration above. Please note that `position 51` refers to the handle +at the top of the chain. + +``` +nft insert rule filter FORWARD position 51 oifname "cni-podman0" ip daddr 192.168.124.0/24 ct state established,related counter packets 0 bytes 0 accept +nft insert rule filter FORWARD position 51 iifname "cni-podman0" ip saddr 192.168.124.0/24 counter packets 0 bytes 0 accept +nft insert rule filter FORWARD position 51 iifname "cni-podman0" oifname "cni-podman0" counter packets 0 bytes 0 accept +``` + +After the plugin's execution, the chain looks like this: + +``` +$ nft list chain ip filter FORWARD -a +table ip filter { + chain FORWARD { # handle 2 + type filter hook forward priority 0; policy drop; + oifname "cni-podman0" ip daddr 192.168.124.0/24 ct state established,related counter packets 100 bytes 113413 accept # handle 71 + iifname "cni-podman0" ip saddr 192.168.124.0/24 counter packets 124 bytes 12996 accept # handle 72 + iifname "cni-podman0" oifname "cni-podman0" counter packets 0 bytes 0 accept # handle 73 + oifname "virbr0" ip daddr 192.168.122.0/24 ct state established,related counter packets 0 bytes 0 accept # handle 51 + iifname "virbr0" ip saddr 192.168.122.0/24 counter packets 0 bytes 0 accept # handle 52 + iifname "virbr0" oifname "virbr0" counter packets 0 bytes 0 accept # handle 53 + log prefix "IPv4 FORWARD drop: " flags all # handle 54 + counter packets 10 bytes 630 drop # handle 55 + } +} +``` + +Subsequent executions of the plugin do not create additional rules in the chain, unless +the CNI network configuration changes. diff --git a/plugins/meta/firewall/firewall.go b/plugins/meta/firewall/firewall.go index 875943beb..f33ac54dd 100644 --- a/plugins/meta/firewall/firewall.go +++ b/plugins/meta/firewall/firewall.go @@ -97,6 +97,8 @@ func getBackend(conf *FirewallNetConf) (FirewallBackend, error) { switch conf.Backend { case "iptables": return newIptablesBackend(conf) + case "nftables": + return newNftablesBackend(conf) case "firewalld": return newFirewalldBackend(conf) } diff --git a/plugins/meta/firewall/nftables.go b/plugins/meta/firewall/nftables.go new file mode 100644 index 000000000..1a88b3665 --- /dev/null +++ b/plugins/meta/firewall/nftables.go @@ -0,0 +1,350 @@ +// Copyright 2018 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 ( + "bytes" + "fmt" + "github.com/containernetworking/cni/pkg/types/current" + "net" + "os/exec" + "strconv" + "strings" +) + +type nftBackend struct { + targetTable string + targetChain string + targetHandle uint64 + targetInterface string + targetNetwork *net.IPNet + rules []*nftRule + markedDelRules []uint64 + newRules [][]string +} + +type nftRule struct { + text string + handle uint64 + verdict string +} + +func newNftRule(s string) (*nftRule, error) { + r := &nftRule{ + text: s, + verdict: "unknown", + } + if err := r.parseText(); err != nil { + return nil, err + } + if strings.HasSuffix(r.text, " accept") { + r.verdict = "accept" + } + if strings.HasSuffix(r.text, " drop") { + r.verdict = "drop" + } + return r, nil +} + +func (r *nftRule) parseText() error { + offset := strings.LastIndex(r.text, "# handle ") + if offset > 0 { + // The rule handle was found + handle := strings.TrimLeft(r.text[offset:], "# handle ") + r.text = strings.TrimSpace(r.text[:offset-1]) + handleID, err := strconv.ParseUint(handle, 0, 64) + if err != nil { + return err + } + r.handle = handleID + } + + return nil +} + +// nftBackend implements the FirewallBackend interface +var _ FirewallBackend = &nftBackend{} + +func newNftablesBackend(conf *FirewallNetConf) (FirewallBackend, error) { + backend := &nftBackend{ + targetTable: "filter", + targetChain: "FORWARD", + targetHandle: 0, + rules: []*nftRule{}, + markedDelRules: []uint64{}, + newRules: [][]string{}, + } + return backend, nil +} + +func (nb *nftBackend) execCommand(args []string) ([]string, []string, error) { + var stdout, stderr bytes.Buffer + cmd := exec.Command("nft", args...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return []string{}, []string{}, fmt.Errorf("Error executing %s: %s", args, err) + } + + stdoutString := stdout.String() + stderrString := stderr.String() + stdoutLines := strings.Split(stdoutString, "\n") + stderrLines := strings.Split(stderrString, "\n") + return stdoutLines, stderrLines, nil +} + +func (nb *nftBackend) getRules() error { + cmdArgs := []string{"list", "chain", "ip", nb.targetTable, nb.targetChain, "-a"} + stdoutLines, _, err := nb.execCommand(cmdArgs) + if err != nil { + return err + } + for _, line := range stdoutLines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "table") { + continue + } + if strings.HasPrefix(line, "type") { + continue + } + if strings.HasPrefix(line, "chain") { + continue + } + if strings.HasPrefix(line, "}") { + continue + } + rule, err := newNftRule(line) + if err != nil { + return err + } + if nb.targetHandle == 0 { + nb.targetHandle = rule.handle + } + nb.rules = append(nb.rules, rule) + } + return nil +} + +func (nb *nftBackend) isRuleChangeRequired() bool { + var requireChange bool + var isIntraInterfaceRuleExists bool + var isInboundInterfaceRuleExists bool + var isOutboundInterfaceRuleExists bool + + intraInterfaceRule := fmt.Sprintf( + "iifname \"%s\" oifname \"%s\"", + nb.targetInterface, + nb.targetInterface, + ) + + inboundInterfaceRule := fmt.Sprintf( + "oifname \"%s\" ip daddr %s ct state established,related", + nb.targetInterface, + nb.targetNetwork.String(), + ) + + outboundInterfaceRule := fmt.Sprintf( + "iifname \"%s\" ip saddr %s", + nb.targetInterface, + nb.targetNetwork.String(), + ) + + for _, rule := range nb.rules { + if rule.verdict != "accept" { + continue + } + if strings.HasPrefix(rule.text, intraInterfaceRule) { + isIntraInterfaceRuleExists = true + continue + } + if strings.HasPrefix(rule.text, inboundInterfaceRule) { + isInboundInterfaceRuleExists = true + continue + } + if strings.HasPrefix(rule.text, outboundInterfaceRule) { + isOutboundInterfaceRuleExists = true + continue + } + } + + var rule []string + + if !isIntraInterfaceRuleExists { + requireChange = true + if len(nb.rules) == 0 { + rule = []string{ + "add", "rule", nb.targetTable, nb.targetChain, + "iifname", fmt.Sprintf("\"%s\"", nb.targetInterface), + "oifname", fmt.Sprintf("\"%s\"", nb.targetInterface), + "counter", "packets", "0", "bytes", "0", "accept", + } + } else { + rule = []string{ + "insert", "rule", nb.targetTable, nb.targetChain, + "position", fmt.Sprintf("%d", nb.targetHandle), + "iifname", fmt.Sprintf("\"%s\"", nb.targetInterface), + "oifname", fmt.Sprintf("\"%s\"", nb.targetInterface), + "counter", "packets", "0", "bytes", "0", "accept", + } + } + nb.newRules = append(nb.newRules, rule) + } + + if !isInboundInterfaceRuleExists { + requireChange = true + if len(nb.rules) == 0 { + rule = []string{ + "add", "rule", nb.targetTable, nb.targetChain, + "oifname", fmt.Sprintf("\"%s\"", nb.targetInterface), + "ip", "daddr", nb.targetNetwork.String(), + "ct", "state", "established,related", + "counter", "packets", "0", "bytes", "0", "accept", + } + } else { + rule = []string{ + "insert", "rule", nb.targetTable, nb.targetChain, + "position", fmt.Sprintf("%d", nb.targetHandle), + "oifname", fmt.Sprintf("\"%s\"", nb.targetInterface), + "ip", "daddr", nb.targetNetwork.String(), + "ct", "state", "established,related", + "counter", "packets", "0", "bytes", "0", "accept", + } + } + nb.newRules = append(nb.newRules, rule) + } + + if !isOutboundInterfaceRuleExists { + requireChange = true + if len(nb.rules) == 0 { + rule = []string{ + "add", "rule", nb.targetTable, nb.targetChain, + "iifname", fmt.Sprintf("\"%s\"", nb.targetInterface), + "ip", "saddr", nb.targetNetwork.String(), + "counter", "packets", "0", "bytes", "0", "accept", + } + } else { + rule = []string{ + "insert", "rule", nb.targetTable, nb.targetChain, + "position", fmt.Sprintf("%d", nb.targetHandle), + "iifname", fmt.Sprintf("\"%s\"", nb.targetInterface), + "ip", "saddr", nb.targetNetwork.String(), + "counter", "packets", "0", "bytes", "0", "accept", + } + } + nb.newRules = append(nb.newRules, rule) + } + + if requireChange { + return true + } + return false +} + +func (nb *nftBackend) addRules() error { + if len(nb.newRules) == 0 { + return nil + } + for _, cmdArgs := range nb.newRules { + stdoutLines, stderrLines, err := nb.execCommand(cmdArgs) + if err != nil { + return fmt.Errorf( + "encountered error %s: %s (stdout: %s, stderr: %s)", + cmdArgs, err, stdoutLines, stderrLines, + ) + } + } + return nil +} + +func (nb *nftBackend) delRules() error { + if len(nb.markedDelRules) == 0 { + return nil + } + return nil +} + +func (nb *nftBackend) isValidInput(result *current.Result) error { + if len(result.Interfaces) == 0 { + return fmt.Errorf("the data passed to firewall plugin did not contain network interfaces") + } + + if result.Interfaces[0].Name == "" { + return fmt.Errorf("the data passed to firewall plugin has no bridge name, e.g. cnibr0") + } + + nb.targetInterface = result.Interfaces[0].Name + + if len(result.IPs) == 0 { + return fmt.Errorf("the data passed to firewall plugin has no IP addresses") + } + + if len(result.IPs) != 1 { + return fmt.Errorf("the data passed to firewall plugin has more than one IP address") + } + + addr := result.IPs[0].Address + + if addr.String() == "" { + return fmt.Errorf("the data passed to firewall plugin has empty IP address") + } + + if addr.IP.To4() == nil { + return fmt.Errorf("the data passed to firewall plugin has non-IPv4 address") + } + + _, netAddr, err := net.ParseCIDR(addr.String()) + if err != nil { + return fmt.Errorf("the data passed to firewall plugin has invalid IPv4 address") + } + + nb.targetNetwork = netAddr + + return nil +} + +func (nb *nftBackend) Add(conf *FirewallNetConf, result *current.Result) error { + if err := nb.isValidInput(result); err != nil { + return fmt.Errorf("nftBackend.Add() %s", err) + } + + if err := nb.getRules(); err != nil { + return fmt.Errorf("nftBackend.Add() %s", err) + } + + if !nb.isRuleChangeRequired() { + return nil + } + + if err := nb.addRules(); err != nil { + return fmt.Errorf("nftBackend.Add() %s", err) + } + + if err := nb.delRules(); err != nil { + return fmt.Errorf("nftBackend.Add() %s", err) + } + + return nil +} + +func (nb *nftBackend) Del(conf *FirewallNetConf, result *current.Result) error { + return nil +} + +func (nb *nftBackend) Check(conf *FirewallNetConf, result *current.Result) error { + return nil +}