Compare commits

..

3 Commits

Author SHA1 Message Date
dependabot[bot]
db0298728b Bump github.com/pires/go-proxyproto from 0.11.0 to 0.15.0
Bumps [github.com/pires/go-proxyproto](https://github.com/pires/go-proxyproto) from 0.11.0 to 0.15.0.
- [Release notes](https://github.com/pires/go-proxyproto/releases)
- [Commits](https://github.com/pires/go-proxyproto/compare/v0.11.0...v0.15.0)

---
updated-dependencies:
- dependency-name: github.com/pires/go-proxyproto
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-10 15:44:18 +00:00
Viktor Liu
e8671a811d [client, relay] Migrate relay QUIC tracer to qlog and bump quic-go to 0.59.1 (#7124) 2026-08-10 17:41:31 +02:00
Zoltan Papp
1ca26d8faa [client] Bump the netbirdio/wails fork to drop the native WebView2 (#7128)
## Describe your changes

The native_webview2loader build tag embedded Microsoft's
WebView2Loader.dll via //go:embed. Those DLLs are gitignored in the fork
and go mod vendor resolves embed patterns regardless of build
constraints, so vendoring the module failed on the missing files when
packaging for openSUSE.

The fork now removes that branch along with the go-winloader dependency,
which drops out of the module graph here. The default GoWebView2Loader
path is unaffected.



## Issue ticket number and link

<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See

https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
  * Updated the application framework revision.
  * Removed an unused dependency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-10 16:41:50 +02:00
13 changed files with 227 additions and 776 deletions

7
go.mod
View File

@@ -96,10 +96,10 @@ require (
github.com/pion/stun/v3 v3.1.0
github.com/pion/transport/v3 v3.1.1
github.com/pion/turn/v3 v3.0.1
github.com/pires/go-proxyproto v0.11.0
github.com/pires/go-proxyproto v0.15.0
github.com/pkg/sftp v1.13.9
github.com/prometheus/client_golang v1.23.2
github.com/quic-go/quic-go v0.55.0
github.com/quic-go/quic-go v0.59.1
github.com/redis/go-redis/v9 v9.7.3
github.com/rs/xid v1.3.0
github.com/shirou/gopsutil/v4 v4.25.8
@@ -239,7 +239,6 @@ require (
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
@@ -340,4 +339,4 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db

15
go.sum
View File

@@ -349,8 +349,6 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -490,8 +488,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701 h1:QL9nupfRom0L9jcY7N9l/Bc6QK2PtC6pHzC+ftpTqpw=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db h1:gBOE2r4AW1soSmpYJC5/n9/1L8UQ8+HLjed8CY/TzZY=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db/go.mod h1:bsdahLwBQxXjlmdPPeQyrTcDJfcqAr/ymFj0RXhwtWI=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@@ -558,8 +556,8 @@ github.com/pion/turn/v3 v3.0.1 h1:wLi7BTQr6/Q20R0vt/lHbjv6y4GChFtC33nkYbasoT8=
github.com/pion/turn/v3 v3.0.1/go.mod h1:MrJDKgqryDyWy1/4NT9TWfXWGMC7UHT6pJIv1+gMeNE=
github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc=
github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8=
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -582,8 +580,8 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -793,7 +791,6 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@@ -13,7 +13,6 @@ import (
"math"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"strconv"
@@ -26,7 +25,6 @@ import (
log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -136,10 +134,6 @@ type ProxyServiceServer struct {
// initial snapshot delivery. Configurable via NB_PROXY_SNAPSHOT_BATCH_SIZE.
snapshotBatchSize int
authAttemptLimiter *authFailureLimiter
authClientLimiter *authFailureLimiter
authFailureMAC []byte
cancel context.CancelFunc
}
@@ -210,10 +204,6 @@ func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeT
snapshotBatchSize: snapshotBatchSizeFromEnv(),
cancel: cancel,
}
s.authAttemptLimiter = newAuthFailureLimiter()
s.authClientLimiter = newAuthClientLimiter()
s.authFailureMAC = make([]byte, sha256.Size)
_, _ = rand.Read(s.authFailureMAC)
go s.cleanupStaleProxies(ctx)
return s
}
@@ -1182,18 +1172,6 @@ func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.Authen
return nil, err
}
failureKey := s.authFailureKey(req)
limitFailures := failureKey != "" && s.authAttemptLimiter != nil && len(s.authFailureMAC) > 0
if limitFailures && s.authAttemptLimiter.isLimited(failureKey) {
return nil, status.Errorf(codes.ResourceExhausted, "too many failed authentication attempts for this credential, please try again later")
}
clientKey := s.authClientKey(ctx, req.GetId())
limitClient := clientKey != "" && s.authClientLimiter != nil
if limitClient && s.authClientLimiter.isLimited(clientKey) {
return nil, status.Errorf(codes.ResourceExhausted, "too many failed authentication attempts from this client, please try again later")
}
service, err := s.serviceManager.GetServiceByID(ctx, req.GetAccountId(), req.GetId())
if err != nil {
log.WithContext(ctx).Debugf("failed to get service from store: %v", err)
@@ -1201,14 +1179,6 @@ func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.Authen
}
authenticated, userId, method := s.authenticateRequest(ctx, req, service)
if !authenticated {
if limitFailures {
s.authAttemptLimiter.recordFailure(failureKey)
}
if limitClient {
s.authClientLimiter.recordFailure(clientKey)
}
}
// Non-OIDC schemes (PIN/Password/Header) authenticate against per-service
// secrets and have no user-level group context, so groups stay nil. Email
@@ -1224,40 +1194,6 @@ func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.Authen
}, nil
}
func (s *ProxyServiceServer) authClientKey(ctx context.Context, serviceID string) string {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return ""
}
values := md.Get(proxyauth.ClientIPMetadataKey)
if len(values) == 0 {
return ""
}
addr, err := netip.ParseAddr(strings.TrimSpace(values[0]))
if err != nil {
return ""
}
return serviceID + "|" + addr.Unmap().String()
}
func (s *ProxyServiceServer) authFailureKey(req *proto.AuthenticateRequest) string {
var secret string
switch v := req.GetRequest().(type) {
case *proto.AuthenticateRequest_Pin:
secret = "pin|" + v.Pin.GetPin()
case *proto.AuthenticateRequest_Password:
secret = "password|" + v.Password.GetPassword()
case *proto.AuthenticateRequest_HeaderAuth:
secret = "header|" + v.HeaderAuth.GetHeaderName() + "|" + v.HeaderAuth.GetHeaderValue()
default:
return ""
}
mac := hmac.New(sha256.New, s.authFailureMAC)
mac.Write([]byte(secret))
return req.GetId() + "|" + hex.EncodeToString(mac.Sum(nil))
}
func (s *ProxyServiceServer) authenticateRequest(ctx context.Context, req *proto.AuthenticateRequest, service *rpservice.Service) (bool, string, proxyauth.Method) {
switch v := req.GetRequest().(type) {
case *proto.AuthenticateRequest_Pin:

View File

@@ -1,189 +0,0 @@
package grpc
import (
"context"
"crypto/rand"
"crypto/sha256"
"fmt"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/time/rate"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
proxyauth "github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/shared/hash/argon2id"
"github.com/netbirdio/netbird/shared/management/proto"
)
const authAttemptsHeaderName = "X-API-Key"
func newAuthAttemptsTestServer(t *testing.T) *ProxyServiceServer {
t.Helper()
firstHash, err := argon2id.Hash("first-key")
require.NoError(t, err)
secondHash, err := argon2id.Hash("second-key")
require.NoError(t, err)
svc := &rpservice.Service{
ID: "svc1",
Domain: "example.com",
Auth: rpservice.AuthConfig{
HeaderAuths: []*rpservice.HeaderAuthConfig{
{Enabled: true, Header: authAttemptsHeaderName, Value: firstHash},
{Enabled: true, Header: authAttemptsHeaderName, Value: secondHash},
},
},
}
ctrl := gomock.NewController(t)
mgr := rpservice.NewMockManager(ctrl)
mgr.EXPECT().GetServiceByID(gomock.Any(), gomock.Any(), gomock.Any()).Return(svc, nil).AnyTimes()
limiter := newAuthFailureLimiter()
t.Cleanup(limiter.stop)
clientLimiter := newAuthClientLimiter()
t.Cleanup(clientLimiter.stop)
mac := make([]byte, sha256.Size)
_, err = rand.Read(mac)
require.NoError(t, err)
return &ProxyServiceServer{
serviceManager: mgr,
authAttemptLimiter: limiter,
authClientLimiter: clientLimiter,
authFailureMAC: mac,
}
}
func clientIPContext(ip string) context.Context {
return metadata.NewIncomingContext(context.Background(), metadata.Pairs(proxyauth.ClientIPMetadataKey, ip))
}
func authAttemptsRequest(credential string) *proto.AuthenticateRequest {
return &proto.AuthenticateRequest{
Id: "svc1",
AccountId: "acc1",
Request: &proto.AuthenticateRequest_HeaderAuth{
HeaderAuth: &proto.HeaderAuthRequest{
HeaderName: authAttemptsHeaderName,
HeaderValue: credential,
},
},
}
}
func TestAuthenticate_ValidCredentialIsNeverRateLimited(t *testing.T) {
s := newAuthAttemptsTestServer(t)
for i := 0; i < proxyAuthFailureBurst*2; i++ {
resp, err := s.Authenticate(context.Background(), authAttemptsRequest("first-key"))
require.NoError(t, err, "a valid credential must never be throttled (attempt %d)", i)
require.True(t, resp.GetSuccess())
}
}
func TestAuthenticate_FailedCredentialIsRateLimited(t *testing.T) {
s := newAuthAttemptsTestServer(t)
for i := 0; i < proxyAuthFailureBurst; i++ {
resp, err := s.Authenticate(context.Background(), authAttemptsRequest("wrong-key"))
require.NoError(t, err, "attempt %d should be within the failure budget", i)
require.False(t, resp.GetSuccess())
}
_, err := s.Authenticate(context.Background(), authAttemptsRequest("wrong-key"))
require.Error(t, err)
assert.Equal(t, codes.ResourceExhausted, status.Code(err))
}
func TestAuthenticate_ThrottledCredentialDoesNotAffectOthers(t *testing.T) {
s := newAuthAttemptsTestServer(t)
for i := 0; i < proxyAuthFailureBurst+2; i++ {
_, _ = s.Authenticate(context.Background(), authAttemptsRequest("wrong-key"))
}
resp, err := s.Authenticate(context.Background(), authAttemptsRequest("first-key"))
require.NoError(t, err, "one throttled credential must not block a valid one")
assert.True(t, resp.GetSuccess())
resp, err = s.Authenticate(context.Background(), authAttemptsRequest("second-key"))
require.NoError(t, err)
assert.True(t, resp.GetSuccess())
_, err = s.Authenticate(context.Background(), authAttemptsRequest("another-wrong-key"))
require.NoError(t, err, "a different failing credential has its own budget")
}
func TestAuthenticate_DistinctCredentialsThrottledPerClient(t *testing.T) {
const budget = 3
s := newAuthAttemptsTestServer(t)
s.authClientLimiter.stop()
s.authClientLimiter = newAuthLimiter(rate.Every(time.Hour), budget)
t.Cleanup(s.authClientLimiter.stop)
ctx := clientIPContext("198.51.100.7")
for i := 0; i < budget; i++ {
resp, err := s.Authenticate(ctx, authAttemptsRequest(fmt.Sprintf("garbage-%d", i)))
require.NoError(t, err, "attempt %d should be within the client budget", i)
require.False(t, resp.GetSuccess())
}
_, err := s.Authenticate(ctx, authAttemptsRequest("garbage-final"))
require.Error(t, err, "a client rotating distinct credentials must be throttled")
assert.Equal(t, codes.ResourceExhausted, status.Code(err))
other := clientIPContext("198.51.100.8")
resp, err := s.Authenticate(other, authAttemptsRequest("first-key"))
require.NoError(t, err, "a different client must be unaffected")
assert.True(t, resp.GetSuccess())
}
func TestAuthenticate_OneStaleCredentialDoesNotExhaustSharedClientBudget(t *testing.T) {
s := newAuthAttemptsTestServer(t)
ctx := clientIPContext("198.51.100.9")
for i := 0; i < proxyAuthFailureBurst*4; i++ {
_, _ = s.Authenticate(ctx, authAttemptsRequest("stale-key"))
}
resp, err := s.Authenticate(ctx, authAttemptsRequest("first-key"))
require.NoError(t, err, "one client stuck on a stale key must not block others behind the same NAT")
assert.True(t, resp.GetSuccess())
}
func TestAuthenticate_ProxyWithoutClientIPIsNotClientLimited(t *testing.T) {
s := newAuthAttemptsTestServer(t)
for i := 0; i < proxyAuthFailureBurst*2; i++ {
_, _ = s.Authenticate(context.Background(), authAttemptsRequest(fmt.Sprintf("garbage-%d", i)))
}
resp, err := s.Authenticate(context.Background(), authAttemptsRequest("first-key"))
require.NoError(t, err, "an old proxy must not have its clients share one budget")
assert.True(t, resp.GetSuccess())
}
func TestAuthenticate_MalformedClientIPIsIgnored(t *testing.T) {
s := newAuthAttemptsTestServer(t)
ctx := clientIPContext("not-an-ip")
for i := 0; i < proxyAuthFailureBurst*2; i++ {
_, _ = s.Authenticate(ctx, authAttemptsRequest(fmt.Sprintf("garbage-%d", i)))
}
resp, err := s.Authenticate(ctx, authAttemptsRequest("first-key"))
require.NoError(t, err)
assert.True(t, resp.GetSuccess())
}

View File

@@ -18,16 +18,12 @@ const (
proxyAuthLimiterCleanup = 5 * time.Minute
// proxyAuthLimiterTTL is how long a limiter is kept after the last failure.
proxyAuthLimiterTTL = 15 * time.Minute
proxyAuthClientBurst = 30
)
// defaultProxyAuthFailureRate is the token replenishment rate for failed auth attempts.
// One token every 12 seconds = 5 per minute.
var defaultProxyAuthFailureRate = rate.Every(12 * time.Second)
var defaultProxyAuthClientRate = rate.Limit(1)
// clientIP identifies a client by its IP address for rate limiting purposes.
type clientIP = string
@@ -41,7 +37,6 @@ type authFailureLimiter struct {
mu sync.Mutex
limiters map[clientIP]*limiterEntry
failureRate rate.Limit
burst int
cancel context.CancelFunc
}
@@ -50,19 +45,10 @@ func newAuthFailureLimiter() *authFailureLimiter {
}
func newAuthFailureLimiterWithRate(failureRate rate.Limit) *authFailureLimiter {
return newAuthLimiter(failureRate, proxyAuthFailureBurst)
}
func newAuthClientLimiter() *authFailureLimiter {
return newAuthLimiter(defaultProxyAuthClientRate, proxyAuthClientBurst)
}
func newAuthLimiter(failureRate rate.Limit, burst int) *authFailureLimiter {
ctx, cancel := context.WithCancel(context.Background())
l := &authFailureLimiter{
limiters: make(map[clientIP]*limiterEntry),
failureRate: failureRate,
burst: burst,
cancel: cancel,
}
go l.cleanupLoop(ctx)
@@ -91,7 +77,7 @@ func (l *authFailureLimiter) recordFailure(ip clientIP) {
entry, exists := l.limiters[ip]
if !exists {
entry = &limiterEntry{
limiter: rate.NewLimiter(l.failureRate, l.burst),
limiter: rate.NewLimiter(l.failureRate, proxyAuthFailureBurst),
}
l.limiters[ip] = entry
}

View File

@@ -30,8 +30,6 @@ const (
SessionJWTIssuer = "netbird-management"
)
const ClientIPMetadataKey = "nb-client-ip"
// ResolveProto determines the protocol scheme based on the forwarded proto
// configuration. When set to "http" or "https" the value is used directly.
// Otherwise TLS state is used: if conn is non-nil "https" is returned, else "http".

View File

@@ -1,188 +0,0 @@
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"os"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/sync/singleflight"
"github.com/netbirdio/netbird/proxy/internal/types"
)
const headerAuthCacheTTL = 60 * time.Second
const envHeaderAuthCacheTTL = "NB_PROXY_HEADER_AUTH_CACHE_TTL"
const headerAuthCachePerService = 1024
const headerAuthCacheSkew = 30 * time.Second
const headerAuthRPCTimeout = 10 * time.Second
type headerCacheKey struct {
serviceID types.ServiceID
headerName string
credential [sha256.Size]byte
}
type headerCacheEntry struct {
token string
expiresAt time.Time
}
type headerAuthCache struct {
mu sync.Mutex
entries map[types.ServiceID]*headerServiceBucket
flight singleflight.Group
ttl time.Duration
maxSize int
macKey []byte
now func() time.Time
}
type headerServiceBucket struct {
items map[headerCacheKey]headerCacheEntry
order []headerCacheKey
}
func newHeaderAuthCache() *headerAuthCache {
macKey := make([]byte, sha256.Size)
_, _ = rand.Read(macKey)
return &headerAuthCache{
entries: make(map[types.ServiceID]*headerServiceBucket),
ttl: headerAuthCacheTTLFromEnv(),
maxSize: headerAuthCachePerService,
macKey: macKey,
now: time.Now,
}
}
func headerAuthCacheTTLFromEnv() time.Duration {
raw := strings.TrimSpace(os.Getenv(envHeaderAuthCacheTTL))
if raw == "" {
return headerAuthCacheTTL
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
log.Warnf("ignoring invalid %s=%q (want a positive Go duration like 30s or 2m); using default %s",
envHeaderAuthCacheTTL, raw, headerAuthCacheTTL)
return headerAuthCacheTTL
}
return d
}
func (c *headerAuthCache) key(serviceID types.ServiceID, headerName, credential string) headerCacheKey {
mac := hmac.New(sha256.New, c.macKey)
mac.Write([]byte(credential))
key := headerCacheKey{serviceID: serviceID, headerName: headerName}
copy(key.credential[:], mac.Sum(nil))
return key
}
func (c *headerAuthCache) get(key headerCacheKey) string {
c.mu.Lock()
defer c.mu.Unlock()
bucket, ok := c.entries[key.serviceID]
if !ok {
return ""
}
entry, ok := bucket.items[key]
if !ok {
return ""
}
if !c.now().Before(entry.expiresAt) {
delete(bucket.items, key)
bucket.order = removeKey(bucket.order, key)
return ""
}
return entry.token
}
func (c *headerAuthCache) put(key headerCacheKey, token string, sessionExpiration time.Duration) {
lifetime := c.ttl
if sessionExpiration > 0 && sessionExpiration-headerAuthCacheSkew < lifetime {
lifetime = sessionExpiration - headerAuthCacheSkew
}
if lifetime <= 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
bucket, ok := c.entries[key.serviceID]
if !ok {
bucket = &headerServiceBucket{items: make(map[headerCacheKey]headerCacheEntry)}
c.entries[key.serviceID] = bucket
}
if _, exists := bucket.items[key]; !exists {
bucket.order = append(bucket.order, key)
}
bucket.items[key] = headerCacheEntry{token: token, expiresAt: c.now().Add(lifetime)}
for len(bucket.order) > c.maxSize {
oldest := bucket.order[0]
bucket.order = bucket.order[1:]
delete(bucket.items, oldest)
}
}
func (c *headerAuthCache) invalidate(key headerCacheKey) {
c.mu.Lock()
defer c.mu.Unlock()
bucket, ok := c.entries[key.serviceID]
if !ok {
return
}
delete(bucket.items, key)
bucket.order = removeKey(bucket.order, key)
}
func (c *headerAuthCache) invalidateService(serviceID types.ServiceID) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, serviceID)
}
type authenticateHeaderFn func() (string, error)
func (c *headerAuthCache) fetch(key headerCacheKey, sessionExpiration time.Duration, authenticate authenticateHeaderFn) (string, bool, error) {
if token := c.get(key); token != "" {
return token, true, nil
}
res, err, _ := c.flight.Do(headerFlightKey(key), func() (any, error) {
if token := c.get(key); token != "" {
return token, nil
}
token, err := authenticate()
if err != nil {
return "", err
}
if token != "" {
c.put(key, token, sessionExpiration)
}
return token, nil
})
if err != nil {
return "", false, err
}
token, _ := res.(string)
return token, false, nil
}
func headerFlightKey(key headerCacheKey) string {
return string(key.serviceID) + "|" + key.headerName + "|" + string(key.credential[:])
}

View File

@@ -1,244 +0,0 @@
package auth
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
"github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/shared/management/proto"
)
func newCountingHeaderScheme(t *testing.T, kp *sessionkey.KeyPair, headerName, expectedValue string, calls *atomic.Int32) Header {
t.Helper()
token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
require.NoError(t, err)
mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
calls.Add(1)
ha := req.GetHeaderAuth()
if ha != nil && ha.GetHeaderValue() == expectedValue {
return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil
}
return &proto.AuthenticateResponse{Success: false}, nil
}}
return NewHeader(mock, "svc1", "acc1", headerName)
}
func doHeaderRequest(t *testing.T, mw *Middleware, credential string) *httptest.ResponseRecorder {
t.Helper()
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "http://example.com/path", nil)
req.Header.Set("X-API-Key", credential)
req = req.WithContext(proxy.WithCapturedData(req.Context(), proxy.NewCapturedData("")))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func TestProtect_HeaderAuth_ReusesSessionTokenAcrossRequests(t *testing.T) {
var calls atomic.Int32
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
hdr := newCountingHeaderScheme(t, kp, "X-API-Key", "secret-key", &calls)
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
for i := 0; i < 25; i++ {
rec := doHeaderRequest(t, mw, "secret-key")
require.Equal(t, http.StatusOK, rec.Code)
}
assert.Equal(t, int32(1), calls.Load(), "a repeated credential must be verified once")
}
func TestProtect_HeaderAuth_DoesNotCacheFailures(t *testing.T) {
var calls atomic.Int32
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
hdr := newCountingHeaderScheme(t, kp, "X-API-Key", "secret-key", &calls)
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
for i := 0; i < 3; i++ {
rec := doHeaderRequest(t, mw, "wrong-key")
require.Equal(t, http.StatusUnauthorized, rec.Code)
}
assert.Equal(t, int32(3), calls.Load(), "rejected credentials must not be cached")
}
func TestProtect_HeaderAuth_MissingHeaderSkipsRPC(t *testing.T) {
var calls atomic.Int32
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
hdr := newCountingHeaderScheme(t, kp, "X-API-Key", "secret-key", &calls)
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
rec := doHeaderRequest(t, mw, "")
assert.NotEqual(t, http.StatusOK, rec.Code)
assert.Zero(t, calls.Load(), "an absent header must not reach management")
}
func TestHeaderAuthCache_EvictsExpiredEntries(t *testing.T) {
c := newHeaderAuthCache()
now := time.Now()
c.now = func() time.Time { return now }
key := c.key("svc1", "X-API-Key", "secret")
c.put(key, "token", time.Hour)
require.Equal(t, "token", c.get(key))
now = now.Add(c.ttl + time.Second)
assert.Empty(t, c.get(key))
}
func TestHeaderAuthCache_SkipsCacheWhenSessionExpiresWithinSkew(t *testing.T) {
c := newHeaderAuthCache()
key := c.key("svc1", "X-API-Key", "secret")
c.put(key, "token", headerAuthCacheSkew)
assert.Empty(t, c.get(key), "a token must never outlive the session it was minted for")
}
func TestHeaderAuthCache_SessionExpirationShortensTTL(t *testing.T) {
c := newHeaderAuthCache()
now := time.Now()
c.now = func() time.Time { return now }
key := c.key("svc1", "X-API-Key", "secret")
c.put(key, "token", headerAuthCacheSkew+10*time.Second)
require.Equal(t, "token", c.get(key))
now = now.Add(11 * time.Second)
assert.Empty(t, c.get(key))
}
func TestHeaderAuthCache_BoundsEntriesPerService(t *testing.T) {
c := newHeaderAuthCache()
c.maxSize = 4
var first headerCacheKey
for i := 0; i < 10; i++ {
key := c.key("svc1", "X-API-Key", fmt.Sprintf("secret-%d", i))
if i == 0 {
first = key
}
c.put(key, "token", time.Hour)
}
assert.Len(t, c.entries["svc1"].items, 4)
assert.Empty(t, c.get(first), "the oldest entry must be evicted")
}
func TestHeaderAuthCache_DistinguishesCredentials(t *testing.T) {
c := newHeaderAuthCache()
good := c.key("svc1", "X-API-Key", "good")
other := c.key("svc1", "X-API-Key", "other")
c.put(good, "token", time.Hour)
assert.Equal(t, "token", c.get(good))
assert.Empty(t, c.get(other))
}
func TestProtect_HeaderAuth_MappingUpdateInvalidatesCache(t *testing.T) {
var calls atomic.Int32
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
hdr := newCountingHeaderScheme(t, kp, "X-API-Key", "secret-key", &calls)
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
require.Equal(t, http.StatusOK, doHeaderRequest(t, mw, "secret-key").Code)
require.Equal(t, int32(1), calls.Load())
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
require.Equal(t, http.StatusOK, doHeaderRequest(t, mw, "secret-key").Code)
assert.Equal(t, int32(2), calls.Load(), "a mapping update must drop the service's cached credentials")
}
func TestHeaderAuthCache_InvalidateService(t *testing.T) {
c := newHeaderAuthCache()
key := c.key("svc1", "X-API-Key", "secret")
other := c.key("svc2", "X-API-Key", "secret")
c.put(key, "token", time.Hour)
c.put(other, "token", time.Hour)
c.invalidateService("svc1")
assert.Empty(t, c.get(key))
assert.Equal(t, "token", c.get(other), "other services must be untouched")
}
func TestHeaderAuthCache_Invalidate(t *testing.T) {
c := newHeaderAuthCache()
key := c.key("svc1", "X-API-Key", "secret")
other := c.key("svc1", "X-API-Key", "second")
c.put(key, "token", time.Hour)
c.put(other, "token", time.Hour)
c.invalidate(key)
assert.Empty(t, c.get(key))
assert.Equal(t, "token", c.get(other))
}
func TestProtect_HeaderAuth_RevalidatesWhenCachedTokenRejected(t *testing.T) {
var calls atomic.Int32
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
hdr := newCountingHeaderScheme(t, kp, "X-API-Key", "secret-key", &calls)
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
key := mw.headerCache.key("svc1", "X-API-Key", "secret-key")
mw.headerCache.put(key, "not-a-valid-token", time.Hour)
rec := doHeaderRequest(t, mw, "secret-key")
assert.Equal(t, http.StatusOK, rec.Code, "an unusable cached token must not fail the request")
assert.Equal(t, int32(1), calls.Load(), "the credential must be re-verified once")
}
func TestHeaderAuthCache_CollapsesConcurrentMisses(t *testing.T) {
c := newHeaderAuthCache()
key := c.key("svc1", "X-API-Key", "secret")
var calls atomic.Int32
release := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _, _ = c.fetch(key, time.Hour, func() (string, error) {
calls.Add(1)
<-release
return "token", nil
})
}()
}
time.Sleep(50 * time.Millisecond)
close(release)
wg.Wait()
assert.Equal(t, int32(1), calls.Load(), "a burst of cold requests must collapse into one RPC")
}

