v0.25.0 netacl and fixes
This commit is contained in:
1 parent
9e86d7999d
commit
4af072f4e5
30 files changed
+918
-228
No files matched your search
@@ -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()}
|
||||
}
|
||||
@@ -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))}
|
||||
}
|
||||
Reference in new issue
Block a user