go-firewall/nft_linux.go
James Coleman 5095d90fa4 Advertise capability flags and harden backend semantics
- 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.
2026-07-13 17:50:43 -05:00

2696 lines
86 KiB
Go

package firewall
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"net"
"strconv"
"strings"
"sync"
)
const (
// 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 the setup commands run only 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
}
// An nftables identifier must begin with a letter; a digit-led prefix would
// make every later `nft add` fail with a raw syntax error.
if c := name[0]; c >= '0' && c <= '9' {
name = "fw_" + name
}
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
}
// 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,
PortPair: true,
ConnState: true,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: true,
DefaultPolicy: true,
RuleCounters: true,
AddressSets: true,
Comments: true,
Negation: true,
RejectAction: true,
FamilyWithoutAddress: true,
}
}
// GetZone reports no zone; nftables has no interface-to-zone mapping in this model.
func (f *NFT) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
return "", nil
}
// 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()
}
// 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
}
// 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)
}
// splitQuoted splits a rule line into whitespace-separated tokens, keeping a
// double-quoted string (a comment, a log prefix) as one token with its quotes
// and interior spacing intact. strings.Fields would split it and a single-space
// rejoin would collapse consecutive spaces — but LogPrefix is part of rule
// identity, so a collapsed read-back would never match its own rule and Sync
// would re-add a duplicate row each pass. nft's quoting has no escape mechanism
// (a `"` unconditionally toggles the quoted state), so no escape tracking is
// needed.
func (f *NFT) splitQuoted(line string) []string {
var tokens []string
var b strings.Builder
inQuote := false
flush := func() {
if b.Len() > 0 {
tokens = append(tokens, b.String())
b.Reset()
}
}
for _, r := range line {
switch {
case r == '"':
inQuote = !inQuote
b.WriteRune(r)
case !inQuote && (r == ' ' || r == '\t'):
flush()
default:
b.WriteRune(r)
}
}
flush()
return tokens
}
// 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, so treat
// nft's default as unset in the reported value. 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
}
// 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
}
// 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
}
// l4ProtoFromToken decodes the value of a `meta l4proto` clause, which is either a
// single protocol name (protoFromToken) or an anonymous set. The only set this
// backend writes, and the only one the Rule model can represent, is the
// both-transports `{ tcp, udp }` of a TCPUDP rule. Any other set — and any single
// protocol the model does not know (igmp, an unmapped number) — belongs to a
// foreign rule whose coverage a single Proto field cannot hold, so it is rejected
// rather than silently narrowed to one member or widened to ProtocolAny.
func (f *NFT) l4ProtoFromToken(tok string) (Protocol, error) {
if !strings.HasPrefix(tok, "{") {
if p := f.protoFromToken(tok); p != ProtocolAny {
return p, nil
}
return ProtocolAny, fmt.Errorf("unsupported l4proto: %s", tok)
}
members := f.parseSetTokens(tok)
if len(members) == 2 {
a, b := f.protoFromToken(members[0]), f.protoFromToken(members[1])
if (a == TCP && b == UDP) || (a == UDP && b == TCP) {
return TCPUDP, nil
}
}
return ProtocolAny, fmt.Errorf("unsupported l4proto set: %s", tok)
}
// 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, "@")
}
// 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 := f.splitQuoted(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. The
// prefix is a single splitQuoted token, spacing intact.
for i+1 < len(tokens) {
if tokens[i+1] == "prefix" && i+2 < len(tokens) {
r.LogPrefix = f.unquote(tokens[i+2])
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", "th":
// `th` is the transport-header selector a TCPUDP rule matches its port
// through; the protocol itself came from the `meta l4proto { tcp, udp }`
// clause that precedes it, so do not overwrite it here.
if tokens[i] != "th" {
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|set> or meta nfproto <family>
if i+2 >= len(tokens) {
return nil, "", fmt.Errorf("unsupported meta match")
}
switch tokens[i+1] {
case "l4proto":
p, perr := f.l4ProtoFromToken(tokens[i+2])
if perr != nil {
return nil, "", perr
}
r.Proto = p
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 "add", "update", "meter":
// A per-source connection limit. nft >= 1.1 lists the meter statement
// as `add @name { ip saddr ct count over N }` (or `update`); 1.0.x
// echoes the written spelling back as `meter name size 65535 { ... }`,
// so the optional size qualifier is consumed too. collapseSetSpaces
// has already collapsed the braces' body into one blob token. The
// set's key pins the rule's family, and nft folds any redundant
// nfproto match away, so the family comes from the key here.
if i+2 >= len(tokens) {
return nil, "", fmt.Errorf("incomplete meter statement")
}
name := strings.TrimPrefix(tokens[i+1], "@")
body := i + 2
if tokens[body] == "size" && body+2 < len(tokens) {
body += 2
}
fam, count, ok := parseMeterBlob(tokens[body])
if !ok {
return nil, "", fmt.Errorf("unsupported dynamic-set statement: %s", tokens[body])
}
r.Family = fam
r.ConnLimit = &ConnLimit{Count: count, PerSource: true}
r.meterSet = name
i = body
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. splitQuoted keeps the quoted comment
// as one token — spaces, a literal '#', and interior spacing intact.
if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "\"") {
r.Comment = f.unquote(tokens[i+1])
i++
} 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
}
// 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
}
// lineHandle extracts the trailing `# handle N` marker nft -a appends to a rule
// line, so an unmodeled row still yields the handle its slot is tracked by. The
// marker is the line's final clause, so the scan runs from the end and cannot be
// fooled by the word "handle" inside a quoted comment.
func (f *NFT) lineHandle(line string) string {
fields := strings.Fields(line)
for i := len(fields) - 1; i > 0; i-- {
if fields[i-1] == "handle" {
return fields[i]
}
}
return ""
}
// listChain returns the chain's rules with their nftables handles, 1:1 with its
// physical rows. A row the model cannot parse is kept as an opaque slot — a nil
// rule with its handle — so a rewrite deletes only rows the model understands
// and the position math stays aligned; GetRules and the dedup scans skip the
// nil entries.
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 (whose
// header lines carry `{ # handle N` markers of their own).
if line == "" || !strings.Contains(line, "handle ") ||
strings.HasPrefix(line, "table ") || strings.HasPrefix(line, "chain ") {
continue
}
rule, handle, perr := f.UnmarshalRule(line, chain)
if perr != nil {
if h := f.lineHandle(line); h != "" {
rules = append(rules, nil)
handles = append(handles, h)
}
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
}
// listOwnRules returns the library's own filter rules from its private table, one
// rule per physical chain row. A read does not create the table; listChain returns
// nothing when the table does not yet exist. nftables' inet table stores a
// family-agnostic rule as one unpinned row and a both-transports rule as one
// `meta l4proto { tcp, udp }` row, so UnmarshalRule reports FamilyAny and TCPUDP
// straight off the row that carries them; nothing is collapsed here. Number per
// direction (input then output chain) so each rule's Number matches the
// InsertRule/MoveRule position within its chain.
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
}
// Opaque (nil) rows stay in the chain but are not reportable rules.
for _, r := range chainRules {
if r != nil {
rules = append(rules, r)
}
}
}
numberByDirection(rules)
return rules, 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 rules, then foreign rules from every other table.
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
}
// 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
}
// 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
}
// 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"
}
// nftTCPUDPSet is the anonymous nftables set that pins a rule to both transports.
// It is how this backend spells TCPUDP, letting a both-transports rule live as a
// single nftables row rather than a fanned-out tcp/udp pair.
const nftTCPUDPSet = "{ tcp, udp }"
// 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.
// TCPUDP is spelled as the anonymous both-transports set.
func (f *NFT) l4Proto(p Protocol) string {
if p == TCPUDP {
return nftTCPUDPSet
}
return p.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, ",") + "}"
}
// 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
}
// A named set is family-typed, so a family-agnostic set reference cannot be
// rendered: l3Match would silently pin it to IPv4 and the rule would never
// read back as the FamilyAny rule the caller keeps trying to add. The entry
// points pin such a rule to the set's own family first (resolveSetRefFamily),
// so reaching here unresolved is a caller bug.
if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) {
return "", "", fmt.Errorf("a set-referencing rule requires a concrete family; the caller must resolve the set's family first")
}
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. A
// per-source connection limit is excluded: its meter key (`ip saddr` /
// `ip6 saddr`) already pins the family, and nft folds the then-redundant
// nfproto match away on listing, so an emitted one would never round-trip.
if r.Family != FamilyAny && r.Source == "" && r.Destination == "" && !r.perSourceLimited() {
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. A TCPUDP rule pins both transports with an anonymous
// set and matches the port through `th`, the transport-header selector, which is
// valid precisely because l4proto is constrained to port-carrying protocols. That
// keeps the rule a single nftables row, so it needs no fan-out and reads back as
// written; every other protocol names itself before its port.
srcSpecs := r.SourcePortSpecs()
portKeyword := r.Proto.String()
if r.Proto == TCPUDP {
parts = append(parts, "meta l4proto "+nftTCPUDPSet)
portKeyword = "th"
}
if r.HasPorts() {
// A port-carrying protocol is guaranteed by the check above.
parts = append(parts, fmt.Sprintf("%s dport %s", portKeyword, f.portExpr(r.PortSpecs())))
}
if len(srcSpecs) > 0 {
parts = append(parts, fmt.Sprintf("%s sport %s", portKeyword, 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.Proto != TCPUDP && !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 keys the count on the source
// address in a named dynamic set (the meter statement auto-creates it); the
// set is family-typed, so a FamilyAny rule is fanned out into one row per
// family by insertRule/MoveRule/ReplaceRulesBatch before reaching here.
if r.ConnLimit != nil {
if r.ConnLimit.PerSource {
fam := r.impliedFamily()
if fam == FamilyAny {
return "", "", fmt.Errorf("a per-source connection limit counts in a family-typed meter; the caller must expand the rule to concrete families first")
}
key := "ip"
if fam == IPv6 {
key = "ip6"
}
parts = append(parts, fmt.Sprintf("meter %s { %s saddr ct count over %d }", f.meterName(chain, r), key, r.ConnLimit.Count))
} else {
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
}
// setRefFamily resolves the single family of the named address set(s) a rule
// references, the nft analog of the hook layer's setRefFamily: a named set is
// family-typed, so a family-agnostic set-referencing rule is pinned to the
// set's own family rather than rejected — its rows could never match the other
// family anyway. A set that does not exist, or a source/destination pair naming
// sets of different families, cannot produce a loadable rule, so both are
// errors.
func (f *NFT) setRefFamily(ctx context.Context, source, destination string) (Family, error) {
fam := FamilyAny
for _, ref := range []string{source, destination} {
if !isSetRef(ref) {
continue
}
_, bare := splitAddrNeg(strings.TrimSpace(ref))
name := strings.TrimPrefix(bare, "@")
set, err := f.getAddressSet(ctx, name)
if err != nil {
return FamilyAny, err
}
if set == nil {
return FamilyAny, fmt.Errorf("rule references unknown address set %q", name)
}
sf := set.Family
if sf != IPv6 {
sf = IPv4
}
if fam != FamilyAny && sf != fam {
return FamilyAny, fmt.Errorf("rule references address sets of different families")
}
fam = sf
}
if fam == FamilyAny {
fam = IPv4
}
return fam, nil
}
// resolveSetRefFamily returns r pinned to its referenced set's family when the
// rule is family-agnostic and names a set; every other rule passes through
// unchanged. Callers resolve before marshalling, so MarshalRule never sees an
// unpinned set reference.
func (f *NFT) resolveSetRefFamily(ctx context.Context, r *Rule) (*Rule, error) {
if r.impliedFamily() != FamilyAny || (!isSetRef(r.Source) && !isSetRef(r.Destination)) {
return r, nil
}
fam, err := f.setRefFamily(ctx, r.Source, r.Destination)
if err != nil {
return nil, err
}
rc := *r
rc.Family = fam
return &rc, nil
}
// resolveNATSetRefFamily is resolveSetRefFamily for NAT rules.
func (f *NFT) resolveNATSetRefFamily(ctx context.Context, r *NATRule) (*NATRule, error) {
if r.impliedFamily() != FamilyAny || (!isSetRef(r.Source) && !isSetRef(r.Destination)) {
return r, nil
}
fam, err := f.setRefFamily(ctx, r.Source, r.Destination)
if err != nil {
return nil, err
}
rc := *r
rc.Family = fam
return &rc, nil
}
// meterName derives the dynamic-set name a per-source connection-limit rule
// counts in. The name is a hash of the rule's identity (chain, family, match,
// limit and verdict) so two distinct per-source rules never share counting
// state, while a re-add — or a split's re-add — of the same rule deterministically
// reuses its set. The comment is excluded, as it is not part of rule identity.
func (f *NFT) meterName(chain string, r *Rule) string {
h := fnv.New64a()
_, _ = fmt.Fprintf(h, "%s|%d|%d|%s|%s|%d|%s|%s|%v|%d|%d|%d",
chain, r.impliedFamily(), r.Proto, r.Source, r.Destination,
r.State, FormatPortRanges(r.PortSpecs(), ","), FormatPortRanges(r.SourcePortSpecs(), ","),
r.Log, r.Action, r.ConnLimit.Count, r.Priority)
return fmt.Sprintf("cl%016x", h.Sum64())
}
// parseMeterBlob decodes the collapsed body of a per-source meter clause. The
// listing prints `add @name { ip saddr ct count over N }`, and collapseSetSpaces
// strips the braces' interior spacing before tokenizing, so the body arrives as
// one blob token like "{ipsaddrctcountoverN}". Only the source-keyed
// connection-count form is modeled; any other dynamic-set body fails, leaving
// the row opaque.
func parseMeterBlob(blob string) (fam Family, count uint, ok bool) {
blob = strings.TrimSuffix(strings.TrimPrefix(blob, "{"), "}")
fam = IPv4
rest, found := strings.CutPrefix(blob, "ipsaddrctcountover")
if !found {
fam = IPv6
rest, found = strings.CutPrefix(blob, "ip6saddrctcountover")
}
if !found {
return FamilyAny, 0, false
}
n, err := strconv.ParseUint(rest, 10, 32)
if err != nil {
return FamilyAny, 0, false
}
return fam, uint(n), true
}
// perSourceFamilySplit reports whether a rule must be fanned out into one row
// per family before marshalling: a per-source connection limit counts in a
// family-typed dynamic set, so a FamilyAny rule has no single nftables row —
// the family analog of the DirAny fan-out.
func perSourceFamilySplit(r *Rule) bool {
return r.perSourceLimited() && r.impliedFamily() == FamilyAny
}
// 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.
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"}
// 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 := f.splitQuoted(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...)
}
}
// ruleExists reports whether existing already contains a rule matching r.
// Opaque (nil) rows never match.
func (f *NFT) ruleExists(existing []*Rule, r *Rule) bool {
for _, e := range existing {
if e != nil && e.EqualForDedup(r, true) {
return true
}
}
return false
}
// physicalIndex maps a 1-based position over the modeled (non-nil) rows to the
// 0-based physical row index it lands at, keeping opaque rows pinned in place;
// a position past the last modeled row maps to the end of the chain.
func physicalIndex[T any](rows []*T, position int) int {
seen := 0
for i, e := range rows {
if e == nil {
continue
}
seen++
if seen == position {
return i
}
}
return len(rows)
}
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
}
// A FamilyAny per-source connection limit has no single row (its meter set is
// family-typed): fan out into a v4 row and a v6 row, each with its own set.
if perSourceFamilySplit(r) {
for _, sub := range expandFamilies(r) {
if err := f.insertRule(ctx, zoneName, position, sub); err != nil {
return err
}
}
return nil
}
// A family-agnostic set reference is pinned to the set's own family.
r, err := f.resolveSetRefFamily(ctx, r)
if err != nil {
return err
}
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 {
// A position counts the modeled rows, matching GetRules' numbering;
// physicalIndex maps it past any opaque rows to the chain's real index.
insPos := physicalIndex(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}, f.splitQuoted(expr)...)
_, err = runCommand(ctx, "nft", args...)
return err
}
// 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 plain append.
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)
}
// 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"
}
// 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
}
// A FamilyAny per-source connection limit occupies one row per family; move
// each to the requested position, mirroring the insertRule fan-out.
if perSourceFamilySplit(r) {
if err := f.ensureTable(ctx); err != nil {
return err
}
for _, sub := range expandFamilies(r) {
if err := f.MoveRule(ctx, zoneName, sub, position); err != nil {
return err
}
}
return nil
}
// A family-agnostic set reference is pinned to the set's own family so the
// re-add below marshals; the rows it targets are already pinned on read.
r, rerr := f.resolveSetRefFamily(ctx, r)
if rerr != nil {
return rerr
}
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
}
// The target's current position is its first matching row's position among
// the modeled rows; moving it there is a no-op.
firstLogical := -1
logical := 0
for _, e := range rules {
if e == nil {
continue
}
logical++
if firstLogical < 0 && e.EqualForRemoval(r, true) {
firstLogical = logical
}
}
if firstLogical < 0 {
return nil
}
if position == firstLogical {
return nil
}
// nft has no native move. Delete every row the target covers — a FamilyAny or
// TCPUDP target spans rows the chain may hold separately, so all of them
// relocate — but a concrete target that matched a merged row must not take
// the untargeted coverage with it: removeCovered re-adds each merged row's
// remainder in its own slot, and only the targeted rule moves.
virtual, _, err := f.removeCovered(ctx, chain, rules, handles, r)
if err != nil {
return err
}
_, expr, err := f.MarshalRule(r)
if err != nil {
return err
}
insPos := physicalIndex(virtual, position)
args := f.placeArgs(chain, expr, insPos, len(virtual))
_, err = runCommand(ctx, "nft", args...)
return err
}
// removeCovered deletes every chain row the target covers and re-adds each
// merged row's untargeted remainder (see splitMergedRow) in the row's own slot,
// so coverage the caller never named survives in place. It returns the chain's
// resulting rows — opaque (nil) rows included, remainders in their slots — so a
// caller can place a follow-up insert, plus whether any row was deleted.
func (f *NFT) removeCovered(ctx context.Context, chain string, rules []*Rule, handles []string, r *Rule) ([]*Rule, bool, error) {
matched := make([]bool, len(rules))
splits := make([][]*Rule, len(rules))
var delHandles []string
for i, e := range rules {
if e == nil || !e.EqualForRemoval(r, true) {
continue
}
matched[i] = true
delHandles = append(delHandles, handles[i])
// A concrete target that matched a multi-state row would drop coverage
// the caller never asked to remove: an unpinned inet row covers both
// families, and a `meta l4proto { tcp, udp }` row both transports.
splits[i] = splitMergedRow(e, r)
}
if len(delHandles) == 0 {
return rules, false, nil
}
for _, h := range delHandles {
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", h); err != nil {
return nil, false, err
}
}
// Rebuild the chain's shape: unmatched rows keep their order, and each
// matched row's remainder rows take its slot. The virtual list's indexes are
// the physical insert positions when the inserts run in ascending order.
virtual := make([]*Rule, 0, len(rules))
type pendingReAdd struct {
rule *Rule
at int
}
var reAdds []pendingReAdd
for i, e := range rules {
if !matched[i] {
virtual = append(virtual, e)
continue
}
for _, s := range splits[i] {
reAdds = append(reAdds, pendingReAdd{s, len(virtual)})
virtual = append(virtual, s)
}
}
base := len(rules) - len(delHandles)
for n, ra := range reAdds {
_, expr, merr := f.MarshalRule(ra.rule)
if merr != nil {
return nil, false, merr
}
args := f.placeArgs(chain, expr, ra.at, base+n)
if _, err := runCommand(ctx, "nft", args...); err != nil {
return nil, false, err
}
}
// A deleted per-source connection-limit row leaves its counting set behind;
// drop each removed row's meter set now that its rule is gone. The delete is
// best-effort: one that fails (a surviving row — foreign, or a remainder
// re-added above — still references the set) leaves the set in place, which
// is the correct outcome.
for i, e := range rules {
if matched[i] && e != nil && e.meterSet != "" {
_, _ = runCommand(ctx, "nft", "delete", "set", "inet", f.table, e.meterSet)
}
}
return virtual, true, nil
}
// 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)
rules, handles, err := f.listChain(ctx, chain)
if err != nil {
return err
}
// Delete every row the target covers, not just the first: a FamilyAny target
// clears both an unpinned row and any family-pinned rows it spans, and a TCPUDP
// target clears both transports. A concrete-family target still removes only its
// own family — see EqualForRemoval; removeCovered re-adds each merged row's
// untargeted remainder in the row's own slot.
_, _, err = f.removeCovered(ctx, chain, rules, handles, r)
return err
}
// 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 := f.splitQuoted(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
}
// 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
}
// 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)
// Only rule lines carry a handle; skip table/chain scaffolding. An
// unmodeled row is kept as an opaque slot — a nil rule with its handle —
// exactly as listChain does for filter rows.
if line == "" || !strings.Contains(line, "handle ") ||
strings.HasPrefix(line, "table ") || strings.HasPrefix(line, "chain ") {
continue
}
rule, handle, perr := f.UnmarshalNATRule(line, chain)
if perr != nil {
if h := f.lineHandle(line); h != "" {
rules = append(rules, nil)
handles = append(handles, h)
}
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
}
// listOwnNATRules returns the library's own NAT rules from its private table, one
// rule per physical chain row.
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
}
// Opaque (nil) rows stay in the chain but are not reportable rules.
for _, r := range chainRules {
if r != nil {
rules = append(rules, r)
}
}
}
// The nat chains live in the same inet table, so a family-agnostic translation is
// one unpinned row that reads back as FamilyAny; nothing is collapsed here. Number
// per nat chain (prerouting then postrouting) so each rule's Number matches the
// InsertNATRule position within its chain.
numberNATByChain(rules)
return rules, 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
}
// 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"
}
// 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)
}
// 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
}
// A named set is family-typed; see MarshalRule's matching check. The NAT
// entry points pin the rule first (resolveNATSetRefFamily).
if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) {
return "", "", fmt.Errorf("a set-referencing rule requires a concrete family; the caller must resolve the set's family first")
}
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
}
// 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
}
// natRuleExists is ruleExists for NAT rules. Opaque (nil) rows never match.
func (f *NFT) natRuleExists(existing []*NATRule, r *NATRule) bool {
for _, e := range existing {
if e != nil && e.EqualForDedup(r) {
return true
}
}
return false
}
// 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
}
// A family-agnostic set reference is pinned to the set's own family.
r, err := f.resolveNATSetRefFamily(ctx, r)
if 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}, f.splitQuoted(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
}
// A family-agnostic set reference is pinned to the set's own family.
r, err := f.resolveNATSetRefFamily(ctx, r)
if 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
}
// A position counts the modeled rows, matching GetNATRules' numbering;
// physicalIndex maps it past any opaque rows to the chain's real index.
insPos := physicalIndex(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 FamilyAny NAT target must clear
// both the unpinned row it names and any family-pinned rows it covers, while a
// concrete-family target removes only its own family. A concrete-family target
// that matched a genuine dual-family row re-adds the untargeted family in the
// row's slot, so a bare masquerade/redirect does not silently stop translating
// the other family.
deleted := 0
for i, e := range rules {
if e == nil || !e.EqualForRemoval(r) {
continue
}
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil {
return err
}
deleted++
if s := splitNATDualRow(e, r); s != nil {
_, expr, merr := f.MarshalNATRule(s)
if merr != nil {
return merr
}
args := f.placeArgs(chain, expr, i-deleted+1, len(rules)-deleted)
if _, err := runCommand(ctx, "nft", args...); err != nil {
return err
}
deleted--
}
}
return nil
}
// 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 policy separately via setChainPolicy.
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, ...).
// Only the "set" objects are pulled 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. A
// malformed envelope is an error: reading it as "no sets" would let a Backup
// silently capture zero sets.
func (f *NFT) decodeSets(out []string) ([]*nftSetJSON, error) {
var env nftListJSON
if err := json.Unmarshal([]byte(strings.Join(out, "\n")), &env); err != nil {
return nil, fmt.Errorf("failed to decode nft set listing: %s", err)
}
var sets []*nftSetJSON
for _, item := range env.Nftables {
if item.Set != nil {
sets = append(sets, item.Set)
}
}
return sets, nil
}
// nftSetIsDynamic reports whether a listed set carries the dynamic flag — the
// mark of a per-source connection-limit meter rather than a caller-managed
// address set.
func nftSetIsDynamic(s *nftSetJSON) bool {
for _, fl := range s.Flags {
if fl == "dynamic" {
return true
}
}
return false
}
// 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 ""
}
// 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, err := f.decodeSets(out)
if err != nil {
return nil, err
}
if len(sets) == 0 {
return nil, nil
}
detail := sets[0]
// A dynamic set is connection-limit counting state, not an address set;
// report it as not-found so no caller manages it as data.
if nftSetIsDynamic(detail) {
return nil, nil
}
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
}
// 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, err := f.decodeSets(out)
if err != nil {
return nil, err
}
result := make([]*AddressSet, 0, len(sets))
for _, s := range sets {
if s.Table != f.table {
continue
}
// A dynamic set is a per-source connection-limit meter, rule mechanics
// rather than a caller-managed address set; reporting one would let a
// Backup/Restore or a caller's sweep manage counting state as data.
if nftSetIsDynamic(s) {
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 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
}
// 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, ", ") + " }"
}
// 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
}
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
}
// 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
}
// 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
}
// Clear the modeled rows by handle rather than flushing the table: an
// unmodeled row (a foreign construct hand-added into the private table) is
// invisible to Backup, so a flush would destroy state the snapshot cannot
// reproduce.
if err := f.clearModeledRows(ctx); err != nil {
return err
}
// Recreate the sets on a clean slate before the rules that reference them. The
// clear above removed every modeled rule, so no modeled 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. An unmodeled row that still
// references a set surfaces here as a delete-set error rather than being
// silently destroyed.
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)
}
// 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
}
// 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
}
// clearModeledRows deletes every modeled rule row from the private table's
// filter and nat chains by handle, in one `nft -f` transaction, leaving
// unmodeled (opaque) rows in place. Chain hooks and policies are untouched.
// The cleared rows' per-source meter sets are swept afterwards, best-effort,
// so a restore does not strand counting state; a rule the restore re-adds
// auto-creates its set again.
func (f *NFT) clearModeledRows(ctx context.Context) error {
var script strings.Builder
staleMeters := map[string]bool{}
for _, chain := range nftFilterChains {
rules, handles, err := f.listChain(ctx, chain)
if err != nil {
return err
}
for i, e := range rules {
if e != nil {
fmt.Fprintf(&script, "delete rule inet %s %s handle %s\n", f.table, chain, handles[i])
if e.meterSet != "" {
staleMeters[e.meterSet] = true
}
}
}
}
for _, chain := range []string{"prerouting", "postrouting"} {
rules, handles, err := f.listNATChain(ctx, chain)
if err != nil {
return err
}
for i, e := range rules {
if e != nil {
fmt.Fprintf(&script, "delete rule inet %s %s handle %s\n", f.table, chain, handles[i])
}
}
}
if script.Len() == 0 {
return nil
}
if _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-"); err != nil {
return err
}
for name := range staleMeters {
_, _ = runCommand(ctx, "nft", "delete", "set", "inet", f.table, name)
}
return nil
}
// ReplaceRulesBatch atomically replaces the private table's filter rules with
// exactly rules, all in one `nft -f` transaction. Every filter chain is
// covered — forward included — by deleting the modeled rows by handle, so
// unmodeled (opaque) rows, chain hooks and policies are preserved. 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
// Meter sets referenced by the deleted rows are garbage once their rows go;
// collect them for the sweep below, keeping any a new rule still names.
staleMeters := map[string]bool{}
for _, chain := range nftFilterChains {
existing, handles, err := f.listChain(ctx, chain)
if err != nil {
return err
}
for i, e := range existing {
if e != nil {
fmt.Fprintf(&script, "delete rule inet %s %s handle %s\n", f.table, chain, handles[i])
if e.meterSet != "" {
staleMeters[e.meterSet] = true
}
}
}
}
for _, top := range rules {
// A DirAny rule fans out into an input row plus its swapped output row,
// and a FamilyAny per-source connection limit into one row per family.
for _, d := range expandDirections(top) {
subs := []*Rule{d}
if perSourceFamilySplit(d) {
subs = expandFamilies(d)
}
for _, r := range subs {
r, err := f.resolveSetRefFamily(ctx, r)
if err != nil {
return err
}
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)
if r.perSourceLimited() {
// The meter statement auto-creates (or reuses) this set; a
// reused set keeps its counting state across the replace.
delete(staleMeters, f.meterName(chain, r))
}
}
}
}
if script.Len() == 0 {
return nil
}
if _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-"); err != nil {
return err
}
// Sweep the now-unreferenced meter sets outside the transaction, best-effort:
// a delete that fails (an opaque row still references the set) leaves it in
// place rather than aborting the whole replace. An unchanged per-source rule
// was pruned from the sweep, so its set — and counting state — survives.
for name := range staleMeters {
_, _ = runCommand(ctx, "nft", "delete", "set", "inet", f.table, name)
}
return nil
}