2a3eb3b04d
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.
410 lines
9.4 KiB
Go
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
|
|
}
|