v0.26.1 per-client connection caps

This commit is contained in:
lixen committed 2026-10-05 02:22:30 -04:00
1 parent ee01cb00fe
commit cdd4fc01a7
36 files changed
+795 -417

No files matched your search

+6 -3
View File
@@ -21,6 +21,7 @@ import (
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/netacl"
"github.com/lixenwraith/logwisp/internal/tlsx"
"github.com/lixenwraith/log"
@@ -78,7 +79,8 @@ type Policy struct {
allow map[string]struct{}
patterns []*regexp.Regexp
binding string
secrets []secretFile // reported at startup when every local user can read them
secrets []secretFile // reported at startup when every local user can read them
acl *netacl.Policy // a forwarded client's rules, in proxy mode
listener *scramListener // scram listener state
dialer *scramDialer // scram dialer state
@@ -110,7 +112,8 @@ func (id Identity) Apply(meta map[string]any) {
// New compiles an auth policy, returning (nil, nil) when auth is disabled.
// tlsCfg is what tlsx built from the sibling `tls` block: a policy the
// transport cannot enforce is rejected here rather than silently accepted.
func New(o *config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transport) (*Policy, error) {
// acl is netacl's from the `acl` block, nil for a dialer.
func New(o *config.AuthOptions, tlsCfg *tls.Config, acl *netacl.Policy, role Role, transport Transport) (*Policy, error) {
if o == nil {
return nil, nil
}
@@ -139,7 +142,7 @@ func New(o *config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transpo
if err != nil {
return nil, err
}
p := &Policy{role: role, transport: transport, method: o.Type, binding: binding}
p := &Policy{role: role, transport: transport, method: o.Type, binding: binding, acl: acl}
if o.Type == MethodMTLS {
err = p.compileMTLS(o, tlsCfg)
} else {
+9 -9
View File
@@ -66,7 +66,7 @@ func TestPeerIdentityModes(t *testing.T) {
func TestNewDisabled(t *testing.T) {
for _, o := range []*config.AuthOptions{nil, {}, {Type: MethodNone}} {
p, err := New(o, nil, RoleListener, TCP)
p, err := New(o, nil, nil, RoleListener, TCP)
if err != nil {
t.Fatalf("New(%+v) error: %v", o, err)
}
@@ -160,7 +160,7 @@ func TestNewValidation(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := New(tc.auth, tc.tls, tc.role, tc.transport); err == nil || !strings.Contains(err.Error(), tc.want) {
if _, err := New(tc.auth, tc.tls, nil, tc.role, tc.transport); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error %v, want one naming %q", err, tc.want)
}
})
@@ -171,16 +171,16 @@ func TestNewValidation(t *testing.T) {
pinned := &tls.Config{InsecureSkipVerify: true, VerifyPeerCertificate: func([][]byte, [][]*x509.Certificate) error { return nil }}
for _, o := range []*config.AuthOptions{{Type: MethodMTLS}, dialer(config.AuthOptions{})} {
for _, c := range []*tls.Config{{}, pinned} {
if _, err := New(o, c, RoleDialer, TCP); err != nil {
if _, err := New(o, c, nil, RoleDialer, TCP); err != nil {
t.Fatalf("dialer policy %s rejected: %v", o.Type, err)
}
}
}
if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, RoleListener, HTTP); err != nil {
if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, nil, RoleListener, HTTP); err != nil {
t.Fatalf("scram listener rejected: %v", err)
}
// Behind proxies TLS ends at the proxy, so the hop may be plaintext
if _, err := New(listener(config.AuthOptions{TrustedProxies: proxies}), nil, RoleListener, HTTP); err != nil {
if _, err := New(listener(config.AuthOptions{TrustedProxies: proxies}), nil, nil, RoleListener, HTTP); err != nil {
t.Fatalf("proxy-mode listener rejected: %v", err)
}
}
@@ -190,7 +190,7 @@ func TestAuthorizeMatching(t *testing.T) {
Type: MethodMTLS,
Allow: []string{"edge-01", " edge-02 "},
AllowPatterns: []string{`^relay-\d{2}$`},
}, mtlsListenerTLS(), RoleListener, TCP)
}, mtlsListenerTLS(), nil, RoleListener, TCP)
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -232,7 +232,7 @@ func TestAuthorizeMatching(t *testing.T) {
// Empty allow and allow_patterns admits any CA-vouched identity, but still
// records it and still refuses a certificate with no usable identity field
func TestAuthorizeUnrestricted(t *testing.T) {
p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), RoleListener, TCP)
p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), nil, RoleListener, TCP)
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -256,7 +256,7 @@ func TestAuthorizeUnrestricted(t *testing.T) {
func TestResolveNodeBindings(t *testing.T) {
newChain := func(binding string) *Policy {
p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), RoleChainListener, TCP)
p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), nil, RoleChainListener, TCP)
if err != nil {
t.Fatalf("New(%q): %v", binding, err)
}
@@ -332,7 +332,7 @@ func TestIdentityApply(t *testing.T) {
func TestVerifyConnectionPinsServer(t *testing.T) {
p, err := New(&config.AuthOptions{Type: MethodMTLS, Allow: []string{"relay.internal"}},
&tls.Config{}, RoleDialer, TCP)
&tls.Config{}, nil, RoleDialer, TCP)
if err != nil {
t.Fatalf("New: %v", err)
}
+18 -27
View File
@@ -11,6 +11,8 @@ import (
"sync"
"time"
"github.com/lixenwraith/logwisp/internal/netacl"
"github.com/lixenwraith/auth"
)
@@ -32,15 +34,9 @@ type proxyMode struct {
func parseProxies(entries []string) (*proxyMode, error) {
m := &proxyMode{}
for _, e := range entries {
e = strings.TrimSpace(e)
prefix, err := netip.ParsePrefix(e)
prefix, err := netacl.Parse(e, "tcp")
if err != nil {
addr, aerr := netip.ParseAddr(e)
if aerr != nil {
return nil, fmt.Errorf("auth: trusted_proxies entry %q is neither an address nor a CIDR", e)
}
addr = addr.Unmap()
prefix = netip.PrefixFrom(addr, addr.BitLen())
return nil, fmt.Errorf("auth: trusted_proxies entry %q: %w", e, err)
}
m.trusted = append(m.trusted, prefix.Masked())
}
@@ -51,15 +47,6 @@ func (m *proxyMode) trusts(addr netip.Addr) bool {
return slices.ContainsFunc(m.trusted, func(p netip.Prefix) bool { return p.Contains(addr) })
}
// parseHop reads a forwarded or peer address, with or without a port
func parseHop(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
}
// exposedHop reports a plaintext hop from a proxy that may be on another host
func (m *proxyMode) exposedHop() bool {
return m.plaintext && slices.ContainsFunc(m.trusted, func(p netip.Prefix) bool { return !p.Addr().IsLoopback() })
@@ -68,25 +55,25 @@ func (m *proxyMode) exposedHop() bool {
// client is the rightmost X-Forwarded-For hop that is not a trusted proxy:
// hops to its left come from the client and prove nothing. When every hop is
// trusted, the client sits inside a proxy range and the leftmost hop is it.
func (m *proxyMode) client(peer string, h http.Header) (string, error) {
if addr, ok := parseHop(peer); !ok || !m.trusts(addr) {
return "", fmt.Errorf("%s is not a trusted proxy", peer)
func (m *proxyMode) client(peer string, h http.Header) (netip.Addr, error) {
if addr, ok := netacl.ParseHost(peer); !ok || !m.trusts(addr) {
return netip.Addr{}, fmt.Errorf("%s is not a trusted proxy", peer)
}
protos := headerList(h, "X-Forwarded-Proto")
if len(protos) == 0 || slices.ContainsFunc(protos, func(v string) bool { return !strings.EqualFold(v, "https") }) {
return "", fmt.Errorf("proxy forwarded X-Forwarded-Proto %q; sessions need https", strings.Join(protos, ","))
return netip.Addr{}, fmt.Errorf("proxy forwarded X-Forwarded-Proto %q; sessions need https", strings.Join(protos, ","))
}
hops := headerList(h, "X-Forwarded-For")
if len(hops) == 0 {
return "", errors.New("proxy sent no client address in X-Forwarded-For")
return netip.Addr{}, errors.New("proxy sent no client address in X-Forwarded-For")
}
for i := len(hops) - 1; ; i-- {
addr, ok := parseHop(hops[i])
addr, ok := netacl.ParseHost(hops[i])
if !ok {
return "", fmt.Errorf("malformed X-Forwarded-For hop %q", hops[i])
return netip.Addr{}, fmt.Errorf("malformed X-Forwarded-For hop %q", hops[i])
}
if i == 0 || !m.trusts(addr) {
return addr.String(), nil
return addr, nil
}
}
}
@@ -140,7 +127,8 @@ func (p *Policy) BehindProxy() bool {
// ClientAddr is the address throttling, sessions and logs name: the socket
// peer, or in proxy mode the forwarded client. A proxy-mode request from an
// untrusted peer, or one the proxy did not receive over https, is refused.
// untrusted peer, one the proxy did not receive over https, or one for a
// client the acl rules refuse, is refused.
func (p *Policy) ClientAddr(r *http.Request) (string, error) {
peer := remoteIP(r.RemoteAddr)
if !p.BehindProxy() {
@@ -151,7 +139,10 @@ func (p *Policy) ClientAddr(r *http.Request) (string, error) {
p.rejected.Add(1)
return "", fmt.Errorf("%w: %w", ErrRefused, err)
}
return addr, nil
if err := p.acl.Forwarded(addr, r.RemoteAddr); err != nil {
return "", fmt.Errorf("%w: %w", ErrRefused, err)
}
return addr.String(), nil
}
// presentedToken is the bearer token or, in proxy mode, the session cookie.
+30
View File
@@ -11,8 +11,10 @@ import (
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/netacl"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
)
// forwarding is the proxy's half of every request: the client and https
@@ -79,6 +81,34 @@ func TestProxyModeTrustsOnlyItsProxies(t *testing.T) {
}
}
// In proxy mode a forwarded client passes the acl rules too, as the proxy
// passed them as the peer; a refusal counts in acl_denied
func TestForwardedClientIsRefusedByTheRules(t *testing.T) {
f := newFixture(t)
acl, err := netacl.New(&config.ACLOptions{Allow: []string{"127.0.0.1", "203.0.113.0/24"}, Deny: []string{"203.0.113.66"}},
"127.0.0.1", netacl.HTTPProxied, log.NewLogger(), "test", "acl")
if err != nil {
t.Fatal(err)
}
l, err := New(&config.AuthOptions{Type: MethodSCRAM, CredentialsFile: f.creds, TrustedProxies: []string{"127.0.0.1"}},
nil, acl, RoleListener, HTTP)
if err != nil {
t.Fatal(err)
}
for client, admitted := range map[string]bool{"203.0.113.7": true, "203.0.113.66": false, "198.51.100.1": false} {
r := httptest.NewRequest(http.MethodGet, "/status", nil)
r.RemoteAddr = "127.0.0.1:5000"
r.Header.Set("X-Forwarded-For", client)
r.Header.Set("X-Forwarded-Proto", "https")
if _, err := l.ClientAddr(r); (err == nil) != admitted {
t.Errorf("forwarded %s: %v", client, err)
}
}
if denied, _ := acl.Refused(); denied != 2 {
t.Fatalf("acl_denied %d, want 2", denied)
}
}
// A browser logs in unbound and gets its session only as a cookie that
// scopes itself to the mount; the cookie opens the endpoints, beside the
// site's own Basic auth too, until logout revokes it. Outside proxy mode
+16 -131
View File
@@ -15,17 +15,15 @@ import (
"mime"
"net"
"net/http"
"net/netip"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/netacl"
"github.com/lixenwraith/logwisp/internal/tlsx"
"github.com/lixenwraith/logwisp/internal/tokenbucket"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/toml"
@@ -47,8 +45,6 @@ const (
limitBurst = 10 // failed or abandoned exchanges per address before throttling
limitRate = 1 // per second
limitPending = 4 // unfinished exchanges per address
limitPeers = 65536
limitIdle = time.Minute
)
// The dialer's floor on the Argon2 cost a server may ask for. A hostile server
@@ -194,7 +190,9 @@ type scramListener struct {
lifetime time.Duration
proxy *proxyMode
server atomic.Pointer[auth.ScramServer]
limit limiter
// limit bounds attempts per client: failed or abandoned exchanges drain
// its bucket (successes are refunded), and limitPending may be unfinished
limit *netacl.Table
throttled atomic.Uint64
busy atomic.Uint64
@@ -233,7 +231,7 @@ func (p *Policy) compileSCRAM(o *config.AuthOptions, tlsCfg *tls.Config) error {
return err
}
}
l := &scramListener{}
l := &scramListener{limit: netacl.NewTable(limitBurst, limitRate, limitPending)}
var err error
if len(o.TrustedProxies) > 0 {
if l.proxy, err = parseProxies(o.TrustedProxies); err != nil {
@@ -307,19 +305,19 @@ func (p *Policy) begin(ip string, first json.RawMessage) (challenge auth.ServerF
p.rejected.Add(1)
return challenge, http.StatusBadRequest, "malformed request", fmt.Errorf("%w: malformed client-first message", ErrRefused)
}
if !l.limit.start(ip) {
if !l.limit.Take(ip) {
l.throttled.Add(1)
return challenge, http.StatusTooManyRequests, "too many attempts", fmt.Errorf("%w: %s throttled", ErrRefused, ip)
}
s := l.server.Load()
if s == nil {
l.limit.release(ip)
l.limit.Release(ip)
l.busy.Add(1)
return challenge, http.StatusServiceUnavailable, "busy", errors.New("auth: scram server is not running")
}
challenge, err = s.ProcessClientFirstMessage(req.Username, req.ClientNonce)
if err != nil {
l.limit.release(ip)
l.limit.Release(ip)
}
switch {
case errors.Is(err, auth.ErrSCRAMTooManyHandshakes), errors.Is(err, auth.ErrSCRAMStopped):
@@ -329,7 +327,7 @@ func (p *Policy) begin(ip string, first json.RawMessage) (challenge auth.ServerF
p.rejected.Add(1)
return challenge, http.StatusBadRequest, "malformed request", fmt.Errorf("%w: %w", ErrRefused, err)
}
l.limit.track(ip, challenge.FullNonce)
l.limit.Hold(ip, challenge.FullNonce, time.Now().Add(auth.ScramHandshakeTimeout))
return challenge, 0, "", nil
}
@@ -337,7 +335,7 @@ func (p *Policy) begin(ip string, first json.RawMessage) (challenge auth.ServerF
// the client certificate to the user when configured.
func (p *Policy) finish(ip, nonce string, step authStep, cs *tls.ConnectionState) (auth.ServerFinalMessage, error) {
l := p.listener
l.limit.done(ip, nonce)
l.limit.Done(ip, nonce)
s := l.server.Load()
if s == nil {
l.busy.Add(1)
@@ -365,7 +363,7 @@ func (p *Policy) finish(ip, nonce string, step authStep, cs *tls.ConnectionState
return auth.ServerFinalMessage{}, fmt.Errorf("%w: %w", ErrRefused, err)
}
p.allowed.Add(1)
l.limit.succeeded(ip)
l.limit.Refund(ip)
return final, nil
}
@@ -386,7 +384,8 @@ func (p *Policy) bindCertificate(cs *tls.ConnectionState, username string) error
// line for Accept. An exchange abandoned after its challenge is released from
// the auth table at once rather than holding a slot for its timeout.
func (p *Policy) exchangeTCP(a *Admission, cs *tls.ConnectionState) (Identity, []byte, error) {
ip := throttleKey(remoteIP(a.conn.RemoteAddr().String()))
addr, _ := netacl.ParseHost(a.conn.RemoteAddr().String())
ip := netacl.Key(addr)
challenge, _, public, err := p.begin(ip, a.Hello.Scram)
if err != nil {
writeStep(a.conn, authStep{Error: public})
@@ -395,7 +394,7 @@ func (p *Policy) exchangeTCP(a *Admission, cs *tls.ConnectionState) (Identity, [
settled := false
defer func() {
if !settled {
p.listener.limit.done(ip, challenge.FullNonce)
p.listener.limit.Done(ip, challenge.FullNonce)
if s := p.listener.server.Load(); s != nil {
s.ProcessClientFinalMessage(challenge.FullNonce, "")
}
@@ -443,7 +442,8 @@ func (p *Policy) ServeAuth(w http.ResponseWriter, r *http.Request) (Identity, er
writeJSON(w, http.StatusForbidden, authStep{Error: "forbidden"})
return Identity{}, err
}
ip := throttleKey(client)
addr, _ := netacl.ParseHost(client)
ip := netacl.Key(addr)
// A cross-origin page cannot send JSON without a preflight nobody answers
if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt != "application/json" {
p.rejected.Add(1)
@@ -900,118 +900,3 @@ func remoteIP(addr string) string {
}
return addr
}
// --- Throttling ---
// throttleKey is what the limiter counts: an 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 throttleKey(ip string) string {
addr, err := netip.ParseAddr(ip)
if err != nil || addr.Is4() {
return ip
}
key := netip.PrefixFrom(addr, 64).Masked().String()
if zone := addr.Zone(); zone != "" {
key += "%" + zone
}
return key
}
// limiter bounds SCRAM attempts per remote address: failed or abandoned
// exchanges drain a token bucket (successes are refunded) and at most
// limitPending may be unfinished. A full table fails closed.
type limiter struct {
mu sync.Mutex
peers map[string]*peerLimit
lastSweep time.Time
}
type peerLimit struct {
bucket *tokenbucket.TokenBucket
pending map[string]time.Time // full nonce -> expiry
reserved int // started, challenge not yet issued
seen time.Time
}
func (l *limiter) start(ip string) bool {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
if l.peers == nil {
l.peers = make(map[string]*peerLimit)
}
if now.Sub(l.lastSweep) > limitIdle/6 {
l.sweep(now)
}
pl := l.peers[ip]
if pl == nil {
if len(l.peers) >= limitPeers {
return false
}
pl = &peerLimit{bucket: tokenbucket.New(limitBurst, limitRate), pending: make(map[string]time.Time)}
l.peers[ip] = pl
}
pl.seen = now
pl.expire(now)
// Reserved under this lock: concurrent hellos cannot all pass the check
if len(pl.pending)+pl.reserved >= limitPending || !pl.bucket.Allow() {
return false
}
pl.reserved++
return true
}
// track turns start's reservation into the exchange's nonce
func (l *limiter) track(ip, nonce string) {
l.mu.Lock()
defer l.mu.Unlock()
if pl := l.peers[ip]; pl != nil {
pl.reserved--
pl.pending[nonce] = time.Now().Add(auth.ScramHandshakeTimeout)
}
}
// release frees start's reservation when no challenge was issued
func (l *limiter) release(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
if pl := l.peers[ip]; pl != nil {
pl.reserved--
}
}
func (l *limiter) done(ip, nonce string) {
l.mu.Lock()
defer l.mu.Unlock()
if pl := l.peers[ip]; pl != nil {
delete(pl.pending, nonce)
}
}
func (l *limiter) succeeded(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
if pl := l.peers[ip]; pl != nil {
pl.bucket.Refund(1)
}
}
func (pl *peerLimit) expire(now time.Time) {
for nonce, expiry := range pl.pending {
if now.After(expiry) {
delete(pl.pending, nonce)
}
}
}
// sweep drops addresses idle long enough for their bucket to be full again
func (l *limiter) sweep(now time.Time) {
l.lastSweep = now
for ip, pl := range l.peers {
pl.expire(now) // an abandoned HTTP challenge has no done
if now.Sub(pl.seen) > limitIdle && len(pl.pending) == 0 && pl.reserved == 0 {
delete(l.peers, ip)
}
}
}
+8 -56
View File
@@ -13,6 +13,7 @@ import (
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"regexp"
@@ -23,6 +24,7 @@ import (
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/netacl"
"github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/auth"
@@ -100,7 +102,7 @@ func (f *fixture) pool(t *testing.T) *x509.CertPool {
func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transport) *Policy {
t.Helper()
o.Type, o.CredentialsFile = MethodSCRAM, f.creds
p, err := New(&o, tlsCfg, role, transport)
p, err := New(&o, tlsCfg, nil, role, transport)
if err != nil {
t.Fatal(err)
}
@@ -114,7 +116,7 @@ func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Confi
func (f *fixture) dialer(t *testing.T, user, password string) *Policy {
t.Helper()
pw := f.write(t, user+"-"+strings.ReplaceAll(password, "/", "_")+".pass", password+"\n")
p, err := New(&config.AuthOptions{Type: MethodSCRAM, Username: user, PasswordFile: pw}, f.clientTLS, RoleDialer, TCP)
p, err := New(&config.AuthOptions{Type: MethodSCRAM, Username: user, PasswordFile: pw}, f.clientTLS, nil, RoleDialer, TCP)
if err != nil {
t.Fatal(err)
}
@@ -294,11 +296,10 @@ func TestAbandonedExchangeReleasesItsSlot(t *testing.T) {
if _, err := l.listener.server.Load().ProcessClientFinalMessage(step.Challenge.FullNonce, "AAAA"); !errors.Is(err, auth.ErrSCRAMInvalidNonce) {
t.Fatalf("abandoned handshake still pending: %v", err)
}
lim := &l.listener.limit
lim.mu.Lock()
defer lim.mu.Unlock()
if pl := lim.peers["pipe"]; pl == nil || len(pl.pending) != 0 || pl.reserved != 0 {
t.Fatalf("limiter still holds the abandoned exchange: %+v", pl)
for i := range limitPending { // a pipe's peer has no address
if !l.listener.limit.Take(netacl.Key(netip.Addr{})) {
t.Fatalf("limiter still holds the abandoned exchange: slot %d taken", i)
}
}
}
@@ -417,15 +418,6 @@ func TestDialerRefusesACheapChallenge(t *testing.T) {
}
}
// Link-local peers share one budget per link, not one across every link: the
// zone names the link and stays in the key.
func TestLinkLocalBudgetIsPerLink(t *testing.T) {
a, b, other := throttleKey("fe80::1%eth0"), throttleKey("fe80::2%eth0"), throttleKey("fe80::1%eth1")
if a != b || a == other {
t.Fatalf("keys %q, %q on one link, %q on another", a, b, other)
}
}
// Only failed attempts drain an address's budget; beyond it the listener
// answers "too many attempts" without running the exchange.
func TestOnlyFailedAttemptsAreThrottled(t *testing.T) {
@@ -447,46 +439,6 @@ func TestOnlyFailedAttemptsAreThrottled(t *testing.T) {
}
}
// Successes are refunded; unfinished exchanges are capped per address from
// the moment they start, so concurrent hellos cannot all pass; a full table
// refuses new addresses, and the sweep frees addresses whose challenges were
// abandoned.
func TestLimiterBounds(t *testing.T) {
var l limiter
for i := range 2 * limitBurst {
if !l.start("10.0.0.1") {
t.Fatalf("successful attempt %d throttled", i)
}
l.track("10.0.0.1", "n")
l.done("10.0.0.1", "n")
l.succeeded("10.0.0.1")
}
for i := range limitPending {
if !l.start("10.0.0.2") {
t.Fatalf("pending exchange %d throttled", i)
}
}
if l.start("10.0.0.2") {
t.Fatal("an exchange beyond the pending cap was admitted before any challenge")
}
l.release("10.0.0.2")
if !l.start("10.0.0.2") {
t.Fatal("a released reservation still counts")
}
for i := len(l.peers); i < limitPeers; i++ {
l.peers[net.IPv4(10, 1, byte(i>>8), byte(i)).String()] = &peerLimit{
seen: time.Now(), pending: map[string]time.Time{"abandoned": time.Now().Add(time.Second)},
}
}
if l.start("192.0.2.1") {
t.Fatal("a full table admitted a new address")
}
l.sweep(time.Now().Add(2 * limitIdle))
if len(l.peers) != 1 { // 10.0.0.2 still holds its reservations
t.Fatalf("%d addresses left after their challenges expired, want 1", len(l.peers))
}
}
// httpListener serves /auth and one protected endpoint over TLS
func (f *fixture) httpListener(t *testing.T, p *Policy, tlsCfg *tls.Config) (*httptest.Server, *atomic.Int64) {
t.Helper()
+8 -6
View File
@@ -375,13 +375,15 @@ type AuthOptions struct {
// ACLOptions is a listener's address rules, entries being addresses or CIDRs:
// deny wins, then a set allow list admits only its entries. proxy_from lists
// the L4 proxies whose PROXY header names the client the rules then see.
// Validation is in internal/netacl.
// the L4 proxies whose PROXY header names the client the rules then see; the
// per-client limits count that client. Validation is in internal/netacl.
type ACLOptions struct {
Allow []string `toml:"allow"`
Deny []string `toml:"deny"`
ProxyProtocol string `toml:"proxy_protocol"` // off, optional or required
ProxyFrom []string `toml:"proxy_from"`
Allow []string `toml:"allow"`
Deny []string `toml:"deny"`
ProxyProtocol string `toml:"proxy_protocol"` // off, optional or required
ProxyFrom []string `toml:"proxy_from"`
MaxConnectionsPerClient int64 `toml:"max_connections_per_client"` // 0 = unlimited
RequestsPerSecondPerClient float64 `toml:"requests_per_second_per_client"` // HTTP listeners; 0 = unlimited
}
// --- TLS Options ---
+1 -1
View File
@@ -41,7 +41,7 @@ var (
{Name: "hosts", List: true, Help: "names and addresses, beyond this host's, of a self or issuer certificate; ',' between them"},
}
aclParams = []PresetParam{
{Name: "allow", List: true, Help: "addresses or CIDRs that may connect, of listen's family; default: all; ',' between them"},
{Name: "allow", List: true, Help: "addresses or CIDRs that may connect, of listen's family (either behind proxy=); default: all; ',' between them"},
{Name: "deny", List: true, Help: "addresses or CIDRs refused, allow or not; ',' between them"},
}
transportParam = PresetParam{Name: "transport", Default: "tcp", Help: "tcp|http: tcp_chain or http_chain"}
+155
View File
@@ -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
View File
@@ -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()}
}
+160 -8
View File
@@ -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.
+15 -7
View File
@@ -156,11 +156,15 @@ func NewHTTPSinkPlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleListener, authz.HTTP)
kind := netacl.HTTP
if opts.Auth != nil && len(opts.Auth.TrustedProxies) > 0 {
kind = netacl.HTTPProxied
}
aclPolicy, err := netacl.New(opts.ACL, opts.Host, kind, logger, "http_sink", id)
if err != nil {
return nil, err
}
aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "http_sink", id)
authPolicy, err := authz.New(opts.Auth, tlsCfg, aclPolicy, authz.RoleListener, authz.HTTP)
if err != nil {
return nil, err
}
@@ -264,14 +268,16 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
// One wrapper covers stream and status, and keeps the handlers themselves
// unaware of authorization; a nil policy admits every request. Login, the
// browser files and the root sit outside it; in proxy mode everything sits
// behind the proxy gate.
// browser files and the root sit outside it; all but login, which SCRAM
// throttling limits, count in the per-client request rate. In proxy mode
// everything sits behind the proxy gate.
limit := func(next http.Handler) http.Handler { return h.acl.Requests(next, clientAddr) }
outer := http.NewServeMux()
outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth)
for p, file := range h.web {
outer.Handle(http.MethodGet+" "+p, file)
outer.Handle(http.MethodGet+" "+p, limit(file))
}
outer.Handle("/", h.authMiddleware(mux))
outer.Handle("/", limit(h.authMiddleware(mux)))
var handler http.Handler = outer
if h.auth.BehindProxy() {
handler = h.proxyGate(outer)
@@ -572,6 +578,7 @@ func (h *HTTPSink) armWrite(rc *http.ResponseController) {
// handleStatus provides a JSON status report
func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
denied, limited := h.acl.Refused()
status := map[string]any{
"service": "LogWisp",
"version": version.Short(),
@@ -599,7 +606,8 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
"dropped_writes": h.droppedWrites.Load(),
"rejected_clients": h.rejectedClients.Load(),
"auth_rejected": h.auth.Rejected(),
"acl_denied": h.acl.Denied(),
"acl_denied": denied,
"acl_limited": limited,
},
}
+13 -8
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"embed"
"errors"
"html"
"net/http"
"path"
@@ -12,6 +13,7 @@ import (
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/netacl"
)
//go:embed web/*.js web/*.html web/*.css
@@ -101,18 +103,21 @@ func serveWebFile(data []byte, contentType string) http.Handler {
// clientKey carries the forwarded client address from proxyGate
type clientKey struct{}
// proxyGate admits only the trusted proxies, and records the client they
// forward for logs and sessions. ServeAuth and AuthorizeRequest check again.
// proxyGate admits only the trusted proxies, forwarding a client the acl
// rules admit, and records that client for logs, sessions and the request
// rate. ServeAuth and AuthorizeRequest check again.
func (h *HTTPSink) proxyGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addr, err := h.auth.ClientAddr(r)
if err != nil {
h.logger.Warn("msg", "Request refused: not through a trusted proxy over https",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", r.RemoteAddr,
"path", r.URL.Path,
"error", err)
if !errors.Is(err, netacl.ErrDenied) { // reported by netacl, once a minute
h.logger.Warn("msg", "Request refused: not through a trusted proxy over https",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", r.RemoteAddr,
"path", r.URL.Path,
"error", err)
}
authz.Refuse(w, http.StatusForbidden)
return
}
+1 -1
View File
@@ -150,7 +150,7 @@ func NewHTTPChainSinkPlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleDialer, authz.HTTP)
authPolicy, err := authz.New(opts.Auth, tlsCfg, nil, authz.RoleDialer, authz.HTTP)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -80,7 +80,7 @@ func TestRefusedTokenLogsInAgainAndDeliversOnce(t *testing.T) {
t.Fatal(err)
}
serverTLS := &tls.Config{Certificates: []tls.Certificate{serverCert}}
listener, err := authz.New(&config.AuthOptions{Type: authz.MethodSCRAM, CredentialsFile: creds}, serverTLS, authz.RoleChainListener, authz.HTTP)
listener, err := authz.New(&config.AuthOptions{Type: authz.MethodSCRAM, CredentialsFile: creds}, serverTLS, nil, authz.RoleChainListener, authz.HTTP)
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -147,11 +147,11 @@ func NewTCPSinkPlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleListener, authz.TCP)
aclPolicy, err := netacl.New(opts.ACL, opts.Host, netacl.TCP, logger, "tcp_sink", id)
if err != nil {
return nil, err
}
aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "tcp_sink", id)
authPolicy, err := authz.New(opts.Auth, tlsCfg, aclPolicy, authz.RoleListener, authz.TCP)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -138,7 +138,7 @@ func NewTCPChainSinkPlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleDialer, authz.TCP)
authPolicy, err := authz.New(opts.Auth, tlsCfg, nil, authz.RoleDialer, authz.TCP)
if err != nil {
return nil, err
}
+4 -4
View File
@@ -118,11 +118,11 @@ func NewHTTPChainSourcePlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleChainListener, authz.HTTP)
aclPolicy, err := netacl.New(opts.ACL, opts.Host, netacl.HTTP, logger, "http_chain_source", id)
if err != nil {
return nil, err
}
aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "http_chain_source", id)
authPolicy, err := authz.New(opts.Auth, tlsCfg, aclPolicy, authz.RoleChainListener, authz.HTTP)
if err != nil {
return nil, err
}
@@ -192,8 +192,8 @@ func (s *HTTPChainSource) Start() error {
mux := http.NewServeMux()
// Method-scoped pattern: mux answers 405 with Allow header on non-POST
mux.HandleFunc(http.MethodPost+" "+s.config.IngestPath, s.handleIngest)
// Answers 404 unless the policy is scram
mux.Handle(http.MethodPost+" "+s.config.IngestPath, s.acl.Requests(http.HandlerFunc(s.handleIngest), nil))
// Answers 404 unless the policy is scram, whose throttling limits it
mux.HandleFunc(http.MethodPost+" "+chain.AuthPath, s.handleAuth)
s.server = &http.Server{
+2 -2
View File
@@ -103,11 +103,11 @@ func NewTCPChainSourcePlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleChainListener, authz.TCP)
aclPolicy, err := netacl.New(opts.ACL, opts.Host, netacl.TCP, logger, "tcp_chain_source", id)
if err != nil {
return nil, err
}
aclPolicy, err := netacl.New(opts.ACL, opts.Host, logger, "tcp_chain_source", id)
authPolicy, err := authz.New(opts.Auth, tlsCfg, aclPolicy, authz.RoleChainListener, authz.TCP)
if err != nil {
return nil, err
}