View File

@@ -16,7 +16,6 @@ import (
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/proxy"
@@ -83,7 +82,6 @@ type Middleware struct {
sessionValidator SessionValidator
geo restrict.GeoResolver
tunnelCache *tunnelValidationCache
headerCache *headerAuthCache
}
// NewMiddleware creates a new authentication middleware. The sessionValidator is
@@ -98,7 +96,6 @@ func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo re
sessionValidator: sessionValidator,
geo: geo,
tunnelCache: newTunnelValidationCache(),
headerCache: newHeaderAuthCache(),
}
}
@@ -455,23 +452,7 @@ func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Reque
}
func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, hdr Header, next http.Handler) bool {
credential := r.Header.Get(hdr.headerName)
if credential == "" {
return false
}
key := mw.headerCache.key(hdr.id, hdr.headerName, credential)
authenticate := func() (string, error) {
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), headerAuthRPCTimeout)
defer cancel()
if clientIP := mw.resolveClientIP(r); clientIP.IsValid() {
ctx = metadata.AppendToOutgoingContext(ctx, auth.ClientIPMetadataKey, clientIP.String())
}
token, _, err := hdr.Authenticate(r.WithContext(ctx))
return token, err
}
token, cached, err := mw.headerCache.fetch(key, config.SessionExpiration, authenticate)
token, _, err := hdr.Authenticate(r)
if err != nil {
return mw.handleHeaderAuthError(w, r, err)
}
@@ -480,17 +461,6 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
}
result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, auth.MethodHeader)
if err != nil && cached {
mw.headerCache.invalidate(key)
if token, err = authenticate(); err != nil {
return mw.handleHeaderAuthError(w, r, err)
}
if token == "" {
return false
}
mw.headerCache.put(key, token, config.SessionExpiration)
result, err = mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, auth.MethodHeader)
}
if err != nil {
setHeaderCapturedData(r.Context(), "", "", nil, nil)
status := http.StatusBadRequest
@@ -675,8 +645,6 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool) error {
mw.headerCache.invalidateService(serviceID)
if len(schemes) == 0 {
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
@@ -713,10 +681,6 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st
// RemoveDomain unregisters authentication for the given domain.
func (mw *Middleware) RemoveDomain(domain string) {
if config, exists := mw.getDomainConfig(domain); exists {
mw.headerCache.invalidateService(config.ServiceID)
}
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
delete(mw.domains, domain)

View File

@@ -146,7 +146,7 @@ func (c *tunnelValidationCache) put(key tunnelCacheKey, resp *proto.ValidateTunn
// removeKey drops the first occurrence of needle from order. The cache
// uses small slices so a linear scan is cheaper than a map+slice combo.
func removeKey[T comparable](order []T, needle T) []T {
func removeKey(order []tunnelCacheKey, needle tunnelCacheKey) []tunnelCacheKey {
for i, k := range order {
if k == needle {
return append(order[:i], order[i+1:]...)

View File

@@ -9,7 +9,6 @@ import (
"time"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/logging"
log "github.com/sirupsen/logrus"
nbnet "github.com/netbirdio/netbird/client/net"
@@ -80,28 +79,6 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
return conn, nil
}
// connectionTracer returns a QUIC tracer that logs the DPLPMTUD result and the
// reason a relay connection closed, so the path MTU settled on and teardown
// cause are visible in logs. Lines carry the relay address as a structured
// field, matching the rest of the relay client logging.
func connectionTracer(addr string) func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
relayLog := log.WithField("relay", addr)
return func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
return &logging.ConnectionTracer{
UpdatedMTU: func(mtu logging.ByteCount, done bool) {
if done {
relayLog.Infof("QUIC path MTU settled at %d", mtu)
return
}
relayLog.Debugf("QUIC path MTU probing at %d", mtu)
},
ClosedConnection: func(err error) {
relayLog.Debugf("QUIC connection closed: %v", err)
},
}
}
}
func prepareURL(address string) (string, error) {
var host string
var defaultPort string

View File

@@ -0,0 +1,145 @@
package quic
import (
"testing"
"github.com/quic-go/quic-go/qlog"
"github.com/quic-go/quic-go/qlogwriter"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
)
func TestCloseReason(t *testing.T) {
transportErr := qlog.TransportErrorCode(0x2) // CONNECTION_REFUSED
appErr := qlog.ApplicationErrorCode(42)
tests := []struct {
name string
event qlog.ConnectionClosed
want string
}{
{
// A close carrying nothing but an initiator still reads sensibly.
name: "initiator only",
event: qlog.ConnectionClosed{Initiator: qlog.InitiatorLocal},
want: "closed by local",
},
{
name: "transport error with trigger",
event: qlog.ConnectionClosed{
Initiator: qlog.InitiatorRemote,
ConnectionError: &transportErr,
Trigger: qlog.ConnectionCloseTriggerIdleTimeout,
},
want: "closed by remote, transport error: CONNECTION_REFUSED, trigger: idle_timeout",
},
{
name: "application error with reason",
event: qlog.ConnectionClosed{
Initiator: qlog.InitiatorLocal,
ApplicationError: &appErr,
Reason: "bye",
},
want: "closed by local, application error: 42, reason: bye",
},
{
// Transport and application errors are mutually exclusive in
// practice; if both are set the transport code wins.
name: "transport error takes precedence over application error",
event: qlog.ConnectionClosed{
Initiator: qlog.InitiatorLocal,
ConnectionError: &transportErr,
ApplicationError: &appErr,
},
want: "closed by local, transport error: CONNECTION_REFUSED",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := closeReason(tt.event); got != tt.want {
t.Errorf("closeReason() = %q, want %q", got, tt.want)
}
})
}
}
func TestLogSinkRecordEvent(t *testing.T) {
tests := []struct {
name string
event qlogwriter.Event
wantLevel log.Level
wantMsg string
}{
{
name: "settled MTU is logged at info",
event: qlog.MTUUpdated{Value: 1400, Done: true},
wantLevel: log.InfoLevel,
wantMsg: "QUIC path MTU settled at 1400",
},
{
// Probing fires repeatedly during discovery, so it stays at debug.
name: "MTU probe is logged at debug",
event: qlog.MTUUpdated{Value: 1300, Done: false},
wantLevel: log.DebugLevel,
wantMsg: "QUIC path MTU probing at 1300",
},
{
name: "connection closed is logged at debug",
event: qlog.ConnectionClosed{Initiator: qlog.InitiatorRemote},
wantLevel: log.DebugLevel,
wantMsg: "QUIC connection closed: closed by remote",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger, hook := test.NewNullLogger()
logger.SetLevel(log.DebugLevel)
recorder := logSink{log: logger.WithField("relay", "relay.example.com:443")}
recorder.RecordEvent(tt.event)
entries := hook.AllEntries()
if len(entries) != 1 {
t.Fatalf("got %d log entries, want 1", len(entries))
}
if entries[0].Level != tt.wantLevel {
t.Errorf("level = %v, want %v", entries[0].Level, tt.wantLevel)
}
if entries[0].Message != tt.wantMsg {
t.Errorf("message = %q, want %q", entries[0].Message, tt.wantMsg)
}
if relay := entries[0].Data["relay"]; relay != "relay.example.com:443" {
t.Errorf("relay field = %v, want relay.example.com:443", relay)
}
})
}
}
// Events the relay client does not care about must not produce log lines.
func TestLogSinkIgnoresUnhandledEvents(t *testing.T) {
logger, hook := test.NewNullLogger()
logger.SetLevel(log.DebugLevel)
recorder := logSink{log: logger.WithField("relay", "relay.example.com:443")}
recorder.RecordEvent(qlog.PacketLost{})
if entries := hook.AllEntries(); len(entries) != 0 {
t.Errorf("got %d log entries, want 0", len(entries))
}
}
func TestLogSinkSupportsSchemas(t *testing.T) {
trace := logSink{log: log.WithField("relay", "relay.example.com:443")}
if !trace.SupportsSchemas(qlog.EventSchema) {
t.Errorf("SupportsSchemas(%q) = false, want true", qlog.EventSchema)
}
if trace.SupportsSchemas("urn:ietf:params:qlog:events:http3-12") {
t.Error("SupportsSchemas() = true for an unrelated schema, want false")
}
if trace.AddProducer() == nil {
t.Error("AddProducer() = nil, want a recorder")
}
}

View File

@@ -0,0 +1,70 @@
package quic
import (
"context"
"fmt"
"strings"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/qlog"
"github.com/quic-go/quic-go/qlogwriter"
log "github.com/sirupsen/logrus"
)
// logSink implements both qlogwriter.Trace and qlogwriter.Recorder, forwarding
// the few qlog events the relay client cares about to logrus instead of
// writing a qlog file. It holds no mutable state and logrus entries are safe
// to share, so one value can serve every producer on the connection.
type logSink struct {
log *log.Entry
}
func (s logSink) AddProducer() qlogwriter.Recorder { return s }
func (s logSink) SupportsSchemas(schema string) bool { return schema == qlog.EventSchema }
func (s logSink) RecordEvent(event qlogwriter.Event) {
switch e := event.(type) {
case qlog.MTUUpdated:
if e.Done {
s.log.Infof("QUIC path MTU settled at %d", e.Value)
return
}
s.log.Debugf("QUIC path MTU probing at %d", e.Value)
case qlog.ConnectionClosed:
s.log.Debugf("QUIC connection closed: %s", closeReason(e))
}
}
func (s logSink) Close() error { return nil }
// connectionTracer returns a QUIC tracer that logs the DPLPMTUD result and the
// reason a relay connection closed, so the path MTU settled on and teardown
// cause are visible in logs. Lines carry the relay address as a structured
// field, matching the rest of the relay client logging.
func connectionTracer(addr string) func(context.Context, bool, quic.ConnectionID) qlogwriter.Trace {
relayLog := log.WithField("relay", addr)
return func(context.Context, bool, quic.ConnectionID) qlogwriter.Trace {
return logSink{log: relayLog}
}
}
// closeReason renders a ConnectionClosed event as a single line. The event
// carries the error as separate initiator, code, trigger and reason fields,
// any of which may be unset.
func closeReason(e qlog.ConnectionClosed) string {
parts := []string{fmt.Sprintf("closed by %s", e.Initiator)}
switch {
case e.ConnectionError != nil:
parts = append(parts, fmt.Sprintf("transport error: %s", *e.ConnectionError))
case e.ApplicationError != nil:
parts = append(parts, fmt.Sprintf("application error: %d", *e.ApplicationError))
}
if e.Trigger != "" {
parts = append(parts, fmt.Sprintf("trigger: %s", e.Trigger))
}
if e.Reason != "" {
parts = append(parts, fmt.Sprintf("reason: %s", e.Reason))
}
return strings.Join(parts, ", ")
}