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()