go-firewall/nft_linux.go
James Coleman a036c8e6e9 Add TCPUDP protocol, coverage relation, and drop read-side merging
Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged
value spanning both transports, distinct from ProtocolAny (which matches
every IP protocol and carries no port). Backends whose native syntax holds
both transports in one row (nftables, ufw, apf) store and read it as one
rule; the rest fan it out with expandProtocols. Removing one transport of a
merged row splits it via splitMergedRow, which composes the family and
protocol splits so an nftables row merged on both axes leaves a correct,
non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT.

Remove read-side merging. GetRules now reports the firewall's actual rows
and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up
separately-stored ones, so mergeFamilies, mergeDirections and their helpers
are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead
compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the
NATRule pair) expand a rule across family, transport and direction and decide
containment cell by cell, which is what lets Sync stay a no-op against its
own output whichever representation a backend chose.

Extract the systemd/SysV service helpers out of the iptables backend into
services.go so every Linux backend shares one implementation, and document
the multi-state rule model and the coverage helpers in the README.
2026-07-09 17:52:19 -05:00

2234 lines
70 KiB
Go

package firewall
import (
"context"
"encoding/json"
"fmt"
"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 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
}
// 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,
}
}
// 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
}
// 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)
}
// 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
}
// 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
}
// 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 belongs to a
// foreign rule whose protocol 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, "{") {
return f.protoFromToken(tok), nil
}
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 := 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", "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 "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
}
// 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
}
// 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
}
// 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
}
rules = append(rules, chainRules...)
}
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
}
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. 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 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
}
// 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 := 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...)
}
}
// 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
}
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 {
// Each chain row is its own rule, so a Number is already a physical index;
// a position past the last row appends.
insPos := min(position-1, len(existing))
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
}
// 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
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
}
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 this rule covers. A FamilyAny or TCPUDP target spans rows the
// chain may hold separately (an IPv4 row and an IPv6 row added one at a time), so
// moving it must relocate all of them; relocating only the first would orphan the
// rest — the same hazard RemoveRule guards against via EqualForRemoval. A
// concrete-family target still moves only its own family.
firstIdx := -1
var toDelete []string
for i, e := range rules {
if e.EqualForRemoval(r, true) {
if firstIdx < 0 {
firstIdx = i
}
toDelete = append(toDelete, handles[i])
}
}
if firstIdx < 0 {
return nil
}
// Each chain row is its own rule, so a position is a physical index. The target's
// current position is that of its first matching row.
if position > len(rules) {
position = len(rules)
}
// If moving to the same position, nothing to do.
if position == firstIdx+1 {
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; clamp the
// target position to that reduced chain (appending when it is at or past the end).
remaining := len(rules) - len(toDelete)
insPos := min(position-1, remaining)
args := f.placeArgs(chain, expr, insPos, remaining)
_, 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 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.
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 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. Re-add the remainder at
// the row's own position so it keeps the removed rule's place in the chain.
if s := splitMergedRow(e, r); len(s) > 0 {
reAdd = s
reAddPos = i + 1
}
}
}
// Re-add in order so the remainder rows keep the removed row's position; each
// insert shifts the next one down by a slot.
for n, s := range reAdd {
if err := f.insertRule(ctx, zoneName, reAddPos+n, s); err != nil {
return err
}
}
// Nothing left to remove.
return 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
}
// 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)
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
}
// 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
}
rules = append(rules, chainRules...)
}
// 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
}
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.
func (f *NFT) natRuleExists(existing []*NATRule, r *NATRule) bool {
for _, e := range existing {
if 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
}
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
}
// Each nat-chain row is its own rule, so a Number is already a physical index; a
// position past the last row appends.
insPos := min(position-1, len(existing))
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.
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
}
// 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, ...).
// 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
}
// 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 := 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
}
// 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 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
}
// 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)
}
// 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
}
// 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
}