v0.26.1 per-client connection caps
This commit is contained in:
1 parent
ee01cb00fe
commit
cdd4fc01a7
36 files changed
+795
-417
No files matched your search
@@ -0,0 +1,155 @@
|
||||
package netacl
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/tokenbucket"
|
||||
)
|
||||
|
||||
const (
|
||||
maxClients = 65536 // per table; past it new clients are refused
|
||||
slotIdle = time.Minute // without a rate: Release drops a client, the sweep only expired holds
|
||||
)
|
||||
|
||||
// Key is what per-client limits count: the address, or for IPv6 its /64,
|
||||
// which one host usually holds whole. Link-local too, per zone: a peer picks
|
||||
// any fe80::/64 address, so per address it would escape its budget.
|
||||
func Key(addr netip.Addr) string {
|
||||
addr = addr.Unmap()
|
||||
if !addr.Is6() {
|
||||
return addr.String()
|
||||
}
|
||||
key := netip.PrefixFrom(addr, 64).Masked().String()
|
||||
if zone := addr.Zone(); zone != "" {
|
||||
key += "%" + zone
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Table limits each client key: an attempt spends a token from its bucket, if
|
||||
// the table has a rate, and takes a slot, if it caps slots; a slot is
|
||||
// released, or held by an id until done or expired. A full table refuses new
|
||||
// keys, failing closed, and a sweep drops the idle ones.
|
||||
type Table struct {
|
||||
burst, rate float64
|
||||
slots int
|
||||
idle time.Duration // a bucket's refill: a client idle that long loses nothing when dropped
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[string]*client
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
type client struct {
|
||||
bucket *tokenbucket.TokenBucket // nil without a rate
|
||||
held map[string]time.Time // slots held by id, until their expiry
|
||||
taken int // slots taken and not yet held or released
|
||||
seen time.Time
|
||||
}
|
||||
|
||||
// NewTable allows each client burst attempts refilled at rate per second (0:
|
||||
// no rate) and slots at once (0: no cap)
|
||||
func NewTable(burst, rate float64, slots int) *Table {
|
||||
t := &Table{burst: burst, rate: rate, slots: slots, idle: slotIdle, clients: make(map[string]*client)}
|
||||
if rate > 0 {
|
||||
t.idle = time.Duration(burst / rate * float64(time.Second))
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Take admits an attempt by key, false when its tokens or slots are spent or
|
||||
// the table is full
|
||||
func (t *Table) Take(key string) bool {
|
||||
now := time.Now()
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if now.Sub(t.lastSweep) > t.idle/6 {
|
||||
t.sweep(now)
|
||||
}
|
||||
c := t.clients[key]
|
||||
if c == nil {
|
||||
if len(t.clients) >= maxClients {
|
||||
return false
|
||||
}
|
||||
c = &client{held: make(map[string]time.Time)}
|
||||
if t.rate > 0 {
|
||||
c.bucket = tokenbucket.New(t.burst, t.rate)
|
||||
}
|
||||
t.clients[key] = c
|
||||
}
|
||||
c.seen = now
|
||||
c.expire(now)
|
||||
// Taken under this lock: concurrent attempts cannot all pass the check
|
||||
if t.slots > 0 && len(c.held)+c.taken >= t.slots || c.bucket != nil && !c.bucket.Allow() {
|
||||
return false
|
||||
}
|
||||
if t.slots > 0 {
|
||||
c.taken++
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Hold moves a slot Take took to id, until done or expired
|
||||
func (t *Table) Hold(key, id string, until time.Time) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if c := t.clients[key]; c != nil {
|
||||
c.taken--
|
||||
c.held[id] = until
|
||||
}
|
||||
}
|
||||
|
||||
// Release frees a slot Take took that no id holds
|
||||
func (t *Table) Release(key string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if c := t.clients[key]; c != nil {
|
||||
c.taken--
|
||||
if c.idle(time.Now(), t.idle) {
|
||||
delete(t.clients, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Done frees the slot id holds
|
||||
func (t *Table) Done(key, id string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if c := t.clients[key]; c != nil {
|
||||
delete(c.held, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Refund returns the token of an attempt that turned out not to count
|
||||
func (t *Table) Refund(key string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if c := t.clients[key]; c != nil && c.bucket != nil {
|
||||
c.bucket.Refund(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) expire(now time.Time) {
|
||||
for id, expiry := range c.held {
|
||||
if now.After(expiry) {
|
||||
delete(c.held, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// idle reports a client holding nothing whose bucket, if any, is full again
|
||||
func (c *client) idle(now time.Time, after time.Duration) bool {
|
||||
return c.taken == 0 && len(c.held) == 0 && (c.bucket == nil || now.Sub(c.seen) > after)
|
||||
}
|
||||
|
||||
func (t *Table) sweep(now time.Time) {
|
||||
t.lastSweep = now
|
||||
for key, c := range t.clients {
|
||||
c.expire(now) // an abandoned hold has no Done
|
||||
if c.idle(now, t.idle) {
|
||||
delete(t.clients, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
-40
@@ -1,8 +1,8 @@
|
||||
// Package netacl is the address seam of the network listeners, beside tlsx
|
||||
// and authz: a Policy reads the PROXY header of the proxies it trusts, then
|
||||
// refuses a peer by its client's address, before TLS or a byte of the
|
||||
// protocol is read. New returns nil when nothing is set, and every method
|
||||
// tolerates a nil receiver.
|
||||
// refuses a peer by its client's address, or past its client's limits, before
|
||||
// TLS or a byte of the protocol is read. New returns nil when nothing is set,
|
||||
// and every method tolerates a nil receiver.
|
||||
package netacl
|
||||
|
||||
import (
|
||||
@@ -12,7 +12,9 @@ import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -29,7 +31,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// warnEvery spaces the WARNs reporting refused peers; acl_denied counts all
|
||||
// warnEvery spaces the WARNs reporting refusals; acl_denied and acl_limited count all
|
||||
warnEvery = time.Minute
|
||||
v1Max = 107 // the longest PROXY v1 line, CRLF included
|
||||
v2Max = 2048 // the longest PROXY v2 address block, TLVs included
|
||||
@@ -46,34 +48,48 @@ var (
|
||||
netip.MustParsePrefix("fe80::/10"), netip.MustParsePrefix("::1/128"),
|
||||
}
|
||||
errScreened = errors.New("PROXY header from a peer outside proxy_from")
|
||||
// ErrDenied marks a refusal by the rules, which the Policy has reported
|
||||
ErrDenied = errors.New("refused by acl")
|
||||
// v2Families holds the family bytes v2 defines, each with the address
|
||||
// bytes taken under PROXY: TCP over IPv4 and IPv6 name the client, the
|
||||
// rest keep the socket address
|
||||
v2Families = map[byte]int{0x00: 0, 0x11: 12, 0x12: 0, 0x21: 36, 0x22: 0, 0x31: 0, 0x32: 0}
|
||||
)
|
||||
|
||||
// Kind is what a listener serves, which decides the limits it takes and whose
|
||||
// addresses its rules judge
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
TCP Kind = iota
|
||||
HTTP // takes requests_per_second_per_client
|
||||
HTTPProxied // HTTP behind trusted_proxies: rules judge each forwarded client too
|
||||
)
|
||||
|
||||
// Policy is the compiled form of config.ACLOptions for one listener
|
||||
type Policy struct {
|
||||
allow, deny, proxyFrom []netip.Prefix
|
||||
mode string // proxy_protocol, when proxy_from is set
|
||||
headerWait time.Duration // for a proxy's header: tlsx.HandshakeTimeout
|
||||
conns, requests *Table // max_connections_per_client, requests_per_second_per_client
|
||||
logger *log.Logger
|
||||
component, id string
|
||||
|
||||
denied, headers atomic.Uint64
|
||||
epoch time.Time // a monotonic base: a stepped clock neither mutes nor floods the WARN
|
||||
nextWarn atomic.Int64 // nanoseconds since epoch
|
||||
denied, limited, headers 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. Entries are of the family core.Network gives host, as one the sockets
|
||||
// never carry is a mistake; behind proxy_from, allow and deny take both, as
|
||||
// the proxy names clients of either.
|
||||
func New(o *config.ACLOptions, host string, l *log.Logger, component, id string) (*Policy, error) {
|
||||
// New compiles the rules of a kind of listener on host, and warns of what they
|
||||
// leave open. Entries are of the family core.Network gives host, as one the
|
||||
// sockets never carry is a mistake; behind a proxy, allow and deny take both,
|
||||
// as it names clients of either.
|
||||
func New(o *config.ACLOptions, host string, kind Kind, l *log.Logger, component, id string) (*Policy, error) {
|
||||
if o == nil {
|
||||
return nil, nil
|
||||
}
|
||||
proxied := o.ProxyProtocol == "optional" || o.ProxyProtocol == "required"
|
||||
rps := o.RequestsPerSecondPerClient
|
||||
switch {
|
||||
case !proxied && o.ProxyProtocol != "" && o.ProxyProtocol != "off":
|
||||
return nil, fmt.Errorf("acl: proxy_protocol %q is none of off, optional and required", o.ProxyProtocol)
|
||||
@@ -81,7 +97,15 @@ func New(o *config.ACLOptions, host string, l *log.Logger, component, id string)
|
||||
return nil, fmt.Errorf("acl: proxy_protocol %s needs proxy_from, the proxies that may send the header", o.ProxyProtocol)
|
||||
case !proxied && len(o.ProxyFrom) > 0:
|
||||
return nil, errors.New("acl: proxy_from needs proxy_protocol optional or required")
|
||||
case len(o.Allow)+len(o.Deny)+len(o.ProxyFrom) == 0:
|
||||
case o.MaxConnectionsPerClient < 0:
|
||||
return nil, fmt.Errorf("acl: max_connections_per_client %d is negative", o.MaxConnectionsPerClient)
|
||||
case !(rps >= 0) || math.IsInf(rps, 0):
|
||||
return nil, fmt.Errorf("acl: requests_per_second_per_client %v is not a rate", rps)
|
||||
case rps > 0 && kind == TCP:
|
||||
return nil, errors.New("acl: requests_per_second_per_client applies only to HTTP listeners")
|
||||
case o.MaxConnectionsPerClient > 0 && kind == HTTPProxied:
|
||||
return nil, errors.New("acl: max_connections_per_client counts connections, behind trusted_proxies the proxy's; cap clients at the proxy")
|
||||
case len(o.Allow)+len(o.Deny)+len(o.ProxyFrom) == 0 && o.MaxConnectionsPerClient == 0 && rps == 0:
|
||||
return nil, nil
|
||||
}
|
||||
network, err := core.Network(host)
|
||||
@@ -92,7 +116,7 @@ func New(o *config.ACLOptions, host string, l *log.Logger, component, id string)
|
||||
compile := func(key, network string, entries []string) ([]netip.Prefix, error) {
|
||||
var rules []netip.Prefix
|
||||
for _, e := range entries {
|
||||
prefix, err := parse(e, network)
|
||||
prefix, err := Parse(e, network)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acl: %s entry %q: %w", key, e, err)
|
||||
}
|
||||
@@ -107,7 +131,7 @@ func New(o *config.ACLOptions, host string, l *log.Logger, component, id string)
|
||||
if p.proxyFrom, err = compile("proxy_from", network, o.ProxyFrom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if proxied {
|
||||
if proxied || kind == HTTPProxied {
|
||||
network = "tcp"
|
||||
}
|
||||
if p.allow, err = compile("allow", network, o.Allow); err != nil {
|
||||
@@ -116,11 +140,18 @@ func New(o *config.ACLOptions, host string, l *log.Logger, component, id string)
|
||||
if p.deny, err = compile("deny", network, o.Deny); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if o.MaxConnectionsPerClient > 0 {
|
||||
p.conns = NewTable(0, 0, int(o.MaxConnectionsPerClient))
|
||||
}
|
||||
if rps > 0 {
|
||||
p.requests = NewTable(max(rps, 1), rps, 0) // a second's worth, at least one
|
||||
}
|
||||
p.logStartup(host, widened)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func parse(entry, network string) (netip.Prefix, error) {
|
||||
// Parse reads an address or CIDR of network's family ("tcp": either)
|
||||
func Parse(entry, network string) (netip.Prefix, error) {
|
||||
entry = strings.TrimSpace(entry)
|
||||
prefix, err := netip.ParsePrefix(entry)
|
||||
if err != nil {
|
||||
@@ -139,6 +170,15 @@ func parse(entry, network string) (netip.Prefix, error) {
|
||||
return prefix, nil
|
||||
}
|
||||
|
||||
// ParseHost reads an address with or without a port, an IPv4-mapped one as IPv4
|
||||
func ParseHost(s string) (netip.Addr, bool) {
|
||||
if ap, err := netip.ParseAddrPort(s); err == nil {
|
||||
return ap.Addr().Unmap(), true
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.TrimSuffix(strings.TrimPrefix(s, "["), "]"))
|
||||
return addr.Unmap(), err == 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",
|
||||
@@ -281,10 +321,10 @@ func (l *listener) proxied(c net.Conn) {
|
||||
pc := &conn{Conn: c, r: br}
|
||||
switch {
|
||||
case err != nil:
|
||||
l.p.refuse(c, "PROXY header: "+err.Error())
|
||||
l.p.refuse(c, &l.p.denied, "PROXY header: "+err.Error())
|
||||
return
|
||||
case !found && l.p.mode == "required":
|
||||
l.p.refuse(c, "no PROXY header")
|
||||
l.p.refuse(c, &l.p.denied, "no PROXY header")
|
||||
return
|
||||
case found:
|
||||
l.p.headers.Add(1)
|
||||
@@ -295,12 +335,27 @@ func (l *listener) proxied(c net.Conn) {
|
||||
l.admit(pc)
|
||||
}
|
||||
|
||||
// admit hands c to Accept if the rules admit the address it ends with
|
||||
// admit hands c to Accept if the rules admit the address it ends with, and
|
||||
// that client has a connection to spare, which closing c returns
|
||||
func (l *listener) admit(c net.Conn) {
|
||||
if !l.p.admits(addrOf(c)) {
|
||||
l.p.refuse(c, "address rules")
|
||||
addr := addrOf(c)
|
||||
if !l.p.admits(addr) {
|
||||
l.p.refuse(c, &l.p.denied, "address rules")
|
||||
return
|
||||
}
|
||||
if l.p.conns != nil {
|
||||
key := Key(addr)
|
||||
if !l.p.conns.Take(key) {
|
||||
l.p.refuse(c, &l.p.limited, "max_connections_per_client")
|
||||
return
|
||||
}
|
||||
pc, ok := c.(*conn)
|
||||
if !ok {
|
||||
pc = &conn{Conn: c}
|
||||
}
|
||||
pc.release = func() { l.p.conns.Release(key) }
|
||||
c = pc
|
||||
}
|
||||
l.deliver(c, nil)
|
||||
}
|
||||
|
||||
@@ -316,36 +371,80 @@ func (l *listener) deliver(c net.Conn, err error) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// refuse closes c, counts it and WARNs at most once per warnEvery
|
||||
func (p *Policy) refuse(c net.Conn, reason string) {
|
||||
n := p.denied.Add(1) // first: a peer that sees the close sees the count
|
||||
// refuse closes c and counts it, first: a peer that sees the close sees the
|
||||
// count
|
||||
func (p *Policy) refuse(c net.Conn, count *atomic.Uint64, reason string) {
|
||||
count.Add(1)
|
||||
c.Close()
|
||||
p.report("Connection", fmt.Sprint(c.RemoteAddr()), PeerAddr(c), reason)
|
||||
}
|
||||
|
||||
// report WARNs of a refusal at most once per warnEvery; the counts keep the rest
|
||||
func (p *Policy) report(what, client, peer, reason string) {
|
||||
now, next := int64(time.Since(p.epoch)), p.nextWarn.Load()
|
||||
if now < next || !p.nextWarn.CompareAndSwap(next, now+int64(warnEvery)) {
|
||||
return
|
||||
}
|
||||
fields := []any{"msg", "Connection refused by acl; more within a minute are only counted",
|
||||
fields := []any{"msg", what + " refused by acl; more within a minute are only counted",
|
||||
"component", p.component,
|
||||
"instance_id", p.id,
|
||||
"remote_addr", fmt.Sprint(c.RemoteAddr()),
|
||||
"remote_addr", client,
|
||||
"reason", reason,
|
||||
"acl_denied", n}
|
||||
if peer := PeerAddr(c); peer != "" {
|
||||
"acl_denied", p.denied.Load(),
|
||||
"acl_limited", p.limited.Load()}
|
||||
if peer != "" {
|
||||
fields = append(fields, "peer_addr", peer)
|
||||
}
|
||||
p.logger.Warn(fields...)
|
||||
}
|
||||
|
||||
// Requests answers 429 to a client past requests_per_second_per_client.
|
||||
// client names it in a request (nil: the socket peer); a nil Policy, or one
|
||||
// without the limit, returns next.
|
||||
func (p *Policy) Requests(next http.Handler, client func(*http.Request) string) http.Handler {
|
||||
if p == nil || p.requests == nil {
|
||||
return next
|
||||
}
|
||||
if client == nil {
|
||||
client = func(r *http.Request) string { return r.RemoteAddr }
|
||||
}
|
||||
retry := strconv.FormatFloat(math.Ceil(1/p.requests.rate), 'f', 0, 64) // until a request's token is back
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
addr, _ := ParseHost(client(r))
|
||||
if !p.requests.Take(Key(addr)) {
|
||||
p.limited.Add(1)
|
||||
p.report("Request", client(r), ContextPeerAddr(r.Context()), "requests_per_second_per_client")
|
||||
w.Header().Set("Retry-After", retry)
|
||||
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Forwarded applies the rules to a client an L7 proxy, peer, forwarded, the
|
||||
// proxy having passed them at connect; a refusal counts in acl_denied
|
||||
func (p *Policy) Forwarded(client netip.Addr, peer string) error {
|
||||
if p == nil || p.admits(client) {
|
||||
return nil
|
||||
}
|
||||
p.denied.Add(1)
|
||||
p.report("Request", client.String(), peer, "address rules")
|
||||
return fmt.Errorf("forwarded client %s %w", client, ErrDenied)
|
||||
}
|
||||
|
||||
// conn is read through the buffer its first bytes went to: past a PROXY
|
||||
// header, whose client it reports, or under a screen refusing one from a peer
|
||||
// outside proxy_from at the first Read
|
||||
// outside proxy_from at the first Read. Closing it releases its client's slot.
|
||||
type conn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
client net.Addr // nil keeps the socket's
|
||||
screen *Policy
|
||||
once sync.Once
|
||||
err error
|
||||
r *bufio.Reader // nil: read the socket
|
||||
client net.Addr // nil keeps the socket's
|
||||
screen *Policy
|
||||
release func() // nil without max_connections_per_client
|
||||
once sync.Once
|
||||
closed sync.Once
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *conn) Read(b []byte) (int, error) {
|
||||
@@ -355,15 +454,34 @@ func (c *conn) Read(b []byte) (int, error) {
|
||||
}
|
||||
if v, _ := signature(c.r); v != 0 {
|
||||
c.err = errScreened
|
||||
c.screen.refuse(c, errScreened.Error())
|
||||
c.screen.refuse(c, &c.screen.denied, errScreened.Error())
|
||||
}
|
||||
})
|
||||
if c.err != nil {
|
||||
switch {
|
||||
case c.err != nil:
|
||||
return 0, c.err
|
||||
case c.r == nil:
|
||||
return c.Conn.Read(b)
|
||||
}
|
||||
return c.r.Read(b)
|
||||
}
|
||||
|
||||
func (c *conn) Close() error {
|
||||
if c.release != nil {
|
||||
c.closed.Do(c.release)
|
||||
}
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// CloseWrite keeps the half-close net/http sends before dropping a connection
|
||||
// with a body unread, lest the peer get a reset instead of the answer
|
||||
func (c *conn) CloseWrite() error {
|
||||
if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok {
|
||||
return cw.CloseWrite()
|
||||
}
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *conn) RemoteAddr() net.Addr {
|
||||
if c.client != nil {
|
||||
return c.client
|
||||
@@ -511,14 +629,21 @@ func (p *Policy) Describe() string {
|
||||
if len(p.proxyFrom) > 0 {
|
||||
s += fmt.Sprintf(" proxy_protocol=%s proxy_from=%d", p.mode, len(p.proxyFrom))
|
||||
}
|
||||
if p.conns != nil {
|
||||
s += fmt.Sprintf(" max_connections_per_client=%d", p.conns.slots)
|
||||
}
|
||||
if p.requests != nil {
|
||||
s += fmt.Sprintf(" requests_per_second_per_client=%g", p.requests.rate)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Policy) Denied() uint64 {
|
||||
// Refused counts the refusals by the rules and by the per-client limits
|
||||
func (p *Policy) Refused() (denied, limited uint64) {
|
||||
if p == nil {
|
||||
return 0
|
||||
return 0, 0
|
||||
}
|
||||
return p.denied.Load()
|
||||
return p.denied.Load(), p.limited.Load()
|
||||
}
|
||||
|
||||
// Stats reports the policy for a plugin's stats details, merged in with
|
||||
@@ -527,5 +652,6 @@ func (p *Policy) Stats() map[string]any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"acl": p.Describe(), "acl_denied": p.denied.Load(), "acl_proxy_headers": p.headers.Load()}
|
||||
return map[string]any{"acl": p.Describe(), "acl_denied": p.denied.Load(), "acl_limited": p.limited.Load(),
|
||||
"acl_proxy_headers": p.headers.Load()}
|
||||
}
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -21,7 +24,7 @@ import (
|
||||
|
||||
func mustNew(t *testing.T, host string, o config.ACLOptions) *Policy {
|
||||
t.Helper()
|
||||
p, err := New(&o, host, log.NewLogger(), "test", "acl")
|
||||
p, err := New(&o, host, HTTP, log.NewLogger(), "test", "acl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -57,7 +60,8 @@ func TestDenyWinsThenAllowListAdmitsOnlyItsEntries(t *testing.T) {
|
||||
// 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. Behind
|
||||
// proxy_from, which keeps that family, deny takes both: the proxy names either.
|
||||
// proxy_from, which keeps that family, or trusted_proxies, deny takes both: the
|
||||
// proxy names either.
|
||||
func TestEntriesFollowTheListenerFamily(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
host, from, entry string
|
||||
@@ -78,10 +82,13 @@ func TestEntriesFollowTheListenerFamily(t *testing.T) {
|
||||
if c.from != "" {
|
||||
o.ProxyProtocol, o.ProxyFrom = "required", []string{c.from}
|
||||
}
|
||||
if _, err := New(o, c.host, log.NewLogger(), "test", "acl"); (err == nil) != c.ok {
|
||||
if _, err := New(o, c.host, HTTP, log.NewLogger(), "test", "acl"); (err == nil) != c.ok {
|
||||
t.Errorf("%s behind %q on %q: %v", c.entry, c.from, c.host, err)
|
||||
}
|
||||
}
|
||||
if _, err := New(&config.ACLOptions{Deny: []string{"2001:db8::/32"}}, "127.0.0.1", HTTPProxied, log.NewLogger(), "test", "acl"); err != nil {
|
||||
t.Errorf("IPv6 entry behind trusted_proxies: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A link-local peer carries its interface zone, which no prefix contains: the
|
||||
@@ -124,7 +131,7 @@ func TestProxyProtocolAndProxyFromComeTogether(t *testing.T) {
|
||||
{"always", nil, false},
|
||||
{"optional", []string{"127.0.0.1"}, true},
|
||||
} {
|
||||
_, err := New(&config.ACLOptions{ProxyProtocol: c.mode, ProxyFrom: c.from}, "127.0.0.1", log.NewLogger(), "test", "acl")
|
||||
_, err := New(&config.ACLOptions{ProxyProtocol: c.mode, ProxyFrom: c.from}, "127.0.0.1", HTTP, log.NewLogger(), "test", "acl")
|
||||
if (err == nil) != c.ok {
|
||||
t.Errorf("proxy_protocol %q with proxy_from %v: %v", c.mode, c.from, err)
|
||||
}
|
||||
@@ -217,7 +224,7 @@ func TestHeaderFromUnlistedPeerIsRefused(t *testing.T) {
|
||||
t.Errorf("%q from an unlisted peer: read %q, %v", c.send, b, err)
|
||||
}
|
||||
}
|
||||
if n := p.Denied(); n != 2 {
|
||||
if n := p.denied.Load(); n != 2 {
|
||||
t.Fatalf("acl_denied = %d", n)
|
||||
}
|
||||
}
|
||||
@@ -273,11 +280,11 @@ func TestSlowHeaderHoldsOnlyItsConnection(t *testing.T) {
|
||||
slow := dial("127.0.0.1")
|
||||
slow.Write([]byte("PROXY TCP4 198"))
|
||||
dial("192.0.2.9")
|
||||
if got := accept(t, ln).RemoteAddr().String(); !strings.HasPrefix(got, "192.0.2.9:") || p.Denied() != 0 {
|
||||
t.Fatalf("Accept returned %s after %d refusals", got, p.Denied())
|
||||
if got := accept(t, ln).RemoteAddr().String(); !strings.HasPrefix(got, "192.0.2.9:") || p.denied.Load() != 0 {
|
||||
t.Fatalf("Accept returned %s after %d refusals", got, p.denied.Load())
|
||||
}
|
||||
closed(t, slow)
|
||||
if n := p.Denied(); n != 1 {
|
||||
if n := p.denied.Load(); n != 1 {
|
||||
t.Fatalf("acl_denied = %d", n)
|
||||
}
|
||||
}
|
||||
@@ -295,6 +302,151 @@ func TestCloseReleasesHeadersInFlight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A client holds at most max_connections_per_client at once, counted by the
|
||||
// address it ends with: behind proxy_from, the one its header names. Closing
|
||||
// a connection returns its slot; refusals count in acl_limited.
|
||||
func TestConnectionsPerClientAreCapped(t *testing.T) {
|
||||
p := mustNew(t, "127.0.0.1", config.ACLOptions{MaxConnectionsPerClient: 2, ProxyProtocol: "optional", ProxyFrom: []string{"127.0.0.1"}})
|
||||
ln, dial := listening(t, p)
|
||||
dial("192.0.2.1")
|
||||
first := accept(t, ln)
|
||||
dial("192.0.2.1")
|
||||
accept(t, ln)
|
||||
third := dial("127.0.0.1")
|
||||
third.Write([]byte("PROXY TCP4 192.0.2.1 192.0.2.9 40000 443\r\n"))
|
||||
closed(t, third)
|
||||
go dial("127.0.0.1").Write([]byte("PROXY TCP4 192.0.2.2 192.0.2.9 40000 443\r\n"))
|
||||
accept(t, ln)
|
||||
first.Close()
|
||||
dial("192.0.2.1")
|
||||
accept(t, ln)
|
||||
if denied, limited := p.Refused(); denied != 0 || limited != 1 {
|
||||
t.Fatalf("acl_denied %d, acl_limited %d", denied, limited)
|
||||
}
|
||||
}
|
||||
|
||||
// A capped connection still half-closes, as net/http does before dropping one
|
||||
// with a body unread
|
||||
func TestCappedConnectionHalfCloses(t *testing.T) {
|
||||
inner, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ln := mustNew(t, "127.0.0.1", config.ACLOptions{MaxConnectionsPerClient: 1}).Listener(inner)
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
peer, err := net.Dial("tcp4", inner.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { peer.Close() })
|
||||
if cw, ok := accept(t, ln).(interface{ CloseWrite() error }); !ok || cw.CloseWrite() != nil {
|
||||
t.Fatal("the accepted connection cannot half-close")
|
||||
}
|
||||
closed(t, peer)
|
||||
}
|
||||
|
||||
// requests_per_second_per_client answers 429 to a client past its budget, a
|
||||
// second's worth and at least one request, counted by the address client
|
||||
// names, with the seconds until its next; refusals count in acl_limited
|
||||
func TestRequestsPerClientAreRateLimited(t *testing.T) {
|
||||
p := mustNew(t, "127.0.0.1", config.ACLOptions{RequestsPerSecondPerClient: 0.5})
|
||||
h := p.Requests(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}),
|
||||
func(r *http.Request) string { return r.Header.Get("X-Client") })
|
||||
get := func(client string) *httptest.ResponseRecorder {
|
||||
w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Client", client)
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for i, want := range []int{http.StatusOK, http.StatusTooManyRequests} {
|
||||
if got := get("192.0.2.1"); got.Code != want {
|
||||
t.Fatalf("request %d: %d, want %d", i, got.Code, want)
|
||||
}
|
||||
}
|
||||
if got := get("192.0.2.1").Header().Get("Retry-After"); got != "2" {
|
||||
t.Fatalf("Retry-After %q, want 2", got)
|
||||
}
|
||||
if got := get("192.0.2.2:5000"); got.Code != http.StatusOK {
|
||||
t.Fatalf("another client: %d", got.Code)
|
||||
}
|
||||
if _, limited := p.Refused(); limited != 2 {
|
||||
t.Fatalf("acl_limited %d", limited)
|
||||
}
|
||||
}
|
||||
|
||||
// Limits count an IPv6 client by its /64, which one host holds whole, and a
|
||||
// link-local one per link; an IPv4-mapped client counts as IPv4
|
||||
func TestKeyGroupsOneHostsAddresses(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
a, b string
|
||||
same bool
|
||||
}{
|
||||
{"2001:db8::1", "2001:db8::2", true},
|
||||
{"2001:db8::1", "2001:db8:0:1::1", false},
|
||||
{"fe80::1%eth0", "fe80::2%eth0", true},
|
||||
{"fe80::1%eth0", "fe80::1%eth1", false},
|
||||
{"::ffff:192.0.2.1", "192.0.2.1", true},
|
||||
{"192.0.2.1", "192.0.2.2", false},
|
||||
} {
|
||||
a, b := Key(netip.MustParseAddr(c.a)), Key(netip.MustParseAddr(c.b))
|
||||
if (a == b) != c.same {
|
||||
t.Errorf("%s keys %q, %s keys %q", c.a, a, c.b, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refunded attempts cost nothing and released ones keep their cost; slots are
|
||||
// capped from the moment they are taken, so concurrent attempts cannot all
|
||||
// pass; a full table refuses new clients, and the sweep frees clients whose
|
||||
// holds expired or whose bucket is full again.
|
||||
func TestTableBounds(t *testing.T) {
|
||||
tb := NewTable(10, 1, 4)
|
||||
for i := range 20 {
|
||||
if !tb.Take("10.0.0.1") {
|
||||
t.Fatalf("refunded attempt %d refused", i)
|
||||
}
|
||||
tb.Hold("10.0.0.1", strconv.Itoa(i), time.Now().Add(time.Minute))
|
||||
tb.Done("10.0.0.1", strconv.Itoa(i))
|
||||
tb.Refund("10.0.0.1")
|
||||
}
|
||||
for i := range 4 {
|
||||
if !tb.Take("10.0.0.2") {
|
||||
t.Fatalf("slot %d refused", i)
|
||||
}
|
||||
}
|
||||
if tb.Take("10.0.0.2") {
|
||||
t.Fatal("an attempt beyond the slots was admitted before any hold")
|
||||
}
|
||||
tb.Release("10.0.0.2")
|
||||
if !tb.Take("10.0.0.2") {
|
||||
t.Fatal("a released slot still counts")
|
||||
}
|
||||
for range 10 {
|
||||
tb.Take("10.0.0.3")
|
||||
tb.Release("10.0.0.3")
|
||||
}
|
||||
if tb.Take("10.0.0.3") {
|
||||
t.Fatal("releasing its slots refilled a client's bucket")
|
||||
}
|
||||
for i := len(tb.clients); i < maxClients; i++ {
|
||||
tb.clients[netip.AddrFrom4([4]byte{10, 1, byte(i >> 8), byte(i)}).String()] = &client{
|
||||
seen: time.Now(), held: map[string]time.Time{"abandoned": time.Now().Add(time.Second)},
|
||||
}
|
||||
}
|
||||
if tb.Take("192.0.2.1") {
|
||||
t.Fatal("a full table admitted a new client")
|
||||
}
|
||||
tb.sweep(time.Now().Add(2 * tb.idle))
|
||||
if len(tb.clients) != 1 { // 10.0.0.2 still holds its slots
|
||||
t.Fatalf("%d clients left after their holds expired, want 1", len(tb.clients))
|
||||
}
|
||||
rate := NewTable(1, 1, 0)
|
||||
rate.Take("10.0.0.4")
|
||||
if rate.sweep(time.Now().Add(1100 * time.Millisecond)); len(rate.clients) != 0 {
|
||||
t.Fatal("a client outlived the refill of its bucket")
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the bytes, the parser neither panics nor reads past a header's
|
||||
// bound, consumes nothing when no header starts the stream, and names a
|
||||
// client only unmapped and without a zone.
|
||||
|
||||
Reference in new issue
Block a user