Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8ad59b053 | |||
| 3174eabd94 | |||
| 0b68110220 | |||
| 70df237121 | |||
| 799c7f3524 | |||
| ecc349cb6f | |||
| 695869c80b | |||
| 96a1ba8351 |
@@ -531,6 +531,13 @@ func TestValidateHosts(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: "interface required",
|
wantErr: "interface required",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "invalid exclusion",
|
||||||
|
zones: map[string]Zone{"fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, "loc": {Type: ZoneIP}},
|
||||||
|
interfaces: []Interface{{Zone: "net", Interface: "eth0"}},
|
||||||
|
hosts: []Host{{Zone: "loc", Interface: "eth0", Addresses: []string{"192.0.2.0/24"}, Exclusions: []string{"192.0.2.0/24!192.0.2.7"}}},
|
||||||
|
wantErr: "invalid address",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
type Host struct {
|
type Host struct {
|
||||||
Zone string `yaml:"zone"`
|
Zone string `yaml:"zone"`
|
||||||
@@ -52,6 +56,13 @@ func (c *Config) validateHosts() error {
|
|||||||
if !h.Dynamic && len(h.Addresses) == 0 {
|
if !h.Dynamic && len(h.Addresses) == 0 {
|
||||||
return fmt.Errorf("host[%d]: at least one address required (or set dynamic: true)", i)
|
return fmt.Errorf("host[%d]: at least one address required (or set dynamic: true)", i)
|
||||||
}
|
}
|
||||||
|
for _, a := range slices.Concat(h.Addresses, h.Exclusions) {
|
||||||
|
if _, err := netip.ParsePrefix(a); err != nil {
|
||||||
|
if _, err := netip.ParseAddr(a); err != nil {
|
||||||
|
return fmt.Errorf("host[%d]: invalid address %q", i, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+234
-37
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -345,12 +346,12 @@ func (c *Compiler) compileConntrackPair(state *FirewallState, tag, chain string,
|
|||||||
(dstAddr == "" || strings.HasPrefix(dstAddr, "!")) {
|
(dstAddr == "" || strings.HasPrefix(dstAddr, "!")) {
|
||||||
return fmt.Errorf("conntrack DEST zone %q needs an address in prerouting", dstZone)
|
return fmt.Errorf("conntrack DEST zone %q needs an address in prerouting", dstZone)
|
||||||
}
|
}
|
||||||
srcIfaces, dstIfaces := c.resolveZoneInterfaces(srcZone, srcAddr), []string{""}
|
srcIfaces, dstIfaces := c.resolveZone(srcZone, srcAddr), []zoneMatch{{}}
|
||||||
if chain == "raw_prerouting" && c.resolveZoneInterfaces(dstZone, dstAddr) == nil {
|
if chain == "raw_prerouting" && c.resolveZone(dstZone, dstAddr) == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if chain == "raw_output" {
|
if chain == "raw_output" {
|
||||||
srcIfaces, dstIfaces = []string{""}, c.resolveZoneInterfaces(dstZone, dstAddr)
|
srcIfaces, dstIfaces = []zoneMatch{{}}, c.resolveZone(dstZone, dstAddr)
|
||||||
}
|
}
|
||||||
out := chain
|
out := chain
|
||||||
if ct.Action == config.ConntrackHelper {
|
if ct.Action == config.ConntrackHelper {
|
||||||
@@ -674,8 +675,8 @@ func splitAddrs(addr string) []string {
|
|||||||
func (c *Compiler) compileZonePair(state *FirewallState, tag, srcZone, srcAddr, dstZone, dstAddr, origDest, proto string,
|
func (c *Compiler) compileZonePair(state *FirewallState, tag, srcZone, srcAddr, dstZone, dstAddr, origDest, proto string,
|
||||||
dports, sports config.PortSpec, action config.RuleAction, logLevel string,
|
dports, sports config.PortSpec, action config.RuleAction, logLevel string,
|
||||||
fwZone string, section config.RuleSection) error {
|
fwZone string, section config.RuleSection) error {
|
||||||
srcIfaces := c.resolveZoneInterfaces(srcZone, srcAddr)
|
srcIfaces := c.resolveZone(srcZone, srcAddr)
|
||||||
dstIfaces := c.resolveZoneInterfaces(dstZone, dstAddr)
|
dstIfaces := c.resolveZone(dstZone, dstAddr)
|
||||||
chain := c.selectChain(srcZone, dstZone, fwZone)
|
chain := c.selectChain(srcZone, dstZone, fwZone)
|
||||||
// ponytail: forward daddr is post-DNAT; lift with `ct original daddr` (expr.Ct Direction, google/nftables v0.3.0).
|
// ponytail: forward daddr is post-DNAT; lift with `ct original daddr` (expr.Ct Direction, google/nftables v0.3.0).
|
||||||
if origDest != "" && chain == "forward" {
|
if origDest != "" && chain == "forward" {
|
||||||
@@ -744,7 +745,7 @@ func (c *Compiler) compileDNATRule(state *FirewallState, tag, srcZone, srcAddr,
|
|||||||
dnatPort = uint16(p)
|
dnatPort = uint16(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
srcIfaces := c.resolveZoneInterfaces(srcZone, srcAddr)
|
srcIfaces := c.resolveZone(srcZone, srcAddr)
|
||||||
|
|
||||||
var odExprs []expr.Any
|
var odExprs []expr.Any
|
||||||
if origDest != "" {
|
if origDest != "" {
|
||||||
@@ -765,12 +766,12 @@ func (c *Compiler) compileDNATRule(state *FirewallState, tag, srcZone, srcAddr,
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, srcIface := range srcIfaces {
|
for _, srcIface := range srcIfaces {
|
||||||
|
zm, err := zoneMatchExprs(srcIface, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
var exprs []expr.Any
|
exprs := slices.Clone(zm)
|
||||||
|
|
||||||
if srcIface != "" {
|
|
||||||
exprs = append(exprs, matchIfaceName(true, srcIface)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if srcAddr != "" {
|
if srcAddr != "" {
|
||||||
src, err := matchSourceCIDR(srcAddr)
|
src, err := matchSourceCIDR(srcAddr)
|
||||||
@@ -857,31 +858,35 @@ func (c *Compiler) compileDNATRule(state *FirewallState, tag, srcZone, srcAddr,
|
|||||||
func (c *Compiler) compilePolicies(state *FirewallState) error {
|
func (c *Compiler) compilePolicies(state *FirewallState) error {
|
||||||
fwZone := c.cfg.FirewallZone()
|
fwZone := c.cfg.FirewallZone()
|
||||||
|
|
||||||
|
overridden := map[string]bool{}
|
||||||
for i, pol := range c.cfg.Policy {
|
for i, pol := range c.cfg.Policy {
|
||||||
tag := fmt.Sprintf("policy:%d", i)
|
tag := fmt.Sprintf("policy:%d", i)
|
||||||
|
explicitIntra := pol.Source == pol.Dest && !isGlobalZone(pol.Source)
|
||||||
|
|
||||||
srcZones := c.expandZoneRef(pol.Source)
|
srcZones := c.expandZoneRef(pol.Source)
|
||||||
dstZones := c.expandZoneRef(pol.Dest)
|
dstZones := c.expandZoneRef(pol.Dest)
|
||||||
|
|
||||||
for _, sz := range srcZones {
|
for _, sz := range srcZones {
|
||||||
for _, dz := range dstZones {
|
for _, dz := range dstZones {
|
||||||
if sz == dz && !strings.HasSuffix(pol.Source, "+") {
|
if sz == dz {
|
||||||
continue
|
if sz == fwZone || (!explicitIntra && !strings.HasSuffix(pol.Source, "+") && !strings.HasSuffix(pol.Dest, "+")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
overridden[sz] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
chain := c.selectChain(sz, dz, fwZone)
|
chain := c.selectChain(sz, dz, fwZone)
|
||||||
srcIfaces := c.resolveZoneInterfaces(sz, "")
|
srcIfaces := c.resolveZone(sz, "")
|
||||||
dstIfaces := c.resolveZoneInterfaces(dz, "")
|
dstIfaces := c.resolveZone(dz, "")
|
||||||
|
|
||||||
for _, si := range srcIfaces {
|
for _, si := range srcIfaces {
|
||||||
for _, di := range dstIfaces {
|
for _, di := range dstIfaces {
|
||||||
var exprs []expr.Any
|
if sz == dz && intraZoneSkip(si, di) {
|
||||||
|
continue
|
||||||
if si != "" {
|
|
||||||
exprs = append(exprs, matchIfaceName(true, si)...)
|
|
||||||
}
|
}
|
||||||
if di != "" && chain != "input" {
|
exprs, err := zonePairExprs(si, di, chain)
|
||||||
exprs = append(exprs, matchIfaceName(false, di)...)
|
if err != nil {
|
||||||
|
return fmt.Errorf("policy[%d]: %w", i, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pol.RateLimit != "" {
|
if pol.RateLimit != "" {
|
||||||
@@ -909,9 +914,66 @@ func (c *Compiler) compilePolicies(state *FirewallState) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return c.compileImplicitIntraZone(state, overridden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileImplicitIntraZone accepts traffic between different interfaces of one zone, shorewall's implicit intra-zone ACCEPT policy.
|
||||||
|
func (c *Compiler) compileImplicitIntraZone(state *FirewallState, overridden map[string]bool) error {
|
||||||
|
fwZone := c.cfg.FirewallZone()
|
||||||
|
zones := make([]string, 0, len(c.cfg.Zones))
|
||||||
|
for z := range c.cfg.Zones {
|
||||||
|
zones = append(zones, z)
|
||||||
|
}
|
||||||
|
sort.Strings(zones)
|
||||||
|
for _, z := range zones {
|
||||||
|
if z == fwZone || overridden[z] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(c.cfg.ZoneInterfaces(z)) == 0 && !slices.ContainsFunc(c.cfg.Hosts, func(h config.Host) bool { return h.Zone == z }) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches := c.resolveZone(z, "")
|
||||||
|
for _, si := range matches {
|
||||||
|
for _, di := range matches {
|
||||||
|
if intraZoneSkip(si, di) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exprs, err := zonePairExprs(si, di, "forward")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("zone %s: %w", z, err)
|
||||||
|
}
|
||||||
|
state.Rules["forward"] = append(state.Rules["forward"], ManagedRule{
|
||||||
|
Chain: "forward",
|
||||||
|
Exprs: append(exprs, &expr.Verdict{Kind: expr.VerdictAccept}),
|
||||||
|
Tag: "intra:" + z,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// intraZoneSkip drops intra-zone pairs on one interface unless both are routeback hosts entries
|
||||||
|
// (interface routeback is compileIntraZone's job), and pairs whose address families can never both match.
|
||||||
|
func intraZoneSkip(si, di zoneMatch) bool {
|
||||||
|
if si.iface != "" && si.iface == di.iface && !(si.routeback && di.routeback) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
a, b := matchFamily(si), matchFamily(di)
|
||||||
|
return a != 0 && b != 0 && a != b
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchFamily is the NFPROTO a zoneMatch is guarded by: its host address's family, else fam.
|
||||||
|
func matchFamily(m zoneMatch) byte {
|
||||||
|
if p, err := parsePrefix(m.addr); err == nil {
|
||||||
|
if p.Addr().Is4() {
|
||||||
|
return unix.NFPROTO_IPV4
|
||||||
|
}
|
||||||
|
return unix.NFPROTO_IPV6
|
||||||
|
}
|
||||||
|
return m.fam
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Compiler) compileSNAT(state *FirewallState) error {
|
func (c *Compiler) compileSNAT(state *FirewallState) error {
|
||||||
for i, snat := range c.cfg.SNAT {
|
for i, snat := range c.cfg.SNAT {
|
||||||
tag := fmt.Sprintf("snat:%d", i)
|
tag := fmt.Sprintf("snat:%d", i)
|
||||||
@@ -1213,11 +1275,23 @@ func (c *Compiler) selectChain(srcZone, dstZone, fwZone string) string {
|
|||||||
return "forward"
|
return "forward"
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveZoneInterfaces returns nil (fail closed) for an unknown zone, or one with no interfaces unless a non-negated address match narrows the rule.
|
// zoneMatch classifies a packet into a zone: an interface (empty: any) and, for a hosts entry, one host
|
||||||
func (c *Compiler) resolveZoneInterfaces(zone, addr string) []string {
|
// address; excl carves out hosts exclusions and the hosts of sub-zones, which shorewall matches first.
|
||||||
|
// fam (an NFPROTO; addr's family when set, else 0: any) guards addr and excl so IPv4 offsets are
|
||||||
|
// never compared against IPv6 bytes. routeback marks a hosts entry with the routeback option.
|
||||||
|
type zoneMatch struct {
|
||||||
|
iface, addr string
|
||||||
|
excl []string
|
||||||
|
fam byte
|
||||||
|
routeback bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveZone returns nil (fail closed) for an unknown zone, or one with neither interfaces nor hosts
|
||||||
|
// unless a non-negated address match narrows the rule.
|
||||||
|
func (c *Compiler) resolveZone(zone, addr string) []zoneMatch {
|
||||||
switch zone {
|
switch zone {
|
||||||
case "", "all", "all+", "any", "any+":
|
case "", "all", "all+", "any", "any+":
|
||||||
return []string{""}
|
return []zoneMatch{{}}
|
||||||
}
|
}
|
||||||
z, ok := c.cfg.Zones[zone]
|
z, ok := c.cfg.Zones[zone]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1225,24 +1299,151 @@ func (c *Compiler) resolveZoneInterfaces(zone, addr string) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if z.Type == config.ZoneFirewall {
|
if z.Type == config.ZoneFirewall {
|
||||||
return []string{""}
|
return []zoneMatch{{}}
|
||||||
}
|
}
|
||||||
if ifaces := c.cfg.ZoneInterfaces(zone); len(ifaces) > 0 {
|
var out []zoneMatch
|
||||||
return ifaces
|
hasHosts := false
|
||||||
|
for _, iface := range c.cfg.ZoneInterfaces(zone) {
|
||||||
|
sub, back := c.subZoneHosts(zone, iface)
|
||||||
|
if len(sub) == 0 {
|
||||||
|
out = append(out, zoneMatch{iface: iface})
|
||||||
|
} else {
|
||||||
|
v4, v6 := splitFamily(sub)
|
||||||
|
out = append(out, zoneMatch{iface: iface, excl: v4, fam: unix.NFPROTO_IPV4},
|
||||||
|
zoneMatch{iface: iface, excl: v6, fam: unix.NFPROTO_IPV6})
|
||||||
|
}
|
||||||
|
for _, b := range back {
|
||||||
|
if addrsOverlap(b, addr) {
|
||||||
|
out = append(out, zoneMatch{iface: iface, addr: b})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, h := range c.cfg.Hosts {
|
||||||
|
if h.Zone != zone {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasHosts = true
|
||||||
|
sub, _ := c.subZoneHosts(zone, h.Interface)
|
||||||
|
v4, v6 := splitFamily(slices.Concat(h.Exclusions, sub))
|
||||||
|
for _, a := range h.Addresses {
|
||||||
|
if addrsOverlap(a, addr) {
|
||||||
|
m := zoneMatch{iface: h.Interface, addr: a, excl: v6, routeback: h.Options.RouteBack}
|
||||||
|
if p, err := parsePrefix(a); err == nil && p.Addr().Is4() {
|
||||||
|
m.excl = v4
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) > 0 || hasHosts {
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
if addr != "" && !strings.HasPrefix(addr, "!") {
|
if addr != "" && !strings.HasPrefix(addr, "!") {
|
||||||
return []string{""}
|
return []zoneMatch{{}}
|
||||||
}
|
}
|
||||||
if !c.warned[zone] {
|
if !c.warned[zone] {
|
||||||
if c.warned == nil {
|
if c.warned == nil {
|
||||||
c.warned = map[string]bool{}
|
c.warned = map[string]bool{}
|
||||||
}
|
}
|
||||||
c.warned[zone] = true
|
c.warned[zone] = true
|
||||||
slog.Warn("compiler: zone has no interfaces, skipping its rules", "zone", zone)
|
slog.Warn("compiler: zone has no interfaces or hosts, skipping its rules", "zone", zone)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subZoneHosts lists the host addresses on iface that belong to sub-zones of zone, and those
|
||||||
|
// sub-zone hosts' exclusions, which fall back to zone.
|
||||||
|
func (c *Compiler) subZoneHosts(zone, iface string) (sub, back []string) {
|
||||||
|
for _, h := range c.cfg.Hosts {
|
||||||
|
if h.Interface == iface && c.cfg.IsSubZone(h.Zone, zone) {
|
||||||
|
sub = append(sub, h.Addresses...)
|
||||||
|
back = append(back, h.Exclusions...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sub, back
|
||||||
|
}
|
||||||
|
|
||||||
|
// addrsOverlap reports whether a host address can match a rule address; unparsable or negated rule addresses keep the host.
|
||||||
|
func addrsOverlap(host, rule string) bool {
|
||||||
|
if rule == "" || strings.HasPrefix(rule, "!") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
h, err := parsePrefix(host)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, r := range strings.Split(rule, ",") {
|
||||||
|
if p, err := parsePrefix(r); err != nil || p.Overlaps(h) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitFamily partitions addresses by family; unparsable ones go to v6 so zoneMatchExprs still rejects them.
|
||||||
|
func splitFamily(addrs []string) (v4, v6 []string) {
|
||||||
|
for _, a := range addrs {
|
||||||
|
if p, err := parsePrefix(a); err == nil && p.Addr().Is4() {
|
||||||
|
v4 = append(v4, a)
|
||||||
|
} else {
|
||||||
|
v6 = append(v6, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v4, v6
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePrefix(s string) (netip.Prefix, error) {
|
||||||
|
if a, err := netip.ParseAddr(s); err == nil {
|
||||||
|
return netip.PrefixFrom(a, a.BitLen()), nil
|
||||||
|
}
|
||||||
|
return netip.ParsePrefix(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneMatchExprs matches a zone on the in (src) or out interface plus its host address and exclusions,
|
||||||
|
// all guarded by m.fam so an IPv4 address never matches IPv6 bytes in an inet table.
|
||||||
|
func zoneMatchExprs(m zoneMatch, src bool) ([]expr.Any, error) {
|
||||||
|
var out []expr.Any
|
||||||
|
if m.iface != "" {
|
||||||
|
out = matchIfaceName(src, m.iface)
|
||||||
|
}
|
||||||
|
var addr []expr.Any
|
||||||
|
if m.addr != "" {
|
||||||
|
p, err := parsePrefix(m.addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid host address %q", m.addr)
|
||||||
|
}
|
||||||
|
if addr, err = matchAddrCIDR(m.addr, src); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.fam = unix.NFPROTO_IPV6
|
||||||
|
if p.Addr().Is4() {
|
||||||
|
m.fam = unix.NFPROTO_IPV4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.fam != 0 {
|
||||||
|
out = append(out, matchNFProto(m.fam)...)
|
||||||
|
}
|
||||||
|
out = append(out, addr...)
|
||||||
|
if len(m.excl) > 0 {
|
||||||
|
e, err := matchAddrCIDR("!"+strings.Join(m.excl, ","), src)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// zonePairExprs matches the source zone inbound and, outside input, the dest zone outbound.
|
||||||
|
func zonePairExprs(src, dst zoneMatch, chain string) ([]expr.Any, error) {
|
||||||
|
out, err := zoneMatchExprs(src, true)
|
||||||
|
if err != nil || chain == "input" {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
d, err := zoneMatchExprs(dst, false)
|
||||||
|
return append(out, d...), err
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Compiler) expandZoneRef(ref string) []string {
|
func (c *Compiler) expandZoneRef(ref string) []string {
|
||||||
base := ref
|
base := ref
|
||||||
var excluded map[string]bool
|
var excluded map[string]bool
|
||||||
@@ -1272,14 +1473,10 @@ func (c *Compiler) expandZoneRef(ref string) []string {
|
|||||||
return []string{base}
|
return []string{base}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Compiler) buildMatchExprs(srcIface, dstIface, chain, proto string, dports, sports config.PortSpec, srcAddr, dstAddr string) ([]l4Match, error) {
|
func (c *Compiler) buildMatchExprs(srcIface, dstIface zoneMatch, chain, proto string, dports, sports config.PortSpec, srcAddr, dstAddr string) ([]l4Match, error) {
|
||||||
var exprs []expr.Any
|
exprs, err := zonePairExprs(srcIface, dstIface, chain)
|
||||||
|
if err != nil {
|
||||||
if srcIface != "" {
|
return nil, err
|
||||||
exprs = append(exprs, matchIfaceName(true, srcIface)...)
|
|
||||||
}
|
|
||||||
if dstIface != "" && chain != "input" {
|
|
||||||
exprs = append(exprs, matchIfaceName(false, dstIface)...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if srcAddr != "" {
|
if srcAddr != "" {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -144,19 +145,17 @@ func TestCompiler_ResolveZoneInterfaces(t *testing.T) {
|
|||||||
}
|
}
|
||||||
c := NewCompiler(cfg)
|
c := NewCompiler(cfg)
|
||||||
|
|
||||||
ifaces := c.resolveZoneInterfaces("net", "")
|
for _, tt := range []struct {
|
||||||
if len(ifaces) != 1 || ifaces[0] != "eth0" {
|
zone string
|
||||||
t.Errorf("resolveZoneInterfaces(net) = %v, want [eth0]", ifaces)
|
want []zoneMatch
|
||||||
}
|
}{
|
||||||
|
{"net", []zoneMatch{{iface: "eth0"}}},
|
||||||
ifaces = c.resolveZoneInterfaces("all", "")
|
{"all", []zoneMatch{{}}},
|
||||||
if len(ifaces) != 1 || ifaces[0] != "" {
|
{"fw", []zoneMatch{{}}},
|
||||||
t.Errorf("resolveZoneInterfaces(all) = %v, want [\"\"]", ifaces)
|
} {
|
||||||
}
|
if got := c.resolveZone(tt.zone, ""); !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("resolveZone(%s) = %v, want %v", tt.zone, got, tt.want)
|
||||||
ifaces = c.resolveZoneInterfaces("fw", "")
|
}
|
||||||
if len(ifaces) != 1 || ifaces[0] != "" {
|
|
||||||
t.Errorf("resolveZoneInterfaces(fw) = %v, want [\"\"]", ifaces)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1849,13 +1848,13 @@ func TestCompile_InterfacelessZonesFailClosed(t *testing.T) {
|
|||||||
c.Zones["hst"] = config.Zone{Type: config.ZoneIP}
|
c.Zones["hst"] = config.Zone{Type: config.ZoneIP}
|
||||||
c.Hosts = []config.Host{{Zone: "hst", Interface: "eth0", Addresses: []string{"192.0.2.0/24"}}}
|
c.Hosts = []config.Host{{Zone: "hst", Interface: "eth0", Addresses: []string{"192.0.2.0/24"}}}
|
||||||
c.Policy = []config.Policy{{Source: "fw", Dest: "hst", Action: config.PolicyAccept}}
|
c.Policy = []config.Policy{{Source: "fw", Dest: "hst", Action: config.PolicyAccept}}
|
||||||
}, "policy:0", 0, []string{"hst"}},
|
}, "policy:0", 1, nil},
|
||||||
{"fw all expansion keeps zones with interfaces", func(c *config.Config) {
|
{"fw all expansion keeps zones with interfaces", func(c *config.Config) {
|
||||||
ipsec(c)
|
ipsec(c)
|
||||||
c.Zones["hst"] = config.Zone{Type: config.ZoneIP}
|
c.Zones["hst"] = config.Zone{Type: config.ZoneIP}
|
||||||
c.Hosts = []config.Host{{Zone: "hst", Interface: "eth0", Addresses: []string{"192.0.2.0/24"}}}
|
c.Hosts = []config.Host{{Zone: "hst", Interface: "eth0", Addresses: []string{"192.0.2.0/24"}}}
|
||||||
c.Policy = []config.Policy{{Source: "fw", Dest: "all", Action: config.PolicyDrop}}
|
c.Policy = []config.Policy{{Source: "fw", Dest: "all", Action: config.PolicyDrop}}
|
||||||
}, "policy:0", 1, []string{"hst", "ips"}},
|
}, "policy:0", 2, []string{"ips"}},
|
||||||
{"negated address does not scope", rule("ips:!192.0.2.1"), "rule:0", 0, []string{"ips"}},
|
{"negated address does not scope", rule("ips:!192.0.2.1"), "rule:0", 0, []string{"ips"}},
|
||||||
{"address scopes", rule("ips:192.0.2.1"), "rule:0", 1, []string{"ips"}},
|
{"address scopes", rule("ips:192.0.2.1"), "rule:0", 1, []string{"ips"}},
|
||||||
}
|
}
|
||||||
@@ -1895,6 +1894,19 @@ func TestCompile_InterfacelessZonesFailClosed(t *testing.T) {
|
|||||||
func describeRule(r ManagedRule) string {
|
func describeRule(r ManagedRule) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
for i, e := range r.Exprs {
|
for i, e := range r.Exprs {
|
||||||
|
if p, ok := e.(*expr.Payload); ok && p.Base == expr.PayloadBaseNetworkHeader && i+2 < len(r.Exprs) {
|
||||||
|
bw, okb := r.Exprs[i+1].(*expr.Bitwise)
|
||||||
|
cmp, okc := r.Exprs[i+2].(*expr.Cmp)
|
||||||
|
if okb && okc {
|
||||||
|
name := map[uint32]string{12: "saddr", 16: "daddr", 8: "saddr", 24: "daddr"}[p.Offset]
|
||||||
|
if cmp.Op == expr.CmpOpNeq {
|
||||||
|
name = "!" + name
|
||||||
|
}
|
||||||
|
ones, _ := net.IPMask(bw.Mask).Size()
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%s/%d", name, net.IP(cmp.Data), ones))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
cmp, ok := func() (*expr.Cmp, bool) {
|
cmp, ok := func() (*expr.Cmp, bool) {
|
||||||
if i+1 >= len(r.Exprs) {
|
if i+1 >= len(r.Exprs) {
|
||||||
return nil, false
|
return nil, false
|
||||||
@@ -1912,6 +1924,8 @@ func describeRule(r ManagedRule) string {
|
|||||||
parts = append(parts, "iif="+strings.TrimRight(string(cmp.Data), "\x00"))
|
parts = append(parts, "iif="+strings.TrimRight(string(cmp.Data), "\x00"))
|
||||||
case expr.MetaKeyOIFNAME:
|
case expr.MetaKeyOIFNAME:
|
||||||
parts = append(parts, "oif="+strings.TrimRight(string(cmp.Data), "\x00"))
|
parts = append(parts, "oif="+strings.TrimRight(string(cmp.Data), "\x00"))
|
||||||
|
case expr.MetaKeyNFPROTO:
|
||||||
|
parts = append(parts, map[byte]string{unix.NFPROTO_IPV4: "ip4", unix.NFPROTO_IPV6: "ip6"}[cmp.Data[0]])
|
||||||
}
|
}
|
||||||
case *expr.Payload:
|
case *expr.Payload:
|
||||||
if m.Base == expr.PayloadBaseNetworkHeader && (m.Len == 4 || m.Len == 16) {
|
if m.Base == expr.PayloadBaseNetworkHeader && (m.Len == 4 || m.Len == 16) {
|
||||||
@@ -2050,25 +2064,25 @@ func TestCompile_CommaZoneLists(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "dnat origdest",
|
name: "dnat origdest",
|
||||||
rule: config.Rule{Action: config.RuleDNAT, Source: "net", Dest: "svr:192.0.2.17", Proto: "tcp", DPort: config.PortSpec{"80"}, OrigDest: "203.0.113.5"},
|
rule: config.Rule{Action: config.RuleDNAT, Source: "net", Dest: "svr:192.0.2.17", Proto: "tcp", DPort: config.PortSpec{"80"}, OrigDest: "203.0.113.5"},
|
||||||
want: map[string][]string{"prerouting": {"iif=eth0 daddr=203.0.113.5"},
|
want: map[string][]string{"prerouting": {"iif=eth0 ip4 daddr=203.0.113.5"},
|
||||||
"forward": {"iif=eth0 oif=eth2 daddr=192.0.2.17"}},
|
"forward": {"iif=eth0 oif=eth2 daddr=192.0.2.17"}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dnat origdest list",
|
name: "dnat origdest list",
|
||||||
rule: config.Rule{Action: config.RuleDNAT, Source: "net", Dest: "svr:192.0.2.17", Proto: "tcp", DPort: config.PortSpec{"80"}, OrigDest: "203.0.113.5,203.0.113.6"},
|
rule: config.Rule{Action: config.RuleDNAT, Source: "net", Dest: "svr:192.0.2.17", Proto: "tcp", DPort: config.PortSpec{"80"}, OrigDest: "203.0.113.5,203.0.113.6"},
|
||||||
want: map[string][]string{"prerouting": {"iif=eth0 daddr=203.0.113.5", "iif=eth0 daddr=203.0.113.6"},
|
want: map[string][]string{"prerouting": {"iif=eth0 ip4 daddr=203.0.113.5", "iif=eth0 ip4 daddr=203.0.113.6"},
|
||||||
"forward": {"iif=eth0 oif=eth2 daddr=192.0.2.17"}},
|
"forward": {"iif=eth0 oif=eth2 daddr=192.0.2.17"}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dnat negated origdest list",
|
name: "dnat negated origdest list",
|
||||||
rule: config.Rule{Action: config.RuleDNAT, Source: "net", Dest: "svr:192.0.2.17", Proto: "tcp", DPort: config.PortSpec{"80"}, OrigDest: "!203.0.113.5,203.0.113.6"},
|
rule: config.Rule{Action: config.RuleDNAT, Source: "net", Dest: "svr:192.0.2.17", Proto: "tcp", DPort: config.PortSpec{"80"}, OrigDest: "!203.0.113.5,203.0.113.6"},
|
||||||
want: map[string][]string{"prerouting": {"iif=eth0 !daddr=203.0.113.5 !daddr=203.0.113.6"},
|
want: map[string][]string{"prerouting": {"iif=eth0 ip4 !daddr=203.0.113.5 !daddr=203.0.113.6"},
|
||||||
"forward": {"iif=eth0 oif=eth2 daddr=192.0.2.17"}},
|
"forward": {"iif=eth0 oif=eth2 daddr=192.0.2.17"}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "accept origdest",
|
name: "accept origdest",
|
||||||
rule: config.Rule{Action: config.RuleAccept, Source: "net", Dest: "fw", Proto: "tcp", DPort: config.PortSpec{"22"}, OrigDest: "203.0.113.5"},
|
rule: config.Rule{Action: config.RuleAccept, Source: "net", Dest: "fw", Proto: "tcp", DPort: config.PortSpec{"22"}, OrigDest: "203.0.113.5"},
|
||||||
want: map[string][]string{"input": {"iif=eth0 daddr=203.0.113.5"}},
|
want: map[string][]string{"input": {"iif=eth0 ip4 daddr=203.0.113.5"}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "origdest does not scope interface-less zone",
|
name: "origdest does not scope interface-less zone",
|
||||||
@@ -2078,7 +2092,7 @@ func TestCompile_CommaZoneLists(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "accept ipv6 origdest",
|
name: "accept ipv6 origdest",
|
||||||
rule: config.Rule{Action: config.RuleAccept, Source: "net", Dest: "fw", Proto: "tcp", DPort: config.PortSpec{"22"}, OrigDest: "2001:db8::5"},
|
rule: config.Rule{Action: config.RuleAccept, Source: "net", Dest: "fw", Proto: "tcp", DPort: config.PortSpec{"22"}, OrigDest: "2001:db8::5"},
|
||||||
want: map[string][]string{"input": {"iif=eth0 daddr=2001:db8::5"}},
|
want: map[string][]string{"input": {"iif=eth0 ip6 daddr=2001:db8::5"}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "blrule zone list",
|
name: "blrule zone list",
|
||||||
@@ -3320,3 +3334,332 @@ func TestCompile_LogLimitSplitsAroundExtrasAndNAT(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hostsCfg models a shorewall setup where lan:net is defined by hosts on net's interfaces.
|
||||||
|
func hostsCfg(mod func(*config.Config)) *config.Config {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Settings: config.Settings{TableName: "test", AddressFamily: config.FamilyINET},
|
||||||
|
Zones: map[string]config.Zone{
|
||||||
|
"fw": {Type: config.ZoneFirewall}, "net": {Type: config.ZoneIP},
|
||||||
|
"lan": {Type: config.ZoneIP, Parents: []string{"net"}}, "vpn": {Type: config.ZoneIP},
|
||||||
|
},
|
||||||
|
Interfaces: []config.Interface{
|
||||||
|
{Zone: "net", Interface: "wlo1"}, {Zone: "net", Interface: "enp2s0"}, {Zone: "vpn", Interface: "tun0"},
|
||||||
|
},
|
||||||
|
Hosts: []config.Host{
|
||||||
|
{Zone: "lan", Interface: "wlo1", Addresses: []string{"192.0.2.0/24"}},
|
||||||
|
{Zone: "lan", Interface: "enp2s0", Addresses: []string{"198.51.100.0/24"}},
|
||||||
|
},
|
||||||
|
Policy: []config.Policy{
|
||||||
|
{Source: "net", Dest: "all", Action: config.PolicyDrop},
|
||||||
|
{Source: "lan", Dest: "fw", Action: config.PolicyReject},
|
||||||
|
{Source: "all", Dest: "all", Action: config.PolicyReject},
|
||||||
|
},
|
||||||
|
Rules: []config.Rule{
|
||||||
|
{Action: config.RuleAccept, Source: "lan,vpn", Dest: "fw", Proto: "tcp", DPort: config.PortSpec{"6768"}},
|
||||||
|
},
|
||||||
|
PortGroups: make(map[string]config.PortGroup),
|
||||||
|
}
|
||||||
|
if mod != nil {
|
||||||
|
mod(cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeTagged(state *FirewallState, chain, tag string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, r := range taggedRules(state, chain, tag) {
|
||||||
|
out = append(out, describeRule(r))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompile_HostsZoneRules(t *testing.T) {
|
||||||
|
var logs bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
|
||||||
|
defer slog.SetDefault(prev)
|
||||||
|
|
||||||
|
state := mustCompile(t, hostsCfg(nil))
|
||||||
|
want := []string{"iif=wlo1 ip4 saddr=192.0.2.0/24", "iif=enp2s0 ip4 saddr=198.51.100.0/24", "iif=tun0"}
|
||||||
|
if got := describeTagged(state, "input", "rule:0"); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("input rule:0 = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if strings.Contains(logs.String(), "skipping") {
|
||||||
|
t.Errorf("unexpected warning:\n%s", logs.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompile_HostsSubZoneBeforeParent(t *testing.T) {
|
||||||
|
state := mustCompile(t, hostsCfg(nil))
|
||||||
|
want := []string{
|
||||||
|
"iif=wlo1 ip4 !saddr=192.0.2.0/24", "iif=wlo1 ip6",
|
||||||
|
"iif=enp2s0 ip4 !saddr=198.51.100.0/24", "iif=enp2s0 ip6",
|
||||||
|
}
|
||||||
|
if got := describeTagged(state, "input", "policy:0"); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("net->fw policy = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
want = []string{"iif=wlo1 ip4 saddr=192.0.2.0/24", "iif=enp2s0 ip4 saddr=198.51.100.0/24"}
|
||||||
|
if got := describeTagged(state, "input", "policy:1"); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("lan->fw policy = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
for _, r := range taggedRules(state, "input", "policy:1") {
|
||||||
|
if !slices.ContainsFunc(r.Exprs, func(e expr.Any) bool {
|
||||||
|
m, ok := e.(*expr.Meta)
|
||||||
|
return ok && m.Key == expr.MetaKeyNFPROTO
|
||||||
|
}) {
|
||||||
|
t.Errorf("host match lacks an nfproto guard: %v", describeRule(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompile_HostsZoneMatches(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mod func(*config.Config)
|
||||||
|
chain string
|
||||||
|
tag string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"forward to hosts zone", func(c *config.Config) {
|
||||||
|
c.Rules = []config.Rule{{Action: config.RuleAccept, Source: "vpn", Dest: "lan", Proto: "tcp"}}
|
||||||
|
}, "forward", "rule:0", []string{"iif=tun0 oif=wlo1 ip4 daddr=192.0.2.0/24", "iif=tun0 oif=enp2s0 ip4 daddr=198.51.100.0/24"}},
|
||||||
|
{"rule address prunes non-overlapping hosts", func(c *config.Config) {
|
||||||
|
c.Rules = []config.Rule{{Action: config.RuleAccept, Source: "lan:192.0.2.5", Dest: "fw", Proto: "tcp"}}
|
||||||
|
}, "input", "rule:0", []string{"iif=wlo1 ip4 saddr=192.0.2.0/24 saddr=192.0.2.5"}},
|
||||||
|
{"host exclusions and sub-zone exclusions fall back to the parent", func(c *config.Config) {
|
||||||
|
c.Hosts[1].Exclusions = []string{"198.51.100.7"}
|
||||||
|
c.Rules = []config.Rule{{Action: config.RuleAccept, Source: "net", Dest: "fw", Proto: "tcp"}}
|
||||||
|
}, "input", "rule:0", []string{
|
||||||
|
"iif=wlo1 ip4 !saddr=192.0.2.0/24", "iif=wlo1 ip6",
|
||||||
|
"iif=enp2s0 ip4 !saddr=198.51.100.0/24", "iif=enp2s0 ip6",
|
||||||
|
"iif=enp2s0 ip4 saddr=198.51.100.7",
|
||||||
|
}},
|
||||||
|
{"host exclusion", func(c *config.Config) {
|
||||||
|
c.Hosts[1].Exclusions = []string{"198.51.100.7"}
|
||||||
|
}, "input", "policy:1", []string{"iif=wlo1 ip4 saddr=192.0.2.0/24", "iif=enp2s0 ip4 saddr=198.51.100.0/24 !saddr=198.51.100.7"}},
|
||||||
|
{"DNAT from hosts zone", func(c *config.Config) {
|
||||||
|
c.Rules = []config.Rule{{Action: config.RuleDNAT, Source: "lan", Dest: "vpn:203.0.113.10", Proto: "tcp", DPort: config.PortSpec{"80"}}}
|
||||||
|
}, "prerouting", "rule:0", []string{"iif=wlo1 ip4 saddr=192.0.2.0/24", "iif=enp2s0 ip4 saddr=198.51.100.0/24"}},
|
||||||
|
{"conntrack from hosts zone", func(c *config.Config) {
|
||||||
|
c.Conntrack = []config.ConntrackRule{{Action: config.ConntrackNoTrack, Source: "lan", Proto: "udp"}}
|
||||||
|
}, "raw_prerouting", "conntrack:0:raw_prerouting", []string{"iif=wlo1 ip4 saddr=192.0.2.0/24", "iif=enp2s0 ip4 saddr=198.51.100.0/24"}},
|
||||||
|
{"blrule from hosts zone", func(c *config.Config) {
|
||||||
|
c.Blrules = []config.BlruleRule{{Action: config.BlruleDrop, Source: "lan", Dest: "all"}}
|
||||||
|
}, "input", "blrule:0", []string{"iif=wlo1 ip4 saddr=192.0.2.0/24", "iif=enp2s0 ip4 saddr=198.51.100.0/24"}},
|
||||||
|
{"all expansion includes hosts zone", func(c *config.Config) {
|
||||||
|
c.Rules = []config.Rule{{Action: config.RuleAccept, Source: "all!net,vpn", Dest: "fw", Proto: "tcp"}}
|
||||||
|
}, "input", "rule:0", []string{"iif=wlo1 ip4 saddr=192.0.2.0/24", "iif=enp2s0 ip4 saddr=198.51.100.0/24"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
state := mustCompile(t, hostsCfg(tt.mod))
|
||||||
|
if got := describeTagged(state, tt.chain, tt.tag); !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("%s %s = %q, want %q", tt.chain, tt.tag, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompile_IntraZoneMultiInterface(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
policy []config.Policy
|
||||||
|
tag string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "implicit accept between distinct interfaces",
|
||||||
|
policy: []config.Policy{{Source: "all", Dest: "all", Action: config.PolicyDrop}},
|
||||||
|
tag: "intra:lxd",
|
||||||
|
want: []string{"iif=lxdbr0 oif=docker0", "iif=lxdbr0 oif=br-", "iif=docker0 oif=lxdbr0", "iif=docker0 oif=br-", "iif=br- oif=lxdbr0", "iif=br- oif=docker0"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit zone policy overrides",
|
||||||
|
policy: []config.Policy{{Source: "lxd", Dest: "lxd", Action: config.PolicyDrop, Log: "info"}, {Source: "all", Dest: "all", Action: config.PolicyDrop}},
|
||||||
|
tag: "policy:0",
|
||||||
|
want: []string{"iif=lxdbr0 oif=docker0", "iif=lxdbr0 oif=br-", "iif=docker0 oif=lxdbr0", "iif=docker0 oif=br-", "iif=br- oif=lxdbr0", "iif=br- oif=docker0"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Settings: config.Settings{TableName: "test", AddressFamily: config.FamilyINET},
|
||||||
|
Zones: map[string]config.Zone{
|
||||||
|
"fw": {Type: config.ZoneFirewall},
|
||||||
|
"net": {Type: config.ZoneIP},
|
||||||
|
"lxd": {Type: config.ZoneIP},
|
||||||
|
},
|
||||||
|
Interfaces: []config.Interface{
|
||||||
|
{Zone: "net", Interface: "eth0"},
|
||||||
|
{Zone: "lxd", Interface: "lxdbr0"},
|
||||||
|
{Zone: "lxd", Interface: "docker0"},
|
||||||
|
{Zone: "lxd", Interface: "br-+"},
|
||||||
|
},
|
||||||
|
Policy: tc.policy,
|
||||||
|
PortGroups: map[string]config.PortGroup{},
|
||||||
|
}
|
||||||
|
state, err := NewCompiler(cfg).Compile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Compile() error: %v", err)
|
||||||
|
}
|
||||||
|
var got, all []string
|
||||||
|
for _, r := range state.Rules["forward"] {
|
||||||
|
if r.Tag == tc.tag {
|
||||||
|
got = append(got, describeRule(r))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(r.Tag, "intra:") {
|
||||||
|
all = append(all, r.Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Errorf("%s rules = %q, want %q", tc.tag, got, tc.want)
|
||||||
|
}
|
||||||
|
if tc.tag != "intra:lxd" && len(all) != 0 {
|
||||||
|
t.Errorf("explicit policy must replace implicit accept, got %q", all)
|
||||||
|
}
|
||||||
|
last := taggedRules(state, "forward", tc.tag)
|
||||||
|
if len(last) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v, ok := last[0].Exprs[len(last[0].Exprs)-1].(*expr.Verdict); !ok || (tc.tag == "intra:lxd") != (v.Kind == expr.VerdictAccept) {
|
||||||
|
t.Errorf("%s verdict = %#v", tc.tag, last[0].Exprs[len(last[0].Exprs)-1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompile_HostsAddressMatchesFamilyGuarded(t *testing.T) {
|
||||||
|
state := mustCompile(t, hostsCfg(func(c *config.Config) {
|
||||||
|
c.Hosts[0].Addresses = append(c.Hosts[0].Addresses, "2001:db8::/64")
|
||||||
|
c.Hosts[0].Exclusions = []string{"192.0.2.9"}
|
||||||
|
}))
|
||||||
|
want := []string{
|
||||||
|
"iif=wlo1 ip4 !saddr=192.0.2.0/24", "iif=wlo1 ip6 !saddr=2001:db8::/64",
|
||||||
|
"iif=wlo1 ip4 saddr=192.0.2.9",
|
||||||
|
"iif=enp2s0 ip4 !saddr=198.51.100.0/24", "iif=enp2s0 ip6",
|
||||||
|
}
|
||||||
|
if got := describeTagged(state, "input", "policy:0"); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("net->fw DROP policy = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
want = []string{
|
||||||
|
"iif=wlo1 ip4 saddr=192.0.2.0/24 !saddr=192.0.2.9", "iif=wlo1 ip6 saddr=2001:db8::/64",
|
||||||
|
"iif=enp2s0 ip4 saddr=198.51.100.0/24",
|
||||||
|
}
|
||||||
|
if got := describeTagged(state, "input", "policy:1"); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("lan->fw policy = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
for chain, rules := range state.Rules {
|
||||||
|
for _, r := range rules {
|
||||||
|
var fam byte
|
||||||
|
for i, e := range r.Exprs {
|
||||||
|
if m, ok := e.(*expr.Meta); ok && m.Key == expr.MetaKeyNFPROTO {
|
||||||
|
fam = r.Exprs[i+1].(*expr.Cmp).Data[0]
|
||||||
|
}
|
||||||
|
p, ok := e.(*expr.Payload)
|
||||||
|
if !ok || p.Base != expr.PayloadBaseNetworkHeader {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Len == 4 && fam != unix.NFPROTO_IPV4 || p.Len == 16 && fam != unix.NFPROTO_IPV6 {
|
||||||
|
t.Errorf("%s %s: %d-byte address compare without its family guard: %s", chain, r.Tag, p.Len, describeRule(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompile_FirewallSelfPolicySkipped(t *testing.T) {
|
||||||
|
for _, action := range []config.PolicyAction{config.PolicyAccept, config.PolicyDrop} {
|
||||||
|
t.Run(string(action), func(t *testing.T) {
|
||||||
|
state := mustCompile(t, listCfg(func(c *config.Config) {
|
||||||
|
c.Policy = []config.Policy{
|
||||||
|
{Source: "fw", Dest: "fw", Action: action},
|
||||||
|
{Source: "net", Dest: "fw", Action: config.PolicyDrop, Log: "info"},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
for _, chain := range []string{"input", "output", "forward"} {
|
||||||
|
if got := taggedRules(state, chain, "policy:0"); len(got) != 0 {
|
||||||
|
t.Errorf("fw->fw emitted %d rules in %s", len(got), chain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := taggedRules(state, "input", "policy:1")
|
||||||
|
if len(got) != 1 || describeRule(got[0]) != "iif=eth0" {
|
||||||
|
t.Errorf("net->fw input rules = %d, want one scoped to eth0", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompile_DestPlusOverridesIntraZone(t *testing.T) {
|
||||||
|
state := mustCompile(t, listCfg(func(c *config.Config) {
|
||||||
|
c.Zones["lxd"] = config.Zone{Type: config.ZoneIP}
|
||||||
|
c.Interfaces = append(c.Interfaces, config.Interface{Zone: "lxd", Interface: "lxdbr0"}, config.Interface{Zone: "lxd", Interface: "docker0"})
|
||||||
|
c.Policy = []config.Policy{{Source: "lxd", Dest: "all+", Action: config.PolicyDrop}}
|
||||||
|
}))
|
||||||
|
if got := taggedRules(state, "forward", "intra:lxd"); len(got) != 0 {
|
||||||
|
t.Errorf("lxd all+ must override implicit intra-zone accept, got %d rules", len(got))
|
||||||
|
}
|
||||||
|
if got := taggedRules(state, "forward", "policy:0"); len(got) == 0 {
|
||||||
|
t.Error("lxd all+ emitted no forward rules")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompile_HostsIntraZone(t *testing.T) {
|
||||||
|
state := mustCompile(t, hostsCfg(nil))
|
||||||
|
want := []string{
|
||||||
|
"iif=wlo1 ip4 saddr=192.0.2.0/24 oif=enp2s0 ip4 daddr=198.51.100.0/24",
|
||||||
|
"iif=enp2s0 ip4 saddr=198.51.100.0/24 oif=wlo1 ip4 daddr=192.0.2.0/24",
|
||||||
|
}
|
||||||
|
if got := describeTagged(state, "forward", "intra:lan"); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("lan intra = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
want = []string{
|
||||||
|
"iif=wlo1 ip4 !saddr=192.0.2.0/24 oif=enp2s0 ip4 !daddr=198.51.100.0/24",
|
||||||
|
"iif=wlo1 ip6 oif=enp2s0 ip6",
|
||||||
|
"iif=enp2s0 ip4 !saddr=198.51.100.0/24 oif=wlo1 ip4 !daddr=192.0.2.0/24",
|
||||||
|
"iif=enp2s0 ip6 oif=wlo1 ip6",
|
||||||
|
}
|
||||||
|
if got := describeTagged(state, "forward", "intra:net"); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("net intra = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
state = mustCompile(t, hostsCfg(func(c *config.Config) {
|
||||||
|
c.Policy = append([]config.Policy{{Source: "lan", Dest: "lan", Action: config.PolicyDrop}}, c.Policy...)
|
||||||
|
}))
|
||||||
|
if got := describeTagged(state, "forward", "intra:lan"); len(got) != 0 {
|
||||||
|
t.Errorf("explicit lan lan policy must replace implicit accept, got %q", got)
|
||||||
|
}
|
||||||
|
want = []string{
|
||||||
|
"iif=wlo1 ip4 saddr=192.0.2.0/24 oif=enp2s0 ip4 daddr=198.51.100.0/24",
|
||||||
|
"iif=enp2s0 ip4 saddr=198.51.100.0/24 oif=wlo1 ip4 daddr=192.0.2.0/24",
|
||||||
|
}
|
||||||
|
if got := describeTagged(state, "forward", "policy:0"); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("lan lan policy = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompile_HostsRouteBack(t *testing.T) {
|
||||||
|
sameIface := func(routeback bool) func(*config.Config) {
|
||||||
|
return func(c *config.Config) {
|
||||||
|
c.Hosts = []config.Host{
|
||||||
|
{Zone: "lan", Interface: "wlo1", Addresses: []string{"192.0.2.0/24"}, Options: config.HostOptions{RouteBack: routeback}},
|
||||||
|
{Zone: "lan", Interface: "wlo1", Addresses: []string{"198.51.100.0/24"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := describeTagged(mustCompile(t, hostsCfg(sameIface(false))), "forward", "intra:lan"); len(got) != 0 {
|
||||||
|
t.Errorf("same-interface hosts without routeback = %q, want none", got)
|
||||||
|
}
|
||||||
|
want := []string{"iif=wlo1 ip4 saddr=192.0.2.0/24 oif=wlo1 ip4 daddr=192.0.2.0/24"}
|
||||||
|
if got := describeTagged(mustCompile(t, hostsCfg(sameIface(true))), "forward", "intra:lan"); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("routeback hosts intra = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := mustCompile(t, hostsCfg(func(c *config.Config) {
|
||||||
|
sameIface(true)(c)
|
||||||
|
c.Policy = append([]config.Policy{{Source: "lan", Dest: "lan", Action: config.PolicyDrop}}, c.Policy...)
|
||||||
|
}))
|
||||||
|
if got := describeTagged(state, "forward", "policy:0"); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("routeback hosts lan lan policy = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -248,6 +248,9 @@ func convertInterfaces(dir string, cfg *config.Config, params map[string]string)
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
zone := subst(field(row, 0), params)
|
zone := subst(field(row, 0), params)
|
||||||
iface := subst(field(row, 1), params)
|
iface := subst(field(row, 1), params)
|
||||||
|
if isDash(zone) {
|
||||||
|
zone = ""
|
||||||
|
}
|
||||||
|
|
||||||
intf := config.Interface{
|
intf := config.Interface{
|
||||||
Zone: zone,
|
Zone: zone,
|
||||||
@@ -392,12 +395,14 @@ func convertHosts(dir string, cfg *config.Config, params map[string]string) erro
|
|||||||
zone := subst(field(row, 0), params)
|
zone := subst(field(row, 0), params)
|
||||||
hostDef := subst(field(row, 1), params)
|
hostDef := subst(field(row, 1), params)
|
||||||
|
|
||||||
|
hostDef, excl, _ := strings.Cut(hostDef, "!")
|
||||||
iface, addrs := splitHostDef(hostDef)
|
iface, addrs := splitHostDef(hostDef)
|
||||||
|
|
||||||
host := config.Host{
|
host := config.Host{
|
||||||
Zone: zone,
|
Zone: zone,
|
||||||
Interface: iface,
|
Interface: iface,
|
||||||
Addresses: addrs,
|
Addresses: addrs,
|
||||||
|
Exclusions: splitAddrList(excl),
|
||||||
}
|
}
|
||||||
|
|
||||||
optsStr := subst(field(row, 2), params)
|
optsStr := subst(field(row, 2), params)
|
||||||
@@ -415,16 +420,19 @@ func splitHostDef(s string) (string, []string) {
|
|||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
iface := s[:idx]
|
return s[:idx], splitAddrList(s[idx+1:])
|
||||||
addrPart := s[idx+1:]
|
}
|
||||||
|
|
||||||
|
// splitAddrList splits a comma address list, unwrapping shorewall6 [addr]/len brackets.
|
||||||
|
func splitAddrList(s string) []string {
|
||||||
var addrs []string
|
var addrs []string
|
||||||
for _, a := range strings.Split(addrPart, ",") {
|
for _, a := range strings.Split(s, ",") {
|
||||||
a = strings.TrimSpace(a)
|
a = strings.NewReplacer("[", "", "]", "").Replace(strings.TrimSpace(a))
|
||||||
if a != "" {
|
if a != "" {
|
||||||
addrs = append(addrs, a)
|
addrs = append(addrs, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return iface, addrs
|
return addrs
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseHostOptions(s string) config.HostOptions {
|
func parseHostOptions(s string) config.HostOptions {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package shorewall
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.unkin.net/unkin/tomswall/internal/config"
|
"git.unkin.net/unkin/tomswall/internal/config"
|
||||||
@@ -776,3 +777,30 @@ func TestConvert_LogLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvert_HostsExclusions(t *testing.T) {
|
||||||
|
dir := minimalShorewallDir(t)
|
||||||
|
writeFile(t, dir, "interfaces", `
|
||||||
|
net eth0
|
||||||
|
- eth1
|
||||||
|
`)
|
||||||
|
writeFile(t, dir, "hosts", `
|
||||||
|
loc eth0:192.0.2.0/24,198.51.100.0/24!192.0.2.7,192.0.2.8 routeback
|
||||||
|
loc eth1:[2001:db8::]/64
|
||||||
|
`)
|
||||||
|
cfg, err := Convert(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Convert: %v", err)
|
||||||
|
}
|
||||||
|
want := []config.Host{
|
||||||
|
{Zone: "loc", Interface: "eth0", Addresses: []string{"192.0.2.0/24", "198.51.100.0/24"},
|
||||||
|
Exclusions: []string{"192.0.2.7", "192.0.2.8"}, Options: config.HostOptions{RouteBack: true}},
|
||||||
|
{Zone: "loc", Interface: "eth1", Addresses: []string{"2001:db8::/64"}},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(cfg.Hosts, want) {
|
||||||
|
t.Errorf("hosts = %+v, want %+v", cfg.Hosts, want)
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Errorf("Validate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user