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()}
|
||||
}
|
||||
Reference in new issue
Block a user