- New Capabilities: PortPair, Negation, RejectAction, FamilyWithoutAddress, DenyActionFromConfig, advertised per backend. - coversDirection isolates DirForward even when output is unowned; add splitNATDualRow so a concrete-family removal re-adds the opposite family's NAT translation. - Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the "invalid" sentinel as input while JSON round-trips it. - Sync counts additions on mid-batch failure and uses RuleBatcher. - NewManager runs a probe loop joining each backend's reason for diagnosability; services.go drops "generated" from enabled, handles it on enable, clears start-limit-hit on restart, and matches rc.local by token. - nftables: per-source connection limits (meter set), quoted-token parsing preserving log-prefix spacing, digit-led prefix sanitizing. - apf/csf: deny-action-from-config with cached STOP settings, port lists and inexpressible shapes routed through the pre-hook, confKeyApplies guard against a missing config line. - atomic config writes fsync before rename and resolve symlinks; readConfValue is last-assignment-wins; runCommand preserves the exit code through the wrapped error. - Move coreos/go-systemd to the maintained v22 module directly.
2696 lines
86 KiB
Go
2696 lines
86 KiB
Go
package firewall
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
const (
|
|
// NFTDefaultTable is the table name used when no rule prefix is supplied.
|
|
NFTDefaultTable = "go_firewall"
|
|
)
|
|
|
|
// NFT manages firewall rules through the nftables `nft` command. To avoid
|
|
// clobbering rules owned by other tooling, every rule this backend creates lives
|
|
// in a private `inet` table (named after the rule prefix) with its own input and
|
|
// output base chains. Reads and writes are scoped to that table.
|
|
type NFT struct {
|
|
// table is the nftables table this backend owns.
|
|
table string
|
|
// mu guards the ensured/natEnsured flags so concurrent callers do not race on
|
|
// the one-time table/chain setup.
|
|
mu sync.Mutex
|
|
// ensured records whether the private table/chains have been created this
|
|
// session, so the setup commands run only once.
|
|
ensured bool
|
|
// natEnsured records the same for the nat base chains, which are created
|
|
// lazily only when a NAT rule is first written.
|
|
natEnsured bool
|
|
}
|
|
|
|
// sanitizeNFTName reduces an arbitrary prefix to a valid nftables identifier
|
|
// (letters, digits and underscores), falling back to the default when nothing
|
|
// usable remains.
|
|
func sanitizeNFTName(prefix string) string {
|
|
var b strings.Builder
|
|
for _, r := range prefix {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
|
|
b.WriteRune(r)
|
|
case r == '-' || r == ' ' || r == '.':
|
|
b.WriteRune('_')
|
|
}
|
|
}
|
|
name := strings.Trim(b.String(), "_")
|
|
if name == "" {
|
|
return NFTDefaultTable
|
|
}
|
|
// An nftables identifier must begin with a letter; a digit-led prefix would
|
|
// make every later `nft add` fail with a raw syntax error.
|
|
if c := name[0]; c >= '0' && c <= '9' {
|
|
name = "fw_" + name
|
|
}
|
|
return name
|
|
}
|
|
|
|
// NewNFT constructs an nftables-backed Manager, deriving the private table name
|
|
// from rulePrefix and verifying the nft command and nf_tables support are usable.
|
|
func NewNFT(ctx context.Context, rulePrefix string) (*NFT, error) {
|
|
nft := &NFT{table: sanitizeNFTName(rulePrefix)}
|
|
|
|
// Confirm the nft command is available and the ruleset can be listed. This
|
|
// requires the nf_tables kernel support to be present.
|
|
if _, err := runCommand(ctx, "nft", "--version"); err != nil {
|
|
return nil, fmt.Errorf("nft command is not available: %s", err)
|
|
}
|
|
if _, err := runCommand(ctx, "nft", "list", "ruleset"); err != nil {
|
|
return nil, fmt.Errorf("unable to list nftables ruleset: %s", err)
|
|
}
|
|
|
|
return nft, nil
|
|
}
|
|
|
|
// Type returns the manager type.
|
|
func (f *NFT) Type() string {
|
|
return NFTType
|
|
}
|
|
|
|
// Capabilities returns the set of features this backend can express.
|
|
func (f *NFT) Capabilities() Capabilities {
|
|
return Capabilities{
|
|
Output: true,
|
|
Forward: true,
|
|
ICMPv6: true,
|
|
PortList: true,
|
|
PortPair: true,
|
|
ConnState: true,
|
|
InterfaceMatch: true,
|
|
Logging: true,
|
|
RateLimit: true,
|
|
ConnLimit: true,
|
|
NAT: true,
|
|
RuleOrdering: true,
|
|
DefaultPolicy: true,
|
|
RuleCounters: true,
|
|
AddressSets: true,
|
|
Comments: true,
|
|
Negation: true,
|
|
RejectAction: true,
|
|
FamilyWithoutAddress: true,
|
|
}
|
|
}
|
|
|
|
// GetZone reports no zone; nftables has no interface-to-zone mapping in this model.
|
|
func (f *NFT) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
|
|
return "", nil
|
|
}
|
|
|
|
// collapseSetSpaces removes the spaces nft inserts inside an anonymous set
|
|
// literal when it lists a rule (`{ 80, 443 }`), so strings.Fields treats the set
|
|
// as a single token (`{80,443}`) — the compact form MarshalRule emits and the set
|
|
// parsers expect. Spaces inside a double-quoted string (a rule comment) are left
|
|
// untouched so a comment with spaces still round-trips. nft's quoting has no
|
|
// backslash-escape mechanism — a `"` unconditionally toggles the quoted state —
|
|
// so no escape tracking is needed here.
|
|
func (f *NFT) collapseSetSpaces(line string) string {
|
|
var b strings.Builder
|
|
depth := 0
|
|
inQuote := false
|
|
for _, r := range line {
|
|
switch {
|
|
case r == '"':
|
|
inQuote = !inQuote
|
|
case inQuote:
|
|
// Preserve everything verbatim inside a quoted comment.
|
|
case r == '{':
|
|
depth++
|
|
case r == '}':
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
}
|
|
if !inQuote && depth > 0 && (r == ' ' || r == '\t') {
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// directionForChain returns the rule direction a filter base-chain name maps
|
|
// to (the inverse of chainForDirection).
|
|
func (f *NFT) directionForChain(chain string) Direction {
|
|
switch chain {
|
|
case "output":
|
|
return DirOutput
|
|
case "forward":
|
|
return DirForward
|
|
}
|
|
return DirInput
|
|
}
|
|
|
|
// unquote reverses the quoting MarshalRule applies to a string value (a log
|
|
// prefix or comment): a double-quoted token has its surrounding quotes stripped
|
|
// literally, not decoded with strconv.Unquote — nft has no backslash-escape
|
|
// mechanism, so a Go-style unquote would wrongly reinterpret a literal backslash
|
|
// sequence in the value (e.g. "C:\new") as an escape and corrupt it. A token that
|
|
// is not a double-quoted string (single-quoted, or bare/older nft) falls back to
|
|
// trimQuotes.
|
|
func (f *NFT) unquote(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
return trimQuotes(s)
|
|
}
|
|
|
|
// splitQuoted splits a rule line into whitespace-separated tokens, keeping a
|
|
// double-quoted string (a comment, a log prefix) as one token with its quotes
|
|
// and interior spacing intact. strings.Fields would split it and a single-space
|
|
// rejoin would collapse consecutive spaces — but LogPrefix is part of rule
|
|
// identity, so a collapsed read-back would never match its own rule and Sync
|
|
// would re-add a duplicate row each pass. nft's quoting has no escape mechanism
|
|
// (a `"` unconditionally toggles the quoted state), so no escape tracking is
|
|
// needed.
|
|
func (f *NFT) splitQuoted(line string) []string {
|
|
var tokens []string
|
|
var b strings.Builder
|
|
inQuote := false
|
|
flush := func() {
|
|
if b.Len() > 0 {
|
|
tokens = append(tokens, b.String())
|
|
b.Reset()
|
|
}
|
|
}
|
|
for _, r := range line {
|
|
switch {
|
|
case r == '"':
|
|
inQuote = !inQuote
|
|
b.WriteRune(r)
|
|
case !inQuote && (r == ' ' || r == '\t'):
|
|
flush()
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
flush()
|
|
return tokens
|
|
}
|
|
|
|
// parsePorts converts a list of nftables port members (e.g. "80",
|
|
// "1000-2000") into PortRange values.
|
|
func (f *NFT) parsePorts(members []string) ([]PortRange, error) {
|
|
var specs []PortRange
|
|
for _, m := range members {
|
|
pr, err := ParsePortRange(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
specs = append(specs, pr)
|
|
}
|
|
return specs, nil
|
|
}
|
|
|
|
// parseRate parses an nftables rate operand starting at tokens[i] (a
|
|
// "<n>/<unit>" token) plus an optional "burst <m> packets" suffix. It returns
|
|
// the RateLimit and the index of the last token consumed.
|
|
func (f *NFT) parseRate(tokens []string, i int) (*RateLimit, int, error) {
|
|
if i >= len(tokens) {
|
|
return nil, 0, fmt.Errorf("missing rate value")
|
|
}
|
|
rate, unit, err := parseRateToken(tokens[i])
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rl := &RateLimit{Rate: rate, Unit: unit}
|
|
if i+2 < len(tokens) && tokens[i+1] == "burst" {
|
|
b, berr := strconv.ParseUint(tokens[i+2], 10, 32)
|
|
if berr != nil {
|
|
return nil, 0, fmt.Errorf("invalid burst %q", tokens[i+2])
|
|
}
|
|
rl.Burst = uint(b)
|
|
i += 2
|
|
// Skip a trailing "packets" keyword.
|
|
if i+1 < len(tokens) && tokens[i+1] == "packets" {
|
|
i++
|
|
}
|
|
// nftables applies a default burst of 5 packets to every limit statement
|
|
// and prints it back on `nft list` even when none was requested, so treat
|
|
// nft's default as unset in the reported value. An explicit Burst of 5 is
|
|
// indistinguishable from the default and equivalent to it.
|
|
if rl.Burst == 5 {
|
|
rl.Burst = 0
|
|
}
|
|
}
|
|
return rl, i, nil
|
|
}
|
|
|
|
// parseSetTokens strips optional `{ }` braces and splits the comma-separated
|
|
// members of an nftables anonymous set (or a single bare value).
|
|
func (f *NFT) parseSetTokens(tok string) []string {
|
|
tok = strings.TrimSpace(tok)
|
|
tok = strings.TrimPrefix(tok, "{")
|
|
tok = strings.TrimSuffix(tok, "}")
|
|
var out []string
|
|
for _, m := range strings.Split(tok, ",") {
|
|
m = strings.TrimSpace(m)
|
|
if m != "" {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// protoFromToken decodes a layer-4 protocol token as it appears in `nft list`
|
|
// output. nft always normalizes `meta l4proto` back to its protocol name when
|
|
// listing, so this backend never needs to decode one — but the numeric IP
|
|
// protocol number is accepted too as defensive tolerance for output this backend
|
|
// has not itself observed. It returns ProtocolAny for an unrecognized token.
|
|
func (f *NFT) protoFromToken(tok string) Protocol {
|
|
if p := GetProtocol(tok); p != ProtocolAny {
|
|
return p
|
|
}
|
|
switch tok {
|
|
case "1":
|
|
return ICMP
|
|
case "58":
|
|
return ICMPv6
|
|
case "6":
|
|
return TCP
|
|
case "17":
|
|
return UDP
|
|
case "132":
|
|
return SCTP
|
|
case "47":
|
|
return GRE
|
|
case "50":
|
|
return ESP
|
|
case "51":
|
|
return AH
|
|
}
|
|
return ProtocolAny
|
|
}
|
|
|
|
// l4ProtoFromToken decodes the value of a `meta l4proto` clause, which is either a
|
|
// single protocol name (protoFromToken) or an anonymous set. The only set this
|
|
// backend writes, and the only one the Rule model can represent, is the
|
|
// both-transports `{ tcp, udp }` of a TCPUDP rule. Any other set — and any single
|
|
// protocol the model does not know (igmp, an unmapped number) — belongs to a
|
|
// foreign rule whose coverage a single Proto field cannot hold, so it is rejected
|
|
// rather than silently narrowed to one member or widened to ProtocolAny.
|
|
func (f *NFT) l4ProtoFromToken(tok string) (Protocol, error) {
|
|
if !strings.HasPrefix(tok, "{") {
|
|
if p := f.protoFromToken(tok); p != ProtocolAny {
|
|
return p, nil
|
|
}
|
|
return ProtocolAny, fmt.Errorf("unsupported l4proto: %s", tok)
|
|
}
|
|
members := f.parseSetTokens(tok)
|
|
if len(members) == 2 {
|
|
a, b := f.protoFromToken(members[0]), f.protoFromToken(members[1])
|
|
if (a == TCP && b == UDP) || (a == UDP && b == TCP) {
|
|
return TCPUDP, nil
|
|
}
|
|
}
|
|
return ProtocolAny, fmt.Errorf("unsupported l4proto set: %s", tok)
|
|
}
|
|
|
|
// stripSetRef drops the '@' nft prints before a named-set reference, yielding
|
|
// the bare set name the Rule model stores in Source/Destination.
|
|
func (f *NFT) stripSetRef(v string) string {
|
|
return strings.TrimPrefix(v, "@")
|
|
}
|
|
|
|
// UnmarshalRule decodes a single rule line from `nft list` output within the
|
|
// given chain. It returns the parsed rule and the nftables handle (used to
|
|
// delete the rule), or an error for lines it does not understand.
|
|
func (f *NFT) UnmarshalRule(line string, chain string) (r *Rule, handle string, err error) {
|
|
r = &Rule{Direction: f.directionForChain(chain)}
|
|
|
|
tokens := f.splitQuoted(f.collapseSetSpaces(line))
|
|
for i := 0; i < len(tokens); i++ {
|
|
switch tokens[i] {
|
|
case "ip", "ip6":
|
|
fam := IPv4
|
|
if tokens[i] == "ip6" {
|
|
fam = IPv6
|
|
}
|
|
r.Family = fam
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
dir := tokens[i]
|
|
|
|
// Consume an optional negation operator.
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
neg := ""
|
|
if tokens[i] == "!=" {
|
|
neg = "!"
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
} else if tokens[i] != "==" {
|
|
// Fall through: current token is the value.
|
|
} else {
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
}
|
|
|
|
switch dir {
|
|
case "saddr":
|
|
r.Source = neg + f.stripSetRef(tokens[i])
|
|
case "daddr":
|
|
r.Destination = neg + f.stripSetRef(tokens[i])
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported address direction: %s", dir)
|
|
}
|
|
case "iifname", "oifname":
|
|
dir := tokens[i]
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete interface match")
|
|
}
|
|
iface := trimQuotes(tokens[i])
|
|
if dir == "iifname" {
|
|
r.InInterface = iface
|
|
} else {
|
|
r.OutInterface = iface
|
|
}
|
|
case "ct":
|
|
if i+1 >= len(tokens) {
|
|
return nil, "", fmt.Errorf("unsupported ct match")
|
|
}
|
|
switch tokens[i+1] {
|
|
case "state":
|
|
// ct state <value>
|
|
if i+2 >= len(tokens) {
|
|
return nil, "", fmt.Errorf("unsupported ct match")
|
|
}
|
|
i += 2
|
|
state, serr := ParseConnState(f.parseSetTokens(tokens[i])...)
|
|
if serr != nil {
|
|
return nil, "", serr
|
|
}
|
|
r.State = state
|
|
case "count":
|
|
// ct count over N
|
|
if i+3 >= len(tokens) || tokens[i+2] != "over" {
|
|
return nil, "", fmt.Errorf("unsupported ct count match")
|
|
}
|
|
n, cerr := strconv.ParseUint(tokens[i+3], 10, 32)
|
|
if cerr != nil {
|
|
return nil, "", fmt.Errorf("invalid ct count %q", tokens[i+3])
|
|
}
|
|
r.ConnLimit = &ConnLimit{Count: uint(n)}
|
|
i += 3
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported ct match")
|
|
}
|
|
case "limit":
|
|
// limit rate N/unit [burst M packets]
|
|
if i+2 >= len(tokens) || tokens[i+1] != "rate" {
|
|
return nil, "", fmt.Errorf("unsupported limit statement")
|
|
}
|
|
rl, next, lerr := f.parseRate(tokens, i+2)
|
|
if lerr != nil {
|
|
return nil, "", lerr
|
|
}
|
|
r.RateLimit = rl
|
|
i = next
|
|
case "log":
|
|
r.Log = true
|
|
// Optional `prefix "..."` and `level <lvl>` qualifiers follow. The
|
|
// prefix is a single splitQuoted token, spacing intact.
|
|
for i+1 < len(tokens) {
|
|
if tokens[i+1] == "prefix" && i+2 < len(tokens) {
|
|
r.LogPrefix = f.unquote(tokens[i+2])
|
|
i += 2
|
|
} else if tokens[i+1] == "level" && i+2 < len(tokens) {
|
|
i += 2
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
case "icmp", "icmpv6", "icmp6":
|
|
// icmp type N / icmpv6 type N
|
|
if tokens[i] == "icmp" {
|
|
r.Proto = ICMP
|
|
} else {
|
|
r.Proto = ICMPv6
|
|
}
|
|
if i+1 < len(tokens) && tokens[i+1] == "type" {
|
|
i += 2
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete icmp type match")
|
|
}
|
|
n, ok := parseICMPTypeFamily(tokens[i], r.Proto == ICMPv6)
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("invalid icmp type %q", tokens[i])
|
|
}
|
|
r.ICMPType = Ptr(n)
|
|
}
|
|
case "tcp", "udp", "sctp", "th":
|
|
// `th` is the transport-header selector a TCPUDP rule matches its port
|
|
// through; the protocol itself came from the `meta l4proto { tcp, udp }`
|
|
// clause that precedes it, so do not overwrite it here.
|
|
if tokens[i] != "th" {
|
|
r.Proto = GetProtocol(tokens[i])
|
|
}
|
|
// A `dport` or `sport` qualifier may follow.
|
|
if i+2 < len(tokens) && (tokens[i+1] == "dport" || tokens[i+1] == "sport") {
|
|
src := tokens[i+1] == "sport"
|
|
i += 2
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete port match")
|
|
}
|
|
members := f.parseSetTokens(tokens[i])
|
|
if len(members) == 0 {
|
|
return nil, "", fmt.Errorf("incomplete port match")
|
|
}
|
|
specs, perr := f.parsePorts(members)
|
|
if perr != nil {
|
|
return nil, "", perr
|
|
}
|
|
// Keep the single-port form in Port for a lone discrete port so
|
|
// it round-trips against rules built that way.
|
|
if src {
|
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
|
r.SourcePort = specs[0].Start
|
|
} else {
|
|
r.SourcePorts = specs
|
|
}
|
|
} else {
|
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
|
r.Port = specs[0].Start
|
|
} else {
|
|
r.Ports = specs
|
|
}
|
|
}
|
|
}
|
|
case "meta":
|
|
// meta l4proto <proto|set> or meta nfproto <family>
|
|
if i+2 >= len(tokens) {
|
|
return nil, "", fmt.Errorf("unsupported meta match")
|
|
}
|
|
switch tokens[i+1] {
|
|
case "l4proto":
|
|
p, perr := f.l4ProtoFromToken(tokens[i+2])
|
|
if perr != nil {
|
|
return nil, "", perr
|
|
}
|
|
r.Proto = p
|
|
case "nfproto":
|
|
switch tokens[i+2] {
|
|
case "ipv4":
|
|
r.Family = IPv4
|
|
case "ipv6":
|
|
r.Family = IPv6
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported nfproto: %s", tokens[i+2])
|
|
}
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported meta match")
|
|
}
|
|
i += 2
|
|
case "add", "update", "meter":
|
|
// A per-source connection limit. nft >= 1.1 lists the meter statement
|
|
// as `add @name { ip saddr ct count over N }` (or `update`); 1.0.x
|
|
// echoes the written spelling back as `meter name size 65535 { ... }`,
|
|
// so the optional size qualifier is consumed too. collapseSetSpaces
|
|
// has already collapsed the braces' body into one blob token. The
|
|
// set's key pins the rule's family, and nft folds any redundant
|
|
// nfproto match away, so the family comes from the key here.
|
|
if i+2 >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete meter statement")
|
|
}
|
|
name := strings.TrimPrefix(tokens[i+1], "@")
|
|
body := i + 2
|
|
if tokens[body] == "size" && body+2 < len(tokens) {
|
|
body += 2
|
|
}
|
|
fam, count, ok := parseMeterBlob(tokens[body])
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("unsupported dynamic-set statement: %s", tokens[body])
|
|
}
|
|
r.Family = fam
|
|
r.ConnLimit = &ConnLimit{Count: count, PerSource: true}
|
|
r.meterSet = name
|
|
i = body
|
|
case "accept":
|
|
r.Action = Accept
|
|
case "drop":
|
|
r.Action = Drop
|
|
case "reject":
|
|
r.Action = Reject
|
|
// An explicitly-written `reject with <proto> <type>` (e.g.
|
|
// `reject with icmp port-unreachable`, `reject with tcp reset`) can appear
|
|
// on a foreign rule — nft itself never adds this clause to a bare `reject`
|
|
// on read, but a rule authored with one keeps it verbatim. The detail runs
|
|
// until the comment or handle marker; consume it so the trailing tokens do
|
|
// not fail the parse and drop the rule.
|
|
if i+1 < len(tokens) && tokens[i+1] == "with" {
|
|
j := i + 2
|
|
for ; j < len(tokens); j++ {
|
|
if tokens[j] == "comment" || tokens[j] == "#" {
|
|
break
|
|
}
|
|
}
|
|
i = j - 1
|
|
}
|
|
case "comment":
|
|
// nft prints the rule comment as a double-quoted string, before an
|
|
// optional `# handle N` marker. splitQuoted keeps the quoted comment
|
|
// as one token — spaces, a literal '#', and interior spacing intact.
|
|
if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "\"") {
|
|
r.Comment = f.unquote(tokens[i+1])
|
|
i++
|
|
} else {
|
|
// Unquoted (a single bare word, or older nft): take tokens up to
|
|
// the handle marker.
|
|
var cparts []string
|
|
for j := i + 1; j < len(tokens); j++ {
|
|
if tokens[j] == "#" {
|
|
break
|
|
}
|
|
cparts = append(cparts, tokens[j])
|
|
}
|
|
r.Comment = f.unquote(strings.Join(cparts, " "))
|
|
i += len(cparts)
|
|
}
|
|
case "#":
|
|
// `nft -a` prints the handle after a comment marker: `# handle N`.
|
|
case "handle":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("missing handle value")
|
|
}
|
|
handle = tokens[i]
|
|
case "counter":
|
|
// `counter packets N bytes M` (always present on a listed rule that
|
|
// has a counter statement). Capture the values for GetRules.
|
|
if i+4 < len(tokens) && tokens[i+1] == "packets" && tokens[i+3] == "bytes" {
|
|
if pkts, perr := strconv.ParseUint(tokens[i+2], 10, 64); perr == nil {
|
|
r.Packets = pkts
|
|
}
|
|
if by, berr := strconv.ParseUint(tokens[i+4], 10, 64); berr == nil {
|
|
r.Bytes = by
|
|
}
|
|
i += 4
|
|
}
|
|
case "packets", "bytes":
|
|
// Skip stray counter value tokens (already consumed under counter).
|
|
if i+1 < len(tokens) {
|
|
i++
|
|
}
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported token: %s", tokens[i])
|
|
}
|
|
}
|
|
|
|
if r.Action == ActionInvalid {
|
|
return nil, "", fmt.Errorf("no valid action was provided")
|
|
}
|
|
return r, handle, nil
|
|
}
|
|
|
|
// headerName extracts the object name from an `nft -a list ruleset` header
|
|
// line such as `table inet foo { # handle 3` or `chain input { # handle 1`: it
|
|
// drops the keyword and everything from the opening brace on. nft -a appends
|
|
// `{ # handle N` (and sometimes ` progname ...`) to headers, which a naive
|
|
// TrimSuffix(line, "{") would leave attached to the name — making a table
|
|
// comparison against our own table miss and re-list our rules as foreign.
|
|
func (f *NFT) headerName(line, keyword string) string {
|
|
name := strings.TrimPrefix(line, keyword+" ")
|
|
if i := strings.IndexByte(name, '{'); i >= 0 {
|
|
name = name[:i]
|
|
}
|
|
return strings.TrimSpace(name)
|
|
}
|
|
|
|
// listForeignRules walks the entire nftables ruleset and returns best-effort
|
|
// parsed rules that live outside this backend's own inet table. Because arbitrary
|
|
// foreign tables use families and constructs the library's Rule model cannot
|
|
// represent, any line that fails to parse is skipped rather than erroring the
|
|
// whole read. This gives callers visibility of rules in other tables alongside
|
|
// the library's own.
|
|
func (f *NFT) listForeignRules(ctx context.Context) ([]*Rule, error) {
|
|
out, err := runCommand(ctx, "nft", "-a", "list", "ruleset")
|
|
if err != nil {
|
|
// No ruleset (or nft unavailable for listing): nothing foreign to report.
|
|
return nil, nil
|
|
}
|
|
|
|
ownTable := "inet " + f.table
|
|
curTable := ""
|
|
curChain := ""
|
|
var rules []*Rule
|
|
for _, line := range out {
|
|
t := strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(t, "table "):
|
|
// e.g. "table inet filter { # handle 3" -> "inet filter"
|
|
curTable = f.headerName(t, "table")
|
|
curChain = ""
|
|
case strings.HasPrefix(t, "chain "):
|
|
// e.g. "chain input { # handle 1" -> "input"
|
|
curChain = f.headerName(t, "chain")
|
|
case t == "}":
|
|
// Closes the current chain (a table close is harmless: no rule lines
|
|
// follow before the next "table" resets the context).
|
|
curChain = ""
|
|
case strings.Contains(t, "handle "):
|
|
// Our own table is read precisely by listChain; skip it here.
|
|
if curTable == ownTable {
|
|
continue
|
|
}
|
|
rule, _, perr := f.UnmarshalRule(t, curChain)
|
|
if perr != nil || rule == nil {
|
|
continue
|
|
}
|
|
// A rule from another table: record where it came from; it is not ours,
|
|
// so HasPrefix stays false.
|
|
rule.table = curTable
|
|
rules = append(rules, rule)
|
|
}
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
// lineHandle extracts the trailing `# handle N` marker nft -a appends to a rule
|
|
// line, so an unmodeled row still yields the handle its slot is tracked by. The
|
|
// marker is the line's final clause, so the scan runs from the end and cannot be
|
|
// fooled by the word "handle" inside a quoted comment.
|
|
func (f *NFT) lineHandle(line string) string {
|
|
fields := strings.Fields(line)
|
|
for i := len(fields) - 1; i > 0; i-- {
|
|
if fields[i-1] == "handle" {
|
|
return fields[i]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// listChain returns the chain's rules with their nftables handles, 1:1 with its
|
|
// physical rows. A row the model cannot parse is kept as an opaque slot — a nil
|
|
// rule with its handle — so a rewrite deletes only rows the model understands
|
|
// and the position math stays aligned; GetRules and the dedup scans skip the
|
|
// nil entries.
|
|
func (f *NFT) listChain(ctx context.Context, chain string) (rules []*Rule, handles []string, err error) {
|
|
out, err := runCommand(ctx, "nft", "-a", "list", "chain", "inet", f.table, chain)
|
|
if err != nil {
|
|
// A missing table simply means there are no rules yet.
|
|
if strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist") {
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
|
|
for _, line := range out {
|
|
line = strings.TrimSpace(line)
|
|
// Only rule lines carry a handle; skip table/chain scaffolding (whose
|
|
// header lines carry `{ # handle N` markers of their own).
|
|
if line == "" || !strings.Contains(line, "handle ") ||
|
|
strings.HasPrefix(line, "table ") || strings.HasPrefix(line, "chain ") {
|
|
continue
|
|
}
|
|
rule, handle, perr := f.UnmarshalRule(line, chain)
|
|
if perr != nil {
|
|
if h := f.lineHandle(line); h != "" {
|
|
rules = append(rules, nil)
|
|
handles = append(handles, h)
|
|
}
|
|
continue
|
|
}
|
|
// Rules live in this backend's own table; membership in the library's
|
|
// private table is what sets HasPrefix, so record the table and flag it
|
|
// as carrying the prefix.
|
|
rule.table = f.table
|
|
rule.HasPrefix = true
|
|
rules = append(rules, rule)
|
|
handles = append(handles, handle)
|
|
}
|
|
return rules, handles, nil
|
|
}
|
|
|
|
// listOwnRules returns the library's own filter rules from its private table, one
|
|
// rule per physical chain row. A read does not create the table; listChain returns
|
|
// nothing when the table does not yet exist. nftables' inet table stores a
|
|
// family-agnostic rule as one unpinned row and a both-transports rule as one
|
|
// `meta l4proto { tcp, udp }` row, so UnmarshalRule reports FamilyAny and TCPUDP
|
|
// straight off the row that carries them; nothing is collapsed here. Number per
|
|
// direction (input then output chain) so each rule's Number matches the
|
|
// InsertRule/MoveRule position within its chain.
|
|
func (f *NFT) listOwnRules(ctx context.Context) ([]*Rule, error) {
|
|
var rules []*Rule
|
|
for _, chain := range nftFilterChains {
|
|
chainRules, _, cerr := f.listChain(ctx, chain)
|
|
if cerr != nil {
|
|
return nil, cerr
|
|
}
|
|
// Opaque (nil) rows stay in the chain but are not reportable rules.
|
|
for _, r := range chainRules {
|
|
if r != nil {
|
|
rules = append(rules, r)
|
|
}
|
|
}
|
|
}
|
|
numberByDirection(rules)
|
|
return rules, nil
|
|
}
|
|
|
|
// GetRules returns the existing filter rules from the zone.
|
|
func (f *NFT) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
|
|
// The library's own rules, then foreign rules from every other table.
|
|
rules, err = f.listOwnRules(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
foreign, ferr := f.listForeignRules(ctx)
|
|
if ferr != nil {
|
|
return nil, ferr
|
|
}
|
|
rules = append(rules, foreign...)
|
|
return rules, nil
|
|
}
|
|
|
|
// addrExpr encodes a source/destination match value: the negation operator
|
|
// (`!= `) when the token starts with "!", followed by the value — a bare address
|
|
// or, for a non-address token, a named-set reference `@name`.
|
|
func (f *NFT) addrExpr(addr string) string {
|
|
neg, bare := splitAddrNeg(addr)
|
|
op := ""
|
|
if neg {
|
|
op = "!= "
|
|
}
|
|
if isSetRef(addr) {
|
|
return op + "@" + bare
|
|
}
|
|
return op + bare
|
|
}
|
|
|
|
// checkQuotable rejects a value containing a double quote. nft's string
|
|
// literals (a rule comment, a log prefix) have no escape mechanism at all — a
|
|
// `"` unconditionally toggles the quoted state — so there is no way to write one
|
|
// containing an embedded quote; nft's own parser errors on the attempt. Rejecting
|
|
// it here gives a clear validation error instead of a confusing raw nft syntax
|
|
// error from the command itself.
|
|
func (f *NFT) checkQuotable(s, field string) error {
|
|
if strings.Contains(s, `"`) {
|
|
return fmt.Errorf("nftables %s cannot contain a double quote", field)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// l3Match returns the nftables layer-3 keyword (ip/ip6) for the given family,
|
|
// inferring it from an address when the family is unspecified.
|
|
func (f *NFT) l3Match(family Family, addr string) string {
|
|
switch family {
|
|
case IPv4:
|
|
return "ip"
|
|
case IPv6:
|
|
return "ip6"
|
|
}
|
|
// Infer from the address for FamilyAny rules.
|
|
bare := strings.TrimPrefix(addr, "!")
|
|
ip, _, err := net.ParseCIDR(bare)
|
|
if err != nil {
|
|
ip = net.ParseIP(bare)
|
|
}
|
|
if ip != nil && ip.To4() == nil {
|
|
return "ip6"
|
|
}
|
|
return "ip"
|
|
}
|
|
|
|
// nftTCPUDPSet is the anonymous nftables set that pins a rule to both transports.
|
|
// It is how this backend spells TCPUDP, letting a both-transports rule live as a
|
|
// single nftables row rather than a fanned-out tcp/udp pair.
|
|
const nftTCPUDPSet = "{ tcp, udp }"
|
|
|
|
// l4Proto returns the protocol keyword nftables accepts after `meta l4proto`.
|
|
// For ICMPv6 this is the `icmpv6` spelling. nft lists such a rule back with the
|
|
// differently-spelled `ipv6-icmp` form, which protoFromToken decodes on read.
|
|
// TCPUDP is spelled as the anonymous both-transports set.
|
|
func (f *NFT) l4Proto(p Protocol) string {
|
|
if p == TCPUDP {
|
|
return nftTCPUDPSet
|
|
}
|
|
return p.String()
|
|
}
|
|
|
|
// portExpr renders a destination port match value: a bare port/range for a
|
|
// single spec, or an anonymous set `{80,443,1000-2000}` for a list.
|
|
func (f *NFT) portExpr(specs []PortRange) string {
|
|
if len(specs) == 1 {
|
|
return specs[0].String()
|
|
}
|
|
return "{" + FormatPortRanges(specs, ",") + "}"
|
|
}
|
|
|
|
// stateExpr renders a ct state match value: a bare name for one state or an
|
|
// anonymous set `{established,related}` for several.
|
|
func (f *NFT) stateExpr(s ConnState) string {
|
|
names := s.Strings()
|
|
if len(names) == 1 {
|
|
return names[0]
|
|
}
|
|
return "{" + strings.Join(names, ",") + "}"
|
|
}
|
|
|
|
// MarshalRule encodes a rule as the nftables expression that follows
|
|
// `nft add rule inet <table> <chain>`.
|
|
func (f *NFT) MarshalRule(r *Rule) (chain string, expr string, err error) {
|
|
// nftables can only match a port alongside a concrete transport protocol.
|
|
if r.PortNeedsConcreteProtocol() {
|
|
return "", "", fmt.Errorf("a port requires a tcp or udp protocol")
|
|
}
|
|
if err := r.checkICMPType(); err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := f.checkQuotable(r.Comment, "comment"); err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := f.checkQuotable(r.LogPrefix, "log prefix"); err != nil {
|
|
return "", "", err
|
|
}
|
|
// A named set is family-typed, so a family-agnostic set reference cannot be
|
|
// rendered: l3Match would silently pin it to IPv4 and the rule would never
|
|
// read back as the FamilyAny rule the caller keeps trying to add. The entry
|
|
// points pin such a rule to the set's own family first (resolveSetRefFamily),
|
|
// so reaching here unresolved is a caller bug.
|
|
if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) {
|
|
return "", "", fmt.Errorf("a set-referencing rule requires a concrete family; the caller must resolve the set's family first")
|
|
}
|
|
|
|
chain = "input"
|
|
switch r.Direction {
|
|
case DirOutput:
|
|
chain = "output"
|
|
case DirForward:
|
|
chain = "forward"
|
|
}
|
|
|
|
// An input hook can only match the inbound interface; an output hook only the
|
|
// outbound one. The forward hook sees both an ingress and an egress interface,
|
|
// so it accepts either.
|
|
if r.IsOutput() && r.InInterface != "" {
|
|
return "", "", fmt.Errorf("an input interface cannot be matched on an output rule")
|
|
}
|
|
if r.IsInput() && r.OutInterface != "" {
|
|
return "", "", fmt.Errorf("an output interface cannot be matched on an input rule")
|
|
}
|
|
|
|
var parts []string
|
|
|
|
// Interface match.
|
|
if r.InInterface != "" {
|
|
parts = append(parts, fmt.Sprintf("iifname %s", strconv.Quote(r.InInterface)))
|
|
}
|
|
if r.OutInterface != "" {
|
|
parts = append(parts, fmt.Sprintf("oifname %s", strconv.Quote(r.OutInterface)))
|
|
}
|
|
|
|
// In an inet table the family is only implied when an address match carries
|
|
// it (ip/ip6). For a family-specific rule with no address, pin the family
|
|
// explicitly so the rule does not silently widen to both families. A
|
|
// per-source connection limit is excluded: its meter key (`ip saddr` /
|
|
// `ip6 saddr`) already pins the family, and nft folds the then-redundant
|
|
// nfproto match away on listing, so an emitted one would never round-trip.
|
|
if r.Family != FamilyAny && r.Source == "" && r.Destination == "" && !r.perSourceLimited() {
|
|
nfproto := "ipv4"
|
|
if r.Family == IPv6 {
|
|
nfproto = "ipv6"
|
|
}
|
|
parts = append(parts, "meta nfproto "+nfproto)
|
|
}
|
|
|
|
// Source address match, honoring negation via a leading '!'. A non-address token
|
|
// names a set, referenced as `@name`.
|
|
if r.Source != "" {
|
|
parts = append(parts, fmt.Sprintf("%s saddr %s", f.l3Match(r.Family, r.Source), f.addrExpr(r.Source)))
|
|
}
|
|
|
|
// Destination address match.
|
|
if r.Destination != "" {
|
|
parts = append(parts, fmt.Sprintf("%s daddr %s", f.l3Match(r.Family, r.Destination), f.addrExpr(r.Destination)))
|
|
}
|
|
|
|
// Protocol / port match. A TCPUDP rule pins both transports with an anonymous
|
|
// set and matches the port through `th`, the transport-header selector, which is
|
|
// valid precisely because l4proto is constrained to port-carrying protocols. That
|
|
// keeps the rule a single nftables row, so it needs no fan-out and reads back as
|
|
// written; every other protocol names itself before its port.
|
|
srcSpecs := r.SourcePortSpecs()
|
|
portKeyword := r.Proto.String()
|
|
if r.Proto == TCPUDP {
|
|
parts = append(parts, "meta l4proto "+nftTCPUDPSet)
|
|
portKeyword = "th"
|
|
}
|
|
if r.HasPorts() {
|
|
// A port-carrying protocol is guaranteed by the check above.
|
|
parts = append(parts, fmt.Sprintf("%s dport %s", portKeyword, f.portExpr(r.PortSpecs())))
|
|
}
|
|
if len(srcSpecs) > 0 {
|
|
parts = append(parts, fmt.Sprintf("%s sport %s", portKeyword, f.portExpr(srcSpecs)))
|
|
}
|
|
if r.Proto.IsICMP() && r.ICMPType != nil {
|
|
// An ICMP type match implies the icmp/icmpv6 protocol.
|
|
kw := "icmp"
|
|
if r.Proto == ICMPv6 {
|
|
kw = "icmpv6"
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s type %d", kw, *r.ICMPType))
|
|
} else if r.Proto != ProtocolAny && r.Proto != TCPUDP && !r.HasPorts() && len(srcSpecs) == 0 {
|
|
parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto))
|
|
}
|
|
|
|
// Connection-tracking state match.
|
|
if r.State != 0 {
|
|
parts = append(parts, "ct state "+f.stateExpr(r.State))
|
|
}
|
|
|
|
// Rate limit: the statement matches only while under the rate, so over-rate
|
|
// packets fall through to later rules rather than taking this rule's verdict.
|
|
if r.RateLimit != nil {
|
|
lim := fmt.Sprintf("limit rate %d/%s", r.RateLimit.Rate, r.RateLimit.Unit)
|
|
if r.RateLimit.Burst > 0 {
|
|
lim += fmt.Sprintf(" burst %d packets", r.RateLimit.Burst)
|
|
}
|
|
parts = append(parts, lim)
|
|
}
|
|
|
|
// Connection limit: `ct count over N` matches while the tracked connection
|
|
// count exceeds the limit. Per-source counting keys the count on the source
|
|
// address in a named dynamic set (the meter statement auto-creates it); the
|
|
// set is family-typed, so a FamilyAny rule is fanned out into one row per
|
|
// family by insertRule/MoveRule/ReplaceRulesBatch before reaching here.
|
|
if r.ConnLimit != nil {
|
|
if r.ConnLimit.PerSource {
|
|
fam := r.impliedFamily()
|
|
if fam == FamilyAny {
|
|
return "", "", fmt.Errorf("a per-source connection limit counts in a family-typed meter; the caller must expand the rule to concrete families first")
|
|
}
|
|
key := "ip"
|
|
if fam == IPv6 {
|
|
key = "ip6"
|
|
}
|
|
parts = append(parts, fmt.Sprintf("meter %s { %s saddr ct count over %d }", f.meterName(chain, r), key, r.ConnLimit.Count))
|
|
} else {
|
|
parts = append(parts, fmt.Sprintf("ct count over %d", r.ConnLimit.Count))
|
|
}
|
|
}
|
|
|
|
// Logging, emitted just before the verdict so the packet is logged and then
|
|
// the action is applied.
|
|
if r.Log {
|
|
if r.LogPrefix != "" {
|
|
// A plain double-quote wrap, not strconv.Quote: nft has no backslash-escape
|
|
// mechanism, so strconv.Quote's Go-style escaping (doubling a literal
|
|
// backslash, etc.) would not round-trip through nft's own quoting. The
|
|
// embedded-quote case that would need escaping is rejected above.
|
|
parts = append(parts, `log prefix "`+r.LogPrefix+`"`)
|
|
} else {
|
|
parts = append(parts, "log")
|
|
}
|
|
}
|
|
|
|
// A counter so GetRules can report per-rule packet/byte statistics. The
|
|
// counter has no effect on matching and is ignored when comparing rules.
|
|
parts = append(parts, "counter")
|
|
|
|
// Action verb.
|
|
switch r.Action {
|
|
case Accept:
|
|
parts = append(parts, "accept")
|
|
case Drop:
|
|
parts = append(parts, "drop")
|
|
case Reject:
|
|
parts = append(parts, "reject")
|
|
default:
|
|
return "", "", fmt.Errorf("no valid action was provided")
|
|
}
|
|
|
|
// An optional user comment, stored as an nftables rule comment. It has no
|
|
// effect on matching and is ignored when comparing rules. A plain quote wrap,
|
|
// not strconv.Quote — see the log-prefix comment above for why.
|
|
if r.Comment != "" {
|
|
parts = append(parts, `comment "`+r.Comment+`"`)
|
|
}
|
|
|
|
return chain, strings.Join(parts, " "), nil
|
|
}
|
|
|
|
// setRefFamily resolves the single family of the named address set(s) a rule
|
|
// references, the nft analog of the hook layer's setRefFamily: a named set is
|
|
// family-typed, so a family-agnostic set-referencing rule is pinned to the
|
|
// set's own family rather than rejected — its rows could never match the other
|
|
// family anyway. A set that does not exist, or a source/destination pair naming
|
|
// sets of different families, cannot produce a loadable rule, so both are
|
|
// errors.
|
|
func (f *NFT) setRefFamily(ctx context.Context, source, destination string) (Family, error) {
|
|
fam := FamilyAny
|
|
for _, ref := range []string{source, destination} {
|
|
if !isSetRef(ref) {
|
|
continue
|
|
}
|
|
_, bare := splitAddrNeg(strings.TrimSpace(ref))
|
|
name := strings.TrimPrefix(bare, "@")
|
|
set, err := f.getAddressSet(ctx, name)
|
|
if err != nil {
|
|
return FamilyAny, err
|
|
}
|
|
if set == nil {
|
|
return FamilyAny, fmt.Errorf("rule references unknown address set %q", name)
|
|
}
|
|
sf := set.Family
|
|
if sf != IPv6 {
|
|
sf = IPv4
|
|
}
|
|
if fam != FamilyAny && sf != fam {
|
|
return FamilyAny, fmt.Errorf("rule references address sets of different families")
|
|
}
|
|
fam = sf
|
|
}
|
|
if fam == FamilyAny {
|
|
fam = IPv4
|
|
}
|
|
return fam, nil
|
|
}
|
|
|
|
// resolveSetRefFamily returns r pinned to its referenced set's family when the
|
|
// rule is family-agnostic and names a set; every other rule passes through
|
|
// unchanged. Callers resolve before marshalling, so MarshalRule never sees an
|
|
// unpinned set reference.
|
|
func (f *NFT) resolveSetRefFamily(ctx context.Context, r *Rule) (*Rule, error) {
|
|
if r.impliedFamily() != FamilyAny || (!isSetRef(r.Source) && !isSetRef(r.Destination)) {
|
|
return r, nil
|
|
}
|
|
fam, err := f.setRefFamily(ctx, r.Source, r.Destination)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rc := *r
|
|
rc.Family = fam
|
|
return &rc, nil
|
|
}
|
|
|
|
// resolveNATSetRefFamily is resolveSetRefFamily for NAT rules.
|
|
func (f *NFT) resolveNATSetRefFamily(ctx context.Context, r *NATRule) (*NATRule, error) {
|
|
if r.impliedFamily() != FamilyAny || (!isSetRef(r.Source) && !isSetRef(r.Destination)) {
|
|
return r, nil
|
|
}
|
|
fam, err := f.setRefFamily(ctx, r.Source, r.Destination)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rc := *r
|
|
rc.Family = fam
|
|
return &rc, nil
|
|
}
|
|
|
|
// meterName derives the dynamic-set name a per-source connection-limit rule
|
|
// counts in. The name is a hash of the rule's identity (chain, family, match,
|
|
// limit and verdict) so two distinct per-source rules never share counting
|
|
// state, while a re-add — or a split's re-add — of the same rule deterministically
|
|
// reuses its set. The comment is excluded, as it is not part of rule identity.
|
|
func (f *NFT) meterName(chain string, r *Rule) string {
|
|
h := fnv.New64a()
|
|
_, _ = fmt.Fprintf(h, "%s|%d|%d|%s|%s|%d|%s|%s|%v|%d|%d|%d",
|
|
chain, r.impliedFamily(), r.Proto, r.Source, r.Destination,
|
|
r.State, FormatPortRanges(r.PortSpecs(), ","), FormatPortRanges(r.SourcePortSpecs(), ","),
|
|
r.Log, r.Action, r.ConnLimit.Count, r.Priority)
|
|
return fmt.Sprintf("cl%016x", h.Sum64())
|
|
}
|
|
|
|
// parseMeterBlob decodes the collapsed body of a per-source meter clause. The
|
|
// listing prints `add @name { ip saddr ct count over N }`, and collapseSetSpaces
|
|
// strips the braces' interior spacing before tokenizing, so the body arrives as
|
|
// one blob token like "{ipsaddrctcountoverN}". Only the source-keyed
|
|
// connection-count form is modeled; any other dynamic-set body fails, leaving
|
|
// the row opaque.
|
|
func parseMeterBlob(blob string) (fam Family, count uint, ok bool) {
|
|
blob = strings.TrimSuffix(strings.TrimPrefix(blob, "{"), "}")
|
|
fam = IPv4
|
|
rest, found := strings.CutPrefix(blob, "ipsaddrctcountover")
|
|
if !found {
|
|
fam = IPv6
|
|
rest, found = strings.CutPrefix(blob, "ip6saddrctcountover")
|
|
}
|
|
if !found {
|
|
return FamilyAny, 0, false
|
|
}
|
|
n, err := strconv.ParseUint(rest, 10, 32)
|
|
if err != nil {
|
|
return FamilyAny, 0, false
|
|
}
|
|
return fam, uint(n), true
|
|
}
|
|
|
|
// perSourceFamilySplit reports whether a rule must be fanned out into one row
|
|
// per family before marshalling: a per-source connection limit counts in a
|
|
// family-typed dynamic set, so a FamilyAny rule has no single nftables row —
|
|
// the family analog of the DirAny fan-out.
|
|
func perSourceFamilySplit(r *Rule) bool {
|
|
return r.perSourceLimited() && r.impliedFamily() == FamilyAny
|
|
}
|
|
|
|
// ensureTable creates the private table and its input/output/forward base chains
|
|
// if they do not already exist. `add` is idempotent in nftables, so re-running is
|
|
// safe.
|
|
//
|
|
// The chain declarations deliberately omit an explicit `policy`: `add chain` on
|
|
// an existing base chain re-asserts the named properties, so writing `policy
|
|
// accept` here would revert a default-drop policy a prior SetDefaultPolicy set.
|
|
// A base chain created without a policy defaults to accept (the intended initial
|
|
// default), and omitting the clause leaves any existing policy untouched.
|
|
func (f *NFT) ensureTable(ctx context.Context) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.ensured {
|
|
return nil
|
|
}
|
|
cmds := [][]string{
|
|
{"add", "table", "inet", f.table},
|
|
{"add", "chain", "inet", f.table, "input", "{", "type", "filter", "hook", "input", "priority", "0", ";", "}"},
|
|
{"add", "chain", "inet", f.table, "output", "{", "type", "filter", "hook", "output", "priority", "0", ";", "}"},
|
|
{"add", "chain", "inet", f.table, "forward", "{", "type", "filter", "hook", "forward", "priority", "0", ";", "}"},
|
|
}
|
|
for _, c := range cmds {
|
|
if _, err := runCommand(ctx, "nft", c...); err != nil {
|
|
return fmt.Errorf("failed to set up nftables table %s: %s", f.table, err)
|
|
}
|
|
}
|
|
f.ensured = true
|
|
return nil
|
|
}
|
|
|
|
// nftFilterChains lists the private table's filter base chains, in the order a
|
|
// read enumerates them.
|
|
var nftFilterChains = []string{"input", "output", "forward"}
|
|
|
|
// placeArgs builds the nft command that places a rule expression at 0-based
|
|
// index insPos in a chain currently holding n rules. nft can insert before an
|
|
// existing rule (`insert ... index k`, k in [0,n-1]) or prepend (`insert` with no
|
|
// index), but it has no insert-at-end form, so an index at or past the end must
|
|
// append with `add rule`.
|
|
func (f *NFT) placeArgs(chain, expr string, insPos, n int) []string {
|
|
fields := f.splitQuoted(expr)
|
|
switch {
|
|
case insPos >= n:
|
|
return append([]string{"add", "rule", "inet", f.table, chain}, fields...)
|
|
case insPos <= 0:
|
|
return append([]string{"insert", "rule", "inet", f.table, chain}, fields...)
|
|
default:
|
|
return append([]string{"insert", "rule", "inet", f.table, chain, "index", strconv.Itoa(insPos)}, fields...)
|
|
}
|
|
}
|
|
|
|
// ruleExists reports whether existing already contains a rule matching r.
|
|
// Opaque (nil) rows never match.
|
|
func (f *NFT) ruleExists(existing []*Rule, r *Rule) bool {
|
|
for _, e := range existing {
|
|
if e != nil && e.EqualForDedup(r, true) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// physicalIndex maps a 1-based position over the modeled (non-nil) rows to the
|
|
// 0-based physical row index it lands at, keeping opaque rows pinned in place;
|
|
// a position past the last modeled row maps to the end of the chain.
|
|
func physicalIndex[T any](rows []*T, position int) int {
|
|
seen := 0
|
|
for i, e := range rows {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
seen++
|
|
if seen == position {
|
|
return i
|
|
}
|
|
}
|
|
return len(rows)
|
|
}
|
|
|
|
func (f *NFT) insertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// A DirAny rule fans out into an input row plus its role-swapped output row;
|
|
// place each in its own chain at the requested position.
|
|
if r.Direction == DirAny {
|
|
for _, sub := range expandDirections(r) {
|
|
if err := f.insertRule(ctx, zoneName, position, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A FamilyAny per-source connection limit has no single row (its meter set is
|
|
// family-typed): fan out into a v4 row and a v6 row, each with its own set.
|
|
if perSourceFamilySplit(r) {
|
|
for _, sub := range expandFamilies(r) {
|
|
if err := f.insertRule(ctx, zoneName, position, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A family-agnostic set reference is pinned to the set's own family.
|
|
r, err := f.resolveSetRefFamily(ctx, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
chain, expr, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Skip if an equivalent rule already exists.
|
|
existing, _, err := f.listChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.ruleExists(existing, r) {
|
|
return nil
|
|
}
|
|
|
|
if position >= 1 {
|
|
// A position counts the modeled rows, matching GetRules' numbering;
|
|
// physicalIndex maps it past any opaque rows to the chain's real index.
|
|
insPos := physicalIndex(existing, position)
|
|
args := f.placeArgs(chain, expr, insPos, len(existing))
|
|
_, err = runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
args := append([]string{"add", "rule", "inet", f.table, chain}, f.splitQuoted(expr)...)
|
|
_, err = runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
// AddRule adds a rule to the zone.
|
|
func (f *NFT) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
|
return f.insertRule(ctx, zoneName, -1, r)
|
|
}
|
|
|
|
// InsertRule inserts rule before the given 1-based position. position <= 0 is
|
|
// treated as 1 (prepend); a position larger than the current rule count appends
|
|
// the rule. Normalizing here keeps insertRule's -1 sentinel reserved for
|
|
// AddRule's plain append.
|
|
func (f *NFT) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
|
|
if position <= 0 {
|
|
position = 1
|
|
}
|
|
return f.insertRule(ctx, zoneName, position, r)
|
|
}
|
|
|
|
// chainForDirection returns the filter base-chain name a rule of the given
|
|
// direction lives in.
|
|
func (f *NFT) chainForDirection(d Direction) string {
|
|
switch d {
|
|
case DirOutput:
|
|
return "output"
|
|
case DirForward:
|
|
return "forward"
|
|
}
|
|
return "input"
|
|
}
|
|
|
|
// MoveRule moves an existing rule to the given 1-based position.
|
|
func (f *NFT) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
|
|
if position <= 0 {
|
|
position = 1
|
|
}
|
|
|
|
// A DirAny rule occupies a slot in both chains; move each half to the requested
|
|
// position within its own chain.
|
|
if r.Direction == DirAny {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
for _, sub := range expandDirections(r) {
|
|
if err := f.MoveRule(ctx, zoneName, sub, position); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A FamilyAny per-source connection limit occupies one row per family; move
|
|
// each to the requested position, mirroring the insertRule fan-out.
|
|
if perSourceFamilySplit(r) {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
for _, sub := range expandFamilies(r) {
|
|
if err := f.MoveRule(ctx, zoneName, sub, position); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A family-agnostic set reference is pinned to the set's own family so the
|
|
// re-add below marshals; the rows it targets are already pinned on read.
|
|
r, rerr := f.resolveSetRefFamily(ctx, r)
|
|
if rerr != nil {
|
|
return rerr
|
|
}
|
|
|
|
chain := f.chainForDirection(r.Direction)
|
|
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
rules, handles, err := f.listChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The target's current position is its first matching row's position among
|
|
// the modeled rows; moving it there is a no-op.
|
|
firstLogical := -1
|
|
logical := 0
|
|
for _, e := range rules {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
logical++
|
|
if firstLogical < 0 && e.EqualForRemoval(r, true) {
|
|
firstLogical = logical
|
|
}
|
|
}
|
|
if firstLogical < 0 {
|
|
return nil
|
|
}
|
|
if position == firstLogical {
|
|
return nil
|
|
}
|
|
|
|
// nft has no native move. Delete every row the target covers — a FamilyAny or
|
|
// TCPUDP target spans rows the chain may hold separately, so all of them
|
|
// relocate — but a concrete target that matched a merged row must not take
|
|
// the untargeted coverage with it: removeCovered re-adds each merged row's
|
|
// remainder in its own slot, and only the targeted rule moves.
|
|
virtual, _, err := f.removeCovered(ctx, chain, rules, handles, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, expr, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
insPos := physicalIndex(virtual, position)
|
|
args := f.placeArgs(chain, expr, insPos, len(virtual))
|
|
_, err = runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
// removeCovered deletes every chain row the target covers and re-adds each
|
|
// merged row's untargeted remainder (see splitMergedRow) in the row's own slot,
|
|
// so coverage the caller never named survives in place. It returns the chain's
|
|
// resulting rows — opaque (nil) rows included, remainders in their slots — so a
|
|
// caller can place a follow-up insert, plus whether any row was deleted.
|
|
func (f *NFT) removeCovered(ctx context.Context, chain string, rules []*Rule, handles []string, r *Rule) ([]*Rule, bool, error) {
|
|
matched := make([]bool, len(rules))
|
|
splits := make([][]*Rule, len(rules))
|
|
var delHandles []string
|
|
for i, e := range rules {
|
|
if e == nil || !e.EqualForRemoval(r, true) {
|
|
continue
|
|
}
|
|
matched[i] = true
|
|
delHandles = append(delHandles, handles[i])
|
|
// A concrete target that matched a multi-state row would drop coverage
|
|
// the caller never asked to remove: an unpinned inet row covers both
|
|
// families, and a `meta l4proto { tcp, udp }` row both transports.
|
|
splits[i] = splitMergedRow(e, r)
|
|
}
|
|
if len(delHandles) == 0 {
|
|
return rules, false, nil
|
|
}
|
|
|
|
for _, h := range delHandles {
|
|
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", h); err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
|
|
// Rebuild the chain's shape: unmatched rows keep their order, and each
|
|
// matched row's remainder rows take its slot. The virtual list's indexes are
|
|
// the physical insert positions when the inserts run in ascending order.
|
|
virtual := make([]*Rule, 0, len(rules))
|
|
type pendingReAdd struct {
|
|
rule *Rule
|
|
at int
|
|
}
|
|
var reAdds []pendingReAdd
|
|
for i, e := range rules {
|
|
if !matched[i] {
|
|
virtual = append(virtual, e)
|
|
continue
|
|
}
|
|
for _, s := range splits[i] {
|
|
reAdds = append(reAdds, pendingReAdd{s, len(virtual)})
|
|
virtual = append(virtual, s)
|
|
}
|
|
}
|
|
base := len(rules) - len(delHandles)
|
|
for n, ra := range reAdds {
|
|
_, expr, merr := f.MarshalRule(ra.rule)
|
|
if merr != nil {
|
|
return nil, false, merr
|
|
}
|
|
args := f.placeArgs(chain, expr, ra.at, base+n)
|
|
if _, err := runCommand(ctx, "nft", args...); err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
|
|
// A deleted per-source connection-limit row leaves its counting set behind;
|
|
// drop each removed row's meter set now that its rule is gone. The delete is
|
|
// best-effort: one that fails (a surviving row — foreign, or a remainder
|
|
// re-added above — still references the set) leaves the set in place, which
|
|
// is the correct outcome.
|
|
for i, e := range rules {
|
|
if matched[i] && e != nil && e.meterSet != "" {
|
|
_, _ = runCommand(ctx, "nft", "delete", "set", "inet", f.table, e.meterSet)
|
|
}
|
|
}
|
|
return virtual, true, nil
|
|
}
|
|
|
|
// RemoveRule removes a rule from the zone.
|
|
func (f *NFT) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// A DirAny target removes both its input row and its role-swapped output row,
|
|
// each from its own chain.
|
|
if r.Direction == DirAny {
|
|
for _, sub := range expandDirections(r) {
|
|
if err := f.RemoveRule(ctx, zoneName, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
chain := f.chainForDirection(r.Direction)
|
|
|
|
rules, handles, err := f.listChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Delete every row the target covers, not just the first: a FamilyAny target
|
|
// clears both an unpinned row and any family-pinned rows it spans, and a TCPUDP
|
|
// target clears both transports. A concrete-family target still removes only its
|
|
// own family — see EqualForRemoval; removeCovered re-adds each merged row's
|
|
// untargeted remainder in the row's own slot.
|
|
_, _, err = f.removeCovered(ctx, chain, rules, handles, r)
|
|
return err
|
|
}
|
|
|
|
// parseNATTarget parses an nftables NAT target token ("addr", "addr:port",
|
|
// "[v6]:port" or ":port") into its address and port.
|
|
func (f *NFT) parseNATTarget(tok string) (addr string, port uint16, err error) {
|
|
tok = strings.TrimSpace(tok)
|
|
if tok == "" {
|
|
return "", 0, nil
|
|
}
|
|
// Bracketed IPv6, optionally with a port.
|
|
if strings.HasPrefix(tok, "[") {
|
|
end := strings.Index(tok, "]")
|
|
if end < 0 {
|
|
return "", 0, fmt.Errorf("invalid nat target %q", tok)
|
|
}
|
|
addr = tok[1:end]
|
|
rest := tok[end+1:]
|
|
if strings.HasPrefix(rest, ":") {
|
|
p, perr := strconv.ParseUint(rest[1:], 10, 16)
|
|
if perr != nil {
|
|
return "", 0, fmt.Errorf("invalid nat target port %q", rest[1:])
|
|
}
|
|
port = uint16(p)
|
|
}
|
|
return addr, port, nil
|
|
}
|
|
// Bare ":port" (redirect target).
|
|
if strings.HasPrefix(tok, ":") {
|
|
p, perr := strconv.ParseUint(tok[1:], 10, 16)
|
|
if perr != nil {
|
|
return "", 0, fmt.Errorf("invalid nat target port %q", tok[1:])
|
|
}
|
|
return "", uint16(p), nil
|
|
}
|
|
// A single colon is IPv4 addr:port; more (or none) is a bare address.
|
|
if strings.Count(tok, ":") == 1 {
|
|
host, ps, _ := strings.Cut(tok, ":")
|
|
p, perr := strconv.ParseUint(ps, 10, 16)
|
|
if perr != nil {
|
|
return "", 0, fmt.Errorf("invalid nat target port %q", ps)
|
|
}
|
|
return host, uint16(p), nil
|
|
}
|
|
return tok, 0, nil
|
|
}
|
|
|
|
// UnmarshalNATRule decodes a single NAT rule line from `nft -a list chain`
|
|
// output within the given chain, returning the parsed rule and its handle.
|
|
func (f *NFT) UnmarshalNATRule(line string, chain string) (r *NATRule, handle string, err error) {
|
|
r = &NATRule{}
|
|
tokens := f.splitQuoted(f.collapseSetSpaces(line))
|
|
for i := 0; i < len(tokens); i++ {
|
|
switch tokens[i] {
|
|
case "ip", "ip6":
|
|
fam := IPv4
|
|
if tokens[i] == "ip6" {
|
|
fam = IPv6
|
|
}
|
|
r.Family = fam
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
dir := tokens[i]
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
neg := ""
|
|
if tokens[i] == "!=" {
|
|
neg = "!"
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete address match")
|
|
}
|
|
}
|
|
switch dir {
|
|
case "saddr":
|
|
r.Source = neg + f.stripSetRef(tokens[i])
|
|
case "daddr":
|
|
r.Destination = neg + f.stripSetRef(tokens[i])
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported address direction: %s", dir)
|
|
}
|
|
case "iifname", "oifname":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete interface match")
|
|
}
|
|
r.Interface = trimQuotes(tokens[i])
|
|
case "tcp", "udp", "sctp":
|
|
r.Proto = GetProtocol(tokens[i])
|
|
if i+1 < len(tokens) && tokens[i+1] == "dport" {
|
|
i += 2
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("incomplete port match")
|
|
}
|
|
specs, perr := f.parsePorts(f.parseSetTokens(tokens[i]))
|
|
if perr != nil {
|
|
return nil, "", perr
|
|
}
|
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
|
r.Port = specs[0].Start
|
|
} else {
|
|
r.Ports = specs
|
|
}
|
|
}
|
|
case "meta":
|
|
if i+2 >= len(tokens) {
|
|
return nil, "", fmt.Errorf("unsupported meta match")
|
|
}
|
|
switch tokens[i+1] {
|
|
case "l4proto":
|
|
r.Proto = f.protoFromToken(tokens[i+2])
|
|
case "nfproto":
|
|
switch tokens[i+2] {
|
|
case "ipv4":
|
|
r.Family = IPv4
|
|
case "ipv6":
|
|
r.Family = IPv6
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported nfproto: %s", tokens[i+2])
|
|
}
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported meta match")
|
|
}
|
|
i += 2
|
|
case "dnat", "snat":
|
|
r.Kind = DNAT
|
|
if tokens[i] == "snat" {
|
|
r.Kind = SNAT
|
|
}
|
|
// nft lists the translation with the address family before `to`
|
|
// (`dnat ip to <addr>`); consume the optional ip/ip6 keyword.
|
|
j := i + 1
|
|
if j < len(tokens) && (tokens[j] == "ip" || tokens[j] == "ip6") {
|
|
if tokens[j] == "ip6" {
|
|
r.Family = IPv6
|
|
} else if r.Family == FamilyAny {
|
|
r.Family = IPv4
|
|
}
|
|
j++
|
|
}
|
|
if j+1 >= len(tokens) || tokens[j] != "to" {
|
|
return nil, "", fmt.Errorf("incomplete %s statement", tokens[i])
|
|
}
|
|
addr, port, terr := f.parseNATTarget(tokens[j+1])
|
|
if terr != nil {
|
|
return nil, "", terr
|
|
}
|
|
r.ToAddress = addr
|
|
r.ToPort = port
|
|
i = j + 1
|
|
case "redirect":
|
|
r.Kind = Redirect
|
|
if i+2 < len(tokens) && tokens[i+1] == "to" {
|
|
_, port, terr := f.parseNATTarget(tokens[i+2])
|
|
if terr != nil {
|
|
return nil, "", terr
|
|
}
|
|
r.ToPort = port
|
|
i += 2
|
|
}
|
|
case "masquerade":
|
|
r.Kind = Masquerade
|
|
case "#":
|
|
// The `# handle N` marker follows.
|
|
case "handle":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, "", fmt.Errorf("missing handle value")
|
|
}
|
|
handle = tokens[i]
|
|
case "counter", "packets", "bytes":
|
|
if tokens[i] == "packets" || tokens[i] == "bytes" {
|
|
i++
|
|
}
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported token: %s", tokens[i])
|
|
}
|
|
}
|
|
if r.Kind == NATInvalid {
|
|
return nil, "", fmt.Errorf("no nat action was provided")
|
|
}
|
|
if r.Family == FamilyAny {
|
|
r.Family = r.impliedFamily()
|
|
}
|
|
return r, handle, nil
|
|
}
|
|
|
|
// listForeignNATRules walks the entire nftables ruleset and returns best-effort
|
|
// NAT rules that live outside this backend's own inet table. Like listForeignRules
|
|
// it skips any line it cannot parse.
|
|
func (f *NFT) listForeignNATRules(ctx context.Context) ([]*NATRule, error) {
|
|
out, err := runCommand(ctx, "nft", "-a", "list", "ruleset")
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
|
|
ownTable := "inet " + f.table
|
|
curTable := ""
|
|
curChain := ""
|
|
var rules []*NATRule
|
|
for _, line := range out {
|
|
t := strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(t, "table "):
|
|
curTable = f.headerName(t, "table")
|
|
curChain = ""
|
|
case strings.HasPrefix(t, "chain "):
|
|
curChain = f.headerName(t, "chain")
|
|
case t == "}":
|
|
curChain = ""
|
|
case strings.Contains(t, "handle "):
|
|
if curTable == ownTable {
|
|
continue
|
|
}
|
|
rule, _, perr := f.UnmarshalNATRule(t, curChain)
|
|
if perr != nil || rule == nil {
|
|
continue
|
|
}
|
|
// A NAT rule from another table: record its source; not ours, so
|
|
// HasPrefix stays false.
|
|
rule.table = curTable
|
|
rules = append(rules, rule)
|
|
}
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
// listNATChain returns every parsed NAT rule (with its handle) in a chain.
|
|
func (f *NFT) listNATChain(ctx context.Context, chain string) (rules []*NATRule, handles []string, err error) {
|
|
out, err := runCommand(ctx, "nft", "-a", "list", "chain", "inet", f.table, chain)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist") {
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
for _, line := range out {
|
|
line = strings.TrimSpace(line)
|
|
// Only rule lines carry a handle; skip table/chain scaffolding. An
|
|
// unmodeled row is kept as an opaque slot — a nil rule with its handle —
|
|
// exactly as listChain does for filter rows.
|
|
if line == "" || !strings.Contains(line, "handle ") ||
|
|
strings.HasPrefix(line, "table ") || strings.HasPrefix(line, "chain ") {
|
|
continue
|
|
}
|
|
rule, handle, perr := f.UnmarshalNATRule(line, chain)
|
|
if perr != nil {
|
|
if h := f.lineHandle(line); h != "" {
|
|
rules = append(rules, nil)
|
|
handles = append(handles, h)
|
|
}
|
|
continue
|
|
}
|
|
// NAT rules live in this backend's own table; membership is what sets
|
|
// HasPrefix, so record the table and flag it as carrying the prefix.
|
|
rule.table = f.table
|
|
rule.HasPrefix = true
|
|
rules = append(rules, rule)
|
|
handles = append(handles, handle)
|
|
}
|
|
return rules, handles, nil
|
|
}
|
|
|
|
// listOwnNATRules returns the library's own NAT rules from its private table, one
|
|
// rule per physical chain row.
|
|
func (f *NFT) listOwnNATRules(ctx context.Context) ([]*NATRule, error) {
|
|
var rules []*NATRule
|
|
for _, chain := range []string{"prerouting", "postrouting"} {
|
|
chainRules, _, cerr := f.listNATChain(ctx, chain)
|
|
if cerr != nil {
|
|
return nil, cerr
|
|
}
|
|
// Opaque (nil) rows stay in the chain but are not reportable rules.
|
|
for _, r := range chainRules {
|
|
if r != nil {
|
|
rules = append(rules, r)
|
|
}
|
|
}
|
|
}
|
|
// The nat chains live in the same inet table, so a family-agnostic translation is
|
|
// one unpinned row that reads back as FamilyAny; nothing is collapsed here. Number
|
|
// per nat chain (prerouting then postrouting) so each rule's Number matches the
|
|
// InsertNATRule position within its chain.
|
|
numberNATByChain(rules)
|
|
return rules, nil
|
|
}
|
|
|
|
// GetNATRules returns the existing NAT rules from the zone.
|
|
func (f *NFT) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) {
|
|
rules, err = f.listOwnNATRules(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
foreign, ferr := f.listForeignNATRules(ctx)
|
|
if ferr != nil {
|
|
return nil, ferr
|
|
}
|
|
rules = append(rules, foreign...)
|
|
return rules, nil
|
|
}
|
|
|
|
// natFamilyKeyword returns the `ip`/`ip6` qualifier nft requires between a
|
|
// dnat/snat verb and its `to` in an inet table (e.g. `dnat ip to <addr>`). nft
|
|
// rejects the unqualified form unless the rule already carries a same-family
|
|
// address match, so the write path always emits it and the read path consumes
|
|
// it (see UnmarshalNATRule). Redirect and masquerade take no address and need
|
|
// no qualifier.
|
|
func (f *NFT) natFamilyKeyword(fam Family, addr string) string {
|
|
if fam == IPv6 || familyOfAddr(addr) == IPv6 {
|
|
return "ip6"
|
|
}
|
|
return "ip"
|
|
}
|
|
|
|
// natTarget renders an nftables NAT translation target "<addr>[:<port>]",
|
|
// bracketing an IPv6 address when a port is present. An empty address yields
|
|
// ":<port>" (used by redirect).
|
|
func (f *NFT) natTarget(fam Family, addr string, port uint16) string {
|
|
if addr == "" {
|
|
if port != 0 {
|
|
return fmt.Sprintf(":%d", port)
|
|
}
|
|
return ""
|
|
}
|
|
if port == 0 {
|
|
return addr
|
|
}
|
|
if fam == IPv6 || familyOfAddr(addr) == IPv6 {
|
|
return fmt.Sprintf("[%s]:%d", addr, port)
|
|
}
|
|
return fmt.Sprintf("%s:%d", addr, port)
|
|
}
|
|
|
|
// MarshalNATRule encodes a NAT rule as the nftables expression that follows
|
|
// `nft add rule inet <table> <chain>`, returning the chain (prerouting for
|
|
// destination NAT, postrouting for source NAT) and the expression.
|
|
func (f *NFT) MarshalNATRule(r *NATRule) (chain string, expr string, err error) {
|
|
if err := r.validate(); err != nil {
|
|
return "", "", err
|
|
}
|
|
// A named set is family-typed; see MarshalRule's matching check. The NAT
|
|
// entry points pin the rule first (resolveNATSetRefFamily).
|
|
if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) {
|
|
return "", "", fmt.Errorf("a set-referencing rule requires a concrete family; the caller must resolve the set's family first")
|
|
}
|
|
|
|
fam := r.impliedFamily()
|
|
var parts []string
|
|
|
|
// Interface, bound to the NAT direction: outbound for source NAT, inbound
|
|
// for destination NAT.
|
|
if r.Interface != "" {
|
|
if r.Kind.isSource() {
|
|
parts = append(parts, "oifname "+strconv.Quote(r.Interface))
|
|
} else {
|
|
parts = append(parts, "iifname "+strconv.Quote(r.Interface))
|
|
}
|
|
}
|
|
|
|
// Pin the family when no address carries it.
|
|
if fam != FamilyAny && r.Source == "" && r.Destination == "" {
|
|
nfproto := "ipv4"
|
|
if fam == IPv6 {
|
|
nfproto = "ipv6"
|
|
}
|
|
parts = append(parts, "meta nfproto "+nfproto)
|
|
}
|
|
|
|
if r.Source != "" {
|
|
parts = append(parts, fmt.Sprintf("%s saddr %s", f.l3Match(fam, r.Source), f.addrExpr(r.Source)))
|
|
}
|
|
if r.Destination != "" {
|
|
parts = append(parts, fmt.Sprintf("%s daddr %s", f.l3Match(fam, r.Destination), f.addrExpr(r.Destination)))
|
|
}
|
|
|
|
if r.HasPorts() {
|
|
parts = append(parts, fmt.Sprintf("%s dport %s", r.Proto.String(), f.portExpr(r.PortSpecs())))
|
|
} else if r.Proto != ProtocolAny {
|
|
parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto))
|
|
}
|
|
|
|
switch r.Kind {
|
|
case DNAT:
|
|
chain = "prerouting"
|
|
parts = append(parts, "dnat "+f.natFamilyKeyword(fam, r.ToAddress)+" to "+f.natTarget(fam, r.ToAddress, r.ToPort))
|
|
case Redirect:
|
|
chain = "prerouting"
|
|
parts = append(parts, "redirect to "+f.natTarget(fam, "", r.ToPort))
|
|
case SNAT:
|
|
chain = "postrouting"
|
|
parts = append(parts, "snat "+f.natFamilyKeyword(fam, r.ToAddress)+" to "+f.natTarget(fam, r.ToAddress, 0))
|
|
case Masquerade:
|
|
chain = "postrouting"
|
|
parts = append(parts, "masquerade")
|
|
default:
|
|
return "", "", fmt.Errorf("invalid nat kind")
|
|
}
|
|
|
|
return chain, strings.Join(parts, " "), nil
|
|
}
|
|
|
|
// ensureNATChains creates the private table's nat base chains (prerouting for
|
|
// destination NAT, postrouting for source NAT) if they do not already exist. It
|
|
// is called lazily the first time a NAT rule is written so filter-only use never
|
|
// installs nat hooks. `add` is idempotent, so re-running is safe.
|
|
func (f *NFT) ensureNATChains(ctx context.Context) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.natEnsured {
|
|
return nil
|
|
}
|
|
cmds := [][]string{
|
|
{"add", "chain", "inet", f.table, "prerouting", "{", "type", "nat", "hook", "prerouting", "priority", "dstnat", ";", "policy", "accept", ";", "}"},
|
|
{"add", "chain", "inet", f.table, "postrouting", "{", "type", "nat", "hook", "postrouting", "priority", "srcnat", ";", "policy", "accept", ";", "}"},
|
|
}
|
|
for _, c := range cmds {
|
|
if _, err := runCommand(ctx, "nft", c...); err != nil {
|
|
return fmt.Errorf("failed to set up nftables nat chains for %s: %s", f.table, err)
|
|
}
|
|
}
|
|
f.natEnsured = true
|
|
return nil
|
|
}
|
|
|
|
// natRuleExists is ruleExists for NAT rules. Opaque (nil) rows never match.
|
|
func (f *NFT) natRuleExists(existing []*NATRule, r *NATRule) bool {
|
|
for _, e := range existing {
|
|
if e != nil && e.EqualForDedup(r) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AddNATRule adds a NAT rule to the zone.
|
|
func (f *NFT) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
|
if err := f.ensureNATChains(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// A family-agnostic set reference is pinned to the set's own family.
|
|
r, err := f.resolveNATSetRefFamily(ctx, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
chain, expr, err := f.MarshalNATRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
existing, _, err := f.listNATChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.natRuleExists(existing, r) {
|
|
return nil
|
|
}
|
|
|
|
args := append([]string{"add", "rule", "inet", f.table, chain}, f.splitQuoted(expr)...)
|
|
_, err = runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
// InsertNATRule inserts a NAT rule before the given 1-based position within its
|
|
// nat chain. position <= 0 is treated as 1; a position larger than the chain's
|
|
// current rule count appends the rule.
|
|
func (f *NFT) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
|
|
if err := f.ensureNATChains(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// A family-agnostic set reference is pinned to the set's own family.
|
|
r, err := f.resolveNATSetRefFamily(ctx, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
chain, expr, err := f.MarshalNATRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
existing, _, err := f.listNATChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.natRuleExists(existing, r) {
|
|
return nil
|
|
}
|
|
|
|
if position <= 0 {
|
|
position = 1
|
|
}
|
|
// A position counts the modeled rows, matching GetNATRules' numbering;
|
|
// physicalIndex maps it past any opaque rows to the chain's real index.
|
|
insPos := physicalIndex(existing, position)
|
|
args := f.placeArgs(chain, expr, insPos, len(existing))
|
|
_, err = runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
// RemoveNATRule removes a NAT rule from the zone.
|
|
func (f *NFT) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
|
if err := f.ensureNATChains(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
chain := "prerouting"
|
|
if r.Kind.isSource() {
|
|
chain = "postrouting"
|
|
}
|
|
|
|
rules, handles, err := f.listNATChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Delete every matching row (see RemoveRule): a FamilyAny NAT target must clear
|
|
// both the unpinned row it names and any family-pinned rows it covers, while a
|
|
// concrete-family target removes only its own family. A concrete-family target
|
|
// that matched a genuine dual-family row re-adds the untargeted family in the
|
|
// row's slot, so a bare masquerade/redirect does not silently stop translating
|
|
// the other family.
|
|
deleted := 0
|
|
for i, e := range rules {
|
|
if e == nil || !e.EqualForRemoval(r) {
|
|
continue
|
|
}
|
|
if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil {
|
|
return err
|
|
}
|
|
deleted++
|
|
if s := splitNATDualRow(e, r); s != nil {
|
|
_, expr, merr := f.MarshalNATRule(s)
|
|
if merr != nil {
|
|
return merr
|
|
}
|
|
args := f.placeArgs(chain, expr, i-deleted+1, len(rules)-deleted)
|
|
if _, err := runCommand(ctx, "nft", args...); err != nil {
|
|
return err
|
|
}
|
|
deleted--
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// chainPolicy reads the policy of one of this backend's base chains. It returns
|
|
// ActionInvalid when the table or chain does not yet exist (no policy to
|
|
// report) or the policy is not recognized.
|
|
func (f *NFT) chainPolicy(ctx context.Context, chain string) (Action, error) {
|
|
out, err := runCommand(ctx, "nft", "list", "chain", "inet", f.table, chain)
|
|
if err != nil {
|
|
// A missing table/chain means no policy to report.
|
|
return ActionInvalid, nil
|
|
}
|
|
for _, line := range out {
|
|
line = strings.TrimSpace(line)
|
|
switch {
|
|
case strings.Contains(line, "policy accept"):
|
|
return Accept, nil
|
|
case strings.Contains(line, "policy drop"):
|
|
return Drop, nil
|
|
}
|
|
}
|
|
return ActionInvalid, nil
|
|
}
|
|
|
|
// GetDefaultPolicy returns the default action applied to packets that match no rule.
|
|
func (f *NFT) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
|
|
in, err := f.chainPolicy(ctx, "input")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := f.chainPolicy(ctx, "output")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fwd, err := f.chainPolicy(ctx, "forward")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &DefaultPolicy{Input: in, Output: out, Forward: fwd}, nil
|
|
}
|
|
|
|
// setChainPolicy updates the policy of a base chain. nftables chain policies may
|
|
// only be accept or drop; reject is not expressible.
|
|
func (f *NFT) setChainPolicy(ctx context.Context, chain string, action Action) error {
|
|
switch action {
|
|
case Accept, Drop:
|
|
case Reject:
|
|
return fmt.Errorf("nftables chain policy may only be accept or drop")
|
|
default:
|
|
return fmt.Errorf("invalid default policy action")
|
|
}
|
|
_, err := runCommand(ctx, "nft", "chain", "inet", f.table, chain, "{", "policy", action.String(), ";", "}")
|
|
return err
|
|
}
|
|
|
|
// SetDefaultPolicy sets the policy separately via setChainPolicy.
|
|
func (f *NFT) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
|
|
if policy == nil {
|
|
return fmt.Errorf("policy cannot be nil")
|
|
}
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
if policy.Input != ActionInvalid {
|
|
if err := f.setChainPolicy(ctx, "input", policy.Input); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if policy.Output != ActionInvalid {
|
|
if err := f.setChainPolicy(ctx, "output", policy.Output); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if policy.Forward != ActionInvalid {
|
|
if err := f.setChainPolicy(ctx, "forward", policy.Forward); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// nftSetJSON is the subset of a `set` object in `nft -j list set(s)` output we
|
|
// decode.
|
|
type nftSetJSON struct {
|
|
Name string `json:"name"`
|
|
Table string `json:"table"`
|
|
Type string `json:"type"`
|
|
Flags []string `json:"flags"`
|
|
Elem []json.RawMessage `json:"elem"`
|
|
}
|
|
|
|
// nftListJSON is the top-level envelope every `nft -j list ...` command emits: a
|
|
// "nftables" array whose items are single-key objects (metainfo, set, rule, ...).
|
|
// Only the "set" objects are pulled out.
|
|
type nftListJSON struct {
|
|
Nftables []struct {
|
|
Set *nftSetJSON `json:"set"`
|
|
} `json:"nftables"`
|
|
}
|
|
|
|
// decodeSets unwraps the `nft -j list set(s)` envelope into its set objects. A
|
|
// malformed envelope is an error: reading it as "no sets" would let a Backup
|
|
// silently capture zero sets.
|
|
func (f *NFT) decodeSets(out []string) ([]*nftSetJSON, error) {
|
|
var env nftListJSON
|
|
if err := json.Unmarshal([]byte(strings.Join(out, "\n")), &env); err != nil {
|
|
return nil, fmt.Errorf("failed to decode nft set listing: %s", err)
|
|
}
|
|
var sets []*nftSetJSON
|
|
for _, item := range env.Nftables {
|
|
if item.Set != nil {
|
|
sets = append(sets, item.Set)
|
|
}
|
|
}
|
|
return sets, nil
|
|
}
|
|
|
|
// nftSetIsDynamic reports whether a listed set carries the dynamic flag — the
|
|
// mark of a per-source connection-limit meter rather than a caller-managed
|
|
// address set.
|
|
func nftSetIsDynamic(s *nftSetJSON) bool {
|
|
for _, fl := range s.Flags {
|
|
if fl == "dynamic" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// decodeElem decodes one JSON element of an nft set into its string form. A
|
|
// scalar becomes the address/CIDR string; a {"prefix":{"addr":..,"len":..}}
|
|
// object becomes a CIDR; a {"range":[lo,hi]} object (which an interval set uses
|
|
// for a non-CIDR span) becomes "lo-hi"; anything unrecognised is skipped.
|
|
func (f *NFT) decodeElem(raw json.RawMessage) string {
|
|
var s string
|
|
if json.Unmarshal(raw, &s) == nil {
|
|
return s
|
|
}
|
|
// nft renders a CIDR element as a prefix object with addr/len fields (an
|
|
// interval/hash:net set), not a two-element array — decode it as such so the
|
|
// entry is not silently dropped on read.
|
|
var prefix struct {
|
|
Prefix struct {
|
|
Addr string `json:"addr"`
|
|
Len json.Number `json:"len"`
|
|
} `json:"prefix"`
|
|
}
|
|
if json.Unmarshal(raw, &prefix) == nil && prefix.Prefix.Addr != "" && prefix.Prefix.Len != "" {
|
|
return prefix.Prefix.Addr + "/" + string(prefix.Prefix.Len)
|
|
}
|
|
// An interval set stores a non-CIDR span as a range object; report it as
|
|
// "lo-hi" rather than silently dropping the entry.
|
|
var rng struct {
|
|
Range []json.RawMessage `json:"range"`
|
|
}
|
|
if json.Unmarshal(raw, &rng) == nil && len(rng.Range) == 2 {
|
|
var lo, hi string
|
|
if json.Unmarshal(rng.Range[0], &lo) == nil && json.Unmarshal(rng.Range[1], &hi) == nil {
|
|
return lo + "-" + hi
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getAddressSet reads a single nftables set as an AddressSet, or nil if it does
|
|
// not exist.
|
|
func (f *NFT) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
|
out, err := runCommand(ctx, "nft", "-j", "list", "set", "inet", f.table, name)
|
|
if err != nil {
|
|
// A missing set is a no-op; any other failure (permission denial, nft
|
|
// binary trouble, ...) must surface rather than read as "not found".
|
|
if strings.Contains(err.Error(), "No such file or directory") {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
sets, err := f.decodeSets(out)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(sets) == 0 {
|
|
return nil, nil
|
|
}
|
|
detail := sets[0]
|
|
// A dynamic set is connection-limit counting state, not an address set;
|
|
// report it as not-found so no caller manages it as data.
|
|
if nftSetIsDynamic(detail) {
|
|
return nil, nil
|
|
}
|
|
set := &AddressSet{Name: name}
|
|
switch detail.Type {
|
|
case "ipv6_addr":
|
|
set.Family = IPv6
|
|
case "ipv4_addr":
|
|
set.Family = IPv4
|
|
}
|
|
for _, fl := range detail.Flags {
|
|
if fl == "interval" {
|
|
set.Type = SetHashNet
|
|
}
|
|
}
|
|
for _, raw := range detail.Elem {
|
|
if e := f.decodeElem(raw); e != "" {
|
|
set.Entries = append(set.Entries, e)
|
|
}
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
// GetAddressSets returns the address sets managed by this backend.
|
|
func (f *NFT) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
// `nft list sets` accepts a family (inet) but not a table name, so list every
|
|
// inet set and keep the ones in our table. It exits 0 with an empty listing
|
|
// when there are no sets, so any error here is a genuine failure.
|
|
out, err := runCommand(ctx, "nft", "-j", "list", "sets", "inet")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sets, err := f.decodeSets(out)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]*AddressSet, 0, len(sets))
|
|
for _, s := range sets {
|
|
if s.Table != f.table {
|
|
continue
|
|
}
|
|
// A dynamic set is a per-source connection-limit meter, rule mechanics
|
|
// rather than a caller-managed address set; reporting one would let a
|
|
// Backup/Restore or a caller's sweep manage counting state as data.
|
|
if nftSetIsDynamic(s) {
|
|
continue
|
|
}
|
|
detail, err := f.getAddressSet(ctx, s.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if detail == nil {
|
|
continue
|
|
}
|
|
result = append(result, detail)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetAddressSet returns a single address set by name, or an error if it does not exist.
|
|
func (f *NFT) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
set, err := f.getAddressSet(ctx, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if set == nil {
|
|
return nil, fmt.Errorf("address set %q not found", name)
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
// setEntries renders the set's entries as an nftables element expression, e.g.
|
|
// `{ 1.2.3.4, 10.0.0.0/8 }`.
|
|
func (f *NFT) setEntries(entries []string) string {
|
|
return "{ " + strings.Join(entries, ", ") + " }"
|
|
}
|
|
|
|
// setMatches reports whether an existing set's definition matches a requested
|
|
// family/type. AddAddressSet uses this to tell a harmless re-add of an identical,
|
|
// already-existing set (safe to treat as success — `nft add set` is idempotent
|
|
// for a matching redefinition and does not itself error) apart from a genuine
|
|
// type/family conflict (which `nft add set` reports as "File exists" and which
|
|
// must surface as an error rather than being silently swallowed). A nil existing
|
|
// set (the read races with a concurrent delete, or fails to parse) never matches,
|
|
// so the caller treats it as a conflict rather than guessing.
|
|
func (f *NFT) setMatches(existing *AddressSet, wantFamily Family, wantType SetType) bool {
|
|
if existing == nil {
|
|
return false
|
|
}
|
|
if wantFamily == FamilyAny {
|
|
wantFamily = IPv4
|
|
}
|
|
return existing.Family == wantFamily && existing.Type == wantType
|
|
}
|
|
|
|
func (f *NFT) setSpec(family Family, t SetType) (string, error) {
|
|
if family == FamilyAny {
|
|
family = IPv4
|
|
}
|
|
var addrType string
|
|
switch family {
|
|
case IPv4:
|
|
addrType = "ipv4_addr"
|
|
case IPv6:
|
|
addrType = "ipv6_addr"
|
|
default:
|
|
return "", fmt.Errorf("a set requires a concrete ip family: %w", ErrUnsupportedSet)
|
|
}
|
|
spec := "{ type " + addrType + " ;"
|
|
if t == SetHashNet {
|
|
spec += " flags interval ;"
|
|
}
|
|
return spec + " }", nil
|
|
}
|
|
|
|
// AddAddressSet creates an address set. Adding a set that already exists (by name) is a no-op.
|
|
func (f *NFT) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
|
if set == nil || set.Name == "" {
|
|
return fmt.Errorf("an address set requires a name")
|
|
}
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
spec, err := f.setSpec(set.Family, set.Type)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
args := append([]string{"add", "set", "inet", f.table, set.Name}, strings.Fields(spec)...)
|
|
if _, err := runCommand(ctx, "nft", args...); err != nil {
|
|
if !strings.Contains(err.Error(), "File exists") {
|
|
return err
|
|
}
|
|
// `nft add set` reports "File exists" only on a genuine type/family
|
|
// conflict — redefining an identical set succeeds on its own. Read the
|
|
// existing set back and only treat this as the harmless case; otherwise
|
|
// the conflict is real and must be reported, not swallowed as success.
|
|
existing, gerr := f.getAddressSet(ctx, set.Name)
|
|
if gerr != nil {
|
|
return gerr
|
|
}
|
|
if !f.setMatches(existing, set.Family, set.Type) {
|
|
return fmt.Errorf("address set %q already exists with a different definition: %w", set.Name, err)
|
|
}
|
|
return nil
|
|
}
|
|
if len(set.Entries) > 0 {
|
|
elem := f.setEntries(set.Entries)
|
|
args := append([]string{"add", "element", "inet", f.table, set.Name}, strings.Fields(elem)...)
|
|
if _, err := runCommand(ctx, "nft", args...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveAddressSet removes an address set by name.
|
|
func (f *NFT) RemoveAddressSet(ctx context.Context, name string) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
_, err := runCommand(ctx, "nft", "delete", "set", "inet", f.table, name)
|
|
if err != nil && (strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist")) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// AddAddressSetEntry adds an entry to the named set.
|
|
func (f *NFT) AddAddressSetEntry(ctx context.Context, name, entry string) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
elem := f.setEntries([]string{entry})
|
|
args := append([]string{"add", "element", "inet", f.table, name}, strings.Fields(elem)...)
|
|
_, err := runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
// RemoveAddressSetEntry removes an entry from the named set.
|
|
func (f *NFT) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
elem := f.setEntries([]string{entry})
|
|
args := append([]string{"delete", "element", "inet", f.table, name}, strings.Fields(elem)...)
|
|
_, err := runCommand(ctx, "nft", args...)
|
|
return err
|
|
}
|
|
|
|
// Backup captures the filter and NAT rules in this backend's private table.
|
|
func (f *NFT) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
|
// Read the private table directly rather than GetRules: Restore flushes and
|
|
// refills only this table, so the backup must not pull in rules from foreign
|
|
// tables (they would be re-added into the wrong table on Restore).
|
|
rules, err := f.listOwnRules(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
natRules, err := f.listOwnNATRules(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
backup := &Backup{Rules: rules, NATRules: natRules}
|
|
if err := captureBackupState(ctx, f, zoneName, backup); err != nil {
|
|
return nil, err
|
|
}
|
|
return backup, nil
|
|
}
|
|
|
|
// Restore replaces the managed rules with the contents of a Backup.
|
|
func (f *NFT) Restore(ctx context.Context, zoneName string, backup *Backup) error {
|
|
if backup == nil {
|
|
return fmt.Errorf("backup cannot be nil")
|
|
}
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := f.ensureNATChains(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Clear the modeled rows by handle rather than flushing the table: an
|
|
// unmodeled row (a foreign construct hand-added into the private table) is
|
|
// invisible to Backup, so a flush would destroy state the snapshot cannot
|
|
// reproduce.
|
|
if err := f.clearModeledRows(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Recreate the sets on a clean slate before the rules that reference them. The
|
|
// clear above removed every modeled rule, so no modeled rule holds a set
|
|
// reference and each set can be removed and rebuilt; the clean rebuild is
|
|
// required because nft's AddAddressSet is a no-op on an existing set and would
|
|
// not otherwise restore a flushed set's elements. An unmodeled row that still
|
|
// references a set surfaces here as a delete-set error rather than being
|
|
// silently destroyed.
|
|
if err := restoreBackupSets(ctx, f, backup, true); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, r := range backup.Rules {
|
|
if err := f.AddRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, r := range backup.NATRules {
|
|
if err := f.AddNATRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return applyBackupPolicy(ctx, f, zoneName, backup)
|
|
}
|
|
|
|
// Reload is a no-op; nftables applies changes immediately, so there is nothing to reload.
|
|
func (f *NFT) Reload(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// Close closes the connection to the manager.
|
|
func (f *NFT) Close(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// AddRulesBatch adds every rule in a single `nft -f` transaction rather than one
|
|
// `nft add rule` invocation per rule. Rules that already exist are skipped. The
|
|
// whole batch applies atomically. It implements RuleBatcher.
|
|
func (f *NFT) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Snapshot each chain so duplicates (existing or within the batch) are
|
|
// skipped, mirroring AddRule.
|
|
existing := map[string][]*Rule{}
|
|
for _, chain := range nftFilterChains {
|
|
rs, _, err := f.listChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing[chain] = rs
|
|
}
|
|
|
|
var script strings.Builder
|
|
n := 0
|
|
for _, top := range rules {
|
|
// A DirAny rule fans out into an input row plus its swapped output row.
|
|
for _, r := range expandDirections(top) {
|
|
chain, expr, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.ruleExists(existing[chain], r) {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr)
|
|
existing[chain] = append(existing[chain], r)
|
|
n++
|
|
}
|
|
}
|
|
if n == 0 {
|
|
return nil
|
|
}
|
|
_, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-")
|
|
return err
|
|
}
|
|
|
|
// clearModeledRows deletes every modeled rule row from the private table's
|
|
// filter and nat chains by handle, in one `nft -f` transaction, leaving
|
|
// unmodeled (opaque) rows in place. Chain hooks and policies are untouched.
|
|
// The cleared rows' per-source meter sets are swept afterwards, best-effort,
|
|
// so a restore does not strand counting state; a rule the restore re-adds
|
|
// auto-creates its set again.
|
|
func (f *NFT) clearModeledRows(ctx context.Context) error {
|
|
var script strings.Builder
|
|
staleMeters := map[string]bool{}
|
|
for _, chain := range nftFilterChains {
|
|
rules, handles, err := f.listChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, e := range rules {
|
|
if e != nil {
|
|
fmt.Fprintf(&script, "delete rule inet %s %s handle %s\n", f.table, chain, handles[i])
|
|
if e.meterSet != "" {
|
|
staleMeters[e.meterSet] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, chain := range []string{"prerouting", "postrouting"} {
|
|
rules, handles, err := f.listNATChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, e := range rules {
|
|
if e != nil {
|
|
fmt.Fprintf(&script, "delete rule inet %s %s handle %s\n", f.table, chain, handles[i])
|
|
}
|
|
}
|
|
}
|
|
if script.Len() == 0 {
|
|
return nil
|
|
}
|
|
if _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-"); err != nil {
|
|
return err
|
|
}
|
|
for name := range staleMeters {
|
|
_, _ = runCommand(ctx, "nft", "delete", "set", "inet", f.table, name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReplaceRulesBatch atomically replaces the private table's filter rules with
|
|
// exactly rules, all in one `nft -f` transaction. Every filter chain is
|
|
// covered — forward included — by deleting the modeled rows by handle, so
|
|
// unmodeled (opaque) rows, chain hooks and policies are preserved. It
|
|
// implements RuleBatcher.
|
|
func (f *NFT) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error {
|
|
if err := f.ensureTable(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
var script strings.Builder
|
|
// Meter sets referenced by the deleted rows are garbage once their rows go;
|
|
// collect them for the sweep below, keeping any a new rule still names.
|
|
staleMeters := map[string]bool{}
|
|
for _, chain := range nftFilterChains {
|
|
existing, handles, err := f.listChain(ctx, chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, e := range existing {
|
|
if e != nil {
|
|
fmt.Fprintf(&script, "delete rule inet %s %s handle %s\n", f.table, chain, handles[i])
|
|
if e.meterSet != "" {
|
|
staleMeters[e.meterSet] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, top := range rules {
|
|
// A DirAny rule fans out into an input row plus its swapped output row,
|
|
// and a FamilyAny per-source connection limit into one row per family.
|
|
for _, d := range expandDirections(top) {
|
|
subs := []*Rule{d}
|
|
if perSourceFamilySplit(d) {
|
|
subs = expandFamilies(d)
|
|
}
|
|
for _, r := range subs {
|
|
r, err := f.resolveSetRefFamily(ctx, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
chain, expr, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr)
|
|
if r.perSourceLimited() {
|
|
// The meter statement auto-creates (or reuses) this set; a
|
|
// reused set keeps its counting state across the replace.
|
|
delete(staleMeters, f.meterName(chain, r))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if script.Len() == 0 {
|
|
return nil
|
|
}
|
|
if _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-"); err != nil {
|
|
return err
|
|
}
|
|
// Sweep the now-unreferenced meter sets outside the transaction, best-effort:
|
|
// a delete that fails (an opaque row still references the set) leaves it in
|
|
// place rather than aborting the whole replace. An unchanged per-source rule
|
|
// was pruned from the sweep, so its set — and counting state — survives.
|
|
for name := range staleMeters {
|
|
_, _ = runCommand(ctx, "nft", "delete", "set", "inet", f.table, name)
|
|
}
|
|
return nil
|
|
}
|