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