Files
nebula/firewall/rules_builder.go
T
2026-09-29 19:38:53 -05:00

308 lines
7.6 KiB
Go

package firewall
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"hash"
"hash/fnv"
"io"
"log/slog"
"net/netip"
"slices"
"github.com/gaissmai/bart"
"github.com/slackhq/nebula/iputil"
)
// RulesBuilder collects the inbound and outbound rules, Build turns them into a Table for each direction
type RulesBuilder struct {
in, out tableRules
// defaultLocalCIDRAny makes rules without a local cidr allow any local address, even with unsafe networks
defaultLocalCIDRAny bool
// ruleSHA and ruleFNV hash every rule as it's added, see Rules
ruleSHA hash.Hash
ruleFNV hash.Hash32
l *slog.Logger
}
// tableRules are one direction's rules, waiting to be built into a Table
type tableRules struct {
anyRules []rule
// protoRules holds the rules for each protocol besides proto `any`, ICMPv6 rules are kept with ICMP
protoRules [256][]rule
}
// rule is a parsed rule, so adding it to a Table can't fail
type rule struct {
startPort, endPort int32
groups []string
host string
caName, caSha string
// cidr is the remote cidr, not valid when the rule doesn't have one
cidr netip.Prefix
anyCIDR bool
// localCIDRs are the local addresses the rule allows, anyLocalCIDR allows all of them.
// Neither is set for a rule without a local cidr until the Table is built and it gets the default.
localCIDRs []netip.Prefix
anyLocalCIDR bool
}
func NewRulesBuilder(l *slog.Logger) *RulesBuilder {
return &RulesBuilder{
ruleSHA: sha256.New(),
ruleFNV: fnv.New32a(),
l: l,
}
}
// AddRule adds a rule for incoming or outgoing traffic, on proto or on every protocol when proto is ProtoAny.
// The rule is parsed now, so a bad rule is reported when it's added rather than when the Tables are built.
func (b *RulesBuilder) AddRule(incoming bool, proto uint8, startPort int32, endPort int32, groups []string, host string, cidr, localCidr, caName string, caSha string) error {
if proto == iputil.IPProtocolICMP || proto == iputil.IPProtocolICMPv6 {
//ICMP traffic doesn't have ports, so we always coerce to "any", even if a value is provided
if startPort != PortAny {
b.l.Warn("ignoring port specification for ICMP firewall rule", "startPort", startPort)
}
startPort = PortAny
endPort = PortAny
}
r, err := parseRule(startPort, endPort, groups, host, cidr, localCidr, caName, caSha)
if err != nil {
return err
}
if incoming {
b.in.add(proto, r)
} else {
b.out.add(proto, r)
}
// The rule hashes are of these lines, changing them changes every hash
fmt.Fprintf(io.MultiWriter(b.ruleSHA, b.ruleFNV),
"incoming: %v, proto: %v, startPort: %v, endPort: %v, groups: %v, host: %v, ip: %v, localIp: %v, caName: %v, caSha: %s\n",
incoming, proto, startPort, endPort, groups, host, cidr, localCidr, caName, caSha,
)
direction := "incoming"
if !incoming {
direction = "outgoing"
}
b.l.Info("Firewall rule added",
"firewallRule", m{"direction": direction, "proto": proto, "startPort": startPort, "endPort": endPort, "groups": groups, "host": host, "cidr": cidr, "localCidr": localCidr, "caName": caName, "caSha": caSha},
)
return nil
}
// Build turns the rules into a Table for each direction, and hashes them. Rules without a local cidr allow
// vpnNetworks when there are unsafe networks, otherwise any local address.
func (b *RulesBuilder) Build(vpnNetworks, unsafeNetworks []netip.Prefix) Rules {
anyLocal := len(unsafeNetworks) == 0 || b.defaultLocalCIDRAny
return Rules{
In: b.in.build(anyLocal, vpnNetworks),
Out: b.out.build(anyLocal, vpnNetworks),
Hash: hex.EncodeToString(b.ruleSHA.Sum(nil)),
HashFNV: b.ruleFNV.Sum32(),
}
}
func parseRule(startPort, endPort int32, groups []string, host, cidr, localCidr, caName, caSha string) (rule, error) {
if startPort > endPort {
return rule{}, fmt.Errorf("start port was lower than end port")
}
r := rule{
startPort: startPort,
endPort: endPort,
groups: groups,
host: host,
caName: caName,
caSha: caSha,
}
var err error
r.cidr, r.anyCIDR, err = parseCIDR(cidr)
if err != nil {
return rule{}, fmt.Errorf("cidr did not parse; %w", err)
}
var localCIDR netip.Prefix
localCIDR, r.anyLocalCIDR, err = parseCIDR(localCidr)
if err != nil {
return rule{}, fmt.Errorf("local_cidr did not parse; %w", err)
}
if localCIDR.IsValid() {
r.localCIDRs = []netip.Prefix{localCIDR}
}
return r, nil
}
// add keeps r for proto, or for every protocol when proto is ProtoAny
func (tr *tableRules) add(proto uint8, r rule) {
switch proto {
case ProtoAny:
tr.anyRules = append(tr.anyRules, r)
case iputil.IPProtocolICMPv6:
tr.protoRules[iputil.IPProtocolICMP] = append(tr.protoRules[iputil.IPProtocolICMP], r)
default:
tr.protoRules[proto] = append(tr.protoRules[proto], r)
}
}
// build turns the rules into a Table. Rules without a local cidr allow defaultLocalCIDRs,
// or any local address when defaultLocalAny is set.
func (tr *tableRules) build(defaultLocalAny bool, defaultLocalCIDRs []netip.Prefix) *Table {
add := func(pr portRules, rules []rule) {
for _, r := range rules {
if !r.anyLocalCIDR && r.localCIDRs == nil {
r.anyLocalCIDR, r.localCIDRs = defaultLocalAny, defaultLocalCIDRs
}
pr.add(r)
}
}
var anyProto portRules
if len(tr.anyRules) > 0 {
anyProto = portRules{}
add(anyProto, tr.anyRules)
}
t := &Table{}
for proto := range t.protos {
rules := tr.protoRules[proto]
if len(rules) == 0 {
t.protos[proto] = anyProto
continue
}
pr := portRules{}
add(pr, tr.anyRules)
add(pr, rules)
t.protos[proto] = pr
}
t.protos[iputil.IPProtocolICMPv6] = t.protos[iputil.IPProtocolICMP]
return t
}
// parseCIDR parses a rule's cidr, which is empty, `any`, or a prefix
func parseCIDR(s string) (prefix netip.Prefix, isAny bool, err error) {
switch s {
case "":
return netip.Prefix{}, false, nil
case "any":
return netip.Prefix{}, true, nil
}
prefix, err = netip.ParsePrefix(s)
return prefix, false, err
}
// isAny reports whether r matches any host, making its groups, host, and cidr irrelevant
func (r rule) isAny() bool {
if len(r.groups) == 0 && r.host == "" && !r.cidr.IsValid() {
return true
}
return slices.Contains(r.groups, "any") || r.host == "any" || r.anyCIDR
}
func (pr portRules) add(r rule) {
for i := r.startPort; i <= r.endPort; i++ {
cr := pr[i]
if cr == nil {
cr = &caRules{}
pr[i] = cr
}
cr.add(r)
}
}
func (cr *caRules) add(r rule) {
if r.caSha == "" && r.caName == "" {
if cr.Any == nil {
cr.Any = &remoteRules{}
}
cr.Any.add(r)
return
}
if r.caSha != "" {
getOrNew(&cr.CAShas, r.caSha).add(r)
}
if r.caName != "" {
getOrNew(&cr.CANames, r.caName).add(r)
}
}
func (rr *remoteRules) add(r rule) {
if r.isAny() {
if rr.Any == nil {
rr.Any = &localRules{}
}
rr.Any.add(r)
return
}
if len(r.groups) > 0 {
lr := &localRules{}
lr.add(r)
rr.Groups = append(rr.Groups, &groupsRule{
Groups: r.groups,
LocalCIDR: lr,
})
}
if r.host != "" {
getOrNew(&rr.Hosts, r.host).add(r)
}
if r.cidr.IsValid() {
if rr.CIDR == nil {
rr.CIDR = new(bart.Table[*localRules])
}
lr, ok := rr.CIDR.Get(r.cidr)
if !ok {
lr = &localRules{}
rr.CIDR.Insert(r.cidr, lr)
}
lr.add(r)
}
}
func (lr *localRules) add(r rule) {
if r.anyLocalCIDR {
lr.Any = true
return
}
if lr.LocalCIDR == nil {
lr.LocalCIDR = new(bart.Lite)
}
for _, prefix := range r.localCIDRs {
lr.LocalCIDR.Insert(prefix)
}
}
// getOrNew returns m[k], adding a new value for k first when there isn't one. m is made if it's nil.
func getOrNew[M ~map[K]*V, K comparable, V any](m *M, k K) *V {
if *m == nil {
*m = make(M)
}
v := (*m)[k]
if v == nil {
v = new(V)
(*m)[k] = v
}
return v
}