This commit is contained in:
Quentin McGaw
2026-02-28 22:38:52 +00:00
parent 781e74f77a
commit cd9ba54b37
12 changed files with 570 additions and 6 deletions
+99
View File
@@ -0,0 +1,99 @@
package nftables
import (
"context"
"fmt"
"github.com/google/nftables"
)
// SaveAndRestore saves the current nftables tree and returns a restore function that
// can be called to restore the saved tree.
func (f *Firewall) SaveAndRestore(_ context.Context) (restore func(context.Context), err error) {
f.mutex.Lock()
defer f.mutex.Unlock()
conn, err := nftables.New()
if err != nil {
return nil, fmt.Errorf("creating nftables connection: %w", err)
}
tables, err := saveTables(conn)
if err != nil {
return nil, fmt.Errorf("saving nftables state: %w", err)
}
return func(_ context.Context) {
conn, err := nftables.New()
if err != nil {
f.logger.Warnf("creating nftables connection for restore: %s", err)
return
}
err = restoreTables(conn, tables)
if err != nil {
f.logger.Warnf("restoring nftables state: %s", err)
}
}, nil
}
type savedTable struct {
table *nftables.Table
chains []savedChain
}
type savedChain struct {
chain *nftables.Chain
rules []*nftables.Rule
}
func saveTables(conn *nftables.Conn) ([]savedTable, error) {
tables, err := conn.ListTables()
if err != nil {
return nil, err
}
savedTables := make([]savedTable, len(tables))
for i, table := range tables {
savedTables[i].table = table
chains, err := conn.ListChains()
if err != nil {
return nil, err
}
for _, chain := range chains {
if chain.Table.Name != table.Name ||
chain.Table.Family != table.Family {
continue
}
rules, err := conn.GetRules(table, chain)
if err != nil {
return nil, fmt.Errorf("getting rules for chain %s in table %s: %w", chain.Name, table.Name, err)
}
savedChain := savedChain{chain: chain, rules: rules}
savedTables[i].chains = append(savedTables[i].chains, savedChain)
}
}
return savedTables, nil
}
func restoreTables(conn *nftables.Conn, savedTables []savedTable) error {
conn.FlushRuleset()
for _, savedTable := range savedTables {
table := conn.AddTable(savedTable.table)
for _, savedChain := range savedTable.chains {
// Make the [nftables.Chain.Table] points to the new [nftables.Table]
// created in this connection.
savedChain.chain.Table = table
savedChain.chain = conn.AddChain(savedChain.chain)
for _, rule := range savedChain.rules {
rule.Table = table
rule.Chain = savedChain.chain
conn.AddRule(rule)
}
}
}
return conn.Flush()
}
+50
View File
@@ -0,0 +1,50 @@
package nftables
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/nftables"
)
var ErrPolicyUnknown = errors.New("unknown policy")
// SetBaseChainsPolicy sets the policy of all the base chains (INPUT, FORWARD, or OUTPUT)
// for the filter table to the given policy (accept or drop).
func (f *Firewall) SetBaseChainsPolicy(_ context.Context, policy string) error {
f.mutex.Lock()
defer f.mutex.Unlock()
var chainPolicy nftables.ChainPolicy
switch strings.ToLower(policy) {
case "accept":
chainPolicy = nftables.ChainPolicyAccept
case "drop":
chainPolicy = nftables.ChainPolicyDrop
default:
return fmt.Errorf("%w: %s", ErrPolicyUnknown, policy)
}
conn, err := nftables.New()
if err != nil {
return fmt.Errorf("creating nftables connection: %w", err)
}
_, inputChain, forwardChain, outputChain := setupFilterWithBaseChains(conn)
inputChain.Policy = &chainPolicy
forwardChain.Policy = &chainPolicy
outputChain.Policy = &chainPolicy
conn.AddChain(inputChain)
conn.AddChain(forwardChain)
conn.AddChain(outputChain)
err = conn.Flush()
if err != nil {
return fmt.Errorf("flushing nftables changes: %w", err)
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package nftables
import (
"context"
"fmt"
"github.com/google/nftables"
"github.com/google/nftables/expr"
)
func (f *Firewall) AcceptEstablishedRelatedTraffic(_ context.Context) error {
f.mutex.Lock()
defer f.mutex.Unlock()
conn, err := nftables.New()
if err != nil {
return fmt.Errorf("creating nftables connection: %w", err)
}
table, inputChain, _, outputChain := setupFilterWithBaseChains(conn)
ctStateExprs := []expr.Any{
&expr.Ct{
Key: expr.CtKeySTATE,
Register: 1,
},
&expr.Bitwise{
SourceRegister: 1,
DestRegister: 1,
Len: 4, //nolint:mnd
Mask: []byte{byte(expr.CtStateBitESTABLISHED | expr.CtStateBitRELATED), 0x00, 0x00, 0x00},
Xor: []byte{0x00, 0x00, 0x00, 0x00},
},
&expr.Cmp{
Op: expr.CmpOpNeq,
Register: 1,
Data: []byte{0x00, 0x00, 0x00, 0x00},
},
&expr.Verdict{
Kind: expr.VerdictAccept,
},
}
conn.AddRule(&nftables.Rule{
Table: table,
Chain: inputChain,
Exprs: ctStateExprs,
})
conn.AddRule(&nftables.Rule{
Table: table,
Chain: outputChain,
Exprs: ctStateExprs,
})
if err := conn.Flush(); err != nil {
return fmt.Errorf("flushing: %w", err)
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package nftables
import (
"errors"
"fmt"
"reflect"
"github.com/google/nftables"
)
var errRuleToDeleteNotFound = errors.New("rule not found for removal")
func (f *Firewall) deleteRule(conn *nftables.Conn, rule *nftables.Rule) error {
for i, existing := range f.rules {
if !reflect.DeepEqual(existing, rule) {
continue
}
err := conn.DelRule(existing)
if err != nil {
return fmt.Errorf("deleting rule: %w", err)
}
f.rules[i], f.rules[len(f.rules)-1] = f.rules[len(f.rules)-1], f.rules[i]
f.rules = f.rules[:len(f.rules)-1]
return nil
}
return fmt.Errorf("%w: %#v", errRuleToDeleteNotFound, rule)
}
+38
View File
@@ -0,0 +1,38 @@
package nftables
import "github.com/google/nftables"
func setupFilterWithBaseChains(conn *nftables.Conn) (table *nftables.Table,
inputChain, forwardChain, outputChain *nftables.Chain,
) {
table = conn.AddTable(&nftables.Table{
Family: nftables.TableFamilyINet,
Name: "filter",
})
inputChain = conn.AddChain(&nftables.Chain{
Name: "input",
Table: table,
Type: nftables.ChainTypeFilter,
Hooknum: nftables.ChainHookInput,
Priority: nftables.ChainPriorityFilter,
})
forwardChain = conn.AddChain(&nftables.Chain{
Name: "forward",
Table: table,
Type: nftables.ChainTypeFilter,
Hooknum: nftables.ChainHookForward,
Priority: nftables.ChainPriorityFilter,
})
outputChain = conn.AddChain(&nftables.Chain{
Name: "output",
Table: table,
Type: nftables.ChainTypeFilter,
Hooknum: nftables.ChainHookOutput,
Priority: nftables.ChainPriorityFilter,
})
return table, inputChain, forwardChain, outputChain
}
+22
View File
@@ -0,0 +1,22 @@
package nftables
import (
"sync"
"github.com/google/nftables"
)
type Firewall struct {
logger Logger
// rules are only rules added and tracked for later removal.
// Not all rules added are tracked for removal.
rules []*nftables.Rule
mutex sync.Mutex
}
func New(logger Logger) *Firewall {
return &Firewall{
logger: logger,
}
}
+170
View File
@@ -0,0 +1,170 @@
package nftables
import (
"context"
"fmt"
"net/netip"
"github.com/google/nftables"
"github.com/google/nftables/expr"
)
func (f *Firewall) AcceptInputThroughInterface(_ context.Context, intf string) error {
f.mutex.Lock()
defer f.mutex.Unlock()
conn, err := nftables.New()
if err != nil {
return fmt.Errorf("creating nftables connection: %w", err)
}
table, inputChain, _, _ := setupFilterWithBaseChains(conn)
rule := &nftables.Rule{
Table: table,
Chain: inputChain,
Exprs: []expr.Any{
&expr.Meta{
Key: expr.MetaKeyIIFNAME,
Register: 1,
},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: []byte(intf + "\x00"),
},
&expr.Verdict{
Kind: expr.VerdictAccept,
},
},
}
conn.AddRule(rule)
err = conn.Flush()
if err != nil {
return fmt.Errorf("flushing: %w", err)
}
return nil
}
// AcceptInputToPort accepts incoming traffic on the specified port, for both TCP and UDP
// protocols, on the interface intf. If intf is empty or "*", the interface is not used as a filter.
// If remove is true, the rule is removed instead of added. This is used for port forwarding, with
// intf set to the VPN tunnel interface.
func (f *Firewall) AcceptInputToPort(_ context.Context, intf string, port uint16, remove bool) error {
f.mutex.Lock()
defer f.mutex.Unlock()
conn, err := nftables.New()
if err != nil {
return fmt.Errorf("creating nftables connection: %w", err)
}
table, inputChain, _, _ := setupFilterWithBaseChains(conn)
portBytes := []byte{byte(port >> 8), byte(port)} //nolint:mnd
const tcp, udp uint8 = 6, 17
protocols := []uint8{tcp, udp}
for _, protocol := range protocols {
const maxExprsLen = 7
exprs := make([]expr.Any, 0, maxExprsLen)
if intf != "" && intf != "*" {
exprs = append(exprs,
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte(intf + "\x00")},
)
}
exprs = append(exprs,
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 9, Len: 1}, //nolint:mnd
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{protocol}},
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, //nolint:mnd
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: portBytes},
&expr.Verdict{Kind: expr.VerdictAccept},
)
rule := &nftables.Rule{
Table: table,
Chain: inputChain,
Exprs: exprs,
}
if !remove {
conn.AddRule(rule)
f.rules = append(f.rules, rule)
continue
}
err = f.deleteRule(conn, rule)
if err != nil {
return fmt.Errorf("deleting rule: %w", err)
}
}
err = conn.Flush()
if err != nil {
f.rules = f.rules[:len(f.rules)-len(protocols)]
return fmt.Errorf("flushing: %w", err)
}
return nil
}
func (f *Firewall) AcceptInputToSubnet(_ context.Context, intf string, subnet netip.Prefix) error {
f.mutex.Lock()
defer f.mutex.Unlock()
conn, err := nftables.New()
if err != nil {
return fmt.Errorf("creating nftables connection: %w", err)
}
table, inputChain, _, _ := setupFilterWithBaseChains(conn)
const maxExprsLen = 5
exprs := make([]expr.Any, 0, maxExprsLen)
if intf != "" && intf != "*" {
exprs = append(exprs,
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte(intf + "\x00")},
)
}
var payloadOffset uint32
if subnet.Addr().Is4() {
payloadOffset = 16
} else {
payloadOffset = 24
}
exprs = append(exprs,
&expr.Payload{
DestRegister: 1,
Base: expr.PayloadBaseNetworkHeader,
Offset: payloadOffset,
Len: uint32(len(subnet.Addr().AsSlice())), //nolint:gosec
},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: subnet.Addr().AsSlice(),
},
&expr.Verdict{Kind: expr.VerdictAccept},
)
rule := &nftables.Rule{
Table: table,
Chain: inputChain,
Exprs: exprs,
}
conn.AddRule(rule)
err = conn.Flush()
if err != nil {
return fmt.Errorf("flushing: %w", err)
}
return nil
}
+5
View File
@@ -0,0 +1,5 @@
package nftables
type Logger interface {
Warnf(format string, args ...any)
}
+78
View File
@@ -0,0 +1,78 @@
package nftables
import (
"context"
"fmt"
"github.com/google/nftables"
"github.com/google/nftables/expr"
)
func (f *Firewall) AcceptIpv6MulticastOutput(_ context.Context, intf string) error {
f.mutex.Lock()
defer f.mutex.Unlock()
conn, err := nftables.New()
if err != nil {
return fmt.Errorf("creating nftables connection: %w", err)
}
table, _, _, outputChain := setupFilterWithBaseChains(conn)
const maxExprsLen = 6
exprs := make([]expr.Any, 0, maxExprsLen)
if intf != "" && intf != "*" {
exprs = append(exprs,
&expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte(intf + "\x00")},
)
}
// ff02::1:ff00:0/104 mask is 13 bytes of 0xff
mask := []byte{
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00,
} //nolint:mnd
addr := []byte{
0xff, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0xff, 0x00, 0x00, 0x00,
} //nolint:mnd
exprs = append(exprs,
&expr.Payload{
DestRegister: 1,
Base: expr.PayloadBaseNetworkHeader,
Offset: 24, // IPv6 Destination Address offset //nolint:mnd
Len: 16, //nolint:mnd
},
&expr.Bitwise{
SourceRegister: 1,
DestRegister: 1,
Len: 16, //nolint:mnd
Mask: mask,
Xor: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, //nolint:mnd
},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: addr,
},
&expr.Verdict{Kind: expr.VerdictAccept},
)
rule := &nftables.Rule{
Table: table,
Chain: outputChain,
Exprs: exprs,
}
conn.AddRule(rule)
err = conn.Flush()
if err != nil {
return fmt.Errorf("flushing: %w", err)
}
return nil
}
+12
View File
@@ -0,0 +1,12 @@
package nftables
import "github.com/google/nftables"
func IsSupported() bool {
conn, err := nftables.New()
if err != nil {
return false
}
_, err = conn.ListTable("filter")
return err == nil
}