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 // "/" token) plus an optional "burst 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 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 ` 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 or meta nfproto 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 ` (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 `. 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 `); 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 `). 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 "[:]", // bracketing an IPv6 address when a port is present. An empty address yields // ":" (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
`, 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 }