go-firewall/nft_linux.go
James Coleman 073c9ad7f0 Add address-set support to APF/CSF and ipset reboot persistence to iptables
iptables: persist ipsets across reboot to match rule persistence.
- Detect the ipset save-file + restore unit (RHEL ipset.service /
  /etc/sysconfig/ipset; Debian netfilter-persistent / /etc/iptables/ipsets),
  non-fatally.
- After each set mutation, `ipset save` into the layout's save-file and
  auto-enable a present-but-disabled restore unit; warn when no mechanism
  exists (sets stay live-only).
- Use ListUnitFiles (not ListUnitFilesByPatterns, which needs systemd >= 230;
  CentOS 7 ships 219).

APF/CSF: gain address sets by persisting ipset commands in the pre-hook.
- The hook carries an `ipset create/flush/add` block ordered ahead of the
  `-m set --match-set` rule lines, so the firewall recreates the set on every
  (re)start before any rule references it.
- Route set-referencing rules (Source/Destination names an ipset) through the
  hook rather than a literal trust-file line (ruleNeedsHook/bareHostShape).
- Implement the six address-set methods, advertise AddressSets, and wire sets
  into Backup/Restore via captureBackupState/restoreBackupSets.

Validated live: reboot simulation for iptables; generated-hook source for
APF/CSF. Unit tests cover the hook ipset round-trip, ordering, in-use guard and
set-ref routing; the capability-gated integration subtest now covers APF/CSF.
2026-07-08 17:20:38 -05:00

2217 lines
69 KiB
Go

