kubernetes/pkg/util/iptables/testing/parse.go

//go:build linux
// +build linux

/*
Copyright 2022 The Kubernetes 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 testing

import (
	"fmt"
	"reflect"
	"regexp"
	"strconv"
	"strings"

	"k8s.io/kubernetes/pkg/util/iptables"
)

// IPTablesDump represents a parsed IPTables rules dump (ie, the output of
// "iptables-save" or input to "iptables-restore")
type IPTablesDump struct {
	Tables []Table
}

// Table represents an IPTables table
type Table struct {
	Name   iptables.Table
	Chains []Chain
}

// Chain represents an IPTables chain
type Chain struct {
	Name    iptables.Chain
	Packets uint64
	Bytes   uint64
	Rules   []*Rule

	// Deleted is set if the input contained a "-X Name" line; this would never
	// appear in iptables-save output but it could appear in iptables-restore *input*.
	Deleted bool
}

var declareTableRegex = regexp.MustCompile(`^\*(.*)$`)
var declareChainRegex = regexp.MustCompile(`^:([^ ]*) - \[([0-9]*):([0-9]*)\]$`)
var addRuleRegex = regexp.MustCompile(`^-A ([^ ]*) (.*)$`)
var deleteChainRegex = regexp.MustCompile(`^-X (.*)$`)

type parseState int

const (
	parseTableDeclaration parseState = iota
	parseChainDeclarations
	parseChains
)

// ParseIPTablesDump parses an IPTables rules dump. Note: this may ignore some bad data.
func ParseIPTablesDump(data string) (*IPTablesDump, error) {
	dump := &IPTablesDump{}
	state := parseTableDeclaration
	lines := strings.Split(strings.Trim(data, "\n"), "\n")
	var t *Table

	for _, line := range lines {
	retry:
		line = strings.TrimSpace(line)
		if line == "" || line[0] == '#' {
			continue
		}

		switch state {
		case parseTableDeclaration:
			// Parse table declaration line ("*filter").
			match := declareTableRegex.FindStringSubmatch(line)
			if match == nil {
				return nil, fmt.Errorf("could not parse iptables data (table %d starts with %q not a table name)", len(dump.Tables)+1, line)
			}
			dump.Tables = append(dump.Tables, Table{Name: iptables.Table(match[1])})
			t = &dump.Tables[len(dump.Tables)-1]
			state = parseChainDeclarations

		case parseChainDeclarations:
			match := declareChainRegex.FindStringSubmatch(line)
			if match == nil {
				state = parseChains
				goto retry
			}

			chain := iptables.Chain(match[1])
			packets, _ := strconv.ParseUint(match[2], 10, 64)
			bytes, _ := strconv.ParseUint(match[3], 10, 64)

			t.Chains = append(t.Chains,
				Chain{
					Name:    chain,
					Packets: packets,
					Bytes:   bytes,
				},
			)

		case parseChains:
			if match := addRuleRegex.FindStringSubmatch(line); match != nil {
				chain := iptables.Chain(match[1])

				c, err := dump.GetChain(t.Name, chain)
				if err != nil {
					return nil, fmt.Errorf("error parsing rule %q: %v", line, err)
				}
				if c.Deleted {
					return nil, fmt.Errorf("cannot add rules to deleted chain %q", chain)
				}

				rule, err := ParseRule(line, false)
				if err != nil {
					return nil, err
				}
				c.Rules = append(c.Rules, rule)
			} else if match := deleteChainRegex.FindStringSubmatch(line); match != nil {
				chain := iptables.Chain(match[1])

				c, err := dump.GetChain(t.Name, chain)
				if err != nil {
					return nil, fmt.Errorf("error parsing rule %q: %v", line, err)
				}
				if len(c.Rules) != 0 {
					return nil, fmt.Errorf("cannot delete chain %q after adding rules", chain)
				}
				c.Deleted = true
			} else if line == "COMMIT" {
				state = parseTableDeclaration
			} else {
				return nil, fmt.Errorf("error parsing rule %q", line)
			}
		}
	}

	if state != parseTableDeclaration {
		return nil, fmt.Errorf("could not parse iptables data (no COMMIT line?)")
	}

	return dump, nil
}

func (dump *IPTablesDump) String() string {
	buffer := &strings.Builder{}
	for _, t := range dump.Tables {
		fmt.Fprintf(buffer, "*%s\n", t.Name)
		for _, c := range t.Chains {
			fmt.Fprintf(buffer, ":%s - [%d:%d]\n", c.Name, c.Packets, c.Bytes)
		}
		for _, c := range t.Chains {
			for _, r := range c.Rules {
				fmt.Fprintf(buffer, "%s\n", r.Raw)
			}
		}
		for _, c := range t.Chains {
			if c.Deleted {
				fmt.Fprintf(buffer, "-X %s\n", c.Name)
			}
		}
		fmt.Fprintf(buffer, "COMMIT\n")
	}
	return buffer.String()
}

func (dump *IPTablesDump) GetTable(table iptables.Table) (*Table, error) {
	for i := range dump.Tables {
		if dump.Tables[i].Name == table {
			return &dump.Tables[i], nil
		}
	}
	return nil, fmt.Errorf("no such table %q", table)
}

func (dump *IPTablesDump) GetChain(table iptables.Table, chain iptables.Chain) (*Chain, error) {
	t, err := dump.GetTable(table)
	if err != nil {
		return nil, err
	}
	for i := range t.Chains {
		if t.Chains[i].Name == chain {
			return &t.Chains[i], nil
		}
	}
	return nil, fmt.Errorf("no such chain %q", chain)
}

// Rule represents a single parsed IPTables rule. (This currently covers all of the rule
// types that we actually use in pkg/proxy/iptables or pkg/proxy/ipvs.)
//
// The parsing is mostly-automated based on type reflection. The `param` tag on a field
// indicates the parameter whose value will be placed into that field. (The code assumes
// that we don't use both the short and long forms of any parameter names (eg, "-s" vs
// "--source"), which is currently true, but it could be extended if necessary.) The
// `negatable` tag indicates if a parameter is allowed to be preceded by "!".
//
// Parameters that take a value are stored as type `*IPTablesValue`, which encapsulates a
// string value and whether the rule was negated (ie, whether the rule requires that we
// *match* or *don't match* that value). But string-valued parameters that can't be
// negated use `IPTablesValue` rather than `string` too, just for API consistency.
//
// Parameters that don't take a value are stored as `*bool`, where the value is `nil` if
// the parameter was not present, `&true` if the parameter was present, or `&false` if the
// parameter was present but negated.
//
// Parsing skips over "-m MODULE" parameters because most parameters have unique names
// anyway even ignoring the module name, and in the cases where they don't (eg "-m tcp
// --sport" vs "-m udp --sport") the parameters are mutually-exclusive and it's more
// convenient to store them in the same struct field anyway.
type Rule struct {
	// Raw contains the original raw rule string
	Raw string

	Chain   iptables.Chain `param:"-A"`
	Comment *IPTablesValue `param:"--comment"`

	Protocol *IPTablesValue `param:"-p" negatable:"true"`

	SourceAddress *IPTablesValue `param:"-s" negatable:"true"`
	SourceType    *IPTablesValue `param:"--src-type" negatable:"true"`
	SourcePort    *IPTablesValue `param:"--sport" negatable:"true"`

	DestinationAddress *IPTablesValue `param:"-d" negatable:"true"`
	DestinationType    *IPTablesValue `param:"--dst-type" negatable:"true"`
	DestinationPort    *IPTablesValue `param:"--dport" negatable:"true"`

	MatchSet *IPTablesValue `param:"--match-set" negatable:"true"`

	Jump            *IPTablesValue `param:"-j"`
	RandomFully     *bool          `param:"--random-fully"`
	Probability     *IPTablesValue `param:"--probability"`
	DNATDestination *IPTablesValue `param:"--to-destination"`

	// We don't actually use the values of these, but we care if they are present
	AffinityCheck *bool          `param:"--rcheck" negatable:"true"`
	MarkCheck     *IPTablesValue `param:"--mark" negatable:"true"`
	CTStateCheck  *IPTablesValue `param:"--ctstate" negatable:"true"`

	// We don't currently care about any of these in the unit tests, but we expect
	// them to be present in some rules that we parse, so we define how to parse them.
	AffinityName    *IPTablesValue `param:"--name"`
	AffinitySeconds *IPTablesValue `param:"--seconds"`
	AffinitySet     *bool          `param:"--set" negatable:"true"`
	AffinityReap    *bool          `param:"--reap"`
	StatisticMode   *IPTablesValue `param:"--mode"`
}

// IPTablesValue is a value of a parameter in an Rule, where the parameter is
// possibly negated.
type IPTablesValue struct {
	Negated bool
	Value   string
}

// for debugging; otherwise %v will just print the pointer value
func (v *IPTablesValue) String() string {
	if v.Negated {
		return fmt.Sprintf("NOT %q", v.Value)
	} else {
		return fmt.Sprintf("%q", v.Value)
	}
}

// Matches returns true if cmp equals / doesn't equal v.Value (depending on
// v.Negated).
func (v *IPTablesValue) Matches(cmp string) bool {
	if v.Negated {
		return v.Value != cmp
	} else {
		return v.Value == cmp
	}
}

// findParamField finds a field in value with the struct tag "param:${param}" and if found,
// returns a pointer to the Value of that field, and the value of its "negatable" tag.
func findParamField(value reflect.Value, param string) (*reflect.Value, bool) {
	typ := value.Type()
	for i := 0; i < typ.NumField(); i++ {
		field := typ.Field(i)
		if field.Tag.Get("param") == param {
			fValue := value.Field(i)
			return &fValue, field.Tag.Get("negatable") == "true"
		}
	}
	return nil, false
}

// wordRegex matches a single word or a quoted string (at the start of the string, or
// preceded by whitespace)
var wordRegex = regexp.MustCompile(`(?:^|\s)("[^"]*"|[^"]\S*)`)

// Used by ParseRule
var boolPtrType = reflect.PointerTo(reflect.TypeOf(true))
var ipTablesValuePtrType = reflect.TypeOf((*IPTablesValue)(nil))

// ParseRule parses rule. If strict is false, it will parse the recognized
// parameters and ignore unrecognized ones. If it is true, parsing will fail if there are
// unrecognized parameters.
func ParseRule(rule string, strict bool) (*Rule, error) {
	parsed := &Rule{Raw: rule}

	// Split rule into "words" (where a quoted string is a single word).
	var words []string
	for _, match := range wordRegex.FindAllStringSubmatch(rule, -1) {
		words = append(words, strings.Trim(match[1], `"`))
	}

	// The chain name must come first (and can't be the only thing there)
	if len(words) < 2 || words[0] != "-A" {
		return nil, fmt.Errorf(`bad iptables rule (does not start with "-A CHAIN")`)
	} else if len(words) < 3 {
		return nil, fmt.Errorf("bad iptables rule (no match rules)")
	}

	// For each word, see if it is a known iptables parameter, based on the struct
	// field tags in Rule. Note that in the non-strict case we implicitly assume that
	// no unrecognized parameter will take an argument that could be mistaken for
	// another parameter.
	v := reflect.ValueOf(parsed).Elem()
	negated := false
	for w := 0; w < len(words); {
		if words[w] == "-m" && w < len(words)-1 {
			// Skip "-m MODULE"; we don't pay attention to that since the
			// parameter names are unique enough anyway.
			w += 2
			continue
		}

		if words[w] == "!" {
			negated = true
			w++
			continue
		}

		// For everything else, see if it corresponds to a field of Rule
		if field, negatable := findParamField(v, words[w]); field != nil {
			if negated && !negatable {
				return nil, fmt.Errorf("cannot negate parameter %q", words[w])
			}
			if field.Type() != boolPtrType && w == len(words)-1 {
				return nil, fmt.Errorf("parameter %q requires an argument", words[w])
			}
			switch field.Type() {
			case boolPtrType:
				boolVal := !negated
				field.Set(reflect.ValueOf(&boolVal))
				w++
			case ipTablesValuePtrType:
				field.Set(reflect.ValueOf(&IPTablesValue{Negated: negated, Value: words[w+1]}))
				w += 2
			default:
				field.SetString(words[w+1])
				w += 2
			}
		} else if strict {
			return nil, fmt.Errorf("unrecognized parameter %q", words[w])
		} else {
			// skip
			w++
		}

		negated = false
	}

	return parsed, nil
}