mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-20 14:41:29 +02:00
Compare commits
1 Commits
modify-pee
...
feature/he
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80bfa33f71 |
@@ -863,40 +863,19 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
// second, look up the activation state of all modified peers before removing
|
||||
// any of them, so an unavailable state leaves the current connections intact
|
||||
active := make(map[string]bool, len(modified))
|
||||
// second, close all modified connections and remove them from the state map
|
||||
for _, p := range modified {
|
||||
peerPubKey := p.GetWgPubKey()
|
||||
state, err := e.statusRecorder.GetPeer(peerPubKey)
|
||||
err := e.removePeer(p.GetWgPubKey())
|
||||
if err != nil {
|
||||
return fmt.Errorf("get status of modified peer %s: %w", peerPubKey, err)
|
||||
}
|
||||
active[peerPubKey] = state.ConnStatus != peer.StatusIdle
|
||||
}
|
||||
// then close all modified connections and remove them from the state map
|
||||
for _, p := range modified {
|
||||
if err := e.removePeer(p.GetWgPubKey()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// third, add the peer connections again, restoring each peer's activation
|
||||
// state: under lazy connections a re-added peer starts idle, but the remote
|
||||
// side of an established connection keeps its state and sends no further
|
||||
// offers, so a previously active peer left idle cannot reconnect until the
|
||||
// remote's connection expires.
|
||||
// third, add the peer connections again
|
||||
for _, p := range modified {
|
||||
if err := e.addNewPeer(p); err != nil {
|
||||
err := e.addNewPeer(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !active[p.GetWgPubKey()] {
|
||||
continue
|
||||
}
|
||||
conn, ok := e.peerStore.PeerConn(p.GetWgPubKey())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
e.connMgr.ActivatePeer(e.ctx, conn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/guard"
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
@@ -467,163 +466,6 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngine_ModifiedPeerKeepsActivationState verifies that a peer re-added by
|
||||
// modifyPeers keeps its previous activation state under lazy connections. A
|
||||
// modified peer is removed and re-added, and a re-add defaults to idle; the
|
||||
// remote side of an established connection keeps its state and sends no further
|
||||
// offers, so a previously active peer parked idle leaves the pair unable to
|
||||
// reconnect until the remote's connection expires.
|
||||
func TestEngine_ModifiedPeerKeepsActivationState(t *testing.T) {
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||
t.Cleanup(cancel)
|
||||
|
||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||
WgIfaceName: "utun103",
|
||||
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33101,
|
||||
MTU: iface.DefaultMTU,
|
||||
LazyConnection: lazyconn.StateOn,
|
||||
}, EngineServices{
|
||||
SignalClient: &signal.MockClient{},
|
||||
MgmClient: &mgmt.MockClient{},
|
||||
RelayManager: relayMgr,
|
||||
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||
}, MobileDependency{})
|
||||
|
||||
wgIface := &MockWGIface{
|
||||
NameFunc: func() string { return "utun103" },
|
||||
IsUserspaceBindFunc: func() bool {
|
||||
return false
|
||||
},
|
||||
RemovePeerFunc: func(peerKey string) error {
|
||||
return nil
|
||||
},
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("10.20.0.1"),
|
||||
Network: netip.MustParsePrefix("10.20.0.0/24"),
|
||||
}
|
||||
},
|
||||
UpdatePeerFunc: func(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
engine.wgInterface = wgIface
|
||||
engine.routeManager = routemanager.NewManager(routemanager.ManagerConfig{
|
||||
Context: ctx,
|
||||
PublicKey: key.PublicKey().String(),
|
||||
DNSRouteInterval: time.Minute,
|
||||
WGInterface: engine.wgInterface,
|
||||
StatusRecorder: engine.statusRecorder,
|
||||
RelayManager: relayMgr,
|
||||
})
|
||||
require.NoError(t, engine.routeManager.Init())
|
||||
engine.dnsServer = &dns.MockServer{
|
||||
UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
|
||||
}
|
||||
udpConn, err := net.ListenUDP("udp4", nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := udpConn.Close(); err != nil {
|
||||
t.Errorf("close UDP listener: %v", err)
|
||||
}
|
||||
})
|
||||
engine.udpMux = udpmux.NewUniversalUDPMuxDefault(udpmux.UniversalUDPMuxParams{UDPConn: udpConn, MTU: 1280})
|
||||
engine.ctx = ctx
|
||||
engine.srWatcher = guard.NewSRWatcher(nil, nil, nil, icemaker.Config{})
|
||||
engine.connMgr = NewConnMgr(engine.config, engine.statusRecorder, engine.peerStore, wgIface)
|
||||
engine.connMgr.Start(ctx)
|
||||
t.Cleanup(engine.connMgr.Close)
|
||||
|
||||
// No agent version: not lazy-capable, so the connection opens permanently.
|
||||
activePeer := &mgmtProto.RemotePeerConfig{
|
||||
WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
||||
AllowedIps: []string{"100.64.0.10/24"},
|
||||
}
|
||||
// Lazy-capable, never activated: managed as idle.
|
||||
idlePeer := &mgmtProto.RemotePeerConfig{
|
||||
WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
||||
AllowedIps: []string{"100.64.0.11/24"},
|
||||
AgentVersion: "development",
|
||||
}
|
||||
|
||||
err = engine.updateNetworkMap(&mgmtProto.NetworkMap{
|
||||
Serial: 1,
|
||||
RemotePeers: []*mgmtProto.RemotePeerConfig{activePeer, idlePeer},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
state, err := engine.statusRecorder.GetPeer(activePeer.WgPubKey)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, peer.StatusConnecting, state.ConnStatus, "peer without lazy support should open a permanent connection")
|
||||
|
||||
state, err = engine.statusRecorder.GetPeer(idlePeer.WgPubKey)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, peer.StatusIdle, state.ConnStatus, "lazy-capable peer should be managed as idle")
|
||||
|
||||
// The active peer's agent version changes, as when a peer registered over the
|
||||
// API logs in and fills in its meta; the idle peer's allowed IPs change. Both
|
||||
// count as modified and are removed and re-added.
|
||||
err = engine.updateNetworkMap(&mgmtProto.NetworkMap{
|
||||
Serial: 2,
|
||||
RemotePeers: []*mgmtProto.RemotePeerConfig{
|
||||
{
|
||||
WgPubKey: activePeer.WgPubKey,
|
||||
AllowedIps: activePeer.AllowedIps,
|
||||
AgentVersion: "development",
|
||||
},
|
||||
{
|
||||
WgPubKey: idlePeer.WgPubKey,
|
||||
AllowedIps: []string{"100.64.0.21/24"},
|
||||
AgentVersion: "development",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
state, err = engine.statusRecorder.GetPeer(activePeer.WgPubKey)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, peer.StatusIdle, state.ConnStatus, "previously active peer should stay active after a modify")
|
||||
|
||||
state, err = engine.statusRecorder.GetPeer(idlePeer.WgPubKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, peer.StatusIdle, state.ConnStatus, "previously idle peer should stay idle after a modify")
|
||||
|
||||
// A missing status entry fails the modify before any connection is removed.
|
||||
require.NoError(t, engine.statusRecorder.RemovePeer(activePeer.WgPubKey))
|
||||
err = engine.updateNetworkMap(&mgmtProto.NetworkMap{
|
||||
Serial: 3,
|
||||
RemotePeers: []*mgmtProto.RemotePeerConfig{
|
||||
{
|
||||
WgPubKey: activePeer.WgPubKey,
|
||||
AllowedIps: []string{"100.64.0.30/24"},
|
||||
AgentVersion: "development",
|
||||
},
|
||||
{
|
||||
WgPubKey: idlePeer.WgPubKey,
|
||||
AllowedIps: []string{"100.64.0.31/24"},
|
||||
AgentVersion: "development",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.ErrorContains(t, err, "get status of modified peer", "a modify with an unavailable peer state should fail")
|
||||
|
||||
activeConn, ok := engine.peerStore.PeerConn(activePeer.WgPubKey)
|
||||
require.True(t, ok, "peer with unavailable state should keep its connection")
|
||||
assert.True(t, compareNetIPLists(activeConn.WgConfig().AllowedIps, activePeer.AllowedIps),
|
||||
"peer with unavailable state should keep its allowed IPs")
|
||||
|
||||
idleConn, ok := engine.peerStore.PeerConn(idlePeer.WgPubKey)
|
||||
require.True(t, ok, "the other modified peer should keep its connection")
|
||||
assert.True(t, compareNetIPLists(idleConn.WgConfig().AllowedIps, []string{"100.64.0.21/24"}),
|
||||
"the other modified peer should keep its allowed IPs")
|
||||
}
|
||||
|
||||
func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1311,7 +1311,7 @@ func (s *ProxyServiceServer) authenticateHeader(ctx context.Context, serviceID s
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return true, "header-user", proxyauth.MethodHeader
|
||||
return true, proxyauth.HeaderUserID, proxyauth.MethodHeader
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
|
||||
@@ -30,6 +30,12 @@ const (
|
||||
SessionJWTIssuer = "netbird-management"
|
||||
)
|
||||
|
||||
// HeaderUserID is the synthetic user id recorded for header-authenticated
|
||||
// requests. Header auth validates a per-service secret and resolves no user
|
||||
// record, so proxy access logs and management-minted session tokens both
|
||||
// attribute the request to this id.
|
||||
const HeaderUserID = "header-user"
|
||||
|
||||
// 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".
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"crypto/sha256"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/auth"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
||||
)
|
||||
|
||||
// ErrHeaderAuthFailed indicates that the header was present but the
|
||||
// credential did not validate. Callers should return 401 instead of
|
||||
// falling through to other auth schemes.
|
||||
var ErrHeaderAuthFailed = errors.New("header authentication failed")
|
||||
|
||||
// Header implements header-based authentication. The proxy checks for the
|
||||
// configured header in each request and validates its value via gRPC.
|
||||
// Header implements header-based authentication. The service mapping carries
|
||||
// the argon2id hash of every value accepted for the header, so the proxy
|
||||
// verifies the credential locally rather than round-tripping to management.
|
||||
type Header struct {
|
||||
id types.ServiceID
|
||||
accountId types.AccountID
|
||||
headerName string
|
||||
client authenticator
|
||||
hashes []string
|
||||
verified *verifiedValues
|
||||
}
|
||||
|
||||
// NewHeader creates a Header authentication scheme for the given header name.
|
||||
func NewHeader(client authenticator, id types.ServiceID, accountId types.AccountID, headerName string) Header {
|
||||
// NewHeader creates a Header authentication scheme accepting any value whose
|
||||
// argon2id hash appears in hashes. An empty hashes slice rejects every request
|
||||
// carrying the header, so a mapping that arrived without its hashes fails
|
||||
// closed instead of leaving the service unprotected.
|
||||
func NewHeader(headerName string, hashes []string) Header {
|
||||
return Header{
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
headerName: headerName,
|
||||
client: client,
|
||||
headerName: http.CanonicalHeaderKey(headerName),
|
||||
hashes: hashes,
|
||||
verified: &verifiedValues{seen: make(map[[32]byte]struct{}, len(hashes))},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,31 +35,55 @@ func (Header) Type() auth.Method {
|
||||
return auth.MethodHeader
|
||||
}
|
||||
|
||||
// Authenticate checks for the configured header in the request. If absent,
|
||||
// returns empty (unauthenticated). If present, validates via gRPC.
|
||||
func (h Header) Authenticate(r *http.Request) (string, string, error) {
|
||||
// Authenticate satisfies Scheme. Header credentials are resolved by Verify
|
||||
// before the scheme loop runs, so a request that reaches here never carries
|
||||
// the header and there is no credential to prompt for.
|
||||
func (Header) Authenticate(*http.Request) (string, string, error) {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// Verify reports whether the request carries the configured header and, when
|
||||
// it does, whether the value matches one of the service's hashes.
|
||||
func (h Header) Verify(r *http.Request) (present, matched bool) {
|
||||
value := r.Header.Get(h.headerName)
|
||||
if value == "" {
|
||||
return "", "", nil
|
||||
return false, false
|
||||
}
|
||||
|
||||
res, err := h.client.Authenticate(r.Context(), &proto.AuthenticateRequest{
|
||||
Id: string(h.id),
|
||||
AccountId: string(h.accountId),
|
||||
Request: &proto.AuthenticateRequest_HeaderAuth{
|
||||
HeaderAuth: &proto.HeaderAuthRequest{
|
||||
HeaderValue: value,
|
||||
HeaderName: h.headerName,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("authenticate header: %w", err)
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
if h.verified.has(digest) {
|
||||
return true, true
|
||||
}
|
||||
|
||||
if res.GetSuccess() {
|
||||
return res.GetSessionToken(), "", nil
|
||||
for _, hash := range h.hashes {
|
||||
if argon2id.Verify(value, hash) == nil {
|
||||
h.verified.add(digest)
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", ErrHeaderAuthFailed
|
||||
return true, false
|
||||
}
|
||||
|
||||
// verifiedValues remembers which header values already passed argon2id
|
||||
// verification. argon2id is deliberately expensive (19 MiB, two passes) and
|
||||
// header credentials repeat on every request, so re-deriving per request would
|
||||
// dominate the hot path. The set cannot outgrow the number of configured
|
||||
// hashes, and a mapping update builds a fresh scheme with an empty set.
|
||||
// Values are keyed by digest so the plaintext credential is not retained.
|
||||
type verifiedValues struct {
|
||||
mu sync.Mutex
|
||||
seen map[[32]byte]struct{}
|
||||
}
|
||||
|
||||
func (v *verifiedValues) has(digest [32]byte) bool {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
_, ok := v.seen[digest]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (v *verifiedValues) add(digest [32]byte) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.seen[digest] = struct{}{}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if mw.forwardWithHeaderAuth(w, r, host, config, next) {
|
||||
if mw.forwardWithHeaderAuth(w, r, config, next) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -436,14 +436,14 @@ func isTunnelSourceIP(ip netip.Addr) bool {
|
||||
|
||||
// forwardWithHeaderAuth checks for a Header auth scheme. If the header validates,
|
||||
// the request is forwarded directly (no redirect), which is important for API clients.
|
||||
func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
|
||||
func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Request, config DomainConfig, next http.Handler) bool {
|
||||
for _, scheme := range config.Schemes {
|
||||
hdr, ok := scheme.(Header)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
handled := mw.tryHeaderScheme(w, r, host, config, hdr, next)
|
||||
handled := mw.tryHeaderScheme(w, r, hdr, next)
|
||||
if handled {
|
||||
return true
|
||||
}
|
||||
@@ -451,40 +451,27 @@ func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Reque
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return mw.handleHeaderAuthError(w, r, err)
|
||||
}
|
||||
if token == "" {
|
||||
// tryHeaderScheme verifies the credential against the hashes the service
|
||||
// mapping carries. No session token is issued: the credential travels on
|
||||
// every request, so there is nothing for a cookie to save.
|
||||
func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, hdr Header, next http.Handler) bool {
|
||||
present, matched := hdr.Verify(r)
|
||||
if !present {
|
||||
return false
|
||||
}
|
||||
|
||||
result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, auth.MethodHeader)
|
||||
if err != nil {
|
||||
if !matched {
|
||||
mw.logger.WithFields(log.Fields{
|
||||
"host": r.Host,
|
||||
"header": hdr.headerName,
|
||||
}).Debug("header auth rejected: value does not match any configured hash")
|
||||
setHeaderCapturedData(r.Context(), "", "", nil, nil)
|
||||
status := http.StatusBadRequest
|
||||
msg := "invalid session token"
|
||||
if errors.Is(err, errValidationUnavailable) {
|
||||
status = http.StatusBadGateway
|
||||
msg = "authentication service unavailable"
|
||||
}
|
||||
http.Error(w, msg, status)
|
||||
return true
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
setHeaderCapturedData(r.Context(), result.UserID, result.UserEmail, result.Groups, result.GroupNames)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return true
|
||||
}
|
||||
|
||||
setSessionCookie(w, token, config.SessionExpiration)
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
cd.SetUserID(result.UserID)
|
||||
cd.SetUserEmail(result.UserEmail)
|
||||
cd.SetUserGroups(result.Groups)
|
||||
cd.SetUserGroupNames(result.GroupNames)
|
||||
cd.SetUserID(auth.HeaderUserID)
|
||||
cd.SetAuthMethod(auth.MethodHeader.String())
|
||||
}
|
||||
|
||||
@@ -492,20 +479,6 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
|
||||
return true
|
||||
}
|
||||
|
||||
func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Request, err error) bool {
|
||||
if errors.Is(err, ErrHeaderAuthFailed) {
|
||||
setHeaderCapturedData(r.Context(), "", "", nil, nil)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return true
|
||||
}
|
||||
mw.logger.WithField("scheme", "header").Warnf("header auth infrastructure error: %v", err)
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
cd.SetOrigin(proxy.OriginAuth)
|
||||
}
|
||||
http.Error(w, "authentication service unavailable", http.StatusBadGateway)
|
||||
return true
|
||||
}
|
||||
|
||||
func setHeaderCapturedData(ctx context.Context, userID, userEmail string, groups, groupNames []string) {
|
||||
cd := proxy.CapturedDataFromContext(ctx)
|
||||
if cd == nil {
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
"github.com/netbirdio/netbird/proxy/internal/restrict"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -1023,38 +1024,24 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code, "should show login page when multiple methods exist")
|
||||
}
|
||||
|
||||
// mockAuthenticator is a minimal mock for the authenticator gRPC interface
|
||||
// used by the Header scheme.
|
||||
type mockAuthenticator struct {
|
||||
fn func(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error)
|
||||
}
|
||||
|
||||
func (m *mockAuthenticator) Authenticate(ctx context.Context, in *proto.AuthenticateRequest, _ ...grpc.CallOption) (*proto.AuthenticateResponse, error) {
|
||||
return m.fn(ctx, in)
|
||||
}
|
||||
|
||||
// newHeaderSchemeWithToken creates a Header scheme backed by a mock that
|
||||
// returns a signed session token when the expected header value is provided.
|
||||
func newHeaderSchemeWithToken(t *testing.T, kp *sessionkey.KeyPair, headerName, expectedValue string) Header {
|
||||
// newHeaderScheme creates a Header scheme accepting each of the given values,
|
||||
// hashed the way management hashes them before putting them on the mapping.
|
||||
func newHeaderScheme(t *testing.T, headerName string, acceptedValues ...string) 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) {
|
||||
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)
|
||||
hashes := make([]string, 0, len(acceptedValues))
|
||||
for _, v := range acceptedValues {
|
||||
hash, err := argon2id.Hash(v)
|
||||
require.NoError(t, err, "hashing an accepted header value must succeed")
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
return NewHeader(headerName, hashes)
|
||||
}
|
||||
|
||||
func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
@@ -1075,19 +1062,12 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "ok", rec.Body.String())
|
||||
|
||||
// Session cookie should be set.
|
||||
var sessionCookie *http.Cookie
|
||||
// The credential rides on every request, so no session cookie is issued.
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.SessionCookieName {
|
||||
sessionCookie = c
|
||||
break
|
||||
}
|
||||
assert.NotEqual(t, auth.SessionCookieName, c.Name, "header auth must not issue a session cookie")
|
||||
}
|
||||
require.NotNil(t, sessionCookie, "session cookie should be set after successful header auth")
|
||||
assert.True(t, sessionCookie.HttpOnly)
|
||||
assert.True(t, sessionCookie.Secure)
|
||||
|
||||
assert.Equal(t, "header-user", capturedData.GetUserID())
|
||||
assert.Equal(t, auth.HeaderUserID, capturedData.GetUserID())
|
||||
assert.Equal(t, "header", capturedData.GetAuthMethod())
|
||||
}
|
||||
|
||||
@@ -1095,7 +1075,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||
// Also add a PIN scheme so we can verify fallthrough behavior.
|
||||
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
@@ -1114,10 +1094,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
||||
return &proto.AuthenticateResponse{Success: false}, nil
|
||||
}}
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
|
||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
@@ -1131,93 +1108,113 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.Equal(t, "header", capturedData.GetAuthMethod())
|
||||
assert.Empty(t, hdr.verified.seen, "a rejected value must not be memoized")
|
||||
}
|
||||
|
||||
func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) {
|
||||
// TestProtect_HeaderAuth_NoHashesFailsClosed covers a mapping that names a
|
||||
// header but carries no hash for it: the check cannot be evaluated, so the
|
||||
// request must be denied rather than let through unauthenticated.
|
||||
func TestProtect_HeaderAuth_NoHashesFailsClosed(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
||||
return nil, errors.New("gRPC unavailable")
|
||||
}}
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req.Header.Set("X-API-Key", "some-key")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
}
|
||||
|
||||
func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
hdr := NewHeader("X-API-Key", nil)
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
backendCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req.Header.Set("X-API-Key", "any-key")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.False(t, backendCalled, "a header auth with no hashes must not admit the request")
|
||||
}
|
||||
|
||||
// TestProtect_HeaderAuth_SubsequentRequestRequiresHeader verifies that header
|
||||
// auth grants no ambient session: a follow-up request that drops the header is
|
||||
// treated as unauthenticated.
|
||||
func TestProtect_HeaderAuth_SubsequentRequestRequiresHeader(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
var backendCalls int
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
backendCalls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// First request with header auth.
|
||||
req1 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req1.Header.Set("X-API-Key", "secret-key")
|
||||
req1 = req1.WithContext(proxy.WithCapturedData(req1.Context(), proxy.NewCapturedData("")))
|
||||
rec1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec1, req1)
|
||||
require.Equal(t, http.StatusOK, rec1.Code)
|
||||
require.Equal(t, 1, backendCalls)
|
||||
|
||||
// Extract session cookie.
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range rec1.Result().Cookies() {
|
||||
if c.Name == auth.SessionCookieName {
|
||||
sessionCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, sessionCookie)
|
||||
|
||||
// Second request with only the session cookie (no header).
|
||||
capturedData2 := proxy.NewCapturedData("")
|
||||
// Same client, second request, header omitted: no cookie was handed out, so
|
||||
// there is nothing to carry the earlier success forward.
|
||||
req2 := httptest.NewRequest(http.MethodGet, "http://example.com/other", nil)
|
||||
req2.AddCookie(sessionCookie)
|
||||
req2 = req2.WithContext(proxy.WithCapturedData(req2.Context(), capturedData2))
|
||||
for _, c := range rec1.Result().Cookies() {
|
||||
req2.AddCookie(c)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec2, req2)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec2.Code)
|
||||
assert.Equal(t, "header-user", capturedData2.GetUserID())
|
||||
assert.Equal(t, "header", capturedData2.GetAuthMethod())
|
||||
assert.Equal(t, http.StatusUnauthorized, rec2.Code, "dropping the header must revoke access")
|
||||
assert.Equal(t, 1, backendCalls, "backend must not be reached without the header")
|
||||
}
|
||||
|
||||
// TestProtect_HeaderAuth_MultipleValuesSameHeader verifies that the proxy
|
||||
// correctly handles multiple valid credentials for the same header name.
|
||||
// In production, the mgmt gRPC authenticateHeader iterates all configured
|
||||
// header auths and accepts if any hash matches (OR semantics). The proxy
|
||||
// creates one Header scheme per entry, but a single gRPC call checks all.
|
||||
// TestProtect_HeaderAuth_RepeatedValueIsMemoized verifies the KDF is run once
|
||||
// per distinct accepted value. argon2id is deliberately expensive, so a
|
||||
// credential that repeats on every request must not be re-derived each time.
|
||||
func TestProtect_HeaderAuth_RepeatedValueIsMemoized(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderScheme(t, "X-API-Key", "key-a", "key-b")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
get := func(value string) int {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req.Header.Set("X-API-Key", value)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusOK, get("key-a"))
|
||||
require.Equal(t, http.StatusOK, get("key-a"))
|
||||
assert.Len(t, hdr.verified.seen, 1, "the same value must be memoized once")
|
||||
|
||||
require.Equal(t, http.StatusOK, get("key-b"))
|
||||
assert.Len(t, hdr.verified.seen, 2, "each accepted value gets its own entry")
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, get("key-c"))
|
||||
assert.Len(t, hdr.verified.seen, 2, "rejected values must not grow the set")
|
||||
}
|
||||
|
||||
// TestProtect_HeaderAuth_MultipleValuesSameHeader verifies that a service with
|
||||
// several accepted credentials for one header name accepts any of them.
|
||||
// Management applied these OR semantics while it still validated the value; the
|
||||
// proxy preserves them by carrying every hash for a name on one scheme.
|
||||
func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
// Mock simulates mgmt behavior: accepts either token-a or token-b.
|
||||
accepted := map[string]bool{"Bearer token-a": true, "Bearer token-b": true}
|
||||
mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
||||
ha := req.GetHeaderAuth()
|
||||
if ha != nil && accepted[ha.GetHeaderValue()] {
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil
|
||||
}
|
||||
return &proto.AuthenticateResponse{Success: false}, nil
|
||||
}}
|
||||
|
||||
// Single Header scheme (as if one entry existed), but the mock checks both values.
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "Authorization")
|
||||
hdr := newHeaderScheme(t, "Authorization", "Bearer token-a", "Bearer token-b")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -2062,9 +2063,7 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
|
||||
if mapping.GetAuth().GetOidc() {
|
||||
schemes = append(schemes, auth.NewOIDC(s.mgmtClient, svcID, accountID, s.ForwardedProto))
|
||||
}
|
||||
for _, ha := range mapping.GetAuth().GetHeaderAuths() {
|
||||
schemes = append(schemes, auth.NewHeader(s.mgmtClient, svcID, accountID, ha.GetHeader()))
|
||||
}
|
||||
schemes = append(schemes, headerAuthSchemes(mapping.GetAuth().GetHeaderAuths())...)
|
||||
|
||||
ipRestrictions := s.parseRestrictions(mapping)
|
||||
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
|
||||
@@ -2080,6 +2079,34 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
|
||||
return nil
|
||||
}
|
||||
|
||||
// headerAuthSchemes builds one scheme per canonical header name, carrying every
|
||||
// hash configured for that name so any of them is accepted — the OR semantics
|
||||
// management applied while it still validated the credential itself. A name
|
||||
// whose entries arrive without a hash yields a scheme with none, which rejects
|
||||
// the header rather than leaving the service unprotected.
|
||||
func headerAuthSchemes(headerAuths []*proto.HeaderAuth) []auth.Scheme {
|
||||
names := make([]string, 0, len(headerAuths))
|
||||
hashes := make(map[string][]string, len(headerAuths))
|
||||
for _, ha := range headerAuths {
|
||||
name := http.CanonicalHeaderKey(ha.GetHeader())
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(names, name) {
|
||||
names = append(names, name)
|
||||
}
|
||||
if hash := ha.GetHashedValue(); hash != "" {
|
||||
hashes[name] = append(hashes[name], hash)
|
||||
}
|
||||
}
|
||||
|
||||
schemes := make([]auth.Scheme, 0, len(names))
|
||||
for _, name := range names {
|
||||
schemes = append(schemes, auth.NewHeader(name, hashes[name]))
|
||||
}
|
||||
return schemes
|
||||
}
|
||||
|
||||
// initMiddlewareManager wires the middleware subsystem at boot. It configures
|
||||
// the per-process FactoryContext concrete middlewares consult, installs the
|
||||
// live-service check, and binds the resolver to the registry concrete
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,8 +17,10 @@ import (
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/auth"
|
||||
proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics"
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -209,6 +213,50 @@ func TestRedactMappingForLog_HandlesEmptyOrNilFields(t *testing.T) {
|
||||
assert.Empty(t, redacted.Path, "empty Path must remain empty")
|
||||
}
|
||||
|
||||
// headerSchemeAccepts reports whether the scheme admits value for headerName.
|
||||
func headerSchemeAccepts(t *testing.T, scheme auth.Scheme, headerName, value string) bool {
|
||||
t.Helper()
|
||||
hdr, ok := scheme.(auth.Header)
|
||||
require.True(t, ok, "header auths must produce Header schemes")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req.Header.Set(headerName, value)
|
||||
_, matched := hdr.Verify(req)
|
||||
return matched
|
||||
}
|
||||
|
||||
func TestHeaderAuthSchemes_GroupsValuesByCanonicalHeaderName(t *testing.T) {
|
||||
hashOf := func(v string) string {
|
||||
hash, err := argon2id.Hash(v)
|
||||
require.NoError(t, err)
|
||||
return hash
|
||||
}
|
||||
|
||||
schemes := headerAuthSchemes([]*proto.HeaderAuth{
|
||||
{Header: "Authorization", HashedValue: hashOf("Bearer a")},
|
||||
{Header: "authorization", HashedValue: hashOf("Bearer b")},
|
||||
{Header: "X-Api-Key", HashedValue: hashOf("key-1")},
|
||||
})
|
||||
|
||||
require.Len(t, schemes, 2, "entries differing only in header-name case must collapse into one scheme")
|
||||
|
||||
assert.True(t, headerSchemeAccepts(t, schemes[0], "Authorization", "Bearer a"), "first value for the header must be accepted")
|
||||
assert.True(t, headerSchemeAccepts(t, schemes[0], "Authorization", "Bearer b"), "second value for the same header must be accepted")
|
||||
assert.False(t, headerSchemeAccepts(t, schemes[0], "Authorization", "Bearer c"), "unconfigured value must be rejected")
|
||||
assert.True(t, headerSchemeAccepts(t, schemes[1], "X-Api-Key", "key-1"), "a second header name keeps its own scheme")
|
||||
}
|
||||
|
||||
// TestHeaderAuthSchemes_MissingHashFailsClosed covers a mapping that names a
|
||||
// header but carries no hash for it. Dropping the scheme would leave a service
|
||||
// whose only auth is that header wide open, so the scheme is kept and denies.
|
||||
func TestHeaderAuthSchemes_MissingHashFailsClosed(t *testing.T) {
|
||||
schemes := headerAuthSchemes([]*proto.HeaderAuth{{Header: "X-Api-Key"}})
|
||||
|
||||
require.Len(t, schemes, 1, "a header without a hash must still register a scheme")
|
||||
assert.False(t, headerSchemeAccepts(t, schemes[0], "X-Api-Key", "anything"),
|
||||
"a header auth without a hash must reject every value")
|
||||
}
|
||||
|
||||
type statusUpdateOnlyClient struct {
|
||||
proto.ProxyServiceClient
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user