v0.25.0 netacl and fixes
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-04 23:43:52 -04:00
1 parent 9e86d7999d
commit 4af072f4e5
30 files changed
+918 -228

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
// Package netacl is the address seam of the network listeners, beside tlsx
// and authz: a Policy refuses a peer by its address as the socket is
// accepted, before TLS or a byte of the protocol is read. New returns nil
// when no rule is set, and every method tolerates a nil receiver.
package netacl
import (
"fmt"
"net"
"net/netip"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/log"
)
// warnEvery spaces the WARNs reporting refused peers; acl_denied counts all
const warnEvery = time.Minute
// Policy is the compiled form of config.ACLOptions for one listener
type Policy struct {
allow, deny []netip.Prefix
logger *log.Logger
component, id string
denied atomic.Uint64
epoch time.Time // a monotonic base: a stepped clock neither mutes nor floods the WARN
nextWarn atomic.Int64 // nanoseconds since epoch
}
// New compiles the rules of a listener on host and warns of what they leave
// open. Each entry must be of the family core.Network gives host: one the
// listener's sockets can never carry is a mistake, not a rule.
func New(o *config.ACLOptions, host string, l *log.Logger, component, id string) (*Policy, error) {
if o == nil || len(o.Allow)+len(o.Deny) == 0 {
return nil, nil
}
network, err := core.Network(host)
if err != nil {
return nil, err
}
var widened []string // entry, network pairs
compile := func(key string, entries []string) ([]netip.Prefix, error) {
var rules []netip.Prefix
for _, e := range entries {
prefix, err := parse(e, network)
if err != nil {
return nil, fmt.Errorf("acl: %s entry %q: %w", key, e, err)
}
if m := prefix.Masked(); m != prefix {
widened = append(widened, e, m.String())
}
rules = append(rules, prefix.Masked())
}
return rules, nil
}
p := &Policy{logger: l, component: component, id: id, epoch: time.Now()}
if p.allow, err = compile("allow", o.Allow); err != nil {
return nil, err
}
if p.deny, err = compile("deny", o.Deny); err != nil {
return nil, err
}
p.logStartup(host, widened)
return p, nil
}
func parse(entry, network string) (netip.Prefix, error) {
entry = strings.TrimSpace(entry)
prefix, err := netip.ParsePrefix(entry)
if err != nil {
addr, aerr := netip.ParseAddr(entry)
if aerr != nil || addr.Zone() != "" {
return netip.Prefix{}, fmt.Errorf("neither an address nor a CIDR (and no zone)")
}
prefix = netip.PrefixFrom(addr, addr.BitLen())
}
switch a := prefix.Addr(); {
case a.Is4In6():
return netip.Prefix{}, fmt.Errorf("IPv4-mapped; write it as IPv4")
case network == "tcp4" && !a.Is4(), network == "tcp6" && a.Is4():
return netip.Prefix{}, fmt.Errorf("not of the listener's family (%s)", network)
}
return prefix, nil
}
func (p *Policy) logStartup(host string, widened []string) {
for i := 0; i < len(widened); i += 2 {
p.logger.Warn("msg", "acl entry has host bits set and matches its whole network",
"component", p.component,
"instance_id", p.id,
"entry", widened[i],
"network", widened[i+1])
}
for _, q := range p.allow {
if q.Bits() == 0 {
p.logger.Warn("msg", "acl.allow admits every address of its family",
"component", p.component,
"instance_id", p.id,
"entry", q.String(),
"hint", "list only the networks that may connect")
}
}
if addr, err := netip.ParseAddr(host); len(p.allow) == 0 && (host == "" || err == nil && addr.IsUnspecified()) {
p.logger.Warn("msg", "Listener on every address admits all that acl.deny does not list",
"component", p.component,
"instance_id", p.id,
"hint", "set acl.allow to the networks that may connect")
}
}
// admits applies the rules: deny wins, then a set allow list admits only its
// entries. A peer whose address is unknown is refused.
func (p *Policy) admits(addr netip.Addr) bool {
addr = addr.Unmap().WithZone("") // a zoned address is in no prefix
in := func(q netip.Prefix) bool { return q.Contains(addr) }
return addr.IsValid() && !slices.ContainsFunc(p.deny, in) &&
(len(p.allow) == 0 || slices.ContainsFunc(p.allow, in))
}
// Listener returns ln refusing the peers the rules exclude as it accepts them,
// so the plugin never sees one. Close stays ln's: a sink's shutdown flush
// reaches it unchanged. A nil Policy returns ln.
func (p *Policy) Listener(ln net.Listener) net.Listener {
if p == nil {
return ln
}
return &listener{Listener: ln, p: p}
}
type listener struct {
net.Listener
p *Policy
}
func (l *listener) Accept() (net.Conn, error) {
for {
conn, err := l.Listener.Accept()
if err != nil {
return nil, err
}
var addr netip.Addr
remote := conn.RemoteAddr()
if t, ok := remote.(*net.TCPAddr); ok {
addr = t.AddrPort().Addr()
}
if l.p.admits(addr) {
return conn, nil
}
conn.Close()
l.p.refused(fmt.Sprint(remote))
}
}
// refused counts a refusal and WARNs at most once per warnEvery
func (p *Policy) refused(remote string) {
n := p.denied.Add(1)
now, next := int64(time.Since(p.epoch)), p.nextWarn.Load()
if now < next || !p.nextWarn.CompareAndSwap(next, now+int64(warnEvery)) {
return
}
p.logger.Warn("msg", "Connection refused by acl; more within a minute are only counted",
"component", p.component,
"instance_id", p.id,
"remote_addr", remote,
"acl_denied", n)
}
// Describe renders the policy for log lines, stats and the status endpoint
func (p *Policy) Describe() string {
if p == nil {
return "none"
}
allow := "any"
if len(p.allow) > 0 {
allow = fmt.Sprint(len(p.allow))
}
return fmt.Sprintf("allow=%s deny=%d", allow, len(p.deny))
}
func (p *Policy) Denied() uint64 {
if p == nil {
return 0
}
return p.denied.Load()
}
// Stats reports the policy for a plugin's stats details, merged in with
// maps.Copy beside authz's. A nil Policy reports nothing.
func (p *Policy) Stats() map[string]any {
if p == nil {
return nil
}
return map[string]any{"acl": p.Describe(), "acl_denied": p.denied.Load()}
}
+125
View File
@@ -0,0 +1,125 @@
package netacl
import (
"io"
"net"
"net/netip"
"strings"
"testing"
"time"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/log"
)
func mustNew(t *testing.T, host string, allow, deny []string) *Policy {
t.Helper()
p, err := New(&config.ACLOptions{Allow: allow, Deny: deny}, host, log.NewLogger(), "test", "acl")
if err != nil {
t.Fatal(err)
}
return p
}
// Deny wins over allow, a set allow list admits only its entries, an empty
// one admits all not denied, an IPv4-mapped peer matches as IPv4, and a peer
// of unknown address is refused.
func TestDenyWinsThenAllowListAdmitsOnlyItsEntries(t *testing.T) {
listed := mustNew(t, "0.0.0.0", []string{"127.0.0.0/8"}, []string{"127.0.0.2"})
denyOnly := mustNew(t, "0.0.0.0", nil, []string{"10.0.0.0/8"})
for _, c := range []struct {
p *Policy
addr string
want bool
}{
{listed, "127.0.0.1", true},
{listed, "127.0.0.2", false},
{listed, "192.0.2.1", false},
{denyOnly, "10.1.2.3", false},
{denyOnly, "192.0.2.1", true},
{denyOnly, "::ffff:10.1.2.3", false},
{denyOnly, "", false},
} {
addr, _ := netip.ParseAddr(c.addr)
if got := c.p.admits(addr); got != c.want {
t.Errorf("%s under %s: admitted %v", c.addr, c.p.Describe(), got)
}
}
}
// Entries are of the family core.Network gives the listener's host, as its
// sockets carry no other: IPv4 on an IPv4 listener, IPv6 on an IPv6 one,
// either behind a hostname; never IPv4-mapped, never with a zone.
func TestEntriesFollowTheListenerFamily(t *testing.T) {
for _, c := range []struct {
host, entry string
ok bool
}{
{"0.0.0.0", "10.0.0.0/8", true},
{"0.0.0.0", "2001:db8::/32", false},
{"::", "2001:db8::1", true},
{"::", "10.0.0.1", false},
{"::", "::ffff:10.0.0.1", false},
{"::", "fe80::1%eth0", false},
{"logs.example", "10.0.0.0/8", true},
{"logs.example", "2001:db8::/32", true},
} {
_, err := New(&config.ACLOptions{Deny: []string{c.entry}}, c.host, log.NewLogger(), "test", "acl")
if (err == nil) != c.ok {
t.Errorf("%s on %q: %v", c.entry, c.host, err)
}
}
}
// A link-local peer carries its interface zone, which no prefix contains:
// it is matched without it, or a deny of fe80::/10 would admit it.
func TestZonedPeerMatchesItsPrefix(t *testing.T) {
p := mustNew(t, "::", nil, []string{"fe80::/10"})
if p.admits(netip.MustParseAddr("fe80::1%eth0")) {
t.Fatal("a zoned link-local peer passed the deny of its prefix")
}
}
// A refused peer is closed as it is accepted and counted: the plugin's
// Accept returns the next admitted one.
func TestRefusedPeerNeverReachesAccept(t *testing.T) {
inner := &fakeListener{conns: make(chan net.Conn, 2)}
refused, refusedPeer := net.Pipe()
admitted, _ := net.Pipe()
inner.conns <- remoteAt(refused, "192.0.2.1")
inner.conns <- remoteAt(admitted, "127.0.0.1")
ln := mustNew(t, "127.0.0.1", nil, []string{"192.0.2.0/24"}).Listener(inner)
conn, err := ln.Accept()
if err != nil {
t.Fatal(err)
}
if got := conn.RemoteAddr().String(); !strings.HasPrefix(got, "127.0.0.1:") {
t.Fatalf("Accept returned %s", got)
}
refusedPeer.SetReadDeadline(time.Now().Add(time.Second))
if _, err := refusedPeer.Read(make([]byte, 1)); err != io.EOF {
t.Fatalf("the refused peer reads %v, want EOF", err)
}
if n := ln.(*listener).p.Stats()["acl_denied"]; n != uint64(1) {
t.Fatalf("acl_denied = %v", n)
}
}
type fakeListener struct {
net.Listener
conns chan net.Conn
}
func (f *fakeListener) Accept() (net.Conn, error) { return <-f.conns, nil }
type remoteConn struct {
net.Conn
remote net.Addr
}
func (c remoteConn) RemoteAddr() net.Addr { return c.remote }
func remoteAt(c net.Conn, addr string) net.Conn {
return remoteConn{c, net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.MustParseAddr(addr), 40000))}
}