Files
tomswall/internal/nftables/compiler.go
T
unkinben 2a3eb3b04d Initial scaffold for tomswall
Spiritual successor to shorewall — manages nftables directly via
google/nftables. Reads a single YAML config covering zones, interfaces,
hosts, policy, rules, snat, and named portgroups. Computes differential
changes against the running nftables state and applies them atomically.
Supports detecting and purging rules added outside of tomswall.
2026-06-28 23:43:16 +10:00

410 lines
9.4 KiB
Go

package nftables
import (
"encoding/binary"
"fmt"
"net"
"strconv"
"strings"
"github.com/google/nftables/expr"
"golang.org/x/sys/unix"
"git.unkin.net/unkin/tomswall/internal/config"
)
type Compiler struct {
cfg *config.Config
}
func NewCompiler(cfg *config.Config) *Compiler {
return &Compiler{cfg: cfg}
}
func (c *Compiler) Compile() (*FirewallState, error) {
state := &FirewallState{
Rules: make(map[string][]ManagedRule),
}
if err := c.compileRules(state); err != nil {
return nil, fmt.Errorf("rules: %w", err)
}
if err := c.compilePolicies(state); err != nil {
return nil, fmt.Errorf("policies: %w", err)
}
if err := c.compileSNAT(state); err != nil {
return nil, fmt.Errorf("snat: %w", err)
}
return state, nil
}
func (c *Compiler) compileRules(state *FirewallState) error {
fwZone := c.cfg.FirewallZone()
for i, rule := range c.cfg.Rules {
tag := fmt.Sprintf("rule:%d", i)
proto := rule.Proto
var ports config.PortSpec
if rule.PortGroup != "" {
pg, _ := c.cfg.ResolvePortGroup(rule.PortGroup)
proto = pg.Proto
ports = pg.Ports
} else {
ports = rule.DPort
}
srcZone, _ := splitZoneSpec(rule.Source)
dstZone, _ := splitZoneSpec(rule.Dest)
srcIfaces := c.resolveZoneInterfaces(srcZone)
dstIfaces := c.resolveZoneInterfaces(dstZone)
chain := c.selectChain(srcZone, dstZone, fwZone)
for _, srcIface := range srcIfaces {
for _, dstIface := range dstIfaces {
exprs, err := c.buildRuleExprs(srcIface, dstIface, chain, proto, ports, rule.Action, rule.Source, rule.Dest)
if err != nil {
return fmt.Errorf("rule[%d]: %w", i, err)
}
state.Rules[chain] = append(state.Rules[chain], ManagedRule{
Chain: chain,
Exprs: exprs,
Tag: tag,
})
}
}
}
return nil
}
func (c *Compiler) compilePolicies(state *FirewallState) error {
fwZone := c.cfg.FirewallZone()
for i, pol := range c.cfg.Policy {
tag := fmt.Sprintf("policy:%d", i)
srcZones := c.expandZoneRef(pol.Source)
dstZones := c.expandZoneRef(pol.Dest)
for _, sz := range srcZones {
for _, dz := range dstZones {
if sz == dz {
continue
}
chain := c.selectChain(sz, dz, fwZone)
srcIfaces := c.resolveZoneInterfaces(sz)
dstIfaces := c.resolveZoneInterfaces(dz)
for _, si := range srcIfaces {
for _, di := range dstIfaces {
var exprs []expr.Any
if si != "" {
exprs = append(exprs, matchIface(true, si)...)
}
if di != "" && chain == "forward" {
exprs = append(exprs, matchIface(false, di)...)
}
exprs = append(exprs, policyVerdict(pol.Action)...)
state.Rules[chain] = append(state.Rules[chain], ManagedRule{
Chain: chain,
Exprs: exprs,
Tag: tag,
})
}
}
}
}
}
return nil
}
func (c *Compiler) compileSNAT(state *FirewallState) error {
for i, snat := range c.cfg.SNAT {
tag := fmt.Sprintf("snat:%d", i)
var exprs []expr.Any
exprs = append(exprs, matchIface(false, snat.DestInterface)...)
if snat.Source != "" {
srcExprs, err := matchSourceCIDR(snat.Source)
if err != nil {
return fmt.Errorf("snat[%d]: %w", i, err)
}
exprs = append(exprs, srcExprs...)
}
if snat.Proto != "" {
exprs = append(exprs, matchProto(snat.Proto)...)
}
switch snat.Action {
case config.SNATMasquerade:
exprs = append(exprs, &expr.Masq{})
case config.SNATAddress:
ip := net.ParseIP(snat.Address)
if ip == nil {
return fmt.Errorf("snat[%d]: invalid address %q", i, snat.Address)
}
ip4 := ip.To4()
if ip4 != nil {
exprs = append(exprs,
&expr.Immediate{Register: 1, Data: ip4},
&expr.NAT{
Type: expr.NATTypeSourceNAT,
Family: unix.NFPROTO_IPV4,
RegAddrMin: 1,
RegAddrMax: 1,
},
)
} else {
exprs = append(exprs,
&expr.Immediate{Register: 1, Data: ip.To16()},
&expr.NAT{
Type: expr.NATTypeSourceNAT,
Family: unix.NFPROTO_IPV6,
RegAddrMin: 1,
RegAddrMax: 1,
},
)
}
}
state.Rules["postrouting"] = append(state.Rules["postrouting"], ManagedRule{
Chain: "postrouting",
Exprs: exprs,
Tag: tag,
})
}
return nil
}
func (c *Compiler) selectChain(srcZone, dstZone, fwZone string) string {
if dstZone == fwZone {
return "input"
}
if srcZone == fwZone {
return "output"
}
return "forward"
}
func (c *Compiler) resolveZoneInterfaces(zone string) []string {
if zone == "all" || zone == "" {
return []string{""}
}
ifaces := c.cfg.ZoneInterfaces(zone)
if len(ifaces) == 0 {
return []string{""}
}
return ifaces
}
func (c *Compiler) expandZoneRef(ref string) []string {
if ref == "all" {
var zones []string
for name := range c.cfg.Zones {
zones = append(zones, name)
}
return zones
}
return []string{ref}
}
func (c *Compiler) buildRuleExprs(srcIface, dstIface, chain, proto string, ports config.PortSpec, action config.RuleAction, srcSpec, dstSpec string) ([]expr.Any, error) {
var exprs []expr.Any
if srcIface != "" {
exprs = append(exprs, matchIface(true, srcIface)...)
}
if dstIface != "" && chain == "forward" {
exprs = append(exprs, matchIface(false, dstIface)...)
}
_, srcAddr := splitZoneSpec(srcSpec)
_, dstAddr := splitZoneSpec(dstSpec)
if srcAddr != "" {
src, err := matchSourceCIDR(srcAddr)
if err != nil {
return nil, err
}
exprs = append(exprs, src...)
}
if dstAddr != "" {
dst, err := matchDestCIDR(dstAddr)
if err != nil {
return nil, err
}
exprs = append(exprs, dst...)
}
if proto != "" {
exprs = append(exprs, matchProto(proto)...)
}
for _, portStr := range ports {
p, err := parsePort(portStr)
if err != nil {
return nil, err
}
exprs = append(exprs, matchDPort(p)...)
}
switch action {
case config.RuleAccept:
exprs = append(exprs, &expr.Verdict{Kind: expr.VerdictAccept})
case config.RuleDrop:
exprs = append(exprs, &expr.Verdict{Kind: expr.VerdictDrop})
case config.RuleReject:
exprs = append(exprs, &expr.Reject{})
}
return exprs, nil
}
func matchIface(input bool, name string) []expr.Any {
key := expr.MetaKeyOIFNAME
if input {
key = expr.MetaKeyIIFNAME
}
padded := make([]byte, 16)
copy(padded, name+"\x00")
return []expr.Any{
&expr.Meta{Key: key, Register: 1},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: padded[:len(name)+1]},
}
}
func matchProto(proto string) []expr.Any {
var protoNum byte
switch strings.ToLower(proto) {
case "tcp":
protoNum = unix.IPPROTO_TCP
case "udp":
protoNum = unix.IPPROTO_UDP
case "icmp":
protoNum = unix.IPPROTO_ICMP
default:
n, _ := strconv.Atoi(proto)
protoNum = byte(n)
}
return []expr.Any{
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{protoNum}},
}
}
func matchDPort(port uint16) []expr.Any {
portBytes := make([]byte, 2)
binary.BigEndian.PutUint16(portBytes, port)
return []expr.Any{
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: portBytes},
}
}
func matchSourceCIDR(cidr string) ([]expr.Any, error) {
ip, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
singleIP := net.ParseIP(cidr)
if singleIP == nil {
return nil, fmt.Errorf("invalid source address %q", cidr)
}
ip4 := singleIP.To4()
return []expr.Any{
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ip4},
}, nil
}
ip4 := ip.To4()
if ip4 == nil {
return nil, fmt.Errorf("IPv6 source addresses not yet supported: %s", cidr)
}
return []expr.Any{
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4},
&expr.Bitwise{
SourceRegister: 1,
DestRegister: 1,
Len: 4,
Mask: ipNet.Mask,
Xor: []byte{0, 0, 0, 0},
},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ipNet.IP.To4()},
}, nil
}
func matchDestCIDR(cidr string) ([]expr.Any, error) {
ip, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
singleIP := net.ParseIP(cidr)
if singleIP == nil {
return nil, fmt.Errorf("invalid dest address %q", cidr)
}
ip4 := singleIP.To4()
return []expr.Any{
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ip4},
}, nil
}
ip4 := ip.To4()
if ip4 == nil {
return nil, fmt.Errorf("IPv6 dest addresses not yet supported: %s", cidr)
}
return []expr.Any{
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4},
&expr.Bitwise{
SourceRegister: 1,
DestRegister: 1,
Len: 4,
Mask: ipNet.Mask,
Xor: []byte{0, 0, 0, 0},
},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ipNet.IP.To4()},
}, nil
}
func policyVerdict(action config.PolicyAction) []expr.Any {
switch action {
case config.PolicyAccept:
return []expr.Any{&expr.Verdict{Kind: expr.VerdictAccept}}
case config.PolicyDrop:
return []expr.Any{&expr.Verdict{Kind: expr.VerdictDrop}}
case config.PolicyReject:
return []expr.Any{&expr.Reject{}}
default:
return []expr.Any{&expr.Verdict{Kind: expr.VerdictDrop}}
}
}
func splitZoneSpec(spec string) (zone, addr string) {
idx := strings.IndexByte(spec, ':')
if idx < 0 {
return spec, ""
}
return spec[:idx], spec[idx+1:]
}
func parsePort(s string) (uint16, error) {
n, err := strconv.ParseUint(s, 10, 16)
if err != nil {
return 0, fmt.Errorf("invalid port %q: %w", s, err)
}
return uint16(n), nil
}