2222 lines
69 KiB
Go
2222 lines
69 KiB
Go
package firewall
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
const (
|
|
// NFTType is the manager type string reported by this backend.
|
|
NFTType = "nftables"
|
|
// NFTDefaultTable is the table name used when no rule prefix is supplied.
|
|
NFTDefaultTable = "go_firewall"
|
|
)
|
|
|
|
// NFT manages firewall rules through the nftables `nft` command. To avoid
|
|
// clobbering rules owned by other tooling, every rule this backend creates lives
|
|
// in a private `inet` table (named after the rule prefix) with its own input and
|
|
// output base chains. Reads and writes are scoped to that table.
|
|
type NFT struct {
|
|
// table is the nftables table this backend owns.
|
|
table string
|
|
// mu guards the ensured/natEnsured flags so concurrent callers do not race on
|
|
// the one-time table/chain setup.
|
|
mu sync.Mutex
|
|
// ensured records whether the private table/chains have been created this
|
|
// session so we only run the setup commands once.
|
|
ensured bool
|
|
// natEnsured records the same for the nat base chains, which are created
|
|
// lazily only when a NAT rule is first written.
|
|
natEnsured bool
|
|
}
|
|
|
|
// sanitizeNFTName reduces an arbitrary prefix to a valid nftables identifier
|
|
// (letters, digits and underscores), falling back to the default when nothing
|
|
// usable remains.
|
|
func sanitizeNFTName(prefix string) string {
|
|
var b strings.Builder
|
|
for _, r := range prefix {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
|
|
b.WriteRune(r)
|
|
case r == '-' || r == ' ' || r == '.':
|
|
b.WriteRune('_')
|
|
}
|
|
}
|
|
name := strings.Trim(b.String(), "_")
|
|
if name == "" {
|
|
return NFTDefaultTable
|
|
}
|
|
return name
|
|
}
|
|
|
|
// NewNFT constructs an nftables-backed Manager, deriving the private table name
|
|
// from rulePrefix and verifying the nft command and nf_tables support are usable.
|
|
func NewNFT(ctx context.Context, rulePrefix string) (*NFT, error) {
|
|
nft := &NFT{table: sanitizeNFTName(rulePrefix)}
|
|
|
|
// Confirm the nft command is available and the ruleset can be listed. This
|
|
// requires the nf_tables kernel support to be present.
|
|
if _, err := runCommand(ctx, "nft", "--version"); err != nil {
|
|
return nil, fmt.Errorf("nft command is not available: %s", err)
|
|
}
|
|
if _, err := runCommand(ctx, "nft", "list", "ruleset"); err != nil {
|
|
return nil, fmt.Errorf("unable to list nftables ruleset: %s", err)
|
|
}
|
|
|
|
return nft, nil
|
|
}
|
|
|
|
// Type returns the manager type.
|
|
func (f *NFT) Type() string {
|
|
return NFTType
|
|
}
|
|
|
|
// GetZone reports no zone; nftables has no interface-to-zone mapping in the model we expose.
|
|
func (f *NFT) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
|
|
return "", nil
|
|
}
|
|
|
|
// ensureTable creates the private table and its input/output/forward base chains
|
|
// if they do not already exist. `add` is idempotent in nftables, so re-running is
|
|
// safe.
|
|
//
|
|
// The chain declarations deliberately omit an explicit `policy`: `add chain` on
|
|
// an existing base chain re-asserts the named properties, so writing `policy
|
|
// accept` here would revert a default-drop policy a prior SetDefaultPolicy set.
|
|
// A base chain created without a policy defaults to accept (the intended initial
|
|
// default), and omitting the clause leaves any existing policy untouched.
|
|
// SetDefaultPolicy sets the policy separately via setChainPolicy.
|
|
func (f *NFT) ensureTable(ctx context.Context) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.ensured {
|
|
return nil
|
|
}
|
|
cmds := [][]string{
|
|
{"add", "table", "inet", f.table},
|
|
{"add", "chain", "inet", f.table, "input", "{", "type", "filter", "hook", "input", "priority", "0", ";", "}"},
|
|
{"add", "chain", "inet", f.table, "output", "{", "type", "filter", "hook", "output", "priority", "0", ";", "}"},
|
|
{"add", "chain", "inet", f.table, "forward", "{", "type", "filter", "hook", "forward", "priority", "0", ";", "}"},
|
|
}
|
|
for _, c := range cmds {
|
|
if _, err := runCommand(ctx, "nft", c...); err != nil {
|
|
return fmt.Errorf("failed to set up nftables table %s: %s", f.table, err)
|
|
}
|
|
}
|
|
f.ensured = true
|
|
return nil
|
|
}
|
|
|
|
// nftFilterChains lists the private table's filter base chains, in the order a
|
|
// read enumerates them.
|
|
var nftFilterChains = []string{"input", "output", "forward"}
|
|
|
|
// chainForDirection returns the filter base-chain name a rule of the given
|
|
// direction lives in.
|
|
func (f *NFT) chainForDirection(d Direction) string {
|
|
switch d {
|
|
case DirOutput:
|
|
return "output"
|
|
case DirForward:
|
|
return "forward"
|
|
}
|
|
return "input"
|
|
}
|
|
|
|
// directionForChain returns the rule direction a filter base-chain name maps
|
|
// to (the inverse of chainForDirection).
|
|
func (f *NFT) directionForChain(chain string) Direction {
|
|
switch chain {
|
|
case "output":
|
|
return DirOutput
|
|
case "forward":
|
|
return DirForward
|
|
}
|
|
return DirInput
|
|
}
|
|
|
|
// ensureNATChains creates the private table's nat base chains (prerouting for
|
|
// destination NAT, postrouting for source NAT) if they do not already exist. It
|
|
// is called lazily the first time a NAT rule is written so filter-only use never
|
|
// installs nat hooks. `add` is idempotent, so re-running is safe.
|
|
func (f *NFT) ensureNATChains(ctx context.Context) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.natEnsured {
|
|
return nil
|
|
}
|
|
cmds := [][]string{
|
|
{"add", "chain", "inet", f.table, "prerouting", "{", "type", "nat", "hook", "prerouting", "priority", "dstnat", ";", "policy", "accept", ";", "}"},
|
|
{"add", "chain", "inet", f.table, "postrouting", "{", "type", "nat", "hook", "postrouting", "priority", "srcnat", ";", "policy", "accept", ";", "}"},
|
|
}
|
|
for _, c := range cmds {
|
|
if _, err := runCommand(ctx, "nft", c...); err != nil {
|
|
return fmt.Errorf("failed to set up nftables nat chains for %s: %s", f.table, err)
|
|
}
|
|
}
|
|
f.natEnsured = true
|
|
return nil
|
|
}
|
|
|
|
// addrExpr encodes a source/destination match value: the negation operator
|
|
// (`!= `) when the token starts with "!", followed by the value — a bare address
|
|
// or, for a non-address token, a named-set reference `@name`.
|
|
func (f *NFT) addrExpr(addr string) string {
|
|
neg, bare := splitAddrNeg(addr)
|
|
op := ""
|
|
if neg {
|
|
op = "!= "
|
|
}
|
|
if isSetRef(addr) {
|
|
return op + "@" + bare
|
|
}
|
|
return op + bare
|
|
}
|
|
|
|
// stripSetRef drops the '@' nft prints before a named-set reference, yielding
|
|
// the bare set name the Rule model stores in Source/Destination.
|
|
func (f *NFT) stripSetRef(v string) string {
|
|
return strings.TrimPrefix(v, "@")
|
|
}
|
|
|
|
// l3Match returns the nftables layer-3 keyword (ip/ip6) for the given family,
|
|
// inferring it from an address when the family is unspecified.
|
|
func (f *NFT) l3Match(family Family, addr string) string {
|
|
switch family {
|
|
case IPv4:
|
|
return "ip"
|
|
case IPv6:
|
|
return "ip6"
|
|
}
|
|
// Infer from the address for FamilyAny rules.
|
|
bare := strings.TrimPrefix(addr, "!")
|
|
ip, _, err := net.ParseCIDR(bare)
|
|
if err != nil {
|
|
ip = net.ParseIP(bare)
|
|
}
|
|
if ip != nil && ip.To4() == nil {
|
|
return "ip6"
|
|
}
|
|
return "ip"
|
|
}
|
|
|
|
// l4Proto returns the protocol keyword nftables accepts after `meta l4proto`.
|
|
// For ICMPv6 this is the `icmpv6` spelling. nft lists such a rule back with the
|
|
// differently-spelled `ipv6-icmp` form, which protoFromToken decodes on read.
|
|
func (f *NFT) l4Proto(p Protocol) string {
|
|
return p.String()
|
|
}
|
|
|
|
// protoFromToken decodes a layer-4 protocol token as it appears in `nft list`
|
|
// output. nft always normalizes `meta l4proto` back to its protocol name when
|
|
// listing, so this backend never needs to decode one — but the numeric IP
|
|
// protocol number is accepted too as defensive tolerance for output this backend
|
|
// has not itself observed. It returns ProtocolAny for an unrecognized token.
|
|
func (f *NFT) protoFromToken(tok string) Protocol {
|
|
if p := GetProtocol(tok); p != ProtocolAny {
|
|
return p
|
|
}
|
|
switch tok {
|
|
case "1":
|
|
return ICMP
|
|
case "58":
|
|
return ICMPv6
|
|
case "6":
|
|
return TCP
|
|
case "17":
|
|
return UDP
|
|
case "132":
|
|
return SCTP
|
|
case "47":
|
|
return GRE
|
|
case "50":
|
|
return ESP
|
|
case "51":
|
|
return AH
|
|
}
|
|
return ProtocolAny
|
|
}
|
|
|
|
// collapseSetSpaces removes the spaces nft inserts inside an anonymous set
|
|
// literal when it lists a rule (`{ 80, 443 }`), so strings.Fields treats the set
|
|
// as a single token (`{80,443}`) — the compact form MarshalRule emits and the set
|
|
// parsers expect. Spaces inside a double-quoted string (a rule comment) are left
|
|
// untouched so a comment with spaces still round-trips. nft's quoting has no
|
|
// backslash-escape mechanism — a `"` unconditionally toggles the quoted state —
|
|
// so no escape tracking is needed here.
|
|
func (f *NFT) collapseSetSpaces(line string) string {
|
|
var b strings.Builder
|
|
depth := 0
|
|
inQuote := false
|
|
for _, r := range line {
|
|
switch {
|
|
case r == '"':
|
|
inQuote = !inQuote
|
|
case inQuote:
|
|
// Preserve everything verbatim inside a quoted comment.
|
|
case r == '{':
|
|
depth++
|
|
case r == '}':
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
}
|
|
if !inQuote && depth > 0 && (r == ' ' || r == '\t') {
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// portExpr renders a destination port match value: a bare port/range for a
|
|
// single spec, or an anonymous set `{80,443,1000-2000}` for a list.
|
|
func (f *NFT) portExpr(specs []PortRange) string {
|
|
if len(specs) == 1 {
|
|
return specs[0].String()
|
|
}
|
|
return "{" + FormatPortRanges(specs, ",") + "}"
|
|
}
|
|
|
|
// stateExpr renders a ct state match value: a bare name for one state or an
|
|
// anonymous set `{established,related}` for several.
|
|
func (f *NFT) stateExpr(s ConnState) string {
|
|
names := s.Strings()
|
|
if len(names) == 1 {
|
|
return names[0]
|
|
}
|
|
return "{" + strings.Join(names, ",") + "}"
|
|
}
|
|
|
|
// parseSetTokens strips optional `{ }` braces and splits the comma-separated
|
|
// members of an nftables anonymous set (or a single bare value).
|
|
func (f *NFT) parseSetTokens(tok string) []string {
|
|
tok = strings.TrimSpace(tok)
|
|
tok = strings.TrimPrefix(tok, "{")
|
|
tok = strings.TrimSuffix(tok, "}")
|
|
var out []string
|
|
for _, m := range strings.Split(tok, ",") {
|
|
m = strings.TrimSpace(m)
|
|
if m != "" {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parsePorts converts a list of nftables port members (e.g. "80",
|
|
// "1000-2000") into PortRange values.
|
|
func (f *NFT) parsePorts(members []string) ([]PortRange, error) {
|
|
var specs []PortRange
|
|
for _, m := range members {
|
|
pr, err := ParsePortRange(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
specs = append(specs, pr)
|
|
}
|
|
return specs, nil
|
|
}
|
|
|
|
// parseRate parses an nftables rate operand starting at tokens[i] (a
|
|
// "<n>/<unit>" token) plus an optional "burst <m> packets" suffix. It returns
|
|
// the RateLimit and the index of the last token consumed.
|
|
func (f *NFT) parseRate(tokens []string, i int) (*RateLimit, int, error) {
|
|
if i >= len(tokens) {
|
|
return nil, 0, fmt.Errorf("missing rate value")
|
|
}
|
|
rate, unit, err := parseRateToken(tokens[i])
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rl := &RateLimit{Rate: rate, Unit: unit}
|
|
if i+2 < len(tokens) && tokens[i+1] == "burst" {
|
|
b, berr := strconv.ParseUint(tokens[i+2], 10, 32)
|
|
if berr != nil {
|
|
return nil, 0, fmt.Errorf("invalid burst %q", tokens[i+2])
|
|
}
|
|
rl.Burst = uint(b)
|
|
i += 2
|
|
// Skip a trailing "packets" keyword.
|
|
if i+1 < len(tokens) && tokens[i+1] == "packets" {
|
|
i++
|
|
}
|
|
// nftables applies a default burst of 5 packets to every limit statement
|
|
// and prints it back on `nft list` even when none was requested. A rule
|
|
// marshaled with Burst 0 (the documented "backend default") would otherwise
|
|
// read back as Burst 5 and fail rule-identity comparison, so treat nft's
|
|
// default as unset. An explicit Burst of 5 is indistinguishable from the
|
|
// default and equivalent to it.
|
|
if rl.Burst == 5 {
|
|
rl.Burst = 0
|
|
}
|
|
}
|
|
return rl, i, nil
|
|
}
|
|
|
|
// natTarget renders an nftables NAT translation target "<addr>[:<port>]",
|
|
// bracketing an IPv6 address when a port is present. An empty address yields
|
|
// ":<port>" (used by redirect).
|
|
func (f *NFT) natTarget(fam Family, addr string, port uint16) string {
|
|
if addr == "" {
|
|
if port != 0 {
|
|
return fmt.Sprintf(":%d", port)
|
|
}
|
|
return ""
|
|
}
|
|
if port == 0 {
|
|
return addr
|
|
}
|
|
if fam == IPv6 || familyOfAddr(addr) == IPv6 {
|
|
return fmt.Sprintf("[%s]:%d", addr, port)
|
|
}
|
|
return fmt.Sprintf("%s:%d", addr, port)
|
|
}
|
|
|
|
// natFamilyKeyword returns the `ip`/`ip6` qualifier nft requires between a
|
|
// dnat/snat verb and its `to` in an inet table (e.g. `dnat ip to <addr>`). nft
|
|
// rejects the unqualified form unless the rule already carries a same-family
|
|
// address match, so the write path always emits it and the read path consumes
|
|
// it (see UnmarshalNATRule). Redirect and masquerade take no address and need
|
|
// no qualifier.
|
|
func (f *NFT) natFamilyKeyword(fam Family, addr string) string {
|
|
if fam == IPv6 || familyOfAddr(addr) == IPv6 {
|
|
return "ip6"
|
|
}
|
|
return "ip"
|
|
}
|
|
|
|
// checkQuotable rejects a value containing a double quote. nft's string
|
|
// literals (a rule comment, a log prefix) have no escape mechanism at all — a
|
|
// `"` unconditionally toggles the quoted state — so there is no way to write one
|
|
// containing an embedded quote; nft's own parser errors on the attempt. Rejecting
|
|
// it here gives a clear validation error instead of a confusing raw nft syntax
|
|
// error from the command itself.
|
|
func (f *NFT) checkQuotable(s, field string) error {
|
|
if strings.Contains(s, `"`) {
|
|
return fmt.Errorf("nftables %s cannot contain a double quote", field)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarshalRule encodes a rule as the nftables expression that follows
|
|
// `nft add rule inet <table> <chain>`.
|
|
func (f *NFT) MarshalRule(r *Rule) (chain string, expr string, err error) {
|
|
// nftables can only match a port alongside a concrete transport protocol.
|
|
if r.PortNeedsConcreteProtocol() {
|
|
return "", "", fmt.Errorf("a port requires a tcp or udp protocol")
|
|
}
|
|
if err := r.checkICMPType(); err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := f.checkQuotable(r.Comment, "comment"); err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := f.checkQuotable(r.LogPrefix, "log prefix"); err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
chain = "input"
|
|
switch r.Direction {
|
|
case DirOutput:
|
|
chain = "output"
|
|
case DirForward:
|
|
chain = "forward"
|
|
}
|
|
|
|
// An input hook can only match the inbound interface; an output hook only the
|
|
// outbound one. The forward hook sees both an ingress and an egress interface,
|
|
// so it accepts either.
|
|
if r.IsOutput() && r.InInterface != "" {
|
|
return "", "", fmt.Errorf("an input interface cannot be matched on an output rule")
|
|
}
|
|
if r.IsInput() && r.OutInterface != "" {
|
|
return "", "", fmt.Errorf("an output interface cannot be matched on an input rule")
|
|
}
|
|
|
|
var parts []string
|
|
|
|
// Interface match.
|
|
if r.InInterface != "" {
|
|
parts = append(parts, fmt.Sprintf("iifname %s", strconv.Quote(r.InInterface)))
|
|
}
|
|
if r.OutInterface != "" {
|
|
parts = append(parts, fmt.Sprintf("oifname %s", strconv.Quote(r.OutInterface)))
|
|
}
|
|
|
|
// In an inet table the family is only implied when an address match carries
|
|
// it (ip/ip6). For a family-specific rule with no address, pin the family
|
|
// explicitly so the rule does not silently widen to both families.
|
|
if r.Family != FamilyAny && r.Source == "" && r.Destination == "" {
|
|
nfproto := "ipv4"
|
|
if r.Family == IPv6 {
|
|
nfproto = "ipv6"
|
|
}
|
|
parts = append(parts, "meta nfproto "+nfproto)
|
|
}
|
|
|
|
// Source address match, honoring negation via a leading '!'. A non-address token
|
|
// names a set, referenced as `@name`.
|
|
if r.Source != "" {
|
|
parts = append(parts, fmt.Sprintf("%s saddr %s", f.l3Match(r.Family, r.Source), f.addrExpr(r.Source)))
|
|
}
|
|
|
|
// Destination address match.
|
|
if r.Destination != "" {
|
|
parts = append(parts, fmt.Sprintf("%s daddr %s", f.l3Match(r.Family, r.Destination), f.addrExpr(r.Destination)))
|
|
}
|
|
|
|
// Protocol / port match.
|
|
if r.HasPorts() {
|
|
// A concrete protocol is guaranteed by the check above.
|
|
parts = append(parts, fmt.Sprintf("%s dport %s", r.Proto.String(), f.portExpr(r.PortSpecs())))
|
|
}
|
|
srcSpecs := r.SourcePortSpecs()
|
|
if len(srcSpecs) > 0 {
|
|
parts = append(parts, fmt.Sprintf("%s sport %s", r.Proto.String(), f.portExpr(srcSpecs)))
|
|
}
|
|
if r.Proto.IsICMP() && r.ICMPType != nil {
|
|
// An ICMP type match implies the icmp/icmpv6 protocol.
|
|
kw := "icmp"
|
|
if r.Proto == ICMPv6 {
|
|
kw = "icmpv6"
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s type %d", kw, *r.ICMPType))
|
|
} else if r.Proto != ProtocolAny && !r.HasPorts() && len(srcSpecs) == 0 {
|
|
parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto))
|
|
}
|
|
|
|
// Connection-tracking state match.
|
|
if r.State != 0 {
|
|
parts = append(parts, "ct state "+f.stateExpr(r.State))
|
|
}
|
|
|
|
// Rate limit: the statement matches only while under the rate, so over-rate
|
|
// packets fall through to later rules rather than taking this rule's verdict.
|
|
if r.RateLimit != nil {
|
|
lim := fmt.Sprintf("limit rate %d/%s", r.RateLimit.Rate, r.RateLimit.Unit)
|
|
if r.RateLimit.Burst > 0 {
|
|
lim += fmt.Sprintf(" burst %d packets", r.RateLimit.Burst)
|
|
}
|
|
parts = append(parts, lim)
|
|
}
|
|
|
|
// Connection limit: `ct count over N` matches while the tracked connection
|
|
// count exceeds the limit. Per-source counting needs a named meter, which
|
|
// this model does not express.
|
|
if r.ConnLimit != nil {
|
|
if r.ConnLimit.PerSource {
|
|
return "", "", fmt.Errorf("nftables per-source connection limiting requires a named meter, unsupported in this model")
|
|
}
|
|
parts = append(parts, fmt.Sprintf("ct count over %d", r.ConnLimit.Count))
|
|
}
|
|
|
|
// Logging, emitted just before the verdict so the packet is logged and then
|
|
// the action is applied.
|
|
if r.Log {
|
|
if r.LogPrefix != "" {
|
|
// A plain double-quote wrap, not strconv.Quote: nft has no backslash-escape
|
|
// mechanism, so strconv.Quote's Go-style escaping (doubling a literal
|
|
// backslash, etc.) would not round-trip through nft's own quoting. The
|
|
// embedded-quote case that would need escaping is rejected above.
|
|
parts = append(parts, `log prefix "`+r.LogPrefix+`"`)
|
|
} else {
|
|
parts = append(parts, "log")
|
|
}
|
|
}
|
|
|
|
// A counter so GetRules can report per-rule packet/byte statistics. The
|
|
// counter has no effect on matching and is ignored when comparing rules.
|
|
parts = append(parts, "counter")
|
|
|
|
// Action verb.
|
|
switch r.Action {
|
|
case Accept:
|
|
parts = append(parts, "accept")
|
|
case Drop:
|
|
parts = append(parts, "drop")
|
|
case Reject:
|
|
parts = append(parts, "reject")
|
|
default:
|
|
return "", "", fmt.Errorf("no valid action was provided")
|
|
}
|
|
|
|
// An optional user comment, stored as an nftables rule comment. It has no
|
|
// effect on matching and is ignored when comparing rules. A plain quote wrap,
|
|
// not strconv.Quote — see the log-prefix comment above for why.
|
|
if r.Comment != "" {
|
|
parts = append(parts, `comment "`+r.Comment+`"`)
|
|
}
|
|
|
|
return chain, strings.Join(parts, " "), nil
|
|
}
|
|
|
|
// unquote reverses the quoting MarshalRule applies to a string value (a log
|
|
// prefix or comment): a double-quoted token has its surrounding quotes stripped
|
|
// literally, not decoded with strconv.Unquote — nft has no backslash-escape
|
|
// mechanism, so a Go-style unquote would wrongly reinterpret a literal backslash
|
|
// sequence in the value (e.g. "C:\new") as an escape and corrupt it. A token that
|
|
// is not a double-quoted string (single-quoted, or bare/older nft) falls back to
|
|
// trimQuotes.
|
|
func (f *NFT) unquote(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
return trimQuotes(s)
|
|
}
|
|
|
|
// joinQuoted reassembles a double-quoted value that strings.Fields split on
|
|
// its internal whitespace. Given the index of the token that begins the value,
|
|
// it returns the unquoted value and the index of the final token consumed. When
|
|
// the token at start is not a quoted string it returns that single token
|
|
// unquoted. It mirrors the reassembly the comment parser does, so a spaced value
|
|
// (a log prefix like "FW DROP: ") round-trips instead of truncating at the space.
|
|
func (f *NFT) joinQuoted(tokens []string, start int) (string, int) {
|
|
if start >= len(tokens) {
|
|
return "", start
|
|
}
|
|
if !strings.HasPrefix(tokens[start], "\"") {
|
|
return f.unquote(tokens[start]), start
|
|
}
|
|
var parts []string
|
|
j := start
|
|
for ; j < len(tokens); j++ {
|
|
parts = append(parts, tokens[j])
|
|
joined := strings.Join(parts, " ")
|
|
// nft's quoting has no escape mechanism, so the first closing quote
|
|
// terminates the value.
|
|
if len(joined) >= 2 && strings.HasSuffix(joined, "\"") {
|
|
break
|
|
}
|
|
}
|
|
if j >= len(tokens) {
|
|
j = len(tokens) - 1
|
|
}
|
|
return f.unquote(strings.Join(parts, " ")), j
|
|
}
|
|
|
|
// UnmarshalRule decodes a single rule line from `nft list` output within the
|
|
// given chain. It returns the parsed rule and the nftables handle (used to
|
|
// delete the rule), or an error for lines it does not understand.
|
|
func (f *NFT) UnmarshalRule(line string, chain string) (r *Rule, handle string, err error) {
|
|
r = &Rule{Direction: f.directionForChain(chain)}
|
|
|
|
tokens := strings.Fields(f.collapseSetSpaces(line))
|
|
for i := 0; i < len(tokens); i++ {
|
|
switch tokens[i] {
|
|
case "ip", "ip6":
|
|
fam := IPv4
|
|
if tokens[i] == "ip6" {
|
|
fam = IPv6
|
|
}
|
|
r.Family = fam
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
dir := tokens[i]
|
|
|
|
// Consume an optional negation operator.
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
neg := ""
|
|
if tokens[i] == "!=" {
|
|
neg = "!"
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
} else if tokens[i] != "==" {
|
|
// Fall through: current token is the value.
|
|
} else {
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
}
|
|
|
|
switch dir {
|
|
case "saddr":
|
|
r.Source = neg + f.stripSetRef(tokens[i])
|
|
case "daddr":
|
|
r.Destination = neg + f.stripSetRef(tokens[i])
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported address direction: %s", dir)
|
|
}
|
|
case "iifname", "oifname":
|
|
dir := tokens[i]
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete interface match")
|
|
}
|
|
iface := trimQuotes(tokens[i])
|
|
if dir == "iifname" {
|
|
r.InInterface = iface
|
|
} else {
|
|
r.OutInterface = iface
|
|
}
|
|
case "ct":
|
|
if i+1 >= len(tokens) {
|
|
return nil, "", fmt.Errorf("unsupported ct match")
|
|
}
|
|
switch tokens[i+1] {
|
|
case "state":
|
|
// ct state <value>
|
|
if i+2 >= len(tokens) {
|
|
return nil, "", fmt.Errorf("unsupported ct match")
|
|
}
|
|
i += 2
|
|
state, serr := ParseConnState(f.parseSetTokens(tokens[i])...)
|
|
if serr != nil {
|
|
return nil, "", serr
|
|
}
|
|
r.State = state
|
|
case "count":
|
|
// ct count over N
|
|
if i+3 >= len(tokens) || tokens[i+2] != "over" {
|
|
return nil, "", fmt.Errorf("unsupported ct count match")
|
|
}
|
|
n, cerr := strconv.ParseUint(tokens[i+3], 10, 32)
|
|
if cerr != nil {
|
|
return nil, "", fmt.Errorf("invalid ct count %q", tokens[i+3])
|
|
}
|
|
r.ConnLimit = &ConnLimit{Count: uint(n)}
|
|
i += 3
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported ct match")
|
|
}
|
|
case "limit":
|
|
// limit rate N/unit [burst M packets]
|
|
if i+2 >= len(tokens) || tokens[i+1] != "rate" {
|
|
return nil, "", fmt.Errorf("unsupported limit statement")
|
|
}
|
|
rl, next, lerr := f.parseRate(tokens, i+2)
|
|
if lerr != nil {
|
|
return nil, "", lerr
|
|
}
|
|
r.RateLimit = rl
|
|
i = next
|
|
case "log":
|
|
r.Log = true
|
|
// Optional `prefix "..."` and `level <lvl>` qualifiers follow.
|
|
for i+1 < len(tokens) {
|
|
if tokens[i+1] == "prefix" && i+2 < len(tokens) {
|
|
// The prefix is a quoted string that strings.Fields may have
|
|
// split on its internal spaces; reassemble it fully.
|
|
r.LogPrefix, i = f.joinQuoted(tokens, i+2)
|
|
} else if tokens[i+1] == "level" && i+2 < len(tokens) {
|
|
i += 2
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
case "icmp", "icmpv6", "icmp6":
|
|
// icmp type N / icmpv6 type N
|
|
if tokens[i] == "icmp" {
|
|
r.Proto = ICMP
|
|
} else {
|
|
r.Proto = ICMPv6
|
|
}
|
|
if i+1 < len(tokens) && tokens[i+1] == "type" {
|
|
i += 2
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete icmp type match")
|
|
}
|
|
n, ok := parseICMPTypeFamily(tokens[i], r.Proto == ICMPv6)
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("invalid icmp type %q", tokens[i])
|
|
}
|
|
r.ICMPType = Ptr(n)
|
|
}
|
|
case "tcp", "udp", "sctp":
|
|
r.Proto = GetProtocol(tokens[i])
|
|
// A `dport` or `sport` qualifier may follow.
|
|
if i+2 < len(tokens) && (tokens[i+1] == "dport" || tokens[i+1] == "sport") {
|
|
src := tokens[i+1] == "sport"
|
|
i += 2
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete port match")
|
|
}
|
|
members := f.parseSetTokens(tokens[i])
|
|
if len(members) == 0 {
|
|
return nil, "", fmt.Errorf("incomplete port match")
|
|
}
|
|
specs, perr := f.parsePorts(members)
|
|
if perr != nil {
|
|
return nil, "", perr
|
|
}
|
|
// Keep the single-port form in Port for a lone discrete port so
|
|
// it round-trips against rules built that way.
|
|
if src {
|
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
|
r.SourcePort = specs[0].Start
|
|
} else {
|
|
r.SourcePorts = specs
|
|
}
|
|
} else {
|
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
|
r.Port = specs[0].Start
|
|
} else {
|
|
r.Ports = specs
|
|
}
|
|
}
|
|
}
|
|
case "meta":
|
|
// meta l4proto <proto> or meta nfproto <family>
|
|
if i+2 >= len(tokens) {
|
|
return nil, "", fmt.Errorf("unsupported meta match")
|
|
}
|
|
switch tokens[i+1] {
|
|
case "l4proto":
|
|
r.Proto = f.protoFromToken(tokens[i+2])
|
|
case "nfproto":
|
|
switch tokens[i+2] {
|
|
case "ipv4":
|
|
r.Family = IPv4
|
|
case "ipv6":
|
|
r.Family = IPv6
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported nfproto: %s", tokens[i+2])
|
|
}
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported meta match")
|
|
}
|
|
i += 2
|
|
case "accept":
|
|
r.Action = Accept
|
|
case "drop":
|
|
r.Action = Drop
|
|
case "reject":
|
|
r.Action = Reject
|
|
// An explicitly-written `reject with <proto> <type>` (e.g.
|
|
// `reject with icmp port-unreachable`, `reject with tcp reset`) can appear
|
|
// on a foreign rule — nft itself never adds this clause to a bare `reject`
|
|
// on read, but a rule authored with one keeps it verbatim. The detail runs
|
|
// until the comment or handle marker; consume it so the trailing tokens do
|
|
// not fail the parse and drop the rule.
|
|
if i+1 < len(tokens) && tokens[i+1] == "with" {
|
|
j := i + 2
|
|
for ; j < len(tokens); j++ {
|
|
if tokens[j] == "comment" || tokens[j] == "#" {
|
|
break
|
|
}
|
|
}
|
|
i = j - 1
|
|
}
|
|
case "comment":
|
|
// nft prints the rule comment as a double-quoted string, before an
|
|
// optional `# handle N` marker. Reassemble it by collecting tokens up
|
|
// to the closing quote, so a comment containing spaces — or a literal
|
|
// '#', which strings.Fields would otherwise mistake for the handle
|
|
// marker and truncate the rule at — round-trips intact.
|
|
if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "\"") {
|
|
var cparts []string
|
|
j := i + 1
|
|
for ; j < len(tokens); j++ {
|
|
cparts = append(cparts, tokens[j])
|
|
joined := strings.Join(cparts, " ")
|
|
// The first closing quote terminates the comment.
|
|
if len(joined) >= 2 && strings.HasSuffix(joined, "\"") {
|
|
break
|
|
}
|
|
}
|
|
r.Comment = f.unquote(strings.Join(cparts, " "))
|
|
i = j
|
|
} else {
|
|
// Unquoted (a single bare word, or older nft): take tokens up to
|
|
// the handle marker.
|
|
var cparts []string
|
|
for j := i + 1; j < len(tokens); j++ {
|
|
if tokens[j] == "#" {
|
|
break
|
|
}
|
|
cparts = append(cparts, tokens[j])
|
|
}
|
|
r.Comment = f.unquote(strings.Join(cparts, " "))
|
|
i += len(cparts)
|
|
}
|
|
case "#":
|
|
// `nft -a` prints the handle after a comment marker: `# handle N`.
|
|
case "handle":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("missing handle value")
|
|
}
|
|
handle = tokens[i]
|
|
case "counter":
|
|
// `counter packets N bytes M` (always present on a listed rule that
|
|
// has a counter statement). Capture the values for GetRules.
|
|
if i+4 < len(tokens) && tokens[i+1] == "packets" && tokens[i+3] == "bytes" {
|
|
if pkts, perr := strconv.ParseUint(tokens[i+2], 10, 64); perr == nil {
|
|
r.Packets = pkts
|
|
}
|
|
if by, berr := strconv.ParseUint(tokens[i+4], 10, 64); berr == nil {
|
|
r.Bytes = by
|
|
}
|
|
i += 4
|
|
}
|
|
case "packets", "bytes":
|
|
// Skip stray counter value tokens (already consumed under counter).
|
|
if i+1 < len(tokens) {
|
|
i++
|
|
}
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported token: %s", tokens[i])
|
|
}
|
|
}
|
|
|
|
if r.Action == ActionInvalid {
|
|
return nil, "", fmt.Errorf("no valid action was provided")
|
|
}
|
|
return r, handle, nil
|
|
}
|
|
|
|
// listChain returns every parsed rule (with its nftables handle) in a chain.
|
|
func (f *NFT) listChain(ctx context.Context, chain string) (rules []*Rule, handles []string, err error) {
|
|
out, err := runCommand(ctx, "nft", "-a", "list", "chain", "inet", f.table, chain)
|
|
if err != nil {
|
|
// A missing table simply means there are no rules yet.
|
|
if strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist") {
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
|
|
for _, line := range out {
|
|
line = strings.TrimSpace(line)
|
|
// Only rule lines carry a handle; skip table/chain scaffolding.
|
|
if line == "" || !strings.Contains(line, "handle ") {
|
|
continue
|
|
}
|
|
rule, handle, perr := f.UnmarshalRule(line, chain)
|
|
if perr != nil {
|
|
continue
|
|
}
|
|
// Rules live in this backend's own table; membership in the library's
|
|
// private table is what sets HasPrefix, so record the table and flag it
|
|
// as carrying the prefix.
|
|
rule.table = f.table
|
|
rule.HasPrefix = true
|
|
rules = append(rules, rule)
|
|
handles = append(handles, handle)
|
|
}
|
|
return rules, handles, nil
|
|
}
|
|
|
|
// headerName extracts the object name from an `nft -a list ruleset` header
|
|
// line such as `table inet foo { # handle 3` or `chain input { # handle 1`: it
|
|
// drops the keyword and everything from the opening brace on. nft -a appends
|
|
// `{ # handle N` (and sometimes ` progname ...`) to headers, which a naive
|
|
// TrimSuffix(line, "{") would leave attached to the name — making a table
|
|
// comparison against our own table miss and re-list our rules as foreign.
|
|
func (f *NFT) headerName(line, keyword string) string {
|
|
name := strings.TrimPrefix(line, keyword+" ")
|
|
if i := strings.IndexByte(name, '{'); i >= 0 {
|
|
name = name[:i]
|
|
}
|
|
return strings.TrimSpace(name)
|
|
}
|
|
|
|
// listForeignRules walks the entire nftables ruleset and returns best-effort
|
|
// parsed rules that live outside this backend's own inet table. Because arbitrary
|
|
// foreign tables use families and constructs the library's Rule model cannot
|
|
// represent, any line that fails to parse is skipped rather than erroring the
|
|
// whole read. This gives callers visibility of rules in other tables alongside
|
|
// the library's own.
|
|
func (f *NFT) listForeignRules(ctx context.Context) ([]*Rule, error) {
|
|
out, err := runCommand(ctx, "nft", "-a", "list", "ruleset")
|
|
if err != nil {
|
|
// No ruleset (or nft unavailable for listing): nothing foreign to report.
|
|
return nil, nil
|
|
}
|
|
|
|
ownTable := "inet " + f.table
|
|
curTable := ""
|
|
curChain := ""
|
|
var rules []*Rule
|
|
for _, line := range out {
|
|
t := strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(t, "table "):
|
|
// e.g. "table inet filter { # handle 3" -> "inet filter"
|
|
curTable = f.headerName(t, "table")
|
|
curChain = ""
|
|
case strings.HasPrefix(t, "chain "):
|
|
// e.g. "chain input { # handle 1" -> "input"
|
|
curChain = f.headerName(t, "chain")
|
|
case t == "}":
|
|
// Closes the current chain (a table close is harmless: no rule lines
|
|
// follow before the next "table" resets the context).
|
|
curChain = ""
|
|
case strings.Contains(t, "handle "):
|
|
// Our own table is read precisely by listChain; skip it here.
|
|
if curTable == ownTable {
|
|
continue
|
|
}
|
|
rule, _, perr := f.UnmarshalRule(t, curChain)
|
|
if perr != nil || rule == nil {
|
|
continue
|
|
}
|
|
// A rule from another table: record where it came from; it is not ours,
|
|
// so HasPrefix stays false.
|
|
rule.table = curTable
|
|
rules = append(rules, rule)
|
|
}
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
// listOwnRules returns the library's own filter rules from its private table,
|
|
// with the IPv4/IPv6 pairs merged. A read does not create the table; listChain
|
|
// returns nothing when the table does not yet exist.
|
|
func (f *NFT) listOwnRules(ctx context.Context) ([]*Rule, error) {
|
|
var rules []*Rule
|
|
for _, chain := range nftFilterChains {
|
|
chainRules, _, cerr := f.listChain(ctx, chain)
|
|
if cerr != nil {
|
|
return nil, cerr
|
|
}
|
|
rules = append(rules, chainRules...)
|
|
}
|
|
// Merge families first, number per direction (input then output chain) so a
|
|
// collapsed v4/v6 pair leaves no gap and each rule's Number matches the
|
|
// InsertRule/MoveRule position within its chain, then collapse each input/output
|
|
// twin into one DirAny rule (numbering first keeps the surviving pure-output
|
|
// rows' physical position).
|
|
merged := mergeFamilies(rules)
|
|
numberByDirection(merged)
|
|
merged = mergeDirections(merged)
|
|
return merged, nil
|
|
}
|
|
|
|
// GetRules returns the existing filter rules from the zone.
|
|
func (f *NFT) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
|
|
// The library's own merged rules, then foreign rules from every other table
|
|
// unmerged (their identity spans tables we do not own).
|
|
rules, err = f.listOwnRules(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
foreign, ferr := f.listForeignRules(ctx)
|
|
if ferr != nil {
|
|
return nil, ferr
|
|
}
|
|
rules = append(rules, foreign...)
|
|
return rules, nil
|
|
}
|
|
|
|
// AddRule adds a rule to the zone.
|
|
func (f *NFT) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
|
return f.insertRule(ctx, zoneName, -1, r)
|
|
}
|
|
|
|
// InsertRule inserts rule before the given 1-based position. position <= 0 is
|
|
// treated as 1 (prepend); a position larger than the current rule count appends
|
|
// the rule. Normalizing here keeps insertRule's -1 sentinel reserved for
|
|
// AddRule's append path.
|
|
func (f *NFT) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
|
|
if position <= 0 {
|
|
position = 1
|
|
}
|
|
return f.insertRule(ctx, zoneName, position, r)
|
|
}
|
|
|
|
// ruleExists reports whether existing already contains a rule matching r.
|
|
func (f *NFT) ruleExists(existing []*Rule, r *Rule) bool {
|
|
for _, e := range existing {
|
|
if e.EqualForDedup(r, true) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// natRuleExists is ruleExists for NAT rules.
|
|
func (f *NFT) natRuleExists(existing []*NATRule, r *NATRule) bool {
|
|
for _, e := range existing {
|
|
if e.EqualForDedup(r) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (f *NFT) insertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// A DirAny rule fans out into an input row plus its role-swapped output row;
|
|
// place each in its own chain at the requested position.
|
|
if r.Direction == DirAny {
|
|
for _, sub := range expandDirections(r) {
|
|
if err := f.insertRule(ctx, zoneName, position, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
chain, expr, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Skip if an equivalent rule already exists.
|
|
existing, _, err := f.listChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.ruleExists(existing, r) {
|
|
return nil
|
|
}
|
|
|
|
if position >= 1 {
|
|
// position is a merged Number; map it back to a physical chain index so a
|
|
// merged v4/v6 pair earlier in the chain does not skew the placement.
|
|
insPos := mergedInsertIndex(mergedFamilyAnchors(existing), len(existing), position)
|
|
args := f.placeArgs(chain, expr, insPos, len(existing))
|
|
_, err = runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
args := append([]string{"add", "rule", "inet", f.table, chain}, strings.Fields(expr)...)
|
|
_, err = runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
// placeArgs builds the nft command that places a rule expression at 0-based
|
|
// index insPos in a chain currently holding n rules. nft can insert before an
|
|
// existing rule (`insert ... index k`, k in [0,n-1]) or prepend (`insert` with no
|
|
// index), but it has no insert-at-end form, so an index at or past the end must
|
|
// append with `add rule`.
|
|
func (f *NFT) placeArgs(chain, expr string, insPos, n int) []string {
|
|
fields := strings.Fields(expr)
|
|
switch {
|
|
case insPos >= n:
|
|
return append([]string{"add", "rule", "inet", f.table, chain}, fields...)
|
|
case insPos <= 0:
|
|
return append([]string{"insert", "rule", "inet", f.table, chain}, fields...)
|
|
default:
|
|
return append([]string{"insert", "rule", "inet", f.table, chain, "index", strconv.Itoa(insPos)}, fields...)
|
|
}
|
|
}
|
|
|
|
// MoveRule moves an existing rule to the given 1-based position.
|
|
func (f *NFT) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
|
|
if position <= 0 {
|
|
position = 1
|
|
}
|
|
|
|
// A DirAny rule occupies a slot in both chains; move each half to the requested
|
|
// position within its own chain.
|
|
if r.Direction == DirAny {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
for _, sub := range expandDirections(r) {
|
|
if err := f.MoveRule(ctx, zoneName, sub, position); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
chain := f.chainForDirection(r.Direction)
|
|
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
rules, handles, err := f.listChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Collect every row that belongs to this rule. GetRules merges an IPv4 and IPv6
|
|
// row that differ only in family into one FamilyAny rule, so moving that merged
|
|
// rule must relocate BOTH underlying rows. Deleting only the first half and
|
|
// re-inserting a single dual-family rule would orphan the twin — the same hazard
|
|
// RemoveRule guards against via EqualForRemoval. A concrete-family target
|
|
// still moves only its own family.
|
|
firstIdx := -1
|
|
var toDelete []string
|
|
deleted := make(map[int]bool)
|
|
for i, e := range rules {
|
|
if e.EqualForRemoval(r, true) {
|
|
if firstIdx < 0 {
|
|
firstIdx = i
|
|
}
|
|
toDelete = append(toDelete, handles[i])
|
|
deleted[i] = true
|
|
}
|
|
}
|
|
if firstIdx < 0 {
|
|
return nil
|
|
}
|
|
|
|
// position and each rule's Number live in the merged (post-mergeFamilies) index
|
|
// space, while the chain holds physical rows; map through the chain's merged
|
|
// anchors so a merged v4/v6 pair does not skew the target. curPos is the target
|
|
// rule's current merged position (its anchor is firstIdx, the first matching row).
|
|
anchors := mergedFamilyAnchors(rules)
|
|
if position > len(anchors) {
|
|
position = len(anchors)
|
|
}
|
|
curPos := 0
|
|
for p, idx := range anchors {
|
|
if idx == firstIdx {
|
|
curPos = p + 1
|
|
break
|
|
}
|
|
}
|
|
// If moving to the same position, nothing to do.
|
|
if position == curPos {
|
|
return nil
|
|
}
|
|
|
|
// nft has no native move; delete every matching row, then re-insert once at the
|
|
// target position within the now-reduced chain.
|
|
for _, h := range toDelete {
|
|
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", h); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
_, expr, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The rows were just deleted, so the chain now holds the remaining rows; map the
|
|
// target merged position to a physical index within that reduced chain (appending
|
|
// when the target is at or past the end).
|
|
reduced := make([]*Rule, 0, len(rules)-len(toDelete))
|
|
for i, e := range rules {
|
|
if !deleted[i] {
|
|
reduced = append(reduced, e)
|
|
}
|
|
}
|
|
insPos := mergedInsertIndex(mergedFamilyAnchors(reduced), len(reduced), position)
|
|
args := f.placeArgs(chain, expr, insPos, len(reduced))
|
|
_, err = runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
// RemoveRule removes a rule from the zone.
|
|
func (f *NFT) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// A DirAny target removes both its input row and its role-swapped output row,
|
|
// each from its own chain.
|
|
if r.Direction == DirAny {
|
|
for _, sub := range expandDirections(r) {
|
|
if err := f.RemoveRule(ctx, zoneName, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
chain := f.chainForDirection(r.Direction)
|
|
|
|
// Find the matching rule so we can delete it by its handle.
|
|
rules, handles, err := f.listChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Delete every matching row, not just the first. GetRules merges an IPv4 and
|
|
// IPv6 row that differ only in family into one FamilyAny rule, so removing that
|
|
// merged rule must clear both underlying rows or its twin is orphaned (and a
|
|
// second reconcile pass would be needed to converge). A concrete-family target
|
|
// still removes only its own family — see EqualForRemoval.
|
|
// A genuine dual-family row is its own merged anchor (FamilyAny rows never merge
|
|
// into a pair), so its merged position is where the re-added family must land to
|
|
// preserve ordering. Capture the anchors before the delete shifts the chain.
|
|
anchors := mergedFamilyAnchors(rules)
|
|
var reAdd *Rule
|
|
reAddPos := 0
|
|
for i, e := range rules {
|
|
if e.EqualForRemoval(r, true) {
|
|
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil {
|
|
return err
|
|
}
|
|
// A concrete-family target that matched a genuine dual-family row (an inet
|
|
// rule with no family pin, covering both) would drop both families; re-add
|
|
// the untargeted family below, at the dual row's own merged position so the
|
|
// surviving family keeps the removed rule's place in the chain.
|
|
if s := splitDualRow(e, r); s != nil {
|
|
reAdd = s
|
|
for p, idx := range anchors {
|
|
if idx == i {
|
|
reAddPos = p + 1
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if reAdd != nil {
|
|
return f.insertRule(ctx, zoneName, reAddPos, reAdd)
|
|
}
|
|
// Nothing left to remove.
|
|
return nil
|
|
}
|
|
|
|
// AddRulesBatch adds every rule in a single `nft -f` transaction rather than one
|
|
// `nft add rule` invocation per rule. Rules that already exist are skipped. The
|
|
// whole batch applies atomically. It implements RuleBatcher.
|
|
func (f *NFT) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Snapshot each chain so duplicates (existing or within the batch) are
|
|
// skipped, mirroring AddRule.
|
|
existing := map[string][]*Rule{}
|
|
for _, chain := range nftFilterChains {
|
|
rs, _, err := f.listChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing[chain] = rs
|
|
}
|
|
|
|
var script strings.Builder
|
|
n := 0
|
|
for _, top := range rules {
|
|
// A DirAny rule fans out into an input row plus its swapped output row.
|
|
for _, r := range expandDirections(top) {
|
|
chain, expr, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.ruleExists(existing[chain], r) {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr)
|
|
existing[chain] = append(existing[chain], r)
|
|
n++
|
|
}
|
|
}
|
|
if n == 0 {
|
|
return nil
|
|
}
|
|
_, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-")
|
|
return err
|
|
}
|
|
|
|
// ReplaceRulesBatch atomically flushes the private table's input and output
|
|
// chains and re-adds exactly rules, all in one `nft -f` transaction. Chain hooks
|
|
// and policies are preserved (flush chain removes only rules). It implements
|
|
// RuleBatcher.
|
|
func (f *NFT) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
var script strings.Builder
|
|
fmt.Fprintf(&script, "flush chain inet %s input\n", f.table)
|
|
fmt.Fprintf(&script, "flush chain inet %s output\n", f.table)
|
|
for _, top := range rules {
|
|
// A DirAny rule fans out into an input row plus its swapped output row.
|
|
for _, r := range expandDirections(top) {
|
|
chain, expr, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr)
|
|
}
|
|
}
|
|
_, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-")
|
|
return err
|
|
}
|
|
|
|
// Backup captures the filter and NAT rules in this backend's private table.
|
|
func (f *NFT) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
|
// Read the private table directly rather than GetRules: Restore flushes and
|
|
// refills only this table, so the backup must not pull in rules from foreign
|
|
// tables (they would be re-added into the wrong table on Restore).
|
|
rules, err := f.listOwnRules(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
natRules, err := f.listOwnNATRules(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
backup := &Backup{Rules: rules, NATRules: natRules}
|
|
if err := captureBackupState(ctx, f, zoneName, backup); err != nil {
|
|
return nil, err
|
|
}
|
|
return backup, nil
|
|
}
|
|
|
|
// Restore replaces the managed rules with the contents of a Backup.
|
|
func (f *NFT) Restore(ctx context.Context, zoneName string, backup *Backup) error {
|
|
if backup == nil {
|
|
return fmt.Errorf("backup cannot be nil")
|
|
}
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := f.ensureNATChains(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Flush the private table, then re-add all rules.
|
|
if _, err := runCommand(ctx, "nft", "flush", "table", "inet", f.table); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Recreate the sets on a clean slate before the rules that reference them. The
|
|
// flush above cleared every rule in the table, so no rule holds a set reference
|
|
// and each set can be removed and rebuilt; the clean rebuild is required because
|
|
// nft's AddAddressSet is a no-op on an existing set and would not otherwise
|
|
// restore a flushed set's elements.
|
|
if err := restoreBackupSets(ctx, f, backup, true); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, r := range backup.Rules {
|
|
if err := f.AddRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, r := range backup.NATRules {
|
|
if err := f.AddNATRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return applyBackupPolicy(ctx, f, zoneName, backup)
|
|
}
|
|
|
|
// MarshalNATRule encodes a NAT rule as the nftables expression that follows
|
|
// `nft add rule inet <table> <chain>`, returning the chain (prerouting for
|
|
// destination NAT, postrouting for source NAT) and the expression.
|
|
func (f *NFT) MarshalNATRule(r *NATRule) (chain string, expr string, err error) {
|
|
if err := r.validate(); err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
fam := r.impliedFamily()
|
|
var parts []string
|
|
|
|
// Interface, bound to the NAT direction: outbound for source NAT, inbound
|
|
// for destination NAT.
|
|
if r.Interface != "" {
|
|
if r.Kind.isSource() {
|
|
parts = append(parts, "oifname "+strconv.Quote(r.Interface))
|
|
} else {
|
|
parts = append(parts, "iifname "+strconv.Quote(r.Interface))
|
|
}
|
|
}
|
|
|
|
// Pin the family when no address carries it.
|
|
if fam != FamilyAny && r.Source == "" && r.Destination == "" {
|
|
nfproto := "ipv4"
|
|
if fam == IPv6 {
|
|
nfproto = "ipv6"
|
|
}
|
|
parts = append(parts, "meta nfproto "+nfproto)
|
|
}
|
|
|
|
if r.Source != "" {
|
|
parts = append(parts, fmt.Sprintf("%s saddr %s", f.l3Match(fam, r.Source), f.addrExpr(r.Source)))
|
|
}
|
|
if r.Destination != "" {
|
|
parts = append(parts, fmt.Sprintf("%s daddr %s", f.l3Match(fam, r.Destination), f.addrExpr(r.Destination)))
|
|
}
|
|
|
|
if r.HasPorts() {
|
|
parts = append(parts, fmt.Sprintf("%s dport %s", r.Proto.String(), f.portExpr(r.PortSpecs())))
|
|
} else if r.Proto != ProtocolAny {
|
|
parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto))
|
|
}
|
|
|
|
switch r.Kind {
|
|
case DNAT:
|
|
chain = "prerouting"
|
|
parts = append(parts, "dnat "+f.natFamilyKeyword(fam, r.ToAddress)+" to "+f.natTarget(fam, r.ToAddress, r.ToPort))
|
|
case Redirect:
|
|
chain = "prerouting"
|
|
parts = append(parts, "redirect to "+f.natTarget(fam, "", r.ToPort))
|
|
case SNAT:
|
|
chain = "postrouting"
|
|
parts = append(parts, "snat "+f.natFamilyKeyword(fam, r.ToAddress)+" to "+f.natTarget(fam, r.ToAddress, 0))
|
|
case Masquerade:
|
|
chain = "postrouting"
|
|
parts = append(parts, "masquerade")
|
|
default:
|
|
return "", "", fmt.Errorf("invalid nat kind")
|
|
}
|
|
|
|
return chain, strings.Join(parts, " "), nil
|
|
}
|
|
|
|
// parseNATTarget parses an nftables NAT target token ("addr", "addr:port",
|
|
// "[v6]:port" or ":port") into its address and port.
|
|
func (f *NFT) parseNATTarget(tok string) (addr string, port uint16, err error) {
|
|
tok = strings.TrimSpace(tok)
|
|
if tok == "" {
|
|
return "", 0, nil
|
|
}
|
|
// Bracketed IPv6, optionally with a port.
|
|
if strings.HasPrefix(tok, "[") {
|
|
end := strings.Index(tok, "]")
|
|
if end < 0 {
|
|
return "", 0, fmt.Errorf("invalid nat target %q", tok)
|
|
}
|
|
addr = tok[1:end]
|
|
rest := tok[end+1:]
|
|
if strings.HasPrefix(rest, ":") {
|
|
p, perr := strconv.ParseUint(rest[1:], 10, 16)
|
|
if perr != nil {
|
|
return "", 0, fmt.Errorf("invalid nat target port %q", rest[1:])
|
|
}
|
|
port = uint16(p)
|
|
}
|
|
return addr, port, nil
|
|
}
|
|
// Bare ":port" (redirect target).
|
|
if strings.HasPrefix(tok, ":") {
|
|
p, perr := strconv.ParseUint(tok[1:], 10, 16)
|
|
if perr != nil {
|
|
return "", 0, fmt.Errorf("invalid nat target port %q", tok[1:])
|
|
}
|
|
return "", uint16(p), nil
|
|
}
|
|
// A single colon is IPv4 addr:port; more (or none) is a bare address.
|
|
if strings.Count(tok, ":") == 1 {
|
|
host, ps, _ := strings.Cut(tok, ":")
|
|
p, perr := strconv.ParseUint(ps, 10, 16)
|
|
if perr != nil {
|
|
return "", 0, fmt.Errorf("invalid nat target port %q", ps)
|
|
}
|
|
return host, uint16(p), nil
|
|
}
|
|
return tok, 0, nil
|
|
}
|
|
|
|
// UnmarshalNATRule decodes a single NAT rule line from `nft -a list chain`
|
|
// output within the given chain, returning the parsed rule and its handle.
|
|
func (f *NFT) UnmarshalNATRule(line string, chain string) (r *NATRule, handle string, err error) {
|
|
r = &NATRule{}
|
|
tokens := strings.Fields(f.collapseSetSpaces(line))
|
|
for i := 0; i < len(tokens); i++ {
|
|
switch tokens[i] {
|
|
case "ip", "ip6":
|
|
fam := IPv4
|
|
if tokens[i] == "ip6" {
|
|
fam = IPv6
|
|
}
|
|
r.Family = fam
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
dir := tokens[i]
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
neg := ""
|
|
if tokens[i] == "!=" {
|
|
neg = "!"
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
}
|
|
switch dir {
|
|
case "saddr":
|
|
r.Source = neg + f.stripSetRef(tokens[i])
|
|
case "daddr":
|
|
r.Destination = neg + f.stripSetRef(tokens[i])
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported address direction: %s", dir)
|
|
}
|
|
case "iifname", "oifname":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete interface match")
|
|
}
|
|
r.Interface = trimQuotes(tokens[i])
|
|
case "tcp", "udp", "sctp":
|
|
r.Proto = GetProtocol(tokens[i])
|
|
if i+1 < len(tokens) && tokens[i+1] == "dport" {
|
|
i += 2
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete port match")
|
|
}
|
|
specs, perr := f.parsePorts(f.parseSetTokens(tokens[i]))
|
|
if perr != nil {
|
|
return nil, "", perr
|
|
}
|
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
|
r.Port = specs[0].Start
|
|
} else {
|
|
r.Ports = specs
|
|
}
|
|
}
|
|
case "meta":
|
|
if i+2 >= len(tokens) {
|
|
return nil, "", fmt.Errorf("unsupported meta match")
|
|
}
|
|
switch tokens[i+1] {
|
|
case "l4proto":
|
|
r.Proto = f.protoFromToken(tokens[i+2])
|
|
case "nfproto":
|
|
switch tokens[i+2] {
|
|
case "ipv4":
|
|
r.Family = IPv4
|
|
case "ipv6":
|
|
r.Family = IPv6
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported nfproto: %s", tokens[i+2])
|
|
}
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported meta match")
|
|
}
|
|
i += 2
|
|
case "dnat", "snat":
|
|
r.Kind = DNAT
|
|
if tokens[i] == "snat" {
|
|
r.Kind = SNAT
|
|
}
|
|
// nft lists the translation with the address family before `to`
|
|
// (`dnat ip to <addr>`); consume the optional ip/ip6 keyword.
|
|
j := i + 1
|
|
if j < len(tokens) && (tokens[j] == "ip" || tokens[j] == "ip6") {
|
|
if tokens[j] == "ip6" {
|
|
r.Family = IPv6
|
|
} else if r.Family == FamilyAny {
|
|
r.Family = IPv4
|
|
}
|
|
j++
|
|
}
|
|
if j+1 >= len(tokens) || tokens[j] != "to" {
|
|
return nil, "", fmt.Errorf("incomplete %s statement", tokens[i])
|
|
}
|
|
addr, port, terr := f.parseNATTarget(tokens[j+1])
|
|
if terr != nil {
|
|
return nil, "", terr
|
|
}
|
|
r.ToAddress = addr
|
|
r.ToPort = port
|
|
i = j + 1
|
|
case "redirect":
|
|
r.Kind = Redirect
|
|
if i+2 < len(tokens) && tokens[i+1] == "to" {
|
|
_, port, terr := f.parseNATTarget(tokens[i+2])
|
|
if terr != nil {
|
|
return nil, "", terr
|
|
}
|
|
r.ToPort = port
|
|
i += 2
|
|
}
|
|
case "masquerade":
|
|
r.Kind = Masquerade
|
|
case "#":
|
|
// The `# handle N` marker follows.
|
|
case "handle":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("missing handle value")
|
|
}
|
|
handle = tokens[i]
|
|
case "counter", "packets", "bytes":
|
|
if tokens[i] == "packets" || tokens[i] == "bytes" {
|
|
i++
|
|
}
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported token: %s", tokens[i])
|
|
}
|
|
}
|
|
if r.Kind == NATInvalid {
|
|
return nil, "", fmt.Errorf("no nat action was provided")
|
|
}
|
|
if r.Family == FamilyAny {
|
|
r.Family = r.impliedFamily()
|
|
}
|
|
return r, handle, nil
|
|
}
|
|
|
|
// listNATChain returns every parsed NAT rule (with its handle) in a chain.
|
|
func (f *NFT) listNATChain(ctx context.Context, chain string) (rules []*NATRule, handles []string, err error) {
|
|
out, err := runCommand(ctx, "nft", "-a", "list", "chain", "inet", f.table, chain)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist") {
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
for _, line := range out {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || !strings.Contains(line, "handle ") {
|
|
continue
|
|
}
|
|
rule, handle, perr := f.UnmarshalNATRule(line, chain)
|
|
if perr != nil {
|
|
continue
|
|
}
|
|
// NAT rules live in this backend's own table; membership is what sets
|
|
// HasPrefix, so record the table and flag it as carrying the prefix.
|
|
rule.table = f.table
|
|
rule.HasPrefix = true
|
|
rules = append(rules, rule)
|
|
handles = append(handles, handle)
|
|
}
|
|
return rules, handles, nil
|
|
}
|
|
|
|
// listForeignNATRules walks the entire nftables ruleset and returns best-effort
|
|
// NAT rules that live outside this backend's own inet table. Like listForeignRules
|
|
// it skips any line it cannot parse.
|
|
func (f *NFT) listForeignNATRules(ctx context.Context) ([]*NATRule, error) {
|
|
out, err := runCommand(ctx, "nft", "-a", "list", "ruleset")
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
|
|
ownTable := "inet " + f.table
|
|
curTable := ""
|
|
curChain := ""
|
|
var rules []*NATRule
|
|
for _, line := range out {
|
|
t := strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(t, "table "):
|
|
curTable = f.headerName(t, "table")
|
|
curChain = ""
|
|
case strings.HasPrefix(t, "chain "):
|
|
curChain = f.headerName(t, "chain")
|
|
case t == "}":
|
|
curChain = ""
|
|
case strings.Contains(t, "handle "):
|
|
if curTable == ownTable {
|
|
continue
|
|
}
|
|
rule, _, perr := f.UnmarshalNATRule(t, curChain)
|
|
if perr != nil || rule == nil {
|
|
continue
|
|
}
|
|
// A NAT rule from another table: record its source; not ours, so
|
|
// HasPrefix stays false.
|
|
rule.table = curTable
|
|
rules = append(rules, rule)
|
|
}
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
// listOwnNATRules returns the library's own NAT rules from its private table,
|
|
// with the IPv4/IPv6 pairs merged.
|
|
func (f *NFT) listOwnNATRules(ctx context.Context) ([]*NATRule, error) {
|
|
var rules []*NATRule
|
|
for _, chain := range []string{"prerouting", "postrouting"} {
|
|
chainRules, _, cerr := f.listNATChain(ctx, chain)
|
|
if cerr != nil {
|
|
return nil, cerr
|
|
}
|
|
rules = append(rules, chainRules...)
|
|
}
|
|
// Merge families first, then number per nat chain (prerouting then postrouting)
|
|
// so a collapsed v4/v6 pair leaves no gap and each rule's Number matches the
|
|
// InsertNATRule position within its chain.
|
|
merged := mergeNATFamilies(rules)
|
|
numberNATByChain(merged)
|
|
return merged, nil
|
|
}
|
|
|
|
// GetNATRules returns the existing NAT rules from the zone.
|
|
func (f *NFT) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) {
|
|
rules, err = f.listOwnNATRules(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
foreign, ferr := f.listForeignNATRules(ctx)
|
|
if ferr != nil {
|
|
return nil, ferr
|
|
}
|
|
rules = append(rules, foreign...)
|
|
return rules, nil
|
|
}
|
|
|
|
// AddNATRule adds a NAT rule to the zone.
|
|
func (f *NFT) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
|
if err := f.ensureNATChains(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
chain, expr, err := f.MarshalNATRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
existing, _, err := f.listNATChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.natRuleExists(existing, r) {
|
|
return nil
|
|
}
|
|
|
|
args := append([]string{"add", "rule", "inet", f.table, chain}, strings.Fields(expr)...)
|
|
_, err = runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
// InsertNATRule inserts a NAT rule before the given 1-based position within its
|
|
// nat chain. position <= 0 is treated as 1; a position larger than the chain's
|
|
// current rule count appends the rule.
|
|
func (f *NFT) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
|
|
if err := f.ensureNATChains(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
chain, expr, err := f.MarshalNATRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
existing, _, err := f.listNATChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.natRuleExists(existing, r) {
|
|
return nil
|
|
}
|
|
|
|
if position <= 0 {
|
|
position = 1
|
|
}
|
|
// position is a merged Number; map it back to a physical nat-chain index so a
|
|
// merged v4/v6 NAT pair earlier in the chain does not skew the placement.
|
|
insPos := mergedInsertIndex(mergedNATFamilyAnchors(existing), len(existing), position)
|
|
args := f.placeArgs(chain, expr, insPos, len(existing))
|
|
_, err = runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
// RemoveNATRule removes a NAT rule from the zone.
|
|
func (f *NFT) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
|
if err := f.ensureNATChains(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
chain := "prerouting"
|
|
if r.Kind.isSource() {
|
|
chain = "postrouting"
|
|
}
|
|
|
|
rules, handles, err := f.listNATChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Delete every matching row (see RemoveRule): a merged FamilyAny NAT rule must
|
|
// clear both its v4 and v6 rows, while a concrete-family target removes only
|
|
// its own family.
|
|
for i, e := range rules {
|
|
if e.EqualForRemoval(r) {
|
|
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Reload is a no-op; nftables applies changes immediately, so there is nothing to reload.
|
|
func (f *NFT) Reload(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// Close closes the connection to the manager.
|
|
func (f *NFT) Close(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// Capabilities returns the set of features this backend can express.
|
|
func (f *NFT) Capabilities() Capabilities {
|
|
return Capabilities{
|
|
Output: true,
|
|
Forward: true,
|
|
ICMPv6: true,
|
|
PortList: true,
|
|
ConnState: true,
|
|
InterfaceMatch: true,
|
|
Logging: true,
|
|
RateLimit: true,
|
|
ConnLimit: true,
|
|
NAT: true,
|
|
RuleOrdering: true,
|
|
DefaultPolicy: true,
|
|
RuleCounters: true,
|
|
AddressSets: true,
|
|
Comments: true,
|
|
}
|
|
}
|
|
|
|
// chainPolicy reads the policy of one of this backend's base chains. It returns
|
|
// ActionInvalid when the table or chain does not yet exist (no policy to
|
|
// report) or the policy is not recognized.
|
|
func (f *NFT) chainPolicy(ctx context.Context, chain string) (Action, error) {
|
|
out, err := runCommand(ctx, "nft", "list", "chain", "inet", f.table, chain)
|
|
if err != nil {
|
|
// A missing table/chain means no policy to report.
|
|
return ActionInvalid, nil
|
|
}
|
|
for _, line := range out {
|
|
line = strings.TrimSpace(line)
|
|
switch {
|
|
case strings.Contains(line, "policy accept"):
|
|
return Accept, nil
|
|
case strings.Contains(line, "policy drop"):
|
|
return Drop, nil
|
|
}
|
|
}
|
|
return ActionInvalid, nil
|
|
}
|
|
|
|
// GetDefaultPolicy returns the default action applied to packets that match no rule.
|
|
func (f *NFT) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
|
|
in, err := f.chainPolicy(ctx, "input")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := f.chainPolicy(ctx, "output")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fwd, err := f.chainPolicy(ctx, "forward")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &DefaultPolicy{Input: in, Output: out, Forward: fwd}, nil
|
|
}
|
|
|
|
// setChainPolicy updates the policy of a base chain. nftables chain policies may
|
|
// only be accept or drop; reject is not expressible.
|
|
func (f *NFT) setChainPolicy(ctx context.Context, chain string, action Action) error {
|
|
switch action {
|
|
case Accept, Drop:
|
|
case Reject:
|
|
return fmt.Errorf("nftables chain policy may only be accept or drop")
|
|
default:
|
|
return fmt.Errorf("invalid default policy action")
|
|
}
|
|
_, err := runCommand(ctx, "nft", "chain", "inet", f.table, chain, "{", "policy", action.String(), ";", "}")
|
|
return err
|
|
}
|
|
|
|
// SetDefaultPolicy sets the default action for the directions named in policy.
|
|
func (f *NFT) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
|
|
if policy == nil {
|
|
return fmt.Errorf("policy cannot be nil")
|
|
}
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
if policy.Input != ActionInvalid {
|
|
if err := f.setChainPolicy(ctx, "input", policy.Input); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if policy.Output != ActionInvalid {
|
|
if err := f.setChainPolicy(ctx, "output", policy.Output); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if policy.Forward != ActionInvalid {
|
|
if err := f.setChainPolicy(ctx, "forward", policy.Forward); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// nftSetJSON is the subset of a `set` object in `nft -j list set(s)` output we
|
|
// decode.
|
|
type nftSetJSON struct {
|
|
Name string `json:"name"`
|
|
Table string `json:"table"`
|
|
Type string `json:"type"`
|
|
Flags []string `json:"flags"`
|
|
Elem []json.RawMessage `json:"elem"`
|
|
}
|
|
|
|
// nftListJSON is the top-level envelope every `nft -j list ...` command emits: a
|
|
// "nftables" array whose items are single-key objects (metainfo, set, rule, ...).
|
|
// We only pull the "set" objects out.
|
|
type nftListJSON struct {
|
|
Nftables []struct {
|
|
Set *nftSetJSON `json:"set"`
|
|
} `json:"nftables"`
|
|
}
|
|
|
|
// decodeSets unwraps the `nft -j list set(s)` envelope into its set objects.
|
|
func (f *NFT) decodeSets(out []string) []*nftSetJSON {
|
|
var env nftListJSON
|
|
if err := json.Unmarshal([]byte(strings.Join(out, "\n")), &env); err != nil {
|
|
return nil
|
|
}
|
|
var sets []*nftSetJSON
|
|
for _, item := range env.Nftables {
|
|
if item.Set != nil {
|
|
sets = append(sets, item.Set)
|
|
}
|
|
}
|
|
return sets
|
|
}
|
|
|
|
// nftSetType maps a set type/family to the nftables set spec that follows
|
|
// `nft add set inet <table> <name> `: a `{ type ... ; [flags interval ;] }`
|
|
// expression. It rejects a combination nftables cannot express in an inet set.
|
|
func (f *NFT) setSpec(family Family, t SetType) (string, error) {
|
|
if family == FamilyAny {
|
|
family = IPv4
|
|
}
|
|
var addrType string
|
|
switch family {
|
|
case IPv4:
|
|
addrType = "ipv4_addr"
|
|
case IPv6:
|
|
addrType = "ipv6_addr"
|
|
default:
|
|
return "", fmt.Errorf("a set requires a concrete ip family: %w", ErrUnsupportedSet)
|
|
}
|
|
spec := "{ type " + addrType + " ;"
|
|
if t == SetHashNet {
|
|
spec += " flags interval ;"
|
|
}
|
|
return spec + " }", nil
|
|
}
|
|
|
|
// setEntries renders the set's entries as an nftables element expression, e.g.
|
|
// `{ 1.2.3.4, 10.0.0.0/8 }`.
|
|
func (f *NFT) setEntries(entries []string) string {
|
|
return "{ " + strings.Join(entries, ", ") + " }"
|
|
}
|
|
|
|
// decodeElem decodes one JSON element of an nft set into its string form. A
|
|
// scalar becomes the address/CIDR string; a {"prefix":{"addr":..,"len":..}}
|
|
// object becomes a CIDR; a {"range":[lo,hi]} object (which an interval set uses
|
|
// for a non-CIDR span) becomes "lo-hi"; anything unrecognised is skipped.
|
|
func (f *NFT) decodeElem(raw json.RawMessage) string {
|
|
var s string
|
|
if json.Unmarshal(raw, &s) == nil {
|
|
return s
|
|
}
|
|
// nft renders a CIDR element as a prefix object with addr/len fields (an
|
|
// interval/hash:net set), not a two-element array — decode it as such so the
|
|
// entry is not silently dropped on read.
|
|
var prefix struct {
|
|
Prefix struct {
|
|
Addr string `json:"addr"`
|
|
Len json.Number `json:"len"`
|
|
} `json:"prefix"`
|
|
}
|
|
if json.Unmarshal(raw, &prefix) == nil && prefix.Prefix.Addr != "" && prefix.Prefix.Len != "" {
|
|
return prefix.Prefix.Addr + "/" + string(prefix.Prefix.Len)
|
|
}
|
|
// An interval set stores a non-CIDR span as a range object; report it as
|
|
// "lo-hi" rather than silently dropping the entry.
|
|
var rng struct {
|
|
Range []json.RawMessage `json:"range"`
|
|
}
|
|
if json.Unmarshal(raw, &rng) == nil && len(rng.Range) == 2 {
|
|
var lo, hi string
|
|
if json.Unmarshal(rng.Range[0], &lo) == nil && json.Unmarshal(rng.Range[1], &hi) == nil {
|
|
return lo + "-" + hi
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetAddressSets returns the address sets managed by this backend.
|
|
func (f *NFT) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
// `nft list sets` accepts a family (inet) but not a table name, so list every
|
|
// inet set and keep the ones in our table. It exits 0 with an empty listing
|
|
// when there are no sets, so any error here is a genuine failure.
|
|
out, err := runCommand(ctx, "nft", "-j", "list", "sets", "inet")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sets := f.decodeSets(out)
|
|
result := make([]*AddressSet, 0, len(sets))
|
|
for _, s := range sets {
|
|
if s.Table != f.table {
|
|
continue
|
|
}
|
|
detail, err := f.getAddressSet(ctx, s.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if detail == nil {
|
|
continue
|
|
}
|
|
result = append(result, detail)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// getAddressSet reads a single nftables set as an AddressSet, or nil if it does
|
|
// not exist.
|
|
func (f *NFT) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
|
out, err := runCommand(ctx, "nft", "-j", "list", "set", "inet", f.table, name)
|
|
if err != nil {
|
|
// A missing set is a no-op; any other failure (permission denial, nft
|
|
// binary trouble, ...) must surface rather than read as "not found".
|
|
if strings.Contains(err.Error(), "No such file or directory") {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
sets := f.decodeSets(out)
|
|
if len(sets) == 0 {
|
|
return nil, nil
|
|
}
|
|
detail := sets[0]
|
|
set := &AddressSet{Name: name}
|
|
switch detail.Type {
|
|
case "ipv6_addr":
|
|
set.Family = IPv6
|
|
case "ipv4_addr":
|
|
set.Family = IPv4
|
|
}
|
|
for _, fl := range detail.Flags {
|
|
if fl == "interval" {
|
|
set.Type = SetHashNet
|
|
}
|
|
}
|
|
for _, raw := range detail.Elem {
|
|
if e := f.decodeElem(raw); e != "" {
|
|
set.Entries = append(set.Entries, e)
|
|
}
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
// GetAddressSet returns a single address set by name, or an error if it does not exist.
|
|
func (f *NFT) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
set, err := f.getAddressSet(ctx, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if set == nil {
|
|
return nil, fmt.Errorf("address set %q not found", name)
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
// setMatches reports whether an existing set's definition matches a requested
|
|
// family/type. AddAddressSet uses this to tell a harmless re-add of an identical,
|
|
// already-existing set (safe to treat as success — `nft add set` is idempotent
|
|
// for a matching redefinition and does not itself error) apart from a genuine
|
|
// type/family conflict (which `nft add set` reports as "File exists" and which
|
|
// must surface as an error rather than being silently swallowed). A nil existing
|
|
// set (the read races with a concurrent delete, or fails to parse) never matches,
|
|
// so the caller treats it as a conflict rather than guessing.
|
|
func (f *NFT) setMatches(existing *AddressSet, wantFamily Family, wantType SetType) bool {
|
|
if existing == nil {
|
|
return false
|
|
}
|
|
if wantFamily == FamilyAny {
|
|
wantFamily = IPv4
|
|
}
|
|
return existing.Family == wantFamily && existing.Type == wantType
|
|
}
|
|
|
|
// AddAddressSet creates an address set. Adding a set that already exists (by name) is a no-op.
|
|
func (f *NFT) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
|
if set == nil || set.Name == "" {
|
|
return fmt.Errorf("an address set requires a name")
|
|
}
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
spec, err := f.setSpec(set.Family, set.Type)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
args := append([]string{"add", "set", "inet", f.table, set.Name}, strings.Fields(spec)...)
|
|
if _, err := runCommand(ctx, "nft", args...); err != nil {
|
|
if !strings.Contains(err.Error(), "File exists") {
|
|
return err
|
|
}
|
|
// `nft add set` reports "File exists" only on a genuine type/family
|
|
// conflict — redefining an identical set succeeds on its own. Read the
|
|
// existing set back and only treat this as the harmless case; otherwise
|
|
// the conflict is real and must be reported, not swallowed as success.
|
|
existing, gerr := f.getAddressSet(ctx, set.Name)
|
|
if gerr != nil {
|
|
return gerr
|
|
}
|
|
if !f.setMatches(existing, set.Family, set.Type) {
|
|
return fmt.Errorf("address set %q already exists with a different definition: %w", set.Name, err)
|
|
}
|
|
return nil
|
|
}
|
|
if len(set.Entries) > 0 {
|
|
elem := f.setEntries(set.Entries)
|
|
args := append([]string{"add", "element", "inet", f.table, set.Name}, strings.Fields(elem)...)
|
|
if _, err := runCommand(ctx, "nft", args...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveAddressSet removes an address set by name.
|
|
func (f *NFT) RemoveAddressSet(ctx context.Context, name string) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
_, err := runCommand(ctx, "nft", "delete", "set", "inet", f.table, name)
|
|
if err != nil && (strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist")) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// AddAddressSetEntry adds an entry to the named set.
|
|
func (f *NFT) AddAddressSetEntry(ctx context.Context, name, entry string) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
elem := f.setEntries([]string{entry})
|
|
args := append([]string{"add", "element", "inet", f.table, name}, strings.Fields(elem)...)
|
|
_, err := runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
// RemoveAddressSetEntry removes an entry from the named set.
|
|
func (f *NFT) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
elem := f.setEntries([]string{entry})
|
|
args := append([]string{"delete", "element", "inet", f.table, name}, strings.Fields(elem)...)
|
|
_, err := runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|