package firewall
import (
"context"
"encoding/json"
"fmt"
"net"
"strconv"
"strings"
"sync"
)
const (
// NFTType is the manager type string reported by this backend.
NFTType = "nftables"
// NFTDefaultTable is the table name used when no rule prefix is supplied.
NFTDefaultTable = "go_firewall"
)
// NFT manages firewall rules through the nftables `nft` command. To avoid
// clobbering rules owned by other tooling, every rule this backend creates lives
// in a private `inet` table (named after the rule prefix) with its own input and
// output base chains. Reads and writes are scoped to that table.
type NFT struct {
// table is the nftables table this backend owns.
table string
// mu guards the ensured/natEnsured flags so concurrent callers do not race on
// the one-time table/chain setup.
mu sync.Mutex
// ensured records whether the private table/chains have been created this
// session so we only run the setup commands once.
ensured bool
// natEnsured records the same for the nat base chains, which are created
// lazily only when a NAT rule is first written.
natEnsured bool
}
// sanitizeNFTName reduces an arbitrary prefix to a valid nftables identifier
// (letters, digits and underscores), falling back to the default when nothing
// usable remains.
func sanitizeNFTName(prefix string) string {
var b strings.Builder
for _, r := range prefix {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
b.WriteRune(r)
case r == '-' || r == ' ' || r == '.':
b.WriteRune('_')
}
}
name := strings.Trim(b.String(), "_")
if name == "" {
return NFTDefaultTable
}
return name
}
// NewNFT constructs an nftables-backed Manager, deriving the private table name
// from rulePrefix and verifying the nft command and nf_tables support are usable.
func NewNFT(ctx context.Context, rulePrefix string) (*NFT, error) {
nft := &NFT{table: sanitizeNFTName(rulePrefix)}
// Confirm the nft command is available and the ruleset can be listed. This
// requires the nf_tables kernel support to be present.
if _, err := runCommand(ctx, "nft", "--version"); err != nil {
return nil, fmt.Errorf("nft command is not available: %s", err)
}
if _, err := runCommand(ctx, "nft", "list", "ruleset"); err != nil {
return nil, fmt.Errorf("unable to list nftables ruleset: %s", err)
}
return nft, nil
}
// Type returns the manager type.
func (f *NFT) Type() string {
return NFTType
}
// 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
}
// 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":
r.Proto = GetProtocol(tokens[i])
// A `dport` or `sport` qualifier may follow.
if i+2 < len(tokens) && (tokens[i+1] == "dport" || tokens[i+1] == "sport") {
src := tokens[i+1] == "sport"
i += 2
if i >= len(tokens) {
return nil, "", fmt.Errorf("incomplete port match")
}
members := f.parseSetTokens(tokens[i])
if len(members) == 0 {
return nil, "", fmt.Errorf("incomplete port match")
}
specs, perr := f.parsePorts(members)
if perr != nil {
return nil, "", perr
}
// Keep the single-port form in Port for a lone discrete port so
// it round-trips against rules built that way.
if src {
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.SourcePort = specs[0].Start
} else {
r.SourcePorts = specs
}
} else {
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.Port = specs[0].Start
} else {
r.Ports = specs
}
}
}
case "meta":
// meta l4proto <proto> or meta nfproto <family>
if i+2 >= len(tokens) {
return nil, "", fmt.Errorf("unsupported meta match")
}
switch tokens[i+1] {
case "l4proto":
r.Proto = f.protoFromToken(tokens[i+2])
case "nfproto":
switch tokens[i+2] {
case "ipv4":
r.Family = IPv4
case "ipv6":
r.Family = IPv6
default:
return nil, "", fmt.Errorf("unsupported nfproto: %s", tokens[i+2])
}
default:
return nil, "", fmt.Errorf("unsupported meta match")
}
i += 2
case "accept":
r.Action = Accept
case "drop":
r.Action = Drop
case "reject":
r.Action = Reject
// An explicitly-written `reject with <proto> <type>` (e.g.
// `reject with icmp port-unreachable`, `reject with tcp reset`) can appear
// on a foreign rule — nft itself never adds this clause to a bare `reject`
// on read, but a rule authored with one keeps it verbatim. The detail runs
// until the comment or handle marker; consume it so the trailing tokens do
// not fail the parse and drop the rule.
if i+1 < len(tokens) && tokens[i+1] == "with" {
j := i + 2
for ; j < len(tokens); j++ {
if tokens[j] == "comment" || tokens[j] == "#" {
break
}
}
i = j - 1
}
case "comment":
// nft prints the rule comment as a double-quoted string, before an
// optional `# handle N` marker. Reassemble it by collecting tokens up
// to the closing quote, so a comment containing spaces — or a literal
// '#', which strings.Fields would otherwise mistake for the handle
// marker and truncate the rule at — round-trips intact.
if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "\"") {
var cparts []string
j := i + 1
for ; j < len(tokens); j++ {
cparts = append(cparts, tokens[j])
joined := strings.Join(cparts, " ")
// The first closing quote terminates the comment.
if len(joined) >= 2 && strings.HasSuffix(joined, "\"") {
break
}
}
r.Comment = f.unquote(strings.Join(cparts, " "))
i = j
} else {
// Unquoted (a single bare word, or older nft): take tokens up to
// the handle marker.
var cparts []string
for j := i + 1; j < len(tokens); j++ {
if tokens[j] == "#" {
break
}
cparts = append(cparts, tokens[j])
}
r.Comment = f.unquote(strings.Join(cparts, " "))
i += len(cparts)
}
case "#":
// `nft -a` prints the handle after a comment marker: `# handle N`.
case "handle":
i++
if i >= len(tokens) {
return nil, "", fmt.Errorf("missing handle value")
}
handle = tokens[i]
case "counter":
// `counter packets N bytes M` (always present on a listed rule that
// has a counter statement). Capture the values for GetRules.
if i+4 < len(tokens) && tokens[i+1] == "packets" && tokens[i+3] == "bytes" {
if pkts, perr := strconv.ParseUint(tokens[i+2], 10, 64); perr == nil {
r.Packets = pkts
}
if by, berr := strconv.ParseUint(tokens[i+4], 10, 64); berr == nil {
r.Bytes = by
}
i += 4
}
case "packets", "bytes":
// Skip stray counter value tokens (already consumed under counter).
if i+1 < len(tokens) {
i++
}
default:
return nil, "", fmt.Errorf("unsupported token: %s", tokens[i])
}
}
if r.Action == ActionInvalid {
return nil, "", fmt.Errorf("no valid action was provided")
}
return r, handle, nil
}
// 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,
// with the IPv4/IPv6 pairs merged. A read does not create the table; listChain
// returns nothing when the table does not yet exist.
func (f *NFT) listOwnRules(ctx context.Context) ([]*Rule, error) {
var rules []*Rule
for _, chain := range nftFilterChains {
chainRules, _, cerr := f.listChain(ctx, chain)
if cerr != nil {
return nil, cerr
}
rules = append(rules, chainRules...)
}
// Merge families first, number per direction (input then output chain) so a
// collapsed v4/v6 pair leaves no gap and each rule's Number matches the
// InsertRule/MoveRule position within its chain, then collapse each input/output
// twin into one DirAny rule (numbering first keeps the surviving pure-output
// rows' physical position).
merged := mergeFamilies(rules)
numberByDirection(merged)
merged = mergeDirections(merged)
return merged, nil
}
// GetRules returns the existing filter rules from the zone.
func (f *NFT) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
// The library's own merged rules, then foreign rules from every other table
// unmerged (their identity spans tables we do not own).
rules, err = f.listOwnRules(ctx)
if err != nil {
return nil, err
}
foreign, ferr := f.listForeignRules(ctx)
if ferr != nil {
return nil, ferr
}
rules = append(rules, foreign...)
return rules, nil
}
// 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"
}
// l4Proto returns the protocol keyword nftables accepts after `meta l4proto`.
// For ICMPv6 this is the `icmpv6` spelling. nft lists such a rule back with the
// differently-spelled `ipv6-icmp` form, which protoFromToken decodes on read.
func (f *NFT) l4Proto(p Protocol) string {
return p.String()
}
// 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.
if r.HasPorts() {
// A concrete protocol is guaranteed by the check above.
parts = append(parts, fmt.Sprintf("%s dport %s", r.Proto.String(), f.portExpr(r.PortSpecs())))
}
srcSpecs := r.SourcePortSpecs()
if len(srcSpecs) > 0 {
parts = append(parts, fmt.Sprintf("%s sport %s", r.Proto.String(), f.portExpr(srcSpecs)))
}
if r.Proto.IsICMP() && r.ICMPType != nil {
// An ICMP type match implies the icmp/icmpv6 protocol.
kw := "icmp"
if r.Proto == ICMPv6 {
kw = "icmpv6"
}
parts = append(parts, fmt.Sprintf("%s type %d", kw, *r.ICMPType))
} else if r.Proto != ProtocolAny && !r.HasPorts() && len(srcSpecs) == 0 {
parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto))
}
// Connection-tracking state match.
if r.State != 0 {
parts = append(parts, "ct state "+f.stateExpr(r.State))
}
// Rate limit: the statement matches only while under the rate, so over-rate
// packets fall through to later rules rather than taking this rule's verdict.
if r.RateLimit != nil {
lim := fmt.Sprintf("limit rate %d/%s", r.RateLimit.Rate, r.RateLimit.Unit)
if r.RateLimit.Burst > 0 {
lim += fmt.Sprintf(" burst %d packets", r.RateLimit.Burst)
}
parts = append(parts, lim)
}
// Connection limit: `ct count over N` matches while the tracked connection
// count exceeds the limit. Per-source counting needs a named meter, which
// this model does not express.
if r.ConnLimit != nil {
if r.ConnLimit.PerSource {
return "", "", fmt.Errorf("nftables per-source connection limiting requires a named meter, unsupported in this model")
}
parts = append(parts, fmt.Sprintf("ct count over %d", r.ConnLimit.Count))
}
// Logging, emitted just before the verdict so the packet is logged and then
// the action is applied.
if r.Log {
if r.LogPrefix != "" {
// A plain double-quote wrap, not strconv.Quote: nft has no backslash-escape
// mechanism, so strconv.Quote's Go-style escaping (doubling a literal
// backslash, etc.) would not round-trip through nft's own quoting. The
// embedded-quote case that would need escaping is rejected above.
parts = append(parts, `log prefix "`+r.LogPrefix+`"`)
} else {
parts = append(parts, "log")
}
}
// A counter so GetRules can report per-rule packet/byte statistics. The
// counter has no effect on matching and is ignored when comparing rules.
parts = append(parts, "counter")
// Action verb.
switch r.Action {
case Accept:
parts = append(parts, "accept")
case Drop:
parts = append(parts, "drop")
case Reject:
parts = append(parts, "reject")
default:
return "", "", fmt.Errorf("no valid action was provided")
}
// An optional user comment, stored as an nftables rule comment. It has no
// effect on matching and is ignored when comparing rules. A plain quote wrap,
// not strconv.Quote — see the log-prefix comment above for why.
if r.Comment != "" {
parts = append(parts, `comment "`+r.Comment+`"`)
}
return chain, strings.Join(parts, " "), nil
}
// 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 {
// position is a merged Number; map it back to a physical chain index so a
// merged v4/v6 pair earlier in the chain does not skew the placement.
insPos := mergedInsertIndex(mergedFamilyAnchors(existing), len(existing), position)
args := f.placeArgs(chain, expr, insPos, len(existing))
_, err = runCommand(ctx, "nft", args...)
return err
}
args := append([]string{"add", "rule", "inet", f.table, chain}, strings.Fields(expr)...)
_, err = runCommand(ctx, "nft", args...)
return err
}
// 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 that belongs to this rule. GetRules merges an IPv4 and IPv6
// row that differ only in family into one FamilyAny rule, so moving that merged
// rule must relocate BOTH underlying rows. Deleting only the first half and
// re-inserting a single dual-family rule would orphan the twin — the same hazard
// RemoveRule guards against via EqualForRemoval. A concrete-family target
// still moves only its own family.
firstIdx := -1
var toDelete []string
deleted := make(map[int]bool)
for i, e := range rules {
if e.EqualForRemoval(r, true) {
if firstIdx < 0 {
firstIdx = i
}
toDelete = append(toDelete, handles[i])
deleted[i] = true
}
}
if firstIdx < 0 {
return nil
}
// position and each rule's Number live in the merged (post-mergeFamilies) index
// space, while the chain holds physical rows; map through the chain's merged
// anchors so a merged v4/v6 pair does not skew the target. curPos is the target
// rule's current merged position (its anchor is firstIdx, the first matching row).
anchors := mergedFamilyAnchors(rules)
if position > len(anchors) {
position = len(anchors)
}
curPos := 0
for p, idx := range anchors {
if idx == firstIdx {
curPos = p + 1
break
}
}
// If moving to the same position, nothing to do.
if position == curPos {
return nil
}
// nft has no native move; delete every matching row, then re-insert once at the
// target position within the now-reduced chain.
for _, h := range toDelete {
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", h); err != nil {
return err
}
}
_, expr, err := f.MarshalRule(r)
if err != nil {
return err
}
// The rows were just deleted, so the chain now holds the remaining rows; map the
// target merged position to a physical index within that reduced chain (appending
// when the target is at or past the end).
reduced := make([]*Rule, 0, len(rules)-len(toDelete))
for i, e := range rules {
if !deleted[i] {
reduced = append(reduced, e)
}
}
insPos := mergedInsertIndex(mergedFamilyAnchors(reduced), len(reduced), position)
args := f.placeArgs(chain, expr, insPos, len(reduced))
_, err = runCommand(ctx, "nft", args...)
return err
}
// RemoveRule removes a rule from the zone.
func (f *NFT) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
if err := f.ensureTable(ctx); err != nil {
return err
}
// A DirAny target removes both its input row and its role-swapped output row,
// each from its own chain.
if r.Direction == DirAny {
for _, sub := range expandDirections(r) {
if err := f.RemoveRule(ctx, zoneName, sub); err != nil {
return err
}
}
return nil
}
chain := f.chainForDirection(r.Direction)
// Find the matching rule so we can delete it by its handle.
rules, handles, err := f.listChain(ctx, chain)
if err != nil {
return err
}
// Delete every matching row, not just the first. GetRules merges an IPv4 and
// IPv6 row that differ only in family into one FamilyAny rule, so removing that
// merged rule must clear both underlying rows or its twin is orphaned (and a
// second reconcile pass would be needed to converge). A concrete-family target
// still removes only its own family — see EqualForRemoval.
// A genuine dual-family row is its own merged anchor (FamilyAny rows never merge
// into a pair), so its merged position is where the re-added family must land to
// preserve ordering. Capture the anchors before the delete shifts the chain.
anchors := mergedFamilyAnchors(rules)
var reAdd *Rule
reAddPos := 0
for i, e := range rules {
if e.EqualForRemoval(r, true) {
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil {
return err
}
// A concrete-family target that matched a genuine dual-family row (an inet
// rule with no family pin, covering both) would drop both families; re-add
// the untargeted family below, at the dual row's own merged position so the
// surviving family keeps the removed rule's place in the chain.
if s := splitDualRow(e, r); s != nil {
reAdd = s
for p, idx := range anchors {
if idx == i {
reAddPos = p + 1
break
}
}
}
}
}
if reAdd != nil {
return f.insertRule(ctx, zoneName, reAddPos, reAdd)
}
// Nothing left to remove.
return nil
}
// 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,
// with the IPv4/IPv6 pairs merged.
func (f *NFT) listOwnNATRules(ctx context.Context) ([]*NATRule, error) {
var rules []*NATRule
for _, chain := range []string{"prerouting", "postrouting"} {
chainRules, _, cerr := f.listNATChain(ctx, chain)
if cerr != nil {
return nil, cerr
}
rules = append(rules, chainRules...)
}
// Merge families first, then number per nat chain (prerouting then postrouting)
// so a collapsed v4/v6 pair leaves no gap and each rule's Number matches the
// InsertNATRule position within its chain.
merged := mergeNATFamilies(rules)
numberNATByChain(merged)
return merged, nil
}
// GetNATRules returns the existing NAT rules from the zone.
func (f *NFT) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) {
rules, err = f.listOwnNATRules(ctx)
if err != nil {
return nil, err
}
foreign, ferr := f.listForeignNATRules(ctx)
if ferr != nil {
return nil, ferr
}
rules = append(rules, foreign...)
return rules, nil
}
// 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
}
// position is a merged Number; map it back to a physical nat-chain index so a
// merged v4/v6 NAT pair earlier in the chain does not skew the placement.
insPos := mergedInsertIndex(mergedNATFamilyAnchors(existing), len(existing), position)
args := f.placeArgs(chain, expr, insPos, len(existing))
_, err = runCommand(ctx, "nft", args...)
return err
}
// RemoveNATRule removes a NAT rule from the zone.
func (f *NFT) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
if err := f.ensureNATChains(ctx); err != nil {
return err
}
chain := "prerouting"
if r.Kind.isSource() {
chain = "postrouting"
}
rules, handles, err := f.listNATChain(ctx, chain)
if err != nil {
return err
}
// Delete every matching row (see RemoveRule): a merged FamilyAny NAT rule must
// clear both its v4 and v6 rows, while a concrete-family target removes only
// its own family.
for i, e := range rules {
if e.EqualForRemoval(r) {
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil {
return err
}
}
}
return nil
}
// 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)
}
// 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
}
// 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
}