Compare commits

..

1 Commits

Author SHA1 Message Date
pascal
ca6a71a0c8 add header auth cache to proxy 2026-08-10 16:47:01 +02:00
13 changed files with 804 additions and 227 deletions

7
go.mod
View File

@@ -97,9 +97,9 @@ require (
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/pkg/sftp v1.13.11
github.com/pkg/sftp v1.13.9
github.com/prometheus/client_golang v1.23.2
github.com/quic-go/quic-go v0.59.1
github.com/quic-go/quic-go v0.55.0
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,6 +239,7 @@ 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
@@ -339,4 +340,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.20260810103952-24e716aea4db
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701

43
go.sum
View File

@@ -349,6 +349,8 @@ 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=
@@ -488,8 +490,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.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/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/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=
@@ -561,8 +563,8 @@ github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g
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=
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -580,8 +582,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.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
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/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=
@@ -723,7 +725,11 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
@@ -737,6 +743,9 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -753,7 +762,10 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
@@ -766,6 +778,10 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -777,6 +793,7 @@ 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=
@@ -797,16 +814,25 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -816,7 +842,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
@@ -830,6 +859,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -13,6 +13,7 @@ import (
"math"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"strconv"
@@ -25,6 +26,7 @@ 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"
@@ -134,6 +136,10 @@ type ProxyServiceServer struct {
// initial snapshot delivery. Configurable via NB_PROXY_SNAPSHOT_BATCH_SIZE.
snapshotBatchSize int
authAttemptLimiter *authFailureLimiter
authClientLimiter *authFailureLimiter
authFailureMAC []byte
cancel context.CancelFunc
}
@@ -204,6 +210,10 @@ 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
}
@@ -1172,6 +1182,18 @@ 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)
@@ -1179,6 +1201,14 @@ 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
@@ -1194,6 +1224,40 @@ 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

@@ -0,0 +1,189 @@
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,12 +18,16 @@ 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
@@ -37,6 +41,7 @@ type authFailureLimiter struct {
mu sync.Mutex
limiters map[clientIP]*limiterEntry
failureRate rate.Limit
burst int
cancel context.CancelFunc
}
@@ -45,10 +50,19 @@ 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)
@@ -77,7 +91,7 @@ func (l *authFailureLimiter) recordFailure(ip clientIP) {
entry, exists := l.limiters[ip]
if !exists {
entry = &limiterEntry{
limiter: rate.NewLimiter(l.failureRate, proxyAuthFailureBurst),
limiter: rate.NewLimiter(l.failureRate, l.burst),
}
l.limiters[ip] = entry
}

View File

@@ -30,6 +30,8 @@ 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

@@ -0,0 +1,188 @@
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

@@ -0,0 +1,244 @@
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,6 +16,7 @@ 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"
@@ -82,6 +83,7 @@ type Middleware struct {
sessionValidator SessionValidator
geo restrict.GeoResolver
tunnelCache *tunnelValidationCache
headerCache *headerAuthCache
}
// NewMiddleware creates a new authentication middleware. The sessionValidator is
@@ -96,6 +98,7 @@ func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo re
sessionValidator: sessionValidator,
geo: geo,
tunnelCache: newTunnelValidationCache(),
headerCache: newHeaderAuthCache(),
}
}
@@ -452,7 +455,23 @@ 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 {
token, _, err := hdr.Authenticate(r)
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)
if err != nil {
return mw.handleHeaderAuthError(w, r, err)
}
@@ -461,6 +480,17 @@ 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
@@ -645,6 +675,8 @@ 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()
@@ -681,6 +713,10 @@ 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(order []tunnelCacheKey, needle tunnelCacheKey) []tunnelCacheKey {
func removeKey[T comparable](order []T, needle T) []T {
for i, k := range order {
if k == needle {
return append(order[:i], order[i+1:]...)

View File

@@ -9,6 +9,7 @@ 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"
@@ -79,6 +80,28 @@ 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

@@ -1,145 +0,0 @@
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

@@ -1,70 +0,0 @@
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, ", ")
}