- New Capabilities: PortPair, Negation, RejectAction, FamilyWithoutAddress, DenyActionFromConfig, advertised per backend. - coversDirection isolates DirForward even when output is unowned; add splitNATDualRow so a concrete-family removal re-adds the opposite family's NAT translation. - Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the "invalid" sentinel as input while JSON round-trips it. - Sync counts additions on mid-batch failure and uses RuleBatcher. - NewManager runs a probe loop joining each backend's reason for diagnosability; services.go drops "generated" from enabled, handles it on enable, clears start-limit-hit on restart, and matches rc.local by token. - nftables: per-source connection limits (meter set), quoted-token parsing preserving log-prefix spacing, digit-led prefix sanitizing. - apf/csf: deny-action-from-config with cached STOP settings, port lists and inexpressible shapes routed through the pre-hook, confKeyApplies guard against a missing config line. - atomic config writes fsync before rename and resolve symlinks; readConfValue is last-assignment-wins; runCommand preserves the exit code through the wrapped error. - Move coreos/go-systemd to the maintained v22 module directly.
2416 lines
92 KiB
Go
2416 lines
92 KiB
Go
package firewall
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// Sentinel errors a caller can match with errors.Is to tell apart a genuine
|
||
// failure from a feature the active backend cannot express. The helpers below
|
||
// (unsupportedNAT, unsupportedOrdering, ...) wrap these, and per-backend marshal
|
||
// paths use fmt.Errorf("...: %w", err) so the message stays readable while the
|
||
// sentinel is preserved for programmatic handling.
|
||
var (
|
||
// ErrUnsupported is the common ancestor of every unsupported-feature error.
|
||
// errors.Is returns true for any of the more specific sentinels below.
|
||
ErrUnsupported = errors.New("feature unsupported by this firewall backend")
|
||
// ErrUnsupportedNAT is returned when a backend cannot express NAT.
|
||
ErrUnsupportedNAT = fmt.Errorf("%w: NAT", ErrUnsupported)
|
||
// ErrUnsupportedOrdering is returned by backends whose rule model is not
|
||
// ordered, for InsertRule/MoveRule.
|
||
ErrUnsupportedOrdering = fmt.Errorf("%w: explicit rule ordering", ErrUnsupported)
|
||
// ErrUnsupportedPolicy is returned when a backend cannot read or set a
|
||
// default policy.
|
||
ErrUnsupportedPolicy = fmt.Errorf("%w: default-policy management", ErrUnsupported)
|
||
// ErrUnsupportedSet is returned when a backend cannot manage address sets
|
||
// (ipset/nftset/tables).
|
||
ErrUnsupportedSet = fmt.Errorf("%w: address sets", ErrUnsupported)
|
||
// ErrUnsupportedLog is returned when per-rule logging cannot be expressed.
|
||
ErrUnsupportedLog = fmt.Errorf("%w: per-rule logging", ErrUnsupported)
|
||
// ErrUnsupportedRateLimit is returned when rate limiting cannot be expressed.
|
||
ErrUnsupportedRateLimit = fmt.Errorf("%w: rate limiting", ErrUnsupported)
|
||
// ErrUnsupportedConnLimit is returned when connection limiting cannot be
|
||
// expressed.
|
||
ErrUnsupportedConnLimit = fmt.Errorf("%w: connection limiting", ErrUnsupported)
|
||
// ErrUnsupportedState is returned when connection-state matching cannot be
|
||
// expressed.
|
||
ErrUnsupportedState = fmt.Errorf("%w: connection-state matching", ErrUnsupported)
|
||
// ErrUnsupportedInterface is returned when per-rule interface matching
|
||
// cannot be expressed.
|
||
ErrUnsupportedInterface = fmt.Errorf("%w: per-rule interface matching", ErrUnsupported)
|
||
// ErrUnsupportedSourcePort is returned when source-port matching cannot be
|
||
// expressed.
|
||
ErrUnsupportedSourcePort = fmt.Errorf("%w: source-port matching", ErrUnsupported)
|
||
// ErrUnsupportedForward is returned when a backend cannot express a rule in
|
||
// the forward (routing) chain.
|
||
ErrUnsupportedForward = fmt.Errorf("%w: forward-chain rules", ErrUnsupported)
|
||
)
|
||
|
||
// Action is the firewall action taken on a rule's matching packets.
|
||
type Action uint8
|
||
|
||
const (
|
||
ActionInvalid Action = iota
|
||
Accept
|
||
Reject
|
||
Drop
|
||
)
|
||
|
||
// String returns the canonical lower-case name of the action.
|
||
func (t Action) String() string {
|
||
switch t {
|
||
case Accept:
|
||
return "accept"
|
||
case Reject:
|
||
return "reject"
|
||
case Drop:
|
||
return "drop"
|
||
}
|
||
return "invalid"
|
||
}
|
||
|
||
// ParseAction parses a caller-supplied action token (case-insensitive),
|
||
// accepting only the concrete actions Accept, Reject and Drop. The sentinel
|
||
// "invalid" (ActionInvalid) is rejected here so callers cannot author a rule or
|
||
// policy with no real action; backup decoding round-trips it separately in
|
||
// Action.UnmarshalJSON.
|
||
func ParseAction(s string) (Action, error) {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "accept":
|
||
return Accept, nil
|
||
case "reject":
|
||
return Reject, nil
|
||
case "drop":
|
||
return Drop, nil
|
||
}
|
||
return 0, fmt.Errorf("unknown action %q", s)
|
||
}
|
||
|
||
// Family is the IP family a rule targets.
|
||
type Family uint8
|
||
|
||
const (
|
||
FamilyAny Family = iota
|
||
IPv4
|
||
IPv6
|
||
)
|
||
|
||
// String returns the canonical lower-case name of the family.
|
||
func (t Family) String() string {
|
||
switch t {
|
||
case IPv4:
|
||
return "ipv4"
|
||
case IPv6:
|
||
return "ipv6"
|
||
}
|
||
return "any"
|
||
}
|
||
|
||
// ParseFamily parses a family token (case-insensitive), accepting the canonical
|
||
// name emitted by Family.String plus the common aliases (v4/inet4, v6/inet6).
|
||
// An unknown value is an error.
|
||
func ParseFamily(s string) (Family, error) {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "any":
|
||
return FamilyAny, nil
|
||
case "ipv4", "v4", "inet4":
|
||
return IPv4, nil
|
||
case "ipv6", "v6", "inet6":
|
||
return IPv6, nil
|
||
}
|
||
return 0, fmt.Errorf("unknown family %q", s)
|
||
}
|
||
|
||
// Protocol is the network protocol a rule matches.
|
||
type Protocol uint8
|
||
|
||
const (
|
||
ProtocolAny Protocol = iota
|
||
UDP
|
||
TCP
|
||
// ICMP and ICMPv6 are the control-message protocols. ICMP is inherently an
|
||
// IPv4 protocol and ICMPv6 an IPv6 one; a rule carrying one of these implies
|
||
// the matching family (see Rule.impliedFamily).
|
||
ICMP
|
||
ICMPv6
|
||
// SCTP is a transport protocol that, like TCP and UDP, carries ports.
|
||
SCTP
|
||
// GRE, ESP and AH are portless IP protocols (tunneling and IPsec). A rule
|
||
// carrying one of these cannot also match a port.
|
||
GRE
|
||
ESP
|
||
AH
|
||
// TCPUDP matches TCP and UDP together. It is the protocol analog of FamilyAny
|
||
// and DirAny: a backend whose native syntax carries both transports in one row
|
||
// stores it as written and reads it back as TCPUDP, while one that cannot fans it
|
||
// into a TCP row and a UDP row on write (expandProtocols). It is distinct from
|
||
// ProtocolAny, which matches *every* IP protocol — a tcp/udp rule reported as
|
||
// "any protocol" would silently widen to ICMP, GRE and the rest. Only TCPUDP
|
||
// carries ports.
|
||
TCPUDP
|
||
)
|
||
|
||
// String returns the canonical lower-case name of the protocol.
|
||
func (t Protocol) String() string {
|
||
switch t {
|
||
case UDP:
|
||
return "udp"
|
||
case TCP:
|
||
return "tcp"
|
||
case TCPUDP:
|
||
return "tcpudp"
|
||
case ICMP:
|
||
return "icmp"
|
||
case ICMPv6:
|
||
return "icmpv6"
|
||
case SCTP:
|
||
return "sctp"
|
||
case GRE:
|
||
return "gre"
|
||
case ESP:
|
||
return "esp"
|
||
case AH:
|
||
return "ah"
|
||
}
|
||
return "any"
|
||
}
|
||
|
||
// IsICMP reports whether the protocol is ICMP or ICMPv6.
|
||
func (t Protocol) IsICMP() bool {
|
||
return t == ICMP || t == ICMPv6
|
||
}
|
||
|
||
// HasPorts reports whether the protocol carries layer-4 ports (TCP, UDP, SCTP or
|
||
// the merged TCPUDP). A port match is only meaningful and only valid for these
|
||
// protocols.
|
||
func (t Protocol) HasPorts() bool {
|
||
return t == TCP || t == UDP || t == SCTP || t == TCPUDP
|
||
}
|
||
|
||
// oppositeProtocol returns the other transport of the TCP/UDP pair a TCPUDP rule
|
||
// fans out to: UDP for TCP and vice versa. Every other protocol has no twin and
|
||
// returns ProtocolAny (the sentinel meaning "no pair"). It is the protocol analog of
|
||
// oppositeFamily, and supports the dual-row split on removal.
|
||
func oppositeProtocol(p Protocol) Protocol {
|
||
switch p {
|
||
case TCP:
|
||
return UDP
|
||
case UDP:
|
||
return TCP
|
||
default:
|
||
return ProtocolAny
|
||
}
|
||
}
|
||
|
||
// Ptr returns a pointer to v. It is a convenience for setting optional rule
|
||
// fields such as ICMPType, e.g. firewall.Ptr[uint8](8).
|
||
func Ptr[T any](v T) *T {
|
||
return &v
|
||
}
|
||
|
||
// icmpNameToNum maps the ICMP type names various tools accept (and their common
|
||
// aliases) to their numeric type. It is used when reading a rule whose ICMP type
|
||
// is written by name; rules this library writes always emit the number, which
|
||
// every backend accepts.
|
||
var icmpNameToNum = map[string]uint8{
|
||
"echo-reply": 0,
|
||
"pong": 0,
|
||
"destination-unreachable": 3,
|
||
"source-quench": 4,
|
||
"redirect": 5,
|
||
"echo-request": 8,
|
||
"ping": 8,
|
||
"router-advertisement": 9,
|
||
"router-solicitation": 10,
|
||
"time-exceeded": 11,
|
||
"ttl-exceeded": 11,
|
||
"parameter-problem": 12,
|
||
"timestamp-request": 13,
|
||
"timestamp-reply": 14,
|
||
"info-request": 15,
|
||
"info-reply": 16,
|
||
"address-mask-request": 17,
|
||
"address-mask-reply": 18,
|
||
"traceroute": 30,
|
||
}
|
||
|
||
// icmpv6NameToNum maps the ICMPv6 type names nftables, ip6tables and ufw print
|
||
// to their numeric type (both the nftables nd-* spellings and the ip6tables
|
||
// long forms such as router-solicitation). ICMPv6 reuses several names from
|
||
// ICMPv4 (echo-request, destination-unreachable, ...) for *different* numbers,
|
||
// so a name read from an ICMPv6 rule must be resolved through this table rather
|
||
// than icmpNameToNum.
|
||
var icmpv6NameToNum = map[string]uint8{
|
||
"destination-unreachable": 1,
|
||
"packet-too-big": 2,
|
||
"time-exceeded": 3,
|
||
"ttl-exceeded": 3,
|
||
"parameter-problem": 4,
|
||
"echo-request": 128,
|
||
"ping": 128,
|
||
"echo-reply": 129,
|
||
"pong": 129,
|
||
"mld-listener-query": 130,
|
||
"mld-listener-report": 131,
|
||
"mld-listener-done": 132,
|
||
"mld-listener-reduction": 132,
|
||
"nd-router-solicit": 133,
|
||
"router-solicitation": 133,
|
||
"nd-router-advert": 134,
|
||
"router-advertisement": 134,
|
||
"nd-neighbor-solicit": 135,
|
||
"neighbor-solicitation": 135,
|
||
"neighbour-solicitation": 135,
|
||
"nd-neighbor-advert": 136,
|
||
"neighbor-advertisement": 136,
|
||
"neighbour-advertisement": 136,
|
||
"nd-redirect": 137,
|
||
"redirect": 137,
|
||
"router-renumbering": 138,
|
||
"ind-neighbor-solicit": 141,
|
||
"ind-neighbor-advert": 142,
|
||
"mld2-listener-report": 143,
|
||
}
|
||
|
||
// parseICMPType parses an ICMP type token as either a number (0-255) or one of
|
||
// the well-known IPv4 names in icmpNameToNum.
|
||
func parseICMPType(tok string) (uint8, bool) {
|
||
return parseICMPTypeFamily(tok, false)
|
||
}
|
||
|
||
// parseICMPTypeFamily parses an ICMP type token like parseICMPType, but resolves
|
||
// names through the ICMPv6 table when v6 is true. Numbers parse identically in
|
||
// either family (and rules this library writes always emit the number), so only
|
||
// the name path is family-dependent.
|
||
func parseICMPTypeFamily(tok string, v6 bool) (uint8, bool) {
|
||
tok = strings.TrimSpace(tok)
|
||
if n, err := strconv.ParseUint(tok, 10, 8); err == nil {
|
||
return uint8(n), true
|
||
}
|
||
if v6 {
|
||
if n, ok := icmpv6NameToNum[strings.ToLower(tok)]; ok {
|
||
return n, true
|
||
}
|
||
return 0, false
|
||
}
|
||
if n, ok := icmpNameToNum[strings.ToLower(tok)]; ok {
|
||
return n, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// ParseICMPType parses an ICMP type token as either a number (0-255) or a
|
||
// well-known type name, resolving names through the ICMPv6 table when v6 is true
|
||
// (the same name maps to a different number under ICMPv6 — e.g. echo-request is 8
|
||
// for ICMPv4 but 128 for ICMPv6). It is the exported form of the resolution the
|
||
// backends use internally, so a caller or CLI authoring a rule by name accepts
|
||
// exactly the spellings the library itself emits and reads back.
|
||
func ParseICMPType(tok string, v6 bool) (uint8, bool) {
|
||
return parseICMPTypeFamily(tok, v6)
|
||
}
|
||
|
||
// GetProtocol converts a string to the network protocol. The common spellings
|
||
// each backend emits for ICMPv6 (icmpv6, ipv6-icmp, icmp6) are all recognized.
|
||
// An unknown token resolves to ProtocolAny (the widest match), so a caller that
|
||
// must distinguish an unknown protocol from a genuine "any" checks the token
|
||
// itself, as the save-file parsers do.
|
||
func GetProtocol(proto string) Protocol {
|
||
switch {
|
||
case strings.EqualFold("udp", proto):
|
||
return UDP
|
||
case strings.EqualFold("tcp", proto):
|
||
return TCP
|
||
case strings.EqualFold("tcpudp", proto):
|
||
return TCPUDP
|
||
case strings.EqualFold("icmp", proto):
|
||
return ICMP
|
||
case strings.EqualFold("icmpv6", proto),
|
||
strings.EqualFold("ipv6-icmp", proto),
|
||
strings.EqualFold("icmp6", proto):
|
||
return ICMPv6
|
||
case strings.EqualFold("sctp", proto):
|
||
return SCTP
|
||
case strings.EqualFold("gre", proto):
|
||
return GRE
|
||
case strings.EqualFold("esp", proto),
|
||
strings.EqualFold("ipsec-esp", proto):
|
||
return ESP
|
||
case strings.EqualFold("ah", proto),
|
||
strings.EqualFold("ipsec-ah", proto):
|
||
return AH
|
||
}
|
||
return ProtocolAny
|
||
}
|
||
|
||
// PortRange is an inclusive range of ports. A single port is represented with
|
||
// End equal to Start (or End left zero, which normalizes to Start).
|
||
type PortRange struct {
|
||
Start uint16
|
||
End uint16
|
||
}
|
||
|
||
// normalized returns the range with a zero or inverted End collapsed to a single
|
||
// port at Start.
|
||
func (pr PortRange) normalized() PortRange {
|
||
if pr.End == 0 || pr.End < pr.Start {
|
||
pr.End = pr.Start
|
||
}
|
||
return pr
|
||
}
|
||
|
||
// String renders the range as "80" for a single port or "80-90" for a span.
|
||
func (pr PortRange) String() string {
|
||
pr = pr.normalized()
|
||
if pr.Start == pr.End {
|
||
return strconv.FormatUint(uint64(pr.Start), 10)
|
||
}
|
||
return fmt.Sprintf("%d-%d", pr.Start, pr.End)
|
||
}
|
||
|
||
// ParsePortRange parses a single "80" or "80-90"/"80:90" token into a PortRange.
|
||
func ParsePortRange(s string) (PortRange, error) {
|
||
s = strings.TrimSpace(s)
|
||
sep := "-"
|
||
if strings.Contains(s, ":") {
|
||
sep = ":"
|
||
}
|
||
lo, hi, isRange := strings.Cut(s, sep)
|
||
start, err := strconv.ParseUint(strings.TrimSpace(lo), 10, 16)
|
||
if err != nil {
|
||
return PortRange{}, fmt.Errorf("invalid port %q", lo)
|
||
}
|
||
pr := PortRange{Start: uint16(start), End: uint16(start)}
|
||
if isRange {
|
||
end, err := strconv.ParseUint(strings.TrimSpace(hi), 10, 16)
|
||
if err != nil {
|
||
return PortRange{}, fmt.Errorf("invalid port %q", hi)
|
||
}
|
||
pr.End = uint16(end)
|
||
if pr.End < pr.Start {
|
||
return PortRange{}, fmt.Errorf("port range end %d is below start %d", pr.End, pr.Start)
|
||
}
|
||
}
|
||
return pr, nil
|
||
}
|
||
|
||
// ParsePortRanges parses a separated list such as "80,443,1000-2000" into a slice
|
||
// of PortRange values. sep is the separator between entries (typically ",").
|
||
func ParsePortRanges(s, sep string) ([]PortRange, error) {
|
||
var out []PortRange
|
||
for _, tok := range strings.Split(s, sep) {
|
||
tok = strings.TrimSpace(tok)
|
||
if tok == "" {
|
||
continue
|
||
}
|
||
pr, err := ParsePortRange(tok)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, pr)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// FormatPortRanges renders a slice of ranges as a separated list.
|
||
func FormatPortRanges(prs []PortRange, sep string) string {
|
||
parts := make([]string, len(prs))
|
||
for i, pr := range prs {
|
||
parts[i] = pr.String()
|
||
}
|
||
return strings.Join(parts, sep)
|
||
}
|
||
|
||
// ConnState is a set of connection-tracking states to match, combined as a
|
||
// bitmask (e.g. StateEstablished|StateRelated). The zero value matches no
|
||
// particular state (i.e. the rule is stateless).
|
||
type ConnState uint8
|
||
|
||
const (
|
||
StateNew ConnState = 1 << iota
|
||
StateEstablished
|
||
StateRelated
|
||
StateInvalid
|
||
)
|
||
|
||
// connStateNames lists the states in canonical rendering order.
|
||
var connStateNames = []struct {
|
||
bit ConnState
|
||
name string
|
||
}{
|
||
{StateNew, "new"},
|
||
{StateEstablished, "established"},
|
||
{StateRelated, "related"},
|
||
{StateInvalid, "invalid"},
|
||
}
|
||
|
||
// Strings returns the set states as lower-case names in canonical order.
|
||
func (s ConnState) Strings() []string {
|
||
var out []string
|
||
for _, cs := range connStateNames {
|
||
if s&cs.bit != 0 {
|
||
out = append(out, cs.name)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// String renders the state set as a comma-separated list (e.g.
|
||
// "established,related"), or the empty string when no state is set.
|
||
func (s ConnState) String() string {
|
||
return strings.Join(s.Strings(), ",")
|
||
}
|
||
|
||
// ParseConnState parses state names (case-insensitive) into a ConnState bitmask.
|
||
// Each token may itself be a comma-separated list. An unknown name is an error.
|
||
func ParseConnState(tokens ...string) (ConnState, error) {
|
||
var state ConnState
|
||
for _, tok := range tokens {
|
||
for _, name := range strings.Split(tok, ",") {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
continue
|
||
}
|
||
matched := false
|
||
for _, cs := range connStateNames {
|
||
if strings.EqualFold(name, cs.name) {
|
||
state |= cs.bit
|
||
matched = true
|
||
break
|
||
}
|
||
}
|
||
if !matched {
|
||
return 0, fmt.Errorf("unknown connection state %q", name)
|
||
}
|
||
}
|
||
}
|
||
return state, nil
|
||
}
|
||
|
||
// RateUnit is the time unit a RateLimit is expressed over.
|
||
type RateUnit uint8
|
||
|
||
const (
|
||
PerSecond RateUnit = iota
|
||
PerMinute
|
||
PerHour
|
||
PerDay
|
||
)
|
||
|
||
// String returns the canonical (nftables-style) unit name.
|
||
func (u RateUnit) String() string {
|
||
switch u {
|
||
case PerMinute:
|
||
return "minute"
|
||
case PerHour:
|
||
return "hour"
|
||
case PerDay:
|
||
return "day"
|
||
}
|
||
return "second"
|
||
}
|
||
|
||
// ParseRateUnit parses a rate-unit token, accepting the long, short and
|
||
// single-letter spellings the various backends emit (e.g. second/sec/s).
|
||
func ParseRateUnit(s string) (RateUnit, error) {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "s", "sec", "second", "seconds":
|
||
return PerSecond, nil
|
||
case "m", "min", "minute", "minutes":
|
||
return PerMinute, nil
|
||
case "h", "hour", "hours":
|
||
return PerHour, nil
|
||
case "d", "day", "days":
|
||
return PerDay, nil
|
||
}
|
||
return 0, fmt.Errorf("unknown rate unit %q", s)
|
||
}
|
||
|
||
// RateLimit caps the rate at which a rule matches packets: up to Rate packets
|
||
// per Unit, with an optional Burst allowance. A nil *RateLimit on a Rule means
|
||
// no rate limiting. Backends that cannot express a rate limit reject a rule
|
||
// carrying one rather than applying it unlimited.
|
||
type RateLimit struct {
|
||
Rate uint
|
||
Unit RateUnit
|
||
Burst uint // 0 leaves the burst at the backend default.
|
||
}
|
||
|
||
// String renders the limit as "<rate>/<unit>" (e.g. "10/minute").
|
||
func (rl RateLimit) String() string {
|
||
return fmt.Sprintf("%d/%s", rl.Rate, rl.Unit)
|
||
}
|
||
|
||
// parseRateToken parses a "<rate>/<unit>" token (e.g. "10/minute") into its
|
||
// numeric rate and unit. Backends use it when decoding a rule.
|
||
func parseRateToken(tok string) (uint, RateUnit, error) {
|
||
num, unitStr, ok := strings.Cut(strings.TrimSpace(tok), "/")
|
||
if !ok {
|
||
return 0, 0, fmt.Errorf("invalid rate %q", tok)
|
||
}
|
||
n, err := strconv.ParseUint(strings.TrimSpace(num), 10, 32)
|
||
if err != nil {
|
||
return 0, 0, fmt.Errorf("invalid rate %q", tok)
|
||
}
|
||
unit, err := ParseRateUnit(unitStr)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
return uint(n), unit, nil
|
||
}
|
||
|
||
// ConnLimit caps the number of concurrent connections a rule matches. When
|
||
// PerSource is set the cap is applied per source address; otherwise it is a
|
||
// single global cap. A nil *ConnLimit means no connection limiting.
|
||
type ConnLimit struct {
|
||
Count uint
|
||
PerSource bool
|
||
}
|
||
|
||
// netfilterDefaultBurst is the burst the kernel's xt_limit applies when a rule
|
||
// names none (5). nft and iptables always print it back, and their read paths
|
||
// collapse it to 0 (unset), so a caller that sets Burst=5 is asking for exactly
|
||
// that default; normBurst folds the two spellings together.
|
||
const netfilterDefaultBurst = 5
|
||
|
||
// normBurst folds an explicit burst of the netfilter default (5) to 0 (unset)
|
||
// so a rule that names Burst=5 matches its own read-back, which reports the
|
||
// default as 0.
|
||
func normBurst(b uint) uint {
|
||
if b == netfilterDefaultBurst {
|
||
return 0
|
||
}
|
||
return b
|
||
}
|
||
|
||
// eqRateLimit reports whether two optional rate limits are equal, treating nil
|
||
// as a distinct "unset" value. The burst is compared through normBurst so an
|
||
// explicit default burst (5) and an unset burst (0) count as the same limit.
|
||
func eqRateLimit(a, b *RateLimit) bool {
|
||
if a == nil || b == nil {
|
||
return a == b
|
||
}
|
||
return a.Rate == b.Rate && a.Unit == b.Unit && normBurst(a.Burst) == normBurst(b.Burst)
|
||
}
|
||
|
||
// eqConnLimit reports whether two optional connection limits are equal, treating
|
||
// nil as a distinct "unset" value.
|
||
func eqConnLimit(a, b *ConnLimit) bool {
|
||
if a == nil || b == nil {
|
||
return a == b
|
||
}
|
||
return *a == *b
|
||
}
|
||
|
||
// familyOfAddr infers the IP family of an address or CIDR string, ignoring a
|
||
// leading '!' negation. It returns FamilyAny when the family cannot be
|
||
// determined.
|
||
func familyOfAddr(addr string) Family {
|
||
addr = strings.TrimPrefix(strings.TrimSpace(addr), "!")
|
||
if addr == "" {
|
||
return FamilyAny
|
||
}
|
||
ip, _, err := net.ParseCIDR(addr)
|
||
if err != nil {
|
||
ip = net.ParseIP(addr)
|
||
}
|
||
if ip == nil {
|
||
return FamilyAny
|
||
}
|
||
if ip.To4() == nil {
|
||
return IPv6
|
||
}
|
||
return IPv4
|
||
}
|
||
|
||
// canonAddr canonicalizes an address match-string to a stable form and reports
|
||
// whether it parsed as an IP or CIDR. It exists because backends print the same
|
||
// address differently: nft and ufw strip a /32 (or /128) host prefix and
|
||
// zero-compress IPv6, while iptables-save adds the /32 — so the literal a rule
|
||
// was written with rarely matches the literal read back. A leading "!" negation
|
||
// is preserved; a host (bare or /32,/128) normalizes to its bare canonical form;
|
||
// a network keeps its masked base and prefix. Non-IP tokens (ipset/zone names,
|
||
// MAC addresses, "any", "") do not parse and are compared verbatim by addrEqual.
|
||
func canonAddr(s string) (string, bool) {
|
||
s = strings.TrimSpace(s)
|
||
neg := ""
|
||
if strings.HasPrefix(s, "!") {
|
||
neg = "!"
|
||
s = strings.TrimSpace(s[1:])
|
||
}
|
||
if s == "" {
|
||
return "", false
|
||
}
|
||
if ip, ipnet, err := net.ParseCIDR(s); err == nil {
|
||
if ones, bits := ipnet.Mask.Size(); ones == bits {
|
||
// A host prefix (/32 or /128) is the same address as the bare host.
|
||
return neg + ip.String(), true
|
||
}
|
||
return neg + ipnet.String(), true
|
||
}
|
||
if ip := net.ParseIP(s); ip != nil {
|
||
return neg + ip.String(), true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// addrEqual reports whether two address match-strings denote the same address,
|
||
// treating a bare host and its /32 (or /128) form — and differing IPv6 spellings
|
||
// — as equal. It underpins rule identity so a rule survives the round-trip
|
||
// through a backend that re-spells addresses (see canonAddr). Tokens that are not
|
||
// IPs/CIDRs fall back to exact string comparison.
|
||
func addrEqual(a, b string) bool {
|
||
if a == b {
|
||
return true
|
||
}
|
||
ca, oka := canonAddr(a)
|
||
cb, okb := canonAddr(b)
|
||
if !oka || !okb {
|
||
return false
|
||
}
|
||
return ca == cb
|
||
}
|
||
|
||
// splitAddrNeg splits an optional leading "!" negation from an address or set
|
||
// match-string, returning whether it was negated and the bare remainder.
|
||
func splitAddrNeg(addr string) (neg bool, bare string) {
|
||
if strings.HasPrefix(addr, "!") {
|
||
return true, addr[1:]
|
||
}
|
||
return false, addr
|
||
}
|
||
|
||
// isSetRef reports whether a Source/Destination match-string names an address set
|
||
// (an ipset, an nft named set, a pf table) rather than an IP or CIDR. A set
|
||
// reference is any non-empty token that, after an optional leading "!" negation,
|
||
// does not parse as an address or subnet. An empty string means "any" and is not a
|
||
// set reference. Backends that support address sets (Capabilities().AddressSets)
|
||
// translate such a token into their native set-match syntax; the set itself is
|
||
// family-typed, so a set-referencing rule should carry a concrete Family.
|
||
func isSetRef(addr string) bool {
|
||
_, bare := splitAddrNeg(strings.TrimSpace(addr))
|
||
// An empty token and the literal "any" both mean the address wildcard, not a
|
||
// named set; canonAddr cannot parse "any", so guard it explicitly.
|
||
if bare == "" || bare == "any" {
|
||
return false
|
||
}
|
||
_, ok := canonAddr(addr)
|
||
return !ok
|
||
}
|
||
|
||
// portSpecsFor normalizes a (Port, Ports) pair into a list of port ranges:
|
||
// Ports when set, otherwise the single Port, otherwise nil.
|
||
func portSpecsFor(port uint16, ports []PortRange) []PortRange {
|
||
if len(ports) > 0 {
|
||
out := make([]PortRange, len(ports))
|
||
for i, pr := range ports {
|
||
out[i] = pr.normalized()
|
||
}
|
||
return out
|
||
}
|
||
if port != 0 {
|
||
return []PortRange{{Start: port, End: port}}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// portSpecsToRule is the inverse of portSpecsFor: it writes a parsed set of port
|
||
// ranges onto a rule's destination-port fields, using the single Port field for
|
||
// one discrete port and the Ports slice otherwise.
|
||
func portSpecsToRule(r *Rule, specs []PortRange) {
|
||
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
||
r.Port = specs[0].Start
|
||
return
|
||
}
|
||
r.Ports = specs
|
||
}
|
||
|
||
// sourcePortSpecsToRule writes a parsed set of port ranges onto a rule's
|
||
// source-port fields, mirroring portSpecsToRule for the source side.
|
||
func sourcePortSpecsToRule(r *Rule, specs []PortRange) {
|
||
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
||
r.SourcePort = specs[0].Start
|
||
return
|
||
}
|
||
r.SourcePorts = specs
|
||
}
|
||
|
||
// portNeedsConcreteProtocol reports whether a (Port, Ports) pair specifies a
|
||
// port without a concrete port-carrying protocol (tcp/udp/sctp).
|
||
func portNeedsConcreteProtocol(port uint16, ports []PortRange, proto Protocol) bool {
|
||
return (port != 0 || len(ports) > 0) && !proto.HasPorts()
|
||
}
|
||
|
||
// Rule is a firewall rule.
|
||
type Rule struct {
|
||
// Direction places the rule in the input, output or forward chain. The zero
|
||
// value (DirInput) is an input rule. A forward rule filters traffic routed
|
||
// through the host and may match both an inbound and an outbound interface;
|
||
// backends that cannot express a forward chain reject such a rule with
|
||
// ErrUnsupportedForward (see Capabilities.Forward).
|
||
Direction Direction
|
||
Priority int
|
||
Family Family
|
||
Source string
|
||
Destination string
|
||
// Port is the single destination port to match. For a port list or range,
|
||
// use Ports instead; when Ports is non-empty it takes precedence over Port.
|
||
Port uint16
|
||
// Ports is a list of destination port ranges to match. A single port is a
|
||
// range whose Start and End are equal. Backends that cannot express a
|
||
// multi-port match reject such a rule rather than silently narrowing it.
|
||
Ports []PortRange
|
||
// SourcePort is the single source port to match. For a port list or range,
|
||
// use SourcePorts instead; when SourcePorts is non-empty it takes precedence
|
||
// over SourcePort.
|
||
SourcePort uint16
|
||
// SourcePorts is a list of source port ranges to match. Backends that cannot
|
||
// express source-port matching reject a rule carrying one rather than silently
|
||
// omit the match.
|
||
SourcePorts []PortRange
|
||
Proto Protocol
|
||
// ICMPType, when set, restricts an ICMP/ICMPv6 rule to a single ICMP message
|
||
// type (e.g. 8 for echo-request). A nil pointer matches every ICMP type. It
|
||
// is only meaningful when Proto is ICMP or ICMPv6.
|
||
ICMPType *uint8
|
||
// State restricts the rule to the given connection-tracking states (e.g.
|
||
// StateEstablished|StateRelated). The zero value applies no state match.
|
||
State ConnState
|
||
// InInterface matches the inbound interface; OutInterface matches the outbound
|
||
// interface. An input rule matches only InInterface, an output rule only
|
||
// OutInterface, and a forward rule may match either or both (ingress and
|
||
// egress). Empty means any interface.
|
||
InInterface string
|
||
OutInterface string
|
||
Action Action
|
||
// Log, when set, logs each matched packet before the Action is applied.
|
||
// LogPrefix is an optional label attached to the log line. Backends that
|
||
// cannot express per-rule logging reject a rule carrying it.
|
||
Log bool
|
||
LogPrefix string
|
||
// RateLimit caps the packet rate the rule matches; ConnLimit caps the
|
||
// concurrent connections it matches. A nil pointer applies no limit. Both
|
||
// are rejected by backends that cannot express them.
|
||
RateLimit *RateLimit
|
||
ConnLimit *ConnLimit
|
||
// Packets and Bytes are per-rule counters, populated by GetRules on backends
|
||
// that can read them (nftables, iptables and pf). They are zero on backends
|
||
// that cannot read counters, and are ignored when adding a rule. They are
|
||
// not part of rule identity, so two rules that differ only in counters are
|
||
// considered equal.
|
||
Packets uint64
|
||
Bytes uint64
|
||
// Comment is an optional human-readable label carried alongside the rule on
|
||
// backends that can store one (an iptables/nftables comment, a pf label, a
|
||
// CSF/APF trailing comment, a WFP description). Like Packets/Bytes it is
|
||
// informational: it is not part of rule identity, so two rules that differ
|
||
// only in their comment are considered equal, and backends that cannot store
|
||
// a comment ignore it when adding a rule and report it empty on read. A
|
||
// caller can check Capabilities().Comments to learn whether it round-trips.
|
||
Comment string
|
||
// HasPrefix reports whether this rule carries the library's configured prefix.
|
||
// For backends that tag rules with the configured prefix — in a comment, or (for
|
||
// name-based backends) in the rule name — it reports whether that prefix is
|
||
// present. For container backends (nft, pf, firewalld) that isolate rules in a
|
||
// private table/anchor/zone, it reports whether the rule lives in that container
|
||
// (recorded in the unexported table field). It is derived on read and purely
|
||
// informational — the library never branches on it: GetRules returns every rule
|
||
// the backend can see, and HasPrefix lets callers tell which ones the library
|
||
// created. An empty rule prefix gives a tag-based backend no namespace of its
|
||
// own, so its rules report HasPrefix=false.
|
||
// Like Comment and Packets/Bytes it is not part of rule identity, so two rules
|
||
// that differ only in HasPrefix are considered equal, and it is ignored when
|
||
// adding a rule.
|
||
HasPrefix bool
|
||
// Number is the rule's 1-based position within its chain in the backend's native
|
||
// ordering, populated by GetRules on backends that support explicit ordering
|
||
// (Capabilities().RuleOrdering). It mirrors the position argument of InsertRule
|
||
// and MoveRule, so a caller can read a rule's Number and pass it back to reorder.
|
||
// It is zero on backends without ordering and on rules outside the backend's
|
||
// ordered set (foreign rules in a container backend's read). A rule the backend
|
||
// stores as one row per IP family is reported once per row, each with its own
|
||
// Number. Like HasPrefix and Packets/Bytes it is derived on read, ignored when
|
||
// adding a rule, and not part of rule identity.
|
||
Number int
|
||
// table records the backend container a container backend (an nft table, a pf
|
||
// anchor, a firewalld zone) read this rule from; it is empty for tag-based
|
||
// backends. It is unexported and informational — it backs HasPrefix for the
|
||
// container backends and, like HasPrefix, is not part of rule identity.
|
||
table string
|
||
// meterSet records the dynamic set a per-source connection-limit row counts
|
||
// in, captured on read by the nftables backend so a removal can also clear
|
||
// the set. It is unexported, derived on read, and not part of rule identity.
|
||
meterSet string
|
||
}
|
||
|
||
// directionFromOutput maps an input/output boolean to a Direction. Backends whose
|
||
// native config distinguishes only inbound from outbound use it when decoding a
|
||
// rule (a routed/forward rule is expressed through their raw-iptables hook, not
|
||
// their native config, so it is never decoded here). It only ever yields a concrete
|
||
// DirInput or DirOutput; a backend reports DirAny only for an entry whose native
|
||
// form covers both directions, which it decodes itself.
|
||
func directionFromOutput(output bool) Direction {
|
||
if output {
|
||
return DirOutput
|
||
}
|
||
return DirInput
|
||
}
|
||
|
||
// IsInput reports whether the rule is an input (inbound) rule. A DirAny rule is
|
||
// not an input rule; use AppliesInput to ask whether a rule materializes into the
|
||
// input chain.
|
||
func (r *Rule) IsInput() bool { return r.Direction == DirInput }
|
||
|
||
// IsOutput reports whether the rule is an output (outbound) rule. A DirAny rule is
|
||
// not an output rule; use AppliesOutput for chain materialization.
|
||
func (r *Rule) IsOutput() bool { return r.Direction == DirOutput }
|
||
|
||
// IsForward reports whether the rule is a forward (routing) rule.
|
||
func (r *Rule) IsForward() bool { return r.Direction == DirForward }
|
||
|
||
// IsAny reports whether the rule applies to both directions (DirAny).
|
||
func (r *Rule) IsAny() bool { return r.Direction == DirAny }
|
||
|
||
// AppliesInput reports whether the rule materializes into the input chain — a
|
||
// concrete input rule or a both-directions DirAny rule.
|
||
func (r *Rule) AppliesInput() bool { return r.Direction == DirInput || r.Direction == DirAny }
|
||
|
||
// AppliesOutput reports whether the rule materializes into the output chain — a
|
||
// concrete output rule or a both-directions DirAny rule.
|
||
func (r *Rule) AppliesOutput() bool { return r.Direction == DirOutput || r.Direction == DirAny }
|
||
|
||
// PortSpecs returns the rule's destination ports as a normalized list of
|
||
// ranges: Ports when set, otherwise the single Port, otherwise nil.
|
||
func (r *Rule) PortSpecs() []PortRange {
|
||
return portSpecsFor(r.Port, r.Ports)
|
||
}
|
||
|
||
// SourcePortSpecs returns the rule's source ports as a normalized list of
|
||
// ranges: SourcePorts when set, otherwise the single SourcePort, otherwise nil.
|
||
func (r *Rule) SourcePortSpecs() []PortRange {
|
||
return portSpecsFor(r.SourcePort, r.SourcePorts)
|
||
}
|
||
|
||
// HasPorts reports whether the rule matches on any destination port.
|
||
func (r *Rule) HasPorts() bool {
|
||
return r.Port != 0 || len(r.Ports) > 0
|
||
}
|
||
|
||
// HasSourcePorts reports whether the rule matches on any source port.
|
||
func (r *Rule) HasSourcePorts() bool {
|
||
return r.SourcePort != 0 || len(r.SourcePorts) > 0
|
||
}
|
||
|
||
// perSourceLimited reports whether the rule carries a per-source connection
|
||
// limit, the form whose count is keyed on the source address.
|
||
func (r *Rule) perSourceLimited() bool {
|
||
return r.ConnLimit != nil && r.ConnLimit.PerSource
|
||
}
|
||
|
||
// HasPortSet reports whether the rule matches more than a single discrete port
|
||
// (a list, or a range spanning more than one port). Backends limited to a single
|
||
// port use this to reject rules they cannot represent.
|
||
func (r *Rule) HasPortSet() bool {
|
||
specs := r.PortSpecs()
|
||
if len(specs) > 1 {
|
||
return true
|
||
}
|
||
if len(specs) == 1 && specs[0].Start != specs[0].End {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// HasSourcePortSet reports whether the rule matches more than a single discrete
|
||
// source port.
|
||
func (r *Rule) HasSourcePortSet() bool {
|
||
specs := r.SourcePortSpecs()
|
||
if len(specs) > 1 {
|
||
return true
|
||
}
|
||
if len(specs) == 1 && specs[0].Start != specs[0].End {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// PortNeedsConcreteProtocol reports whether the rule specifies a destination or
|
||
// source port without a concrete port-carrying protocol (TCP/UDP/SCTP). Most
|
||
// firewall backends cannot express a port match without such a protocol, so they
|
||
// use this to reject such a rule rather than silently widening it (matching every
|
||
// protocol) or emitting an invalid rule. A ported `any` in a ufw tuple is not this
|
||
// shape: it means tcp+udp and is modeled as TCPUDP.
|
||
func (r *Rule) PortNeedsConcreteProtocol() bool {
|
||
return portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) || portNeedsConcreteProtocol(r.SourcePort, r.SourcePorts, r.Proto)
|
||
}
|
||
|
||
// impliedFamily returns the family a rule effectively targets, resolving
|
||
// FamilyAny from an ICMP protocol (ICMP => IPv4, ICMPv6 => IPv6) or, failing
|
||
// that, from a concrete source/destination address. A rule that names an IPv4
|
||
// address is an IPv4 rule even when its Family was left unset, so inferring it
|
||
// keeps the rule out of the wrong-family save file (an IPv4 address in an
|
||
// ip6tables ruleset is rejected on load).
|
||
func (r *Rule) impliedFamily() Family {
|
||
if r.Family != FamilyAny {
|
||
return r.Family
|
||
}
|
||
switch r.Proto {
|
||
case ICMP:
|
||
return IPv4
|
||
case ICMPv6:
|
||
return IPv6
|
||
}
|
||
for _, a := range []string{r.Source, r.Destination} {
|
||
if fam := familyOfAddr(a); fam != FamilyAny {
|
||
return fam
|
||
}
|
||
}
|
||
return FamilyAny
|
||
}
|
||
|
||
// directionSwapped returns a copy of r with its source and destination roles
|
||
// swapped: the transform between a rule's inbound and outbound materialization.
|
||
// A rule that matches inbound traffic from a host (Source=X, dport=P, in-iface)
|
||
// matches the same flow outbound as traffic to that host (Destination=X, sport=P,
|
||
// out-iface), so the source/destination address, the source/destination ports and
|
||
// the in/out interface all swap sides. Everything protocol- or policy-bound (Proto,
|
||
// ICMPType, State, Action, Log, rate/conn limits, Family, Priority, Comment,
|
||
// counters, Number, table) is direction-independent and is left untouched. It backs
|
||
// the DirAny write-side fan-out (expandDirections), the direction split on removal,
|
||
// and the inbound-frame comparison rule identity uses. The Ports/SourcePorts slice
|
||
// headers are swapped, not their elements; callers do not mutate them, matching
|
||
// splitDualRow's shallow-copy style.
|
||
func (r *Rule) directionSwapped() *Rule {
|
||
s := *r
|
||
s.Source, s.Destination = r.Destination, r.Source
|
||
s.Port, s.SourcePort = r.SourcePort, r.Port
|
||
s.Ports, s.SourcePorts = r.SourcePorts, r.Ports
|
||
s.InInterface, s.OutInterface = r.OutInterface, r.InInterface
|
||
return &s
|
||
}
|
||
|
||
// canonicalMatch expresses a rule's match fields in the inbound (input-chain)
|
||
// frame so two rules stored in opposite directions can be compared like-for-like.
|
||
// An output rule is role-swapped into the inbound frame; input, forward and DirAny
|
||
// rules are already inbound-framed (a DirAny rule is authored inbound). It does not
|
||
// change Direction — direction coverage is decided separately by coversDirection.
|
||
func (r *Rule) canonicalMatch() *Rule {
|
||
if r.Direction == DirOutput {
|
||
return r.directionSwapped()
|
||
}
|
||
return r
|
||
}
|
||
|
||
// portRangeInSpecs reports whether pr equals any range in specs (normalized).
|
||
func portRangeInSpecs(pr PortRange, specs []PortRange) bool {
|
||
pr = pr.normalized()
|
||
for _, sp := range specs {
|
||
if sp.normalized() == pr {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// coalescePortRanges normalizes a port-range list to its minimal canonical form:
|
||
// each range is normalized, the list is sorted, and overlapping or directly
|
||
// contiguous ranges are merged. It exists because several backends re-spell a
|
||
// port set on read — nft in particular lists an anonymous set back with adjacent
|
||
// and overlapping ranges merged, so "{22,23,24-30}" comes back as "{22-30}". Rule
|
||
// identity compares port sets through this canonical form (see portRangesEqual)
|
||
// so such a rule still matches its own read-back and Sync does not churn.
|
||
func coalescePortRanges(prs []PortRange) []PortRange {
|
||
if len(prs) == 0 {
|
||
return nil
|
||
}
|
||
cp := make([]PortRange, len(prs))
|
||
for i, pr := range prs {
|
||
cp[i] = pr.normalized()
|
||
}
|
||
sort.Slice(cp, func(i, j int) bool {
|
||
if cp[i].Start != cp[j].Start {
|
||
return cp[i].Start < cp[j].Start
|
||
}
|
||
return cp[i].End < cp[j].End
|
||
})
|
||
out := []PortRange{cp[0]}
|
||
for _, pr := range cp[1:] {
|
||
last := &out[len(out)-1]
|
||
// Merge when the next range overlaps the current one or begins exactly one
|
||
// past its end (a contiguous span). The End<65535 guard avoids a uint16
|
||
// wrap when the current range already reaches the maximum port.
|
||
if pr.Start <= last.End || (last.End < 65535 && pr.Start == last.End+1) {
|
||
if pr.End > last.End {
|
||
last.End = pr.End
|
||
}
|
||
continue
|
||
}
|
||
out = append(out, pr)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// portRangesEqual compares two port-range lists as sets, treating overlapping or
|
||
// contiguous ranges that cover the same ports as equal (see coalescePortRanges).
|
||
func portRangesEqual(a, b []PortRange) bool {
|
||
ac := coalescePortRanges(a)
|
||
bc := coalescePortRanges(b)
|
||
if len(ac) != len(bc) {
|
||
return false
|
||
}
|
||
for i := range ac {
|
||
if ac[i] != bc[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// eqU8Ptr reports whether two optional uint8 values are equal, treating nil as a
|
||
// distinct "unset" value.
|
||
func eqU8Ptr(a, b *uint8) bool {
|
||
if a == nil || b == nil {
|
||
return a == b
|
||
}
|
||
return *a == *b
|
||
}
|
||
|
||
// matchFields reports whether the non-family match fields of two rules are
|
||
// equal. It is the shared core of Equal and EqualBase.
|
||
func (r *Rule) matchFields(rule *Rule, directionHonored bool) bool {
|
||
if r.Direction != rule.Direction && directionHonored {
|
||
return false
|
||
}
|
||
// Priority orders a rule relative to the others (firewalld rich rules), so two
|
||
// rules that differ only in priority are distinct — otherwise a reconcile could
|
||
// never change a rule's priority. Backends without per-rule priority leave it 0,
|
||
// so this never affects them.
|
||
if r.Priority != rule.Priority {
|
||
return false
|
||
}
|
||
if !addrEqual(r.Source, rule.Source) || !addrEqual(r.Destination, rule.Destination) {
|
||
return false
|
||
}
|
||
if !portRangesEqual(r.PortSpecs(), rule.PortSpecs()) {
|
||
return false
|
||
}
|
||
if !portRangesEqual(r.SourcePortSpecs(), rule.SourcePortSpecs()) {
|
||
return false
|
||
}
|
||
if r.Proto != rule.Proto {
|
||
return false
|
||
}
|
||
if !eqU8Ptr(r.ICMPType, rule.ICMPType) {
|
||
return false
|
||
}
|
||
if r.State != rule.State {
|
||
return false
|
||
}
|
||
if r.InInterface != rule.InInterface || r.OutInterface != rule.OutInterface {
|
||
return false
|
||
}
|
||
if r.Action != rule.Action {
|
||
return false
|
||
}
|
||
// Logging and rate/connection limits change the rule's effect, so two rules
|
||
// that differ only in these are distinct (they are not deduplicated, and a
|
||
// removal must name the same modifiers it was added with).
|
||
if r.Log != rule.Log || r.LogPrefix != rule.LogPrefix {
|
||
return false
|
||
}
|
||
if !eqRateLimit(r.RateLimit, rule.RateLimit) {
|
||
return false
|
||
}
|
||
if !eqConnLimit(r.ConnLimit, rule.ConnLimit) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Equal reports whether two rules are the same. Family is compared through
|
||
// impliedFamily so a FamilyAny rule matches the concrete family its own content
|
||
// forces (an ICMP rule is IPv4, an ICMPv6 rule IPv6, an addressed rule its
|
||
// address's family). outputHonored is false on a backend with no output concept
|
||
// (Capabilities().Output), where direction does not distinguish two rules.
|
||
func (r *Rule) Equal(rule *Rule, outputHonored bool) bool {
|
||
if r.impliedFamily() != rule.impliedFamily() {
|
||
return false
|
||
}
|
||
return r.matchFields(rule, outputHonored)
|
||
}
|
||
|
||
// EqualBase reports whether two rules are the same, ignoring family.
|
||
// outputHonored has Equal's meaning.
|
||
func (r *Rule) EqualBase(rule *Rule, outputHonored bool) bool {
|
||
return r.matchFields(rule, outputHonored)
|
||
}
|
||
|
||
// coversDirection reports whether an existing rule's direction (have) already
|
||
// covers a caller rule's direction (want) — the asymmetric add/dedup form. DirAny
|
||
// spans input and output, so it covers either; it never covers DirForward (a
|
||
// routed rule has no in/out twin). When outputHonored is false the backend has no
|
||
// output concept, so input and output never distinguish two rules — but a
|
||
// forward rule is still its own chain and only covered by another forward rule.
|
||
func coversDirection(have, want Direction, outputHonored bool) bool {
|
||
if !outputHonored {
|
||
return (have == DirForward) == (want == DirForward)
|
||
}
|
||
if have == want {
|
||
return true
|
||
}
|
||
return have == DirAny && (want == DirInput || want == DirOutput)
|
||
}
|
||
|
||
// coversDirectionRemoval reports whether two rules touch a common direction (the
|
||
// symmetric remove/move form): a DirAny on either side spans input and output, so
|
||
// it touches any concrete direction and vice versa. DirForward stands alone.
|
||
func coversDirectionRemoval(a, b Direction, outputHonored bool) bool {
|
||
if !outputHonored {
|
||
return (a == DirForward) == (b == DirForward)
|
||
}
|
||
if a == b {
|
||
return true
|
||
}
|
||
if a == DirAny && (b == DirInput || b == DirOutput) {
|
||
return true
|
||
}
|
||
if b == DirAny && (a == DirInput || a == DirOutput) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// EqualForDedup reports whether the receiver (an existing rule) already makes o
|
||
// redundant on add: the same base rule, with the receiver's family, direction
|
||
// and transport (a TCPUDP row covers its tcp/udp halves) all covering o's. It is the family- and direction-aware add guard the container
|
||
// backends need because EqualBase ignores Family — without the coverage check,
|
||
// adding an IPv6 rule whose IPv4 twin already exists would be dropped as a false
|
||
// duplicate, leaving that family unprotected. Family and direction are checked
|
||
// first so a non-covering row skips the field compare. Coverage: a FamilyAny
|
||
// receiver covers either family, a DirAny receiver covers either direction; a
|
||
// concrete value covers only its own. The match fields are compared in the inbound
|
||
// frame (canonicalMatch) so a DirAny row and a concrete DirOutput target line up,
|
||
// with direction excluded from the field compare since coversDirection already
|
||
// gated it.
|
||
func (r *Rule) EqualForDedup(o *Rule, outputHonored bool) bool {
|
||
return r.covers(o, outputHonored)
|
||
}
|
||
|
||
// coversFamily reports whether an existing rule's family (have) already covers a caller
|
||
// rule's family want. FamilyAny spans both IP families, so it covers either; a
|
||
// concrete family covers only itself. Both sides are implied families, so an
|
||
// address- or ICMP-pinned rule is compared by the family it actually targets.
|
||
func coversFamily(have, want Family) bool {
|
||
return have == FamilyAny || have == want
|
||
}
|
||
|
||
// covers is the coverage relation behind Covers and EqualForDedup: r's match is the
|
||
// same as o's in every ordinary field, and r's family, transport and direction each
|
||
// span o's. outputHonored is false on a backend with no output concept, where
|
||
// direction never distinguishes two rules.
|
||
func (r *Rule) covers(o *Rule, outputHonored bool) bool {
|
||
if !coversFamily(r.impliedFamily(), o.impliedFamily()) {
|
||
return false
|
||
}
|
||
if !coversDirection(r.Direction, o.Direction, outputHonored) {
|
||
return false
|
||
}
|
||
if !coversProtocol(r.Proto, o.Proto) {
|
||
return false
|
||
}
|
||
// Protocol is gated above, so neutralize it on the tcp/udp axis rather than let
|
||
// matchFields re-test it exactly — a TCPUDP row must absorb a concrete TCP add.
|
||
return r.canonicalMatch().protoNeutralized().EqualBase(o.canonicalMatch().protoNeutralized(), false)
|
||
}
|
||
|
||
// Covers reports whether the receiver's coverage contains o's: the same match in
|
||
// every ordinary field, with the receiver's family, transport and direction each
|
||
// spanning o's. FamilyAny spans both IP families, TCPUDP spans TCP and UDP, and
|
||
// DirAny spans input and output; a concrete value spans only itself. ProtocolAny is
|
||
// not a multi-state value — it matches every IP protocol — so it covers only
|
||
// ProtocolAny.
|
||
//
|
||
// It is the exported form of the coverage relation the library reasons with. A caller
|
||
// holding a rule read back from GetRules uses it to tell whether that rule already
|
||
// contains one it is about to add, rather than re-deriving the per-axis rules. It is
|
||
// asymmetric: a TCPUDP rule covers its TCP half, never the reverse. Direction is
|
||
// always honored; a backend that has no output concept reports Capabilities().Output
|
||
// false and folds a DirAny rule to its input half on write.
|
||
func (r *Rule) Covers(o *Rule) bool {
|
||
return r.covers(o, true)
|
||
}
|
||
|
||
// EqualForRemoval reports whether the receiver (an existing row) should be acted
|
||
// on when the caller targets o: the same base rule, and o's family, transport and
|
||
// direction each touch the row's. It is the overlap relation a removal walks the
|
||
// stored rows with: a target removes every row it covers, and also matches a row
|
||
// that covers more than the target, which the backend then deletes and re-adds
|
||
// minus the targeted cell (splitMergedRow). A FamilyAny/TCPUDP/DirAny target
|
||
// matches every row on that axis, such a row matches any target, and otherwise
|
||
// the values must match so acting on one twin never disturbs the other. The
|
||
// field compare runs in the inbound frame (canonicalMatch) with direction
|
||
// excluded, since the axis gates above already decided it.
|
||
func (r *Rule) EqualForRemoval(o *Rule, outputHonored bool) bool {
|
||
ft, fr := o.impliedFamily(), r.impliedFamily()
|
||
if ft != FamilyAny && fr != FamilyAny && ft != fr {
|
||
return false
|
||
}
|
||
if !coversDirectionRemoval(r.Direction, o.Direction, outputHonored) {
|
||
return false
|
||
}
|
||
if !coversProtocolRemoval(r.Proto, o.Proto) {
|
||
return false
|
||
}
|
||
// Protocol is gated above; neutralize it on the tcp/udp axis so a TCPUDP row
|
||
// matches a concrete-transport target (the caller then splits it) and a TCPUDP
|
||
// target matches each concrete row it covers.
|
||
return r.canonicalMatch().protoNeutralized().EqualBase(o.canonicalMatch().protoNeutralized(), false)
|
||
}
|
||
|
||
// oppositeFamily returns the other concrete IP family: IPv4 for IPv6 and vice
|
||
// versa. FamilyAny has no opposite and returns FamilyAny. It supports the
|
||
// dual-row split on removal, where deleting a single dual-family row to satisfy a
|
||
// concrete-family target must re-add the family the caller did not target.
|
||
func oppositeFamily(f Family) Family {
|
||
switch f {
|
||
case IPv4:
|
||
return IPv6
|
||
case IPv6:
|
||
return IPv4
|
||
default:
|
||
return FamilyAny
|
||
}
|
||
}
|
||
|
||
// splitDualRow returns the rule a backend must re-add after deleting a genuine
|
||
// dual-family row — a single stored object with no family pin that covers both
|
||
// families — to satisfy a concrete-family removal: a copy of the stored row
|
||
// pinned to the family the caller did NOT target, so that family's coverage
|
||
// survives the delete. It returns nil when no split applies: the target is
|
||
// family-agnostic (the whole rule is meant to go), or the matched row is itself
|
||
// concrete-family (it removes only its own family and never a twin). Backends
|
||
// whose model cannot express the surviving single-family rule reject the removal
|
||
// with ErrUnsupported instead of calling this.
|
||
func splitDualRow(matched, target *Rule) *Rule {
|
||
tf := target.impliedFamily()
|
||
if tf == FamilyAny || matched.impliedFamily() != FamilyAny {
|
||
return nil
|
||
}
|
||
opp := *matched
|
||
opp.Family = oppositeFamily(tf)
|
||
return &opp
|
||
}
|
||
|
||
// splitNATDualRow is splitDualRow's NAT analog: the copy of a genuine
|
||
// dual-family NAT row (a single stored translation with no family pin) that a
|
||
// backend re-adds after deleting it for a concrete-family removal, pinned to
|
||
// the family the caller did NOT target. It returns nil when no split applies.
|
||
// Family is the only axis a NAT rule spans.
|
||
func splitNATDualRow(matched, target *NATRule) *NATRule {
|
||
tf := target.impliedFamily()
|
||
if tf == FamilyAny || matched.impliedFamily() != FamilyAny {
|
||
return nil
|
||
}
|
||
opp := *matched
|
||
opp.Family = oppositeFamily(tf)
|
||
return &opp
|
||
}
|
||
|
||
// expandDirections returns the concrete-direction rows a rule materializes into on
|
||
// write: itself for a concrete direction, or an inbound (DirInput) row plus its
|
||
// role-swapped outbound (DirOutput) twin for a DirAny rule. Backends call it before
|
||
// their existing per-family fan-out so per-chain marshalling never has to reason
|
||
// about DirAny. The returned rows are copies; the caller's rule is untouched.
|
||
func expandDirections(r *Rule) []*Rule {
|
||
if r.Direction != DirAny {
|
||
return []*Rule{r}
|
||
}
|
||
in := *r
|
||
in.Direction = DirInput
|
||
out := r.directionSwapped()
|
||
out.Direction = DirOutput
|
||
return []*Rule{&in, out}
|
||
}
|
||
|
||
// expandProtocols returns the concrete-transport rows a rule materializes into on
|
||
// write: itself for a single protocol, or a TCP row plus a UDP row for a TCPUDP
|
||
// rule. Backends whose native config has no both-transports form call it before
|
||
// marshalling so per-row emission never has to reason about TCPUDP. The returned
|
||
// rows are copies; the caller's rule is untouched. It is the protocol analog of
|
||
// expandDirections.
|
||
func expandProtocols(r *Rule) []*Rule {
|
||
if r.Proto != TCPUDP {
|
||
return []*Rule{r}
|
||
}
|
||
tcp, udp := *r, *r
|
||
tcp.Proto, udp.Proto = TCP, UDP
|
||
return []*Rule{&tcp, &udp}
|
||
}
|
||
|
||
// expandFamilies returns the concrete-family rows a rule materializes into: itself
|
||
// when it already targets one family, or an IPv4 row plus an IPv6 row when it targets
|
||
// both. It reads the implied family, so a rule pinned by an address or an ICMP
|
||
// protocol is never split. Backends fan families out in their own way (a save file
|
||
// per family, a family-less inet row, a dual-stack config list), so this exists for
|
||
// the coverage math in cells/CoveredBy rather than for any write path.
|
||
func expandFamilies(r *Rule) []*Rule {
|
||
if r.impliedFamily() != FamilyAny {
|
||
return []*Rule{r}
|
||
}
|
||
v4, v6 := *r, *r
|
||
v4.Family, v6.Family = IPv4, IPv6
|
||
return []*Rule{&v4, &v6}
|
||
}
|
||
|
||
// cells returns the concrete rules r covers: the cross product of its three merged
|
||
// axes, each expanded to the values it spans. A FamilyAny + TCPUDP + DirAny rule
|
||
// yields eight cells; a fully concrete rule yields itself. The direction expansion
|
||
// role-swaps the outbound half, so each cell is stated in its own natural frame —
|
||
// covers compares in the inbound frame, so that swap round-trips. On a backend with
|
||
// no output concept (outputHonored false) the direction axis does not distinguish
|
||
// two rules, so it is not expanded.
|
||
func (r *Rule) cells(outputHonored bool) []*Rule {
|
||
dirs := []*Rule{r}
|
||
if outputHonored {
|
||
dirs = expandDirections(r)
|
||
}
|
||
var out []*Rule
|
||
for _, d := range dirs {
|
||
for _, p := range expandProtocols(d) {
|
||
out = append(out, expandFamilies(p)...)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// coveredBy is the coverage relation behind CoveredBy, with the direction axis
|
||
// gated on whether the backend distinguishes output rules at all.
|
||
func (r *Rule) coveredBy(rules []*Rule, outputHonored bool) bool {
|
||
for _, cell := range r.cells(outputHonored) {
|
||
covered := false
|
||
for _, have := range rules {
|
||
if have.covers(cell, outputHonored) {
|
||
covered = true
|
||
break
|
||
}
|
||
}
|
||
if !covered {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// CoveredBy reports whether every concrete rule the receiver spans is covered by at
|
||
// least one rule in rules. It is the set form of Covers, and its inverse: where
|
||
// a.Covers(b) asks whether one rule contains another, b.CoveredBy([]*Rule{a}) asks
|
||
// whether a set contains one.
|
||
//
|
||
// A rule that spans several axes is rarely stored as one object: GetRules reports the
|
||
// firewall's actual rows, so a rule the caller authored as FamilyAny may read back as
|
||
// an IPv4 row and an IPv6 row on a backend that cannot store one family-agnostic row.
|
||
// Such a rule is fully present in the set even though no single member Covers it, so
|
||
// coverage is decided cell by cell rather than rule by rule. A caller uses it to
|
||
// decide whether a rule is already installed before adding it.
|
||
//
|
||
// It expands the receiver across family, transport and direction and requires every
|
||
// resulting cell to be covered, so a rule spanning both transports is not reported
|
||
// present when only its TCP half is. The receiver is not modified.
|
||
func (r *Rule) CoveredBy(rules []*Rule) bool {
|
||
return r.coveredBy(rules, true)
|
||
}
|
||
|
||
// splitDualRowProtocol returns the rule a backend must re-add after deleting one
|
||
// transport of a genuine TCPUDP row — a single stored rule covering both TCP and
|
||
// UDP — to satisfy a concrete-protocol removal: the surviving opposite transport.
|
||
// It mirrors splitDualRow for the protocol axis. It returns nil when no split
|
||
// applies: the matched row is not a merged TCPUDP row, or the target names no
|
||
// single transport (so the whole row goes).
|
||
func splitDualRowProtocol(matched, target *Rule) *Rule {
|
||
if matched.Proto != TCPUDP {
|
||
return nil
|
||
}
|
||
opp := oppositeProtocol(target.Proto)
|
||
if opp == ProtocolAny {
|
||
return nil
|
||
}
|
||
s := *matched
|
||
s.Proto = opp
|
||
return &s
|
||
}
|
||
|
||
// splitMergedRow returns the rows a backend must re-add after deleting a single
|
||
// stored row that covered more than the caller targeted. A row may be merged on two
|
||
// axes at once — nftables' inet table holds a FamilyAny rule as one unpinned row,
|
||
// and a TCPUDP rule as one `meta l4proto { tcp, udp }` row — so removing one cell of
|
||
// that family×transport grid can leave a remainder that needs two rows to express.
|
||
// It composes the per-axis splits: the untargeted family keeps the row's full
|
||
// transport coverage, and the untargeted transport is then scoped to the family the
|
||
// target named, so the two rows never overlap. It returns nil when the target covers
|
||
// the whole row.
|
||
func splitMergedRow(matched, target *Rule) []*Rule {
|
||
var out []*Rule
|
||
if s := splitDualRow(matched, target); s != nil {
|
||
out = append(out, s)
|
||
}
|
||
if s := splitDualRowProtocol(matched, target); s != nil {
|
||
// A family split above already re-added the untargeted family across both
|
||
// transports, so this row must not repeat it: pin it to the targeted family.
|
||
if len(out) > 0 {
|
||
s.Family = target.impliedFamily()
|
||
}
|
||
out = append(out, s)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// coversProtocol reports whether an existing rule's protocol (have) already covers a
|
||
// caller rule's protocol want (the asymmetric add/dedup form). TCPUDP spans TCP and
|
||
// UDP, so it covers either; every other protocol covers only itself. ProtocolAny is
|
||
// not a merged value — it matches every IP protocol — so it covers only ProtocolAny.
|
||
func coversProtocol(have, want Protocol) bool {
|
||
if have == want {
|
||
return true
|
||
}
|
||
return have == TCPUDP && (want == TCP || want == UDP)
|
||
}
|
||
|
||
// coversProtocolRemoval reports whether two rules touch a common transport (the
|
||
// symmetric remove/move form): a TCPUDP on either side spans TCP and UDP, so it
|
||
// touches either concrete transport and vice versa.
|
||
func coversProtocolRemoval(a, b Protocol) bool {
|
||
if a == b {
|
||
return true
|
||
}
|
||
if a == TCPUDP && (b == TCP || b == UDP) {
|
||
return true
|
||
}
|
||
if b == TCPUDP && (a == TCP || a == UDP) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// protoNeutralized returns a copy of r with its protocol cleared to TCPUDP when it
|
||
// sits on the merged tcp/udp axis, so the field compare in EqualForDedup and
|
||
// EqualForRemoval does not re-test a protocol coversProtocol has already gated.
|
||
// Every other protocol is returned unchanged, keeping matchFields' exact protocol
|
||
// equality for rules that never merge.
|
||
func (r *Rule) protoNeutralized() *Rule {
|
||
if !onProtocolAxis(r.Proto) {
|
||
return r
|
||
}
|
||
c := *r
|
||
c.Proto = TCPUDP
|
||
return &c
|
||
}
|
||
|
||
// onProtocolAxis reports whether a protocol participates in the tcp/udp merge:
|
||
// the two concrete transports and their merged TCPUDP form.
|
||
func onProtocolAxis(p Protocol) bool {
|
||
return p == TCP || p == UDP || p == TCPUDP
|
||
}
|
||
|
||
// splitDualRowDirection returns the rule a backend must re-add after deleting one
|
||
// direction of a genuine DirAny row — a single stored object covering both the
|
||
// input and output directions — to satisfy a concrete-direction removal: the
|
||
// surviving opposite-direction rule, materialized in its natural frame. It mirrors
|
||
// splitDualRow for the direction axis. It returns nil when no split applies: the
|
||
// matched row is itself a concrete direction (it removes only itself, its twin
|
||
// living in a separate physical row), or the target is direction-agnostic
|
||
// (DirAny/DirForward, so the whole rule is meant to go). Backends whose model
|
||
// cannot express the surviving single-direction rule reject the removal with
|
||
// ErrUnsupported instead of calling this.
|
||
func splitDualRowDirection(matched, target *Rule) *Rule {
|
||
if matched.Direction != DirAny {
|
||
return nil
|
||
}
|
||
switch target.Direction {
|
||
case DirInput:
|
||
// The input cell is removed; the output cell survives, in its natural
|
||
// outbound frame (the stored DirAny row is inbound-framed).
|
||
s := matched.directionSwapped()
|
||
s.Direction = DirOutput
|
||
return s
|
||
case DirOutput:
|
||
// The output cell is removed; the input cell survives unchanged in frame.
|
||
s := *matched
|
||
s.Direction = DirInput
|
||
return &s
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// unsupportedNAT is the error a backend returns from its NAT methods when its
|
||
// model cannot express network address translation. backend names the backend.
|
||
func unsupportedNAT(backend string) error {
|
||
return fmt.Errorf("%s does not support NAT in this model: %w", backend, ErrUnsupportedNAT)
|
||
}
|
||
|
||
// unsupportedForward is the error a backend returns when it cannot express a
|
||
// rule in the forward (routing) chain. backend names the backend.
|
||
func unsupportedForward(backend string) error {
|
||
return fmt.Errorf("%s does not support forward-chain rules in this model: %w", backend, ErrUnsupportedForward)
|
||
}
|
||
|
||
// dirAnyInputFallback maps a DirAny rule to its input half on a backend that has no
|
||
// output concept (Capabilities().Output is false), where the two directions cannot
|
||
// be distinguished: a both-directions rule degrades to an input rule rather than
|
||
// being rejected. Such a backend applies it at the top of AddRule/RemoveRule. The
|
||
// input half keeps every field (DirAny is authored in the inbound frame), only the
|
||
// direction changes. On an output-capable backend DirAny is fanned out via
|
||
// expandDirections instead, so a non-DirAny rule — or a rule on a backend that does
|
||
// distinguish output — is returned unchanged.
|
||
func dirAnyInputFallback(r *Rule, outputSupported bool) *Rule {
|
||
if r.Direction == DirAny && !outputSupported {
|
||
in := *r
|
||
in.Direction = DirInput
|
||
return &in
|
||
}
|
||
return r
|
||
}
|
||
|
||
// checkICMPType reports an ICMP type set on a non-ICMP rule, which is
|
||
// meaningless. Backends that honor ICMPType call it to reject such a rule.
|
||
func (r *Rule) checkICMPType() error {
|
||
if r.ICMPType != nil && !r.Proto.IsICMP() {
|
||
return fmt.Errorf("an icmp type requires the icmp or icmpv6 protocol")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CheckExpandedProtocol reports a TCPUDP rule reaching a row-level marshaller.
|
||
// TCPUDP is a merged, logical protocol: a backend with no both-transports form fans
|
||
// it into a tcp row and a udp row with expandProtocols before marshalling, so a
|
||
// TCPUDP rule arriving here means that fan-out was skipped. Backends whose native
|
||
// syntax does carry both transports in one row (nftables' `meta l4proto { tcp, udp }`)
|
||
// do not call it.
|
||
func (r *Rule) CheckExpandedProtocol() error {
|
||
if r.Proto == TCPUDP {
|
||
return fmt.Errorf("the tcpudp protocol matches two transports and must be expanded to a tcp and a udp rule")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// logicalInsertIndex maps a 1-based logical position (a rule's Number, as GetRules
|
||
// reports it) to the 0-based physical index to insert before, given the physical
|
||
// index of each logical rule and the physical row count. A position past the last
|
||
// logical rule appends (returns physicalLen). It exists because a backend's physical
|
||
// list may hold rows GetRules does not report as their own rule — an unmodeled
|
||
// foreign line pf keeps as an opaque row, a route tuple ufw numbers but this backend
|
||
// cannot model, the LOG line iptables folds into the action line that follows it —
|
||
// so a logical position counted over the reported rules lands at the wrong physical
|
||
// row unless it is mapped through the anchors. When every physical row is its own
|
||
// logical rule this reduces to position-1, the plain physical index.
|
||
func logicalInsertIndex(anchors []int, physicalLen, position int) int {
|
||
if position < 1 {
|
||
position = 1
|
||
}
|
||
if position-1 >= len(anchors) {
|
||
return physicalLen
|
||
}
|
||
return anchors[position-1]
|
||
}
|
||
|
||
// numberByDirection assigns each rule a 1-based Number within its direction
|
||
// (input, output or forward), in slice order. Backends whose input, output and
|
||
// forward chains are ordered independently (iptables, nftables) number rules
|
||
// this way so a rule's Number matches the InsertRule/MoveRule position for its
|
||
// chain. A DirAny rule counts in the input bucket — its Number reflects the input
|
||
// chain, as a FamilyAny rule's Number reflects the IPv4 chain. It is derived on
|
||
// read and, like HasPrefix, ignored on add and not part of rule identity.
|
||
func numberByDirection(rules []*Rule) {
|
||
var in, out, fwd int
|
||
for _, r := range rules {
|
||
switch r.Direction {
|
||
case DirOutput:
|
||
out++
|
||
r.Number = out
|
||
case DirForward:
|
||
fwd++
|
||
r.Number = fwd
|
||
default:
|
||
// DirInput and DirAny both number in the input chain.
|
||
in++
|
||
r.Number = in
|
||
}
|
||
}
|
||
}
|
||
|
||
// numberSequential assigns each rule a 1-based Number in slice order, for a backend
|
||
// that evaluates all its filter rules as one ordered list whose position spans
|
||
// directions (pf's anchor, ufw's numbered list).
|
||
func numberSequential(rules []*Rule) {
|
||
for i, r := range rules {
|
||
r.Number = i + 1
|
||
}
|
||
}
|
||
|
||
// NATKind is the kind of network address translation a NATRule performs.
|
||
type NATKind uint8
|
||
|
||
const (
|
||
NATInvalid NATKind = iota
|
||
// DNAT rewrites the destination of matching inbound packets to ToAddress
|
||
// (and ToPort when set) — a classic port-forward to another host.
|
||
DNAT
|
||
// Redirect sends matching inbound packets to a port on the local host
|
||
// (ToPort). It is destination NAT to this machine and takes no ToAddress.
|
||
Redirect
|
||
// SNAT rewrites the source of matching outbound packets to the fixed
|
||
// ToAddress.
|
||
SNAT
|
||
// Masquerade rewrites the source of matching outbound packets to the
|
||
// address of the outgoing interface, chosen dynamically at send time.
|
||
Masquerade
|
||
)
|
||
|
||
// String returns the canonical lower-case name of the NAT kind.
|
||
func (k NATKind) String() string {
|
||
switch k {
|
||
case DNAT:
|
||
return "dnat"
|
||
case Redirect:
|
||
return "redirect"
|
||
case SNAT:
|
||
return "snat"
|
||
case Masquerade:
|
||
return "masquerade"
|
||
}
|
||
return "invalid"
|
||
}
|
||
|
||
// ParseNATKind parses a NAT-kind token (case-insensitive), accepting only the
|
||
// concrete kinds NATKind.String emits. The sentinel "invalid" (NATInvalid) is
|
||
// rejected, mirroring ParseAction, so callers cannot author a NAT rule with no
|
||
// real kind; backup decoding round-trips it separately in NATKind.UnmarshalJSON.
|
||
func ParseNATKind(s string) (NATKind, error) {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "dnat":
|
||
return DNAT, nil
|
||
case "redirect":
|
||
return Redirect, nil
|
||
case "snat":
|
||
return SNAT, nil
|
||
case "masquerade":
|
||
return Masquerade, nil
|
||
}
|
||
return 0, fmt.Errorf("unknown nat kind %q", s)
|
||
}
|
||
|
||
// isSource reports whether the kind performs source NAT (applied to outbound
|
||
// traffic in the postrouting stage) as opposed to destination NAT (inbound,
|
||
// prerouting).
|
||
func (k NATKind) isSource() bool {
|
||
return k == SNAT || k == Masquerade
|
||
}
|
||
|
||
// A NATRule describes a network-address-translation rule: a port-forward
|
||
// (DNAT/Redirect) applied to inbound traffic, or source NAT (SNAT/Masquerade)
|
||
// applied to outbound traffic. NAT rules are managed separately from filter
|
||
// Rules through the Manager's AddNATRule/RemoveNATRule/GetNATRules methods.
|
||
type NATRule struct {
|
||
Kind NATKind
|
||
Family Family
|
||
Proto Protocol
|
||
// Interface matches the inbound interface for DNAT/Redirect and the
|
||
// outbound interface for SNAT/Masquerade. Empty means any interface.
|
||
Interface string
|
||
Source string
|
||
Destination string
|
||
// Port is the single matched destination port; Ports is a list/range that
|
||
// overrides Port when non-empty. A port match requires a concrete tcp/udp
|
||
// protocol, as with filter rules.
|
||
Port uint16
|
||
Ports []PortRange
|
||
// ToAddress is the translation target: the new destination for DNAT, the
|
||
// new source for SNAT. It is empty for Redirect and Masquerade.
|
||
ToAddress string
|
||
// ToPort is the translation target port for DNAT/Redirect (0 leaves the
|
||
// port unchanged). It is unused for SNAT/Masquerade.
|
||
ToPort uint16
|
||
// HasPrefix reports whether this NAT rule is one the library recognizes as its
|
||
// own — the configured prefix in its comment for tag-based backends, or
|
||
// membership in the library's private table/anchor/zone for container backends
|
||
// (recorded in the unexported table field). Like Rule.HasPrefix it is derived on
|
||
// read, informational, not part of rule identity, and ignored when adding a rule.
|
||
HasPrefix bool
|
||
// Number is the NAT rule's 1-based position within its nat chain in the backend's
|
||
// native ordering, populated by GetNATRules on backends that support explicit
|
||
// ordering (Capabilities().RuleOrdering). It mirrors the position argument of
|
||
// InsertNATRule. It is zero on backends without ordering and on rules outside the
|
||
// backend's ordered set. Like HasPrefix it is derived on read, ignored when adding
|
||
// a rule, and not part of rule identity.
|
||
Number int
|
||
// table records the container a container backend read this NAT rule from (empty
|
||
// for tag-based backends); unexported and informational, backing HasPrefix.
|
||
table string
|
||
}
|
||
|
||
// PortSpecs returns the rule's matched destination ports as a normalized list.
|
||
func (r *NATRule) PortSpecs() []PortRange {
|
||
return portSpecsFor(r.Port, r.Ports)
|
||
}
|
||
|
||
// HasPorts reports whether the rule matches on any destination port.
|
||
func (r *NATRule) HasPorts() bool {
|
||
return r.Port != 0 || len(r.Ports) > 0
|
||
}
|
||
|
||
// HasPortSet reports whether the rule matches more than a single discrete port.
|
||
func (r *NATRule) HasPortSet() bool {
|
||
specs := r.PortSpecs()
|
||
if len(specs) > 1 {
|
||
return true
|
||
}
|
||
return len(specs) == 1 && specs[0].Start != specs[0].End
|
||
}
|
||
|
||
// impliedFamily returns the family the rule effectively targets, inferring it
|
||
// from the translation, destination or source address when unspecified.
|
||
func (r *NATRule) impliedFamily() Family {
|
||
if r.Family != FamilyAny {
|
||
return r.Family
|
||
}
|
||
for _, a := range []string{r.ToAddress, r.Destination, r.Source} {
|
||
if f := familyOfAddr(a); f != FamilyAny {
|
||
return f
|
||
}
|
||
}
|
||
return FamilyAny
|
||
}
|
||
|
||
// validate reports whether the rule is well formed for its kind. It is called
|
||
// by every backend before marshaling so an ill-formed rule fails uniformly.
|
||
func (r *NATRule) validate() error {
|
||
switch r.Kind {
|
||
case DNAT:
|
||
if r.ToAddress == "" {
|
||
return fmt.Errorf("dnat requires a translation address")
|
||
}
|
||
case Redirect:
|
||
if r.ToAddress != "" {
|
||
return fmt.Errorf("redirect translates to a local port, not an address")
|
||
}
|
||
if r.ToPort == 0 {
|
||
return fmt.Errorf("redirect requires a translation port")
|
||
}
|
||
case SNAT:
|
||
if r.ToAddress == "" {
|
||
return fmt.Errorf("snat requires a translation address")
|
||
}
|
||
if r.ToPort != 0 {
|
||
return fmt.Errorf("snat does not translate the port")
|
||
}
|
||
case Masquerade:
|
||
if r.ToAddress != "" || r.ToPort != 0 {
|
||
return fmt.Errorf("masquerade takes no translation target")
|
||
}
|
||
default:
|
||
return fmt.Errorf("invalid nat kind")
|
||
}
|
||
// TCPUDP is a multi-state, logical protocol with no NAT form: a translation is applied
|
||
// per transport, and no backend's NAT syntax carries both in one rule. The filter
|
||
// path fans TCPUDP out with expandProtocols; NAT has no such fan-out, so reject it
|
||
// here rather than let a backend emit a `tcpudp` protocol token. A caller wanting
|
||
// both transports translated adds a tcp rule and a udp rule.
|
||
if r.Proto == TCPUDP {
|
||
return fmt.Errorf("nat rules take a single transport; add a tcp rule and a udp rule: %w", ErrUnsupportedNAT)
|
||
}
|
||
if portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) {
|
||
return fmt.Errorf("a port requires a tcp or udp protocol")
|
||
}
|
||
// A translation port (DNAT/Redirect ToPort) is only valid when the rule
|
||
// carries a port-bearing protocol: iptables' DNAT/REDIRECT/SNAT targets and
|
||
// nft's dnat/redirect reject a target port without tcp/udp/sctp. Redirect
|
||
// always sets ToPort, so this also guards a bare Redirect left ProtocolAny.
|
||
if r.ToPort != 0 && !r.Proto.HasPorts() {
|
||
return fmt.Errorf("a translation port requires a tcp or udp protocol")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// EqualBase reports whether two NAT rules describe the same translation and
|
||
// match, ignoring the IP family (mirroring Rule.EqualBase). Backends use it to
|
||
// deduplicate and remove rules regardless of a FamilyAny/concrete distinction.
|
||
func (r *NATRule) EqualBase(o *NATRule) bool {
|
||
if r.Kind != o.Kind || r.Proto != o.Proto {
|
||
return false
|
||
}
|
||
if r.Interface != o.Interface || !addrEqual(r.Source, o.Source) || !addrEqual(r.Destination, o.Destination) {
|
||
return false
|
||
}
|
||
if !portRangesEqual(r.PortSpecs(), o.PortSpecs()) {
|
||
return false
|
||
}
|
||
// ToAddress is compared through addrEqual, like Source/Destination: a backend
|
||
// re-spells the translation target on read (IPv6 zero-compression/case, a /32
|
||
// host prefix), so a byte-for-byte compare would fail to dedup an existing NAT
|
||
// rule and RemoveNATRule would fail to find it.
|
||
return addrEqual(r.ToAddress, o.ToAddress) && r.ToPort == o.ToPort
|
||
}
|
||
|
||
// Equal reports whether two NAT rules are the same, including the IP family.
|
||
// Family is compared through impliedFamily so a FamilyAny rule matches the
|
||
// concrete family a backend stores it under (mirroring Rule.Equal).
|
||
func (r *NATRule) Equal(o *NATRule) bool {
|
||
return r.impliedFamily() == o.impliedFamily() && r.EqualBase(o)
|
||
}
|
||
|
||
// EqualForDedup is the NAT-rule add guard mirroring Rule.EqualForDedup: the same
|
||
// base translation, and the receiver's family covers o's.
|
||
func (r *NATRule) EqualForDedup(o *NATRule) bool {
|
||
return r.Covers(o)
|
||
}
|
||
|
||
// Covers reports whether the receiver's coverage contains o's, mirroring Rule.Covers
|
||
// for NAT rules. Family is the only axis a NAT rule spans — a translation applies per
|
||
// transport and NAT has no direction — so a FamilyAny rule covers either family and a
|
||
// concrete one covers only itself.
|
||
func (r *NATRule) Covers(o *NATRule) bool {
|
||
return coversFamily(r.impliedFamily(), o.impliedFamily()) && r.EqualBase(o)
|
||
}
|
||
|
||
// CoveredBy reports whether every concrete NAT rule the receiver spans is covered by
|
||
// at least one rule in rules, mirroring Rule.CoveredBy. A FamilyAny receiver requires
|
||
// both families to be covered, whether by one FamilyAny rule or by an IPv4 rule and
|
||
// an IPv6 rule.
|
||
func (r *NATRule) CoveredBy(rules []*NATRule) bool {
|
||
for _, cell := range r.cells() {
|
||
covered := false
|
||
for _, have := range rules {
|
||
if have.Covers(cell) {
|
||
covered = true
|
||
break
|
||
}
|
||
}
|
||
if !covered {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// cells returns the concrete-family NAT rules the receiver spans, the NAT analog of
|
||
// Rule.cells over the single axis a NAT rule spans.
|
||
func (r *NATRule) cells() []*NATRule {
|
||
if r.impliedFamily() != FamilyAny {
|
||
return []*NATRule{r}
|
||
}
|
||
v4, v6 := *r, *r
|
||
v4.Family, v6.Family = IPv4, IPv6
|
||
return []*NATRule{&v4, &v6}
|
||
}
|
||
|
||
// EqualForRemoval is the NAT-rule remove guard mirroring Rule.EqualForRemoval:
|
||
// the same base translation, and o's family may touch the receiver row's.
|
||
func (r *NATRule) EqualForRemoval(o *NATRule) bool {
|
||
ft, fr := o.impliedFamily(), r.impliedFamily()
|
||
return (ft == FamilyAny || fr == FamilyAny || ft == fr) && r.EqualBase(o)
|
||
}
|
||
|
||
// numberNATByChain assigns each NAT rule a 1-based Number within its nat chain —
|
||
// prerouting for destination NAT, postrouting for source NAT — matching the
|
||
// InsertNATRule position on chain-ordered backends (iptables, nftables).
|
||
func numberNATByChain(rules []*NATRule) {
|
||
var pre, post int
|
||
for _, r := range rules {
|
||
if r.Kind.isSource() {
|
||
post++
|
||
r.Number = post
|
||
} else {
|
||
pre++
|
||
r.Number = pre
|
||
}
|
||
}
|
||
}
|
||
|
||
// numberNATSequential assigns each NAT rule a 1-based Number in slice order, for a
|
||
// backend that evaluates its translation rules as one ordered list (pf).
|
||
func numberNATSequential(rules []*NATRule) {
|
||
for i, r := range rules {
|
||
r.Number = i + 1
|
||
}
|
||
}
|
||
|
||
// Backup is a portable snapshot of the state a backend manages. It can be used
|
||
// to restore that state later via Manager.Restore.
|
||
type Backup struct {
|
||
// Filter rules saved in the order they were returned by GetRules.
|
||
Rules []*Rule
|
||
// NAT rules saved in the order they were returned by GetNATRules.
|
||
NATRules []*NATRule
|
||
// DefaultPolicy is the per-direction default action captured at backup time,
|
||
// on backends that advertise Capabilities().DefaultPolicy; nil otherwise. A
|
||
// direction the backend does not expose is ActionInvalid and is left unchanged
|
||
// on Restore. Capturing it lets Restore re-assert a restrictive policy (e.g. a
|
||
// default DROP) rather than silently inherit the restore host's current one.
|
||
DefaultPolicy *DefaultPolicy
|
||
// AddressSets are the named address sets (ipsets, nftables sets, pf tables) the
|
||
// backend manages, captured on backends that advertise
|
||
// Capabilities().AddressSets; nil otherwise. Restore recreates them before the
|
||
// filter rules so a set-referencing rule (@set) resolves on a host that does
|
||
// not yet have the set.
|
||
AddressSets []*AddressSet
|
||
}
|
||
|
||
// Direction names the traffic direction a default policy or rule applies to.
|
||
type Direction uint8
|
||
|
||
const (
|
||
// DirInput is the inbound (input) direction. It must remain the zero value so
|
||
// a rule with no explicit direction is an input rule.
|
||
DirInput Direction = iota
|
||
// DirOutput is the outbound (output) direction.
|
||
DirOutput
|
||
// DirForward is the routing (forward) direction, where a backend models it.
|
||
DirForward
|
||
// DirAny applies to both the input and output directions. It is the direction
|
||
// analog of FamilyAny: a backend that can store a bidirectional rule as one
|
||
// object reads it back as DirAny, while one that cannot fans it into a concrete
|
||
// input row plus a role-swapped output row on write (expandDirections). It never
|
||
// covers DirForward (a routed rule has no input/output twin) and must be declared
|
||
// last so DirInput stays the zero value.
|
||
DirAny
|
||
)
|
||
|
||
// String returns the canonical lower-case name of the direction.
|
||
func (d Direction) String() string {
|
||
switch d {
|
||
case DirOutput:
|
||
return "output"
|
||
case DirForward:
|
||
return "forward"
|
||
case DirAny:
|
||
return "any"
|
||
}
|
||
return "input"
|
||
}
|
||
|
||
// ParseDirection parses a direction token (case-insensitive), accepting the
|
||
// canonical name emitted by Direction.String. An unknown value is an error.
|
||
func ParseDirection(s string) (Direction, error) {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "input", "in":
|
||
return DirInput, nil
|
||
case "output", "out":
|
||
return DirOutput, nil
|
||
case "forward", "fwd":
|
||
return DirForward, nil
|
||
case "any", "both":
|
||
return DirAny, nil
|
||
}
|
||
return 0, fmt.Errorf("unknown direction %q", s)
|
||
}
|
||
|
||
// DefaultPolicy describes the default action a firewall applies to packets that
|
||
// match no rule, per direction. A field left as ActionInvalid has backend-
|
||
// defined meaning: on Get it means the backend does not expose that direction,
|
||
// and on Set it means the direction should be left unchanged.
|
||
type DefaultPolicy struct {
|
||
Input Action
|
||
Output Action
|
||
Forward Action
|
||
}
|
||
|
||
// SetType names the kind of entries an AddressSet holds.
|
||
type SetType uint8
|
||
|
||
const (
|
||
// SetHashIP is a set of individual IP addresses.
|
||
SetHashIP SetType = iota
|
||
// SetHashNet is a set of CIDR network ranges.
|
||
SetHashNet
|
||
)
|
||
|
||
// String returns the ipset-style name of the set type.
|
||
func (t SetType) String() string {
|
||
switch t {
|
||
case SetHashNet:
|
||
return "hash:net"
|
||
}
|
||
return "hash:ip"
|
||
}
|
||
|
||
// ParseSetType parses a set-type token (case-insensitive), accepting the
|
||
// canonical name emitted by SetType.String ("hash:ip"/"hash:net") plus the short
|
||
// aliases "ip"/"net". An unknown value is an error.
|
||
func ParseSetType(s string) (SetType, error) {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "hash:ip", "ip":
|
||
return SetHashIP, nil
|
||
case "hash:net", "net":
|
||
return SetHashNet, nil
|
||
}
|
||
return 0, fmt.Errorf("unknown set type %q", s)
|
||
}
|
||
|
||
// AddressSet is a named collection of addresses (an ipset, an nftables set or a
|
||
// pf table) that rules can match against. It is managed separately from filter
|
||
// and NAT rules through the Manager's address-set methods.
|
||
type AddressSet struct {
|
||
// Name of the set. Backends that namespace sets (nftables table, pf anchor)
|
||
// keep it within their own container.
|
||
Name string
|
||
// Family restricts the set to an IP family. Some backends require a concrete
|
||
// family (nftables inet sets carry a single address type); FamilyAny is
|
||
// resolved to IPv4 by those backends.
|
||
Family Family
|
||
// Type is the entry kind, defaulting to SetHashIP when zero.
|
||
Type SetType
|
||
// Entries are the addresses or CIDRs in the set.
|
||
Entries []string
|
||
}
|
||
|
||
// Capabilities advertises which features a backend can express. It lets a
|
||
// caller detect support before trial-and-error: a false field means the
|
||
// corresponding operation returns an unsupported error (or, for RuleCounters,
|
||
// simply reports zero).
|
||
type Capabilities struct {
|
||
// Output is true when the backend distinguishes input from output rules.
|
||
Output bool
|
||
// Forward is true when the backend can express a rule in the forward (routing)
|
||
// chain. A false Forward means a rule with Direction DirForward is rejected
|
||
// with ErrUnsupportedForward.
|
||
Forward bool
|
||
// Zones is true when the backend maps interfaces to zones.
|
||
Zones bool
|
||
// Priority is true when per-rule priority is honored.
|
||
Priority bool
|
||
// ICMPv6 is true when ICMPv6 protocol/type matching is honored. (Plain ICMP
|
||
// and ICMP-type matching are supported by every backend, so they are not
|
||
// advertised as capabilities.)
|
||
ICMPv6 bool
|
||
// PortList is true when multi-port (comma list) matching is honored. (Port
|
||
// ranges are supported by every backend, so they are not advertised.)
|
||
PortList bool
|
||
// PortPair is true when a source-port match can be combined with a
|
||
// destination-port match in one rule. A false PortPair means such a rule is
|
||
// rejected with ErrUnsupportedSourcePort (a firewalld rich rule carries a
|
||
// single port element).
|
||
PortPair bool
|
||
// ConnState is true when connection-tracking state can be matched.
|
||
ConnState bool
|
||
// InterfaceMatch is true when a rule can bind to a per-rule interface (as
|
||
// opposed to a zone).
|
||
InterfaceMatch bool
|
||
// Logging/RateLimit/ConnLimit describe the per-rule modifiers.
|
||
Logging bool
|
||
RateLimit bool
|
||
ConnLimit bool
|
||
// NAT is true when AddNATRule/RemoveNATRule/GetNATRules are supported.
|
||
NAT bool
|
||
// RuleOrdering is true when InsertRule/MoveRule are supported, and when NAT is
|
||
// also true, InsertNATRule.
|
||
RuleOrdering bool
|
||
// DefaultPolicy is true when GetDefaultPolicy/SetDefaultPolicy are supported.
|
||
DefaultPolicy bool
|
||
// RuleCounters is true when GetRules populates the Packets/Bytes fields.
|
||
RuleCounters bool
|
||
// AddressSets is true when the address-set methods are supported.
|
||
AddressSets bool
|
||
// Comments is true when a rule's Comment field round-trips: it is stored on
|
||
// AddRule and populated by GetRules. A false Comments means the backend
|
||
// silently ignores the Comment field.
|
||
Comments bool
|
||
// Negation is true when a "!"-negated Source/Destination match is honored.
|
||
// A false Negation means such a rule is rejected with ErrUnsupported (WFP
|
||
// has no negated address condition).
|
||
Negation bool
|
||
// RejectAction is true when the Reject action — refuse with an error
|
||
// response, as opposed to a silent Drop — is expressible. A false
|
||
// RejectAction means a Reject rule is rejected with ErrUnsupported (WFP
|
||
// only permits or blocks).
|
||
RejectAction bool
|
||
// FamilyWithoutAddress is true when a rule with a concrete Family but no
|
||
// Source/Destination is expressible. A false value means family scoping
|
||
// rides on addresses alone and such a rule is rejected with ErrUnsupported
|
||
// (a WFP filter scopes family through its address conditions).
|
||
FamilyWithoutAddress bool
|
||
// DenyActionFromConfig is true when the backend's native deny store carries
|
||
// no per-entry action — the firewall tool applies the deny action its own
|
||
// config names (csf.conf DROP, conf.apf ALL_STOP). A deny added with the
|
||
// config's action is stored natively and reads back with it; one with a
|
||
// differing action is expressed elsewhere (the raw-iptables hook). Because
|
||
// the native entry encodes no action, RemoveRule clears it whatever action
|
||
// the removal target names.
|
||
DenyActionFromConfig bool
|
||
}
|
||
|
||
// The backend type strings Manager.Type reports, one per backend. They are declared
|
||
// here beside the interface rather than beside each implementation because every
|
||
// backend lives behind a build tag for its own platform: a caller — or a test — that
|
||
// branches on mgr.Type() must be able to name any backend on any platform, not only
|
||
// the ones that compile for the host.
|
||
const (
|
||
IPTablesType = "iptables"
|
||
NFTType = "nftables"
|
||
UFWType = "ufw"
|
||
FirewallDType = "firewalld"
|
||
CSFType = "csf"
|
||
APFType = "apf"
|
||
PFType = "pf"
|
||
WFType = "windows-firewall"
|
||
)
|
||
|
||
// Manager is the standard firewall manager interface.
|
||
//
|
||
// Every method that performs I/O (shelling out, D-Bus, or the Windows API)
|
||
// takes a context.Context as its first argument so callers can apply timeouts
|
||
// and cancellation. Type and Capabilities are pure and take none.
|
||
type Manager interface {
|
||
// Type returns the manager type.
|
||
Type() string
|
||
|
||
// Capabilities returns the set of features this backend can express.
|
||
Capabilities() Capabilities
|
||
|
||
// GetZone returns the zone for the specified interface.
|
||
GetZone(ctx context.Context, iface string) (string, error)
|
||
|
||
// GetRules returns the existing filter rules from the zone.
|
||
GetRules(ctx context.Context, zoneName string) ([]*Rule, error)
|
||
|
||
// AddRule adds a rule to the zone.
|
||
AddRule(ctx context.Context, zoneName string, rule *Rule) error
|
||
|
||
// InsertRule adds rule at the given position. position uses 1-based indexing
|
||
// (1 = first rule); a non-positive position is treated as 1, and a position
|
||
// larger than the current rule count appends the rule. Backends that do not
|
||
// support ordered rules return an error.
|
||
InsertRule(ctx context.Context, zoneName string, position int, rule *Rule) error
|
||
|
||
// MoveRule moves an existing rule to the given position. position uses 1-based
|
||
// indexing; a non-positive position is treated as 1, and a position larger
|
||
// than the current rule count moves the rule to the end. Backends that do not
|
||
// support ordered rules return an error.
|
||
MoveRule(ctx context.Context, zoneName string, rule *Rule, position int) error
|
||
|
||
// RemoveRule removes a rule from the zone.
|
||
RemoveRule(ctx context.Context, zoneName string, rule *Rule) error
|
||
|
||
// GetNATRules returns the existing NAT rules from the zone. Backends without
|
||
// NAT support return an unsupported error.
|
||
GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error)
|
||
|
||
// AddNATRule adds a NAT rule to the zone.
|
||
AddNATRule(ctx context.Context, zoneName string, rule *NATRule) error
|
||
|
||
// InsertNATRule adds a NAT rule at the given position within its nat chain.
|
||
// position uses 1-based indexing (1 = first rule in that chain); a non-positive
|
||
// position is treated as 1, and a position larger than the chain's current rule
|
||
// count appends the rule. Backends that do not support ordered rules return an
|
||
// error; backends without NAT support return the NAT sentinel.
|
||
InsertNATRule(ctx context.Context, zoneName string, position int, rule *NATRule) error
|
||
|
||
// RemoveNATRule removes a NAT rule from the zone.
|
||
RemoveNATRule(ctx context.Context, zoneName string, rule *NATRule) error
|
||
|
||
// GetDefaultPolicy returns the default action applied to packets that match
|
||
// no rule. A direction the backend cannot express is returned as
|
||
// ActionInvalid. Backends that cannot manage a default policy at all return
|
||
// an unsupported error.
|
||
GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error)
|
||
|
||
// SetDefaultPolicy sets the default action for the directions named in
|
||
// policy. A direction left as ActionInvalid is left unchanged. Backends that
|
||
// cannot manage a default policy return an unsupported error.
|
||
SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error
|
||
|
||
// GetAddressSets returns the address sets managed by this backend. Backends
|
||
// that cannot manage address sets return an unsupported error.
|
||
GetAddressSets(ctx context.Context) ([]*AddressSet, error)
|
||
|
||
// GetAddressSet returns a single address set by name, or an error if it does
|
||
// not exist. Backends that cannot manage address sets return an unsupported
|
||
// error.
|
||
GetAddressSet(ctx context.Context, name string) (*AddressSet, error)
|
||
|
||
// AddAddressSet creates an address set. Adding a set that already exists (by
|
||
// name) is a no-op. Backends that cannot manage address sets return an
|
||
// unsupported error.
|
||
AddAddressSet(ctx context.Context, set *AddressSet) error
|
||
|
||
// RemoveAddressSet removes an address set by name. Backends that cannot
|
||
// manage address sets return an unsupported error.
|
||
RemoveAddressSet(ctx context.Context, name string) error
|
||
|
||
// AddAddressSetEntry adds an entry to the named set. Backends that cannot
|
||
// manage address sets return an unsupported error.
|
||
AddAddressSetEntry(ctx context.Context, name, entry string) error
|
||
|
||
// RemoveAddressSetEntry removes an entry from the named set. Backends that
|
||
// cannot manage address sets return an unsupported error.
|
||
RemoveAddressSetEntry(ctx context.Context, name, entry string) error
|
||
|
||
// Backup captures the current filter and NAT rules the manager reports, plus —
|
||
// on backends that advertise them — the default policy and the managed address
|
||
// sets. On container backends (nftables table, pf anchor, firewalld zone) this
|
||
// is scoped to the library's container by construction; on tag/comment backends
|
||
// it is the whole chain, foreign rules included. It does not filter on the
|
||
// HasPrefix flag.
|
||
Backup(ctx context.Context, zoneName string) (*Backup, error)
|
||
|
||
// Restore reconciles the firewall to the contents of a Backup. The captured
|
||
// address sets are recreated first (so a set-referencing rule resolves), then
|
||
// existing filter and NAT rules the backend acts on are removed and the backup
|
||
// rules added, and finally the captured default policy is re-asserted. Like Sync
|
||
// it reconciles the actual state and does not filter on HasPrefix.
|
||
Restore(ctx context.Context, zoneName string, backup *Backup) error
|
||
|
||
// Reload reloads the manager to activate new rules.
|
||
Reload(ctx context.Context) error
|
||
|
||
// Close closes the connection to the manager.
|
||
Close(ctx context.Context) error
|
||
}
|
||
|
||
// RuleBatcher is an optional interface a Manager may implement to apply many
|
||
// rules in a single atomic operation instead of one AddRule per rule. Backends
|
||
// whose commit is a whole-file or whole-ruleset load (iptables-restore, nft -f,
|
||
// pfctl -f) implement it so a batch is one syscall storm rather than N, and so a
|
||
// partial failure leaves the managed set unchanged. The package-level AddRules,
|
||
// ReplaceRules and Sync helpers use it when the backend provides it and fall
|
||
// back to per-rule operations otherwise, so callers need not type-assert.
|
||
type RuleBatcher interface {
|
||
// AddRulesBatch adds every rule in one atomic operation. Rules that already
|
||
// exist are skipped, mirroring AddRule. Either all applicable rules are added
|
||
// or, on error, none are.
|
||
AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error
|
||
// ReplaceRulesBatch atomically replaces the backend's filter rules with
|
||
// exactly rules: any rule the backend acts on that is not present in rules is
|
||
// removed (it does not filter on HasPrefix) and any missing rule is added, in
|
||
// one operation.
|
||
ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error
|
||
}
|
||
|
||
// AddRules adds every rule in rules to the zone. When mgr implements RuleBatcher
|
||
// the rules are applied in a single atomic operation; otherwise AddRules falls
|
||
// back to calling AddRule for each rule in turn (stopping at the first error).
|
||
func AddRules(ctx context.Context, mgr Manager, zoneName string, rules []*Rule) error {
|
||
if b, ok := mgr.(RuleBatcher); ok {
|
||
return b.AddRulesBatch(ctx, zoneName, rules)
|
||
}
|
||
for _, r := range rules {
|
||
if err := mgr.AddRule(ctx, zoneName, r); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ReplaceRules makes the zone's filter rules equal to rules. It reconciles the
|
||
// actual firewall state (it does not filter on HasPrefix), so a rule not in rules
|
||
// is removed whether or not it carries the configured prefix. When mgr implements
|
||
// RuleBatcher the replacement is atomic; otherwise ReplaceRules falls back to Sync,
|
||
// which applies a minimal add/remove diff (a rule unchanged between the existing
|
||
// and desired sets is never removed and re-added). On a non-batching backend, Sync
|
||
// still removes unwanted existing rules before adding new ones, so replacing with a
|
||
// fully disjoint rule set leaves a real window with none of the desired rules
|
||
// present.
|
||
func ReplaceRules(ctx context.Context, mgr Manager, zoneName string, rules []*Rule) error {
|
||
if b, ok := mgr.(RuleBatcher); ok {
|
||
return b.ReplaceRulesBatch(ctx, zoneName, rules)
|
||
}
|
||
_, _, err := Sync(ctx, mgr, zoneName, rules)
|
||
return err
|
||
}
|
||
|
||
// Sync reconciles the zone's filter rules toward desired: it removes any rule
|
||
// the backend reports that desired does not cover and adds desired rules that are
|
||
// not yet present, leaving rules already in place untouched. It reconciles the
|
||
// actual firewall state and does not filter on HasPrefix — a rule without the
|
||
// configured prefix (HasPrefix=false) is reconciled like any other, so a foreign
|
||
// rule not in desired is removed. A rule unchanged between existing and desired is
|
||
// never removed and re-added, but removal still runs as its own pass before
|
||
// additions, so a desired set that shares nothing with the existing rules is not
|
||
// applied atomically. Sync reports how many rules were added and removed.
|
||
//
|
||
// The diff is a coverage relation, not rule-for-rule equality, because GetRules
|
||
// reports the firewall's actual rows and a backend stores a rule the way its model
|
||
// allows: iptables holds a FamilyAny rule as an IPv4 row plus an IPv6 row, pf holds
|
||
// a DirAny rule as an inbound row plus an outbound row, while nftables holds either
|
||
// as one row. An existing rule is kept when every concrete cell it spans is wanted
|
||
// (Rule.CoveredBy over desired), and a desired rule is added when some cell it spans
|
||
// is not yet present (its CoveredBy over existing). Comparing this way keeps Sync a
|
||
// no-op against its own output whichever representation the backend chose, where
|
||
// plain equality would remove-and-re-add every fanned-out rule on each run. A rule
|
||
// only partially covered by desired is removed whole and the wanted part re-added.
|
||
// The Comment, HasPrefix and Packets/Bytes fields never affect the diff.
|
||
func Sync(ctx context.Context, mgr Manager, zoneName string, desired []*Rule) (added, removed int, err error) {
|
||
existing, err := mgr.GetRules(ctx, zoneName)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
outputHonored := mgr.Capabilities().Output
|
||
|
||
// Remove any existing rule desired does not fully cover. Sync reconciles the
|
||
// actual firewall state toward desired, so any rule the backend reports and can
|
||
// act on is fair game; backends whose mutations are scoped to a private
|
||
// table/anchor simply no-op on rules outside it. A rule whose cells are spread
|
||
// across several desired rules is still fully wanted and is kept.
|
||
kept := make([]*Rule, 0, len(existing))
|
||
for _, e := range existing {
|
||
if e.coveredBy(desired, outputHonored) {
|
||
kept = append(kept, e)
|
||
continue
|
||
}
|
||
if err := mgr.RemoveRule(ctx, zoneName, e); err != nil {
|
||
return added, removed, err
|
||
}
|
||
removed++
|
||
}
|
||
|
||
// Add any wanted rule that is not already present. A rule already in the
|
||
// firewall — whoever created it — counts as present, so Sync does not add a
|
||
// duplicate of a rule the surviving rows already cover. Queued additions count
|
||
// as present too: adding a covered duplicate would double-apply it on a
|
||
// RuleBatcher backend (which sees both as new) and over-count added on the rest.
|
||
var toAdd []*Rule
|
||
for _, d := range desired {
|
||
if d.coveredBy(kept, outputHonored) || d.coveredBy(toAdd, outputHonored) {
|
||
continue
|
||
}
|
||
toAdd = append(toAdd, d)
|
||
}
|
||
// Count additions as they land so the reported added stays accurate on a
|
||
// mid-batch failure: a RuleBatcher applies all-or-nothing, while the
|
||
// per-rule fallback may have added several rules before erroring.
|
||
if b, ok := mgr.(RuleBatcher); ok {
|
||
if err := b.AddRulesBatch(ctx, zoneName, toAdd); err != nil {
|
||
return added, removed, err
|
||
}
|
||
added = len(toAdd)
|
||
return added, removed, nil
|
||
}
|
||
for _, r := range toAdd {
|
||
if err := mgr.AddRule(ctx, zoneName, r); err != nil {
|
||
return added, removed, err
|
||
}
|
||
added++
|
||
}
|
||
return added, removed, nil
|
||
}
|
||
|
||
// unsupportedOrdering is returned by backends that do not support explicit
|
||
// rule ordering for InsertRule or MoveRule.
|
||
func unsupportedOrdering(backend string) error {
|
||
return fmt.Errorf("%s does not support explicit rule ordering in this model: %w", backend, ErrUnsupportedOrdering)
|
||
}
|
||
|
||
// unsupportedPolicy is returned by backends that cannot read or set a default
|
||
// policy through this model.
|
||
func unsupportedPolicy(backend string) error {
|
||
return fmt.Errorf("%s does not support default-policy management in this model: %w", backend, ErrUnsupportedPolicy)
|
||
}
|
||
|
||
// unsupportedSet is returned by backends that cannot manage address sets
|
||
// (ipset/nftset/tables) through this model.
|
||
func unsupportedSet(backend string) error {
|
||
return fmt.Errorf("%s does not support address sets in this model: %w", backend, ErrUnsupportedSet)
|
||
}
|