mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-21 08:09:55 +00:00
feat(private-service): expose NetBird-only services over tunnel peers
Adds a new "private" service mode for the reverse proxy: services reachable exclusively over the embedded WireGuard tunnel, gated by per-peer group membership instead of operator auth schemes. Wire contract - ProxyMapping.private (field 13): the proxy MUST call ValidateTunnelPeer and fail closed; operator schemes are bypassed. - ProxyCapabilities.private (4) + supports_private_service (5): capability gate. Management never streams private mappings to proxies that don't claim the capability; the broadcast path applies the same filter via filterMappingsForProxy. - ValidateTunnelPeer RPC: resolves an inbound tunnel IP to a peer, checks the peer's groups against service.AccessGroups, and mints a session JWT on success. checkPeerGroupAccess fails closed when a private service has empty AccessGroups. - ValidateSession/ValidateTunnelPeer responses now carry peer_group_ids + peer_group_names so the proxy can authorise policy-aware middlewares without an extra management round-trip. - ProxyInboundListener + SendStatusUpdate.inbound_listener: per-account inbound listener state surfaced to dashboards. - PathTargetOptions.direct_upstream (11): bypass the embedded NetBird client and dial the target via the proxy host's network stack for upstreams reachable without WireGuard. Data model - Service.Private (bool) + Service.AccessGroups ([]string, JSON- serialised). Validate() rejects bearer auth on private services. Copy() deep-copies AccessGroups. pgx getServices loads the columns. - DomainConfig.Private threaded into the proxy auth middleware. Request handler routes private services through forwardWithTunnelPeer and returns 403 on validation failure. - Account-level SynthesizePrivateServiceZones (synthetic DNS) and injectPrivateServicePolicies (synthetic ACL) gate on len(svc.AccessGroups) > 0. Proxy - /netbird proxy --private (embedded mode) flag; Config.Private in proxy/lifecycle.go. - Per-account inbound listener (proxy/inbound.go) binding HTTP/HTTPS on the embedded NetBird client's WireGuard tunnel netstack. - proxy/internal/auth/tunnel_cache: ValidateTunnelPeer response cache with single-flight de-duplication and per-account eviction. - Local peerstore short-circuit: when the inbound IP isn't in the account roster, deny fast without an RPC. - proxy/server.go reports SupportsPrivateService=true and redacts the full ProxyMapping JSON from info logs (auth_token + header-auth hashed values now only at debug level). Identity forwarding - ValidateSessionJWT returns user_id, email, method, groups, group_names. sessionkey.Claims carries Email + Groups + GroupNames so the proxy can stamp identity onto upstream requests without an extra management round-trip on every cookie-bearing request. - CapturedData carries userEmail / userGroups / userGroupNames; the proxy stamps X-NetBird-User and X-NetBird-Groups on r.Out from the authenticated identity (strips client-supplied values first to prevent spoofing). - AccessLog.UserGroups: access-log enrichment captures the user's group memberships at write time so the dashboard can render group context without reverse-resolving stale memberships. OpenAPI/dashboard surface - ReverseProxyService gains private + access_groups; ReverseProxyCluster gains private + supports_private. ReverseProxyTarget target_type enum gains "cluster". ServiceTargetOptions gains direct_upstream. ProxyAccessLog gains user_groups.
This commit is contained in:
47
proxy/internal/auth/identity.go
Normal file
47
proxy/internal/auth/identity.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// PeerIdentity describes the locally-known facts about a peer reachable on
|
||||
// the proxy's per-account WireGuard listener. Phase 3 fills PubKey, TunnelIP
|
||||
// and FQDN from the embedded client's peerstore. UserID, Email and Groups
|
||||
// stay zero in V1 — full identity still travels through ValidateTunnelPeer.
|
||||
// Phase V2 will populate them once RemotePeerConfig carries user identity.
|
||||
type PeerIdentity struct {
|
||||
PubKey string
|
||||
TunnelIP netip.Addr
|
||||
FQDN string
|
||||
|
||||
// V2 fields (zero in V1).
|
||||
UserID string
|
||||
Email string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// TunnelLookupFunc resolves a tunnel IP to a peer identity using locally
|
||||
// available peerstore data. ok=false means the IP is not in the calling
|
||||
// account's roster.
|
||||
type TunnelLookupFunc func(ip netip.Addr) (PeerIdentity, bool)
|
||||
|
||||
type tunnelLookupContextKey struct{}
|
||||
|
||||
// WithTunnelLookup attaches a per-account peerstore lookup function to
|
||||
// the request context. The auth middleware calls this lookup before
|
||||
// hitting management's ValidateTunnelPeer to short-circuit unknown IPs
|
||||
// and to skip the RPC for already-cached identities.
|
||||
func WithTunnelLookup(ctx context.Context, lookup TunnelLookupFunc) context.Context {
|
||||
if lookup == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, tunnelLookupContextKey{}, lookup)
|
||||
}
|
||||
|
||||
// TunnelLookupFromContext returns the peerstore lookup attached to ctx,
|
||||
// or nil when the request did not arrive on a per-account listener.
|
||||
func TunnelLookupFromContext(ctx context.Context) TunnelLookupFunc {
|
||||
v, _ := ctx.Value(tunnelLookupContextKey{}).(TunnelLookupFunc)
|
||||
return v
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type authenticator interface {
|
||||
// SessionValidator validates session tokens and checks user access permissions.
|
||||
type SessionValidator interface {
|
||||
ValidateSession(ctx context.Context, in *proto.ValidateSessionRequest, opts ...grpc.CallOption) (*proto.ValidateSessionResponse, error)
|
||||
ValidateTunnelPeer(ctx context.Context, in *proto.ValidateTunnelPeerRequest, opts ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error)
|
||||
}
|
||||
|
||||
// Scheme defines an authentication mechanism for a domain.
|
||||
@@ -56,12 +57,21 @@ type DomainConfig struct {
|
||||
AccountID types.AccountID
|
||||
ServiceID types.ServiceID
|
||||
IPRestrictions *restrict.Filter
|
||||
// Private routes the domain through ValidateTunnelPeer; failure → 403.
|
||||
Private bool
|
||||
}
|
||||
|
||||
type validationResult struct {
|
||||
UserID string
|
||||
UserEmail string
|
||||
Valid bool
|
||||
DeniedReason string
|
||||
Groups []string
|
||||
// GroupNames carries the human-readable display names for Groups,
|
||||
// ordered identically (positional pairing). May be shorter than
|
||||
// Groups for tokens minted before names were embedded; the consumer
|
||||
// falls back to ids for missing positions.
|
||||
GroupNames []string
|
||||
}
|
||||
|
||||
// Middleware applies per-domain authentication and IP restriction checks.
|
||||
@@ -71,6 +81,7 @@ type Middleware struct {
|
||||
logger *log.Logger
|
||||
sessionValidator SessionValidator
|
||||
geo restrict.GeoResolver
|
||||
tunnelCache *tunnelValidationCache
|
||||
}
|
||||
|
||||
// NewMiddleware creates a new authentication middleware. The sessionValidator is
|
||||
@@ -84,6 +95,7 @@ func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo re
|
||||
logger: logger,
|
||||
sessionValidator: sessionValidator,
|
||||
geo: geo,
|
||||
tunnelCache: newTunnelValidationCache(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +123,15 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Private services bypass operator schemes and gate on tunnel peer.
|
||||
if config.Private {
|
||||
if mw.forwardWithTunnelPeer(w, r, host, config, next) {
|
||||
return
|
||||
}
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Domains with no authentication schemes pass through after IP checks.
|
||||
if len(config.Schemes) == 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -129,10 +150,54 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if mw.forwardWithTunnelPeer(w, r, host, config, next) {
|
||||
return
|
||||
}
|
||||
|
||||
if mw.blockOIDCOnPlainHTTP(w, r, config) {
|
||||
return
|
||||
}
|
||||
|
||||
mw.authenticateWithSchemes(w, r, host, config)
|
||||
})
|
||||
}
|
||||
|
||||
// requestIsPlainHTTP reports whether the request arrived without TLS.
|
||||
// Used to gate cookie-on-plain warnings and the OIDC plain-HTTP block.
|
||||
func requestIsPlainHTTP(r *http.Request) bool {
|
||||
return r.TLS == nil
|
||||
}
|
||||
|
||||
// hasOIDCScheme reports whether any of the configured schemes requires
|
||||
// TLS to round-trip safely with an external IdP.
|
||||
func hasOIDCScheme(schemes []Scheme) bool {
|
||||
for _, s := range schemes {
|
||||
if s.Type() == auth.MethodOIDC {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// blockOIDCOnPlainHTTP fails fast when an OIDC-configured domain is hit
|
||||
// over plain HTTP. Most IdPs reject http:// redirect URIs, so surfacing
|
||||
// the misconfiguration here yields a clearer error than the IdP's
|
||||
// "invalid redirect_uri" round-trip.
|
||||
func (mw *Middleware) blockOIDCOnPlainHTTP(w http.ResponseWriter, r *http.Request, config DomainConfig) bool {
|
||||
if !requestIsPlainHTTP(r) {
|
||||
return false
|
||||
}
|
||||
if !hasOIDCScheme(config.Schemes) {
|
||||
return false
|
||||
}
|
||||
mw.logger.WithFields(log.Fields{
|
||||
"host": r.Host,
|
||||
"remote": r.RemoteAddr,
|
||||
}).Warn("OIDC scheme reached on plain HTTP path; rejecting with 400 — use port 443")
|
||||
http.Error(w, "OIDC requires TLS — use port 443", http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
func (mw *Middleware) getDomainConfig(host string) (DomainConfig, bool) {
|
||||
mw.domainsMux.RLock()
|
||||
defer mw.domainsMux.RUnlock()
|
||||
@@ -246,18 +311,117 @@ func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Re
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
userID, method, err := auth.ValidateSessionJWT(cookie.Value, host, config.SessionPublicKey)
|
||||
if requestIsPlainHTTP(r) {
|
||||
mw.logger.WithFields(log.Fields{
|
||||
"host": host,
|
||||
"remote": r.RemoteAddr,
|
||||
}).Warn("session cookie on plain HTTP path; cookie auth requires TLS — use port 443")
|
||||
}
|
||||
userID, email, method, groups, groupNames, err := auth.ValidateSessionJWT(cookie.Value, host, config.SessionPublicKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
cd.SetUserID(userID)
|
||||
cd.SetUserEmail(email)
|
||||
cd.SetUserGroups(groups)
|
||||
cd.SetUserGroupNames(groupNames)
|
||||
cd.SetAuthMethod(method)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return true
|
||||
}
|
||||
|
||||
// forwardWithTunnelPeer is the OIDC fast-path for requests originating on the
|
||||
// netbird mesh. When the source IP belongs to a private/CGNAT range the proxy
|
||||
// asks management to resolve it to a peer/user and to gate by the service's
|
||||
// distribution_groups. On success the proxy installs the freshly minted JWT
|
||||
// as a session cookie, sets UserID + Method=oidc on the captured data, and
|
||||
// forwards directly — operators see the same access-log shape as if the user
|
||||
// had completed an OIDC redirect. Any failure (private-range mismatch,
|
||||
// management unreachable, peer unknown, user not in group) returns false so
|
||||
// the caller falls back to the existing OIDC scheme dispatch.
|
||||
//
|
||||
// Phase 3 adds a local-first short-circuit: when the request arrived on a
|
||||
// per-account inbound listener the context carries a peerstore lookup
|
||||
// (TunnelLookupFromContext). If the lookup says the IP isn't in the account's
|
||||
// roster the proxy denies fast without calling management. If the lookup
|
||||
// confirms a known peer the RPC still runs for the user-identity tail
|
||||
// (UserID + group access), but its result is cached for tunnelCacheTTL so
|
||||
// repeat requests skip management entirely.
|
||||
func (mw *Middleware) forwardWithTunnelPeer(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
|
||||
if mw.sessionValidator == nil {
|
||||
return false
|
||||
}
|
||||
clientIP := mw.resolveClientIP(r)
|
||||
if !clientIP.IsValid() {
|
||||
return false
|
||||
}
|
||||
if !isTunnelSourceIP(clientIP) {
|
||||
return false
|
||||
}
|
||||
|
||||
if lookup := TunnelLookupFromContext(r.Context()); lookup != nil {
|
||||
if _, ok := lookup(clientIP); !ok {
|
||||
mw.logger.WithFields(log.Fields{
|
||||
"host": host,
|
||||
"remote": clientIP,
|
||||
}).Debug("local peerstore: tunnel IP not in account roster; denying without RPC")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
resp, _, err := mw.tunnelCache.fetch(r.Context(), tunnelCacheKey{
|
||||
accountID: config.AccountID,
|
||||
tunnelIP: clientIP,
|
||||
domain: host,
|
||||
}, mw.validateTunnelPeer)
|
||||
if err != nil {
|
||||
mw.logger.WithError(err).Debug("ValidateTunnelPeer failed; falling back to OIDC")
|
||||
return false
|
||||
}
|
||||
if !resp.GetValid() || resp.GetSessionToken() == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
setSessionCookie(w, resp.GetSessionToken(), config.SessionExpiration)
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
cd.SetOrigin(proxy.OriginAuth)
|
||||
cd.SetUserID(resp.GetUserId())
|
||||
cd.SetUserEmail(resp.GetUserEmail())
|
||||
cd.SetUserGroups(resp.GetPeerGroupIds())
|
||||
cd.SetUserGroupNames(resp.GetPeerGroupNames())
|
||||
cd.SetAuthMethod(auth.MethodOIDC.String())
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return true
|
||||
}
|
||||
|
||||
// validateTunnelPeer adapts the SessionValidator interface to the cache's
|
||||
// validateTunnelPeerFn signature.
|
||||
func (mw *Middleware) validateTunnelPeer(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
|
||||
return mw.sessionValidator.ValidateTunnelPeer(ctx, req)
|
||||
}
|
||||
|
||||
// cgnatPrefix covers RFC 6598 100.64.0.0/10, the CGNAT block NetBird
|
||||
// allocates tunnel addresses from by default. IsPrivate() doesn't include
|
||||
// it, so we check it explicitly.
|
||||
var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
|
||||
|
||||
// isTunnelSourceIP reports whether ip falls within an address range typical
|
||||
// of NetBird tunnels: RFC1918 private space, IPv6 ULA, or CGNAT 100.64/10
|
||||
// (NetBird's default range). Loopback and link-local are excluded — the
|
||||
// fast-path is meant for peer-to-peer mesh traffic, not localhost.
|
||||
func isTunnelSourceIP(ip netip.Addr) bool {
|
||||
if !ip.IsValid() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
|
||||
return false
|
||||
}
|
||||
if ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
return cgnatPrefix.Contains(ip)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -286,7 +450,7 @@ 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 {
|
||||
setHeaderCapturedData(r.Context(), "")
|
||||
setHeaderCapturedData(r.Context(), "", "", nil, nil)
|
||||
status := http.StatusBadRequest
|
||||
msg := "invalid session token"
|
||||
if errors.Is(err, errValidationUnavailable) {
|
||||
@@ -298,7 +462,7 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
setHeaderCapturedData(r.Context(), result.UserID)
|
||||
setHeaderCapturedData(r.Context(), result.UserID, result.UserEmail, result.Groups, result.GroupNames)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return true
|
||||
}
|
||||
@@ -306,6 +470,9 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
|
||||
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.SetAuthMethod(auth.MethodHeader.String())
|
||||
}
|
||||
|
||||
@@ -315,7 +482,7 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
|
||||
|
||||
func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Request, err error) bool {
|
||||
if errors.Is(err, ErrHeaderAuthFailed) {
|
||||
setHeaderCapturedData(r.Context(), "")
|
||||
setHeaderCapturedData(r.Context(), "", "", nil, nil)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return true
|
||||
}
|
||||
@@ -327,7 +494,7 @@ func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Reque
|
||||
return true
|
||||
}
|
||||
|
||||
func setHeaderCapturedData(ctx context.Context, userID string) {
|
||||
func setHeaderCapturedData(ctx context.Context, userID, userEmail string, groups, groupNames []string) {
|
||||
cd := proxy.CapturedDataFromContext(ctx)
|
||||
if cd == nil {
|
||||
return
|
||||
@@ -335,6 +502,9 @@ func setHeaderCapturedData(ctx context.Context, userID string) {
|
||||
cd.SetOrigin(proxy.OriginAuth)
|
||||
cd.SetAuthMethod(auth.MethodHeader.String())
|
||||
cd.SetUserID(userID)
|
||||
cd.SetUserEmail(userEmail)
|
||||
cd.SetUserGroups(groups)
|
||||
cd.SetUserGroupNames(groupNames)
|
||||
}
|
||||
|
||||
// authenticateWithSchemes tries each configured auth scheme in order.
|
||||
@@ -405,6 +575,9 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
cd.SetOrigin(proxy.OriginAuth)
|
||||
cd.SetUserID(result.UserID)
|
||||
cd.SetUserEmail(result.UserEmail)
|
||||
cd.SetUserGroups(result.Groups)
|
||||
cd.SetUserGroupNames(result.GroupNames)
|
||||
cd.SetAuthMethod(scheme.Type().String())
|
||||
requestID = cd.GetRequestID()
|
||||
}
|
||||
@@ -419,6 +592,9 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
cd.SetOrigin(proxy.OriginAuth)
|
||||
cd.SetUserID(result.UserID)
|
||||
cd.SetUserEmail(result.UserEmail)
|
||||
cd.SetUserGroups(result.Groups)
|
||||
cd.SetUserGroupNames(result.GroupNames)
|
||||
cd.SetAuthMethod(scheme.Type().String())
|
||||
}
|
||||
redirectURL := stripSessionTokenParam(r.URL)
|
||||
@@ -454,12 +630,9 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// AddDomain registers authentication schemes for the given domain.
|
||||
// If schemes are provided, a valid session public key is required to sign/verify
|
||||
// session JWTs. Returns an error if the key is missing or invalid.
|
||||
// Callers must not serve the domain if this returns an error, to avoid
|
||||
// exposing an unauthenticated service.
|
||||
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter) error {
|
||||
// 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 {
|
||||
if len(schemes) == 0 {
|
||||
mw.domainsMux.Lock()
|
||||
defer mw.domainsMux.Unlock()
|
||||
@@ -467,6 +640,7 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st
|
||||
AccountID: accountID,
|
||||
ServiceID: serviceID,
|
||||
IPRestrictions: ipRestrictions,
|
||||
Private: private,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -488,6 +662,7 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st
|
||||
AccountID: accountID,
|
||||
ServiceID: serviceID,
|
||||
IPRestrictions: ipRestrictions,
|
||||
Private: private,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -518,18 +693,27 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri
|
||||
}).Debug("Session validation denied")
|
||||
return &validationResult{
|
||||
UserID: resp.UserId,
|
||||
UserEmail: resp.GetUserEmail(),
|
||||
Valid: false,
|
||||
DeniedReason: resp.DeniedReason,
|
||||
Groups: resp.GetPeerGroupIds(),
|
||||
GroupNames: resp.GetPeerGroupNames(),
|
||||
}, nil
|
||||
}
|
||||
return &validationResult{UserID: resp.UserId, Valid: true}, nil
|
||||
return &validationResult{
|
||||
UserID: resp.UserId,
|
||||
UserEmail: resp.GetUserEmail(),
|
||||
Valid: true,
|
||||
Groups: resp.GetPeerGroupIds(),
|
||||
GroupNames: resp.GetPeerGroupNames(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
userID, _, err := auth.ValidateSessionJWT(token, host, publicKey)
|
||||
userID, email, _, groups, groupNames, err := auth.ValidateSessionJWT(token, host, publicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &validationResult{UserID: userID, Valid: true}, nil
|
||||
return &validationResult{UserID: userID, UserEmail: email, Valid: true, Groups: groups, GroupNames: groupNames}, nil
|
||||
}
|
||||
|
||||
// stripSessionTokenParam returns the request URI with the session_token query
|
||||
|
||||
@@ -4,13 +4,16 @@ import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -62,7 +65,7 @@ func TestAddDomain_ValidKey(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
mw.domainsMux.RLock()
|
||||
@@ -79,7 +82,7 @@ func TestAddDomain_EmptyKey(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil)
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid session public key size")
|
||||
|
||||
@@ -93,7 +96,7 @@ func TestAddDomain_InvalidBase64(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil)
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decode session public key")
|
||||
|
||||
@@ -108,7 +111,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
|
||||
|
||||
shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort"))
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil)
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid session public key size")
|
||||
|
||||
@@ -121,7 +124,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
|
||||
func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil)
|
||||
err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false)
|
||||
require.NoError(t, err, "domains with no auth schemes should not require a key")
|
||||
|
||||
mw.domainsMux.RLock()
|
||||
@@ -137,8 +140,8 @@ func TestAddDomain_OverwritesPreviousConfig(t *testing.T) {
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false))
|
||||
|
||||
mw.domainsMux.RLock()
|
||||
config := mw.domains["example.com"]
|
||||
@@ -154,7 +157,7 @@ func TestRemoveDomain(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
mw.RemoveDomain("example.com")
|
||||
|
||||
@@ -178,7 +181,7 @@ func TestProtect_UnknownDomainPassesThrough(t *testing.T) {
|
||||
|
||||
func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -195,7 +198,7 @@ func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -216,7 +219,7 @@ func TestProtect_HostWithPortIsMatched(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -237,9 +240,9 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "example.com", auth.MethodPIN, time.Hour)
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
@@ -262,15 +265,48 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
|
||||
assert.Equal(t, "authenticated", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestProtect_SessionCookieGroupsPropagate verifies the cookie path lifts the
|
||||
// JWT's groups claim into CapturedData so policy-aware middlewares can
|
||||
// authorise without an extra management round-trip.
|
||||
func TestProtect_SessionCookieGroupsPropagate(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
groups := []string{"engineering", "sre"}
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cd := proxy.CapturedDataFromContext(r.Context())
|
||||
require.NotNil(t, cd, "captured data must be present in request context")
|
||||
assert.Equal(t, "test-user", cd.GetUserID())
|
||||
assert.Equal(t, groups, cd.GetUserGroups(), "JWT groups claim must propagate to CapturedData")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData))
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token})
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "request with valid groups-bearing cookie must succeed")
|
||||
assert.Equal(t, groups, capturedData.GetUserGroups(), "CapturedData groups must be retained after handler completes")
|
||||
}
|
||||
|
||||
func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
// Sign a token that expired 1 second ago.
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "example.com", auth.MethodPIN, -time.Second)
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var backendCalled bool
|
||||
@@ -293,10 +329,10 @@ func TestProtect_WrongDomainCookieIsRejected(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
// Token signed for a different domain audience.
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "other.com", auth.MethodPIN, time.Hour)
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
var backendCalled bool
|
||||
@@ -320,10 +356,10 @@ func TestProtect_WrongKeyCookieIsRejected(t *testing.T) {
|
||||
kp2 := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
// Token signed with a different private key.
|
||||
token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "example.com", auth.MethodPIN, time.Hour)
|
||||
token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
var backendCalled bool
|
||||
@@ -345,7 +381,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "pin-user", "example.com", auth.MethodPIN, time.Hour)
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "pin-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
scheme := &stubScheme{
|
||||
@@ -357,7 +393,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -410,7 +446,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -427,7 +463,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "password-user", "example.com", auth.MethodPassword, time.Hour)
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "password-user", "", "example.com", auth.MethodPassword, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
// First scheme (PIN) always fails, second scheme (password) succeeds.
|
||||
@@ -446,7 +482,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
|
||||
return "", "password", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -476,7 +512,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) {
|
||||
return "invalid-jwt-token", "", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -500,7 +536,7 @@ func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString(randomBytes)
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
|
||||
err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil)
|
||||
err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false)
|
||||
require.NoError(t, err, "any 32-byte key should be accepted at registration time")
|
||||
}
|
||||
|
||||
@@ -509,10 +545,10 @@ func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
// Attempt to overwrite with an invalid key.
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil)
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false)
|
||||
require.Error(t, err)
|
||||
|
||||
// The original valid config should still be intact.
|
||||
@@ -536,7 +572,7 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -563,7 +599,7 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) {
|
||||
return "", "password", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -590,7 +626,7 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -678,7 +714,7 @@ func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}))
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -714,7 +750,7 @@ func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}))
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -755,7 +791,7 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}))
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -781,11 +817,12 @@ func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) {
|
||||
return "", oidcURL, nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
@@ -809,11 +846,12 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
@@ -834,7 +872,7 @@ func (m *mockAuthenticator) Authenticate(ctx context.Context, in *proto.Authenti
|
||||
// returns a signed session token when the expected header value is provided.
|
||||
func newHeaderSchemeWithToken(t *testing.T, kp *sessionkey.KeyPair, headerName, expectedValue string) Header {
|
||||
t.Helper()
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "example.com", auth.MethodHeader, time.Hour)
|
||||
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) {
|
||||
@@ -852,7 +890,7 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
@@ -895,7 +933,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "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))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -915,7 +953,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
|
||||
return &proto.AuthenticateResponse{Success: false}, nil
|
||||
}}
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -938,7 +976,7 @@ func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) {
|
||||
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))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -955,7 +993,7 @@ func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
|
||||
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)
|
||||
@@ -1006,7 +1044,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
|
||||
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, time.Hour)
|
||||
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
|
||||
}
|
||||
@@ -1015,7 +1053,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
|
||||
|
||||
// Single Header scheme (as if one entry existed), but the mock checks both values.
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "Authorization")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", 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) {
|
||||
@@ -1059,3 +1097,173 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
|
||||
assert.False(t, backendCalled, "unknown token should be rejected")
|
||||
})
|
||||
}
|
||||
|
||||
// TestProtect_OIDCOnPlainHTTP_BlockedWith400 verifies that when an OIDC
|
||||
// scheme is configured and the request arrived without TLS, the middleware
|
||||
// short-circuits with a 400 instead of dispatching to the IdP redirect.
|
||||
func TestProtect_OIDCOnPlainHTTP_BlockedWith400(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{
|
||||
method: auth.MethodOIDC,
|
||||
authFn: func(_ *http.Request) (string, string, error) {
|
||||
return "", "https://idp.example.com/authorize", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, "OIDC over plain HTTP should be rejected")
|
||||
assert.Contains(t, rec.Body.String(), "OIDC requires TLS", "response body should explain the rejection")
|
||||
}
|
||||
|
||||
// TestProtect_OIDCOverTLS_NotBlocked confirms the same configuration works
|
||||
// over TLS — the block only fires on plain HTTP.
|
||||
func TestProtect_OIDCOverTLS_NotBlocked(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{
|
||||
method: auth.MethodOIDC,
|
||||
authFn: func(_ *http.Request) (string, string, error) {
|
||||
return "", "https://idp.example.com/authorize", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusFound, rec.Code, "OIDC over TLS should redirect to IdP")
|
||||
}
|
||||
|
||||
// TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked confirms that the OIDC
|
||||
// block only fires when an OIDC scheme is configured. PIN-only domains
|
||||
// pass through normally on plain HTTP.
|
||||
func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code, "PIN-only domain should serve the login page on plain HTTP")
|
||||
}
|
||||
|
||||
// TestProtect_SessionCookieOnPlainHTTP_LogsWarn verifies that a request
|
||||
// carrying a valid session cookie over plain HTTP is still forwarded but
|
||||
// emits a WARN-level log line for the operator.
|
||||
func TestProtect_SessionCookieOnPlainHTTP_LogsWarn(t *testing.T) {
|
||||
logger, hook := newTestLogger()
|
||||
|
||||
mw := NewMiddleware(logger, nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "user-1", "", "example.com", auth.MethodPassword, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
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.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token})
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.True(t, backendCalled, "backend should still be reached — we don't drop the cookie")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var found bool
|
||||
for _, entry := range hook.entries() {
|
||||
if entry.Level == log.WarnLevel && strings.Contains(entry.Message, "session cookie on plain HTTP path") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected WARN log for session cookie on plain HTTP")
|
||||
}
|
||||
|
||||
// TestProtect_SessionCookieOverTLS_NoWarn confirms the WARN only fires
|
||||
// on plain HTTP — TLS requests with a session cookie behave as before.
|
||||
func TestProtect_SessionCookieOverTLS_NoWarn(t *testing.T) {
|
||||
logger, hook := newTestLogger()
|
||||
|
||||
mw := NewMiddleware(logger, nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "user-1", "", "example.com", auth.MethodPassword, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token})
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
for _, entry := range hook.entries() {
|
||||
assert.NotContains(t, entry.Message, "session cookie on plain HTTP path", "no plain-HTTP cookie warn expected over TLS")
|
||||
}
|
||||
}
|
||||
|
||||
// captureHook is a minimal logrus hook that records emitted entries for
|
||||
// inspection in tests. It avoids pulling in the full sirupsen test
|
||||
// helpers package (which the rest of the codebase doesn't use).
|
||||
type captureHook struct {
|
||||
mu sync.Mutex
|
||||
records []log.Entry
|
||||
}
|
||||
|
||||
func (h *captureHook) Levels() []log.Level { return log.AllLevels }
|
||||
|
||||
func (h *captureHook) Fire(entry *log.Entry) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.records = append(h.records, *entry)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *captureHook) entries() []log.Entry {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return append([]log.Entry{}, h.records...)
|
||||
}
|
||||
|
||||
// newTestLogger builds an isolated logrus logger with a capture hook so
|
||||
// tests can assert on emitted records without contending on the global
|
||||
// logger.
|
||||
func newTestLogger() (*log.Logger, *captureHook) {
|
||||
hook := &captureHook{}
|
||||
logger := log.New()
|
||||
logger.SetOutput(io.Discard)
|
||||
logger.AddHook(hook)
|
||||
logger.SetLevel(log.DebugLevel)
|
||||
return logger, hook
|
||||
}
|
||||
|
||||
171
proxy/internal/auth/tunnel_cache.go
Normal file
171
proxy/internal/auth/tunnel_cache.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// tunnelCacheTTL caps how long a positive ValidateTunnelPeer result is
|
||||
// reused before re-fetching from management. 5 minutes balances freshness
|
||||
// against management load on busy mesh networks.
|
||||
const tunnelCacheTTL = 30 * time.Second
|
||||
|
||||
// tunnelCachePerAccount caps the number of cached identities per account.
|
||||
// Bounded eviction avoids memory growth in pathological cases (huge peer
|
||||
// roster, brief request bursts) while staying generous for normal use.
|
||||
const tunnelCachePerAccount = 1024
|
||||
|
||||
// tunnelCacheKey identifies a cached entry by tunnel IP and originating
|
||||
// account. Domain is part of the value, not the key, because the
|
||||
// management response is per (account, IP) — domain only gates whether a
|
||||
// re-fetch is needed if the operator is accessing a different service.
|
||||
type tunnelCacheKey struct {
|
||||
accountID types.AccountID
|
||||
tunnelIP netip.Addr
|
||||
domain string
|
||||
}
|
||||
|
||||
// tunnelCacheEntry stores a positive validation response with the time it
|
||||
// was minted. Entries past tunnelCacheTTL are treated as misses.
|
||||
type tunnelCacheEntry struct {
|
||||
resp *proto.ValidateTunnelPeerResponse
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
// tunnelValidationCache memoizes ValidateTunnelPeer responses keyed by
|
||||
// (accountID, tunnelIP, domain). Only successful, valid responses are
|
||||
// cached — denials skip the cache so policy changes apply immediately.
|
||||
// Single-flight de-duplicates concurrent fetches for the same key so a
|
||||
// burst of cold requests collapses into a single RPC.
|
||||
type tunnelValidationCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[types.AccountID]*accountBucket
|
||||
flight singleflight.Group
|
||||
ttl time.Duration
|
||||
maxSize int
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// accountBucket holds the cached entries for a single account, with a
|
||||
// FIFO eviction queue used when the bucket exceeds maxSize.
|
||||
type accountBucket struct {
|
||||
items map[tunnelCacheKey]tunnelCacheEntry
|
||||
order []tunnelCacheKey
|
||||
}
|
||||
|
||||
// newTunnelValidationCache constructs a cache with default TTL and bounds.
|
||||
func newTunnelValidationCache() *tunnelValidationCache {
|
||||
return &tunnelValidationCache{
|
||||
entries: make(map[types.AccountID]*accountBucket),
|
||||
ttl: tunnelCacheTTL,
|
||||
maxSize: tunnelCachePerAccount,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// get returns a cached response for the key, or nil when missing or
|
||||
// expired. Expired entries are evicted lazily on read.
|
||||
func (c *tunnelValidationCache) get(key tunnelCacheKey) *proto.ValidateTunnelPeerResponse {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
bucket, ok := c.entries[key.accountID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
entry, ok := bucket.items[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if c.now().Sub(entry.cachedAt) > c.ttl {
|
||||
delete(bucket.items, key)
|
||||
bucket.order = removeKey(bucket.order, key)
|
||||
return nil
|
||||
}
|
||||
return entry.resp
|
||||
}
|
||||
|
||||
// put records a positive response under the key. Evicts the oldest entry
|
||||
// in the account's bucket when the bound is exceeded.
|
||||
func (c *tunnelValidationCache) put(key tunnelCacheKey, resp *proto.ValidateTunnelPeerResponse) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
bucket, ok := c.entries[key.accountID]
|
||||
if !ok {
|
||||
bucket = &accountBucket{items: make(map[tunnelCacheKey]tunnelCacheEntry)}
|
||||
c.entries[key.accountID] = bucket
|
||||
}
|
||||
if _, exists := bucket.items[key]; !exists {
|
||||
bucket.order = append(bucket.order, key)
|
||||
}
|
||||
bucket.items[key] = tunnelCacheEntry{resp: resp, cachedAt: c.now()}
|
||||
|
||||
for len(bucket.order) > c.maxSize {
|
||||
oldest := bucket.order[0]
|
||||
bucket.order = bucket.order[1:]
|
||||
delete(bucket.items, oldest)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
for i, k := range order {
|
||||
if k == needle {
|
||||
return append(order[:i], order[i+1:]...)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// flightKey turns a cache key into a single-flight string. AccountID and
|
||||
// IP isolation by themselves are insufficient because different domains
|
||||
// for the same peer/account may have different group access.
|
||||
func flightKey(key tunnelCacheKey) string {
|
||||
return string(key.accountID) + "|" + key.tunnelIP.String() + "|" + key.domain
|
||||
}
|
||||
|
||||
// validateTunnelPeerFn is the RPC entry point the cache wraps. It matches
|
||||
// the SessionValidator.ValidateTunnelPeer signature without exposing the
|
||||
// gRPC option variadic, since callers don't need it on the cache hot path.
|
||||
type validateTunnelPeerFn func(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error)
|
||||
|
||||
// fetch returns a cached response when present, otherwise calls validate
|
||||
// under single-flight and caches the result. Denied responses pass
|
||||
// through but are not cached so policy changes apply immediately.
|
||||
func (c *tunnelValidationCache) fetch(ctx context.Context, key tunnelCacheKey, validate validateTunnelPeerFn) (*proto.ValidateTunnelPeerResponse, bool, error) {
|
||||
if resp := c.get(key); resp != nil {
|
||||
return resp, true, nil
|
||||
}
|
||||
|
||||
flight := flightKey(key)
|
||||
res, err, _ := c.flight.Do(flight, func() (any, error) {
|
||||
if cached := c.get(key); cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
resp, err := validate(ctx, &proto.ValidateTunnelPeerRequest{
|
||||
TunnelIp: key.tunnelIP.String(),
|
||||
Domain: key.domain,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.GetValid() && resp.GetSessionToken() != "" {
|
||||
c.put(key, resp)
|
||||
}
|
||||
return resp, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
resp, _ := res.(*proto.ValidateTunnelPeerResponse)
|
||||
return resp, false, nil
|
||||
}
|
||||
171
proxy/internal/auth/tunnel_cache_test.go
Normal file
171
proxy/internal/auth/tunnel_cache_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func newTestKey(account types.AccountID, ip string, domain string) tunnelCacheKey {
|
||||
return tunnelCacheKey{
|
||||
accountID: account,
|
||||
tunnelIP: netip.MustParseAddr(ip),
|
||||
domain: domain,
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelCache_HitSkipsRPC(t *testing.T) {
|
||||
cache := newTunnelValidationCache()
|
||||
key := newTestKey("acct-1", "100.64.0.10", "svc.example")
|
||||
|
||||
var calls int32
|
||||
validate := func(_ context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}, nil
|
||||
}
|
||||
|
||||
resp, fromCache, err := cache.fetch(context.Background(), key, validate)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp, "first fetch returns RPC response")
|
||||
assert.False(t, fromCache, "first fetch must not be cached")
|
||||
|
||||
resp2, fromCache2, err := cache.fetch(context.Background(), key, validate)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp2, "second fetch returns cached response")
|
||||
assert.True(t, fromCache2, "second fetch must be served from cache")
|
||||
assert.Equal(t, "user-1", resp2.GetUserId(), "cached response should preserve user identity")
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "validate should run exactly once with one cache hit")
|
||||
}
|
||||
|
||||
func TestTunnelCache_ExpiredEntryRefetches(t *testing.T) {
|
||||
cache := newTunnelValidationCache()
|
||||
clock := time.Now()
|
||||
cache.now = func() time.Time { return clock }
|
||||
|
||||
key := newTestKey("acct-1", "100.64.0.10", "svc.example")
|
||||
var calls int32
|
||||
validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"}, nil
|
||||
}
|
||||
|
||||
_, _, err := cache.fetch(context.Background(), key, validate)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "first fetch issues one RPC")
|
||||
|
||||
clock = clock.Add(tunnelCacheTTL + time.Second)
|
||||
|
||||
_, fromCache, err := cache.fetch(context.Background(), key, validate)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, fromCache, "expired entry must miss the cache")
|
||||
assert.Equal(t, int32(2), atomic.LoadInt32(&calls), "expired entry forces a re-fetch")
|
||||
}
|
||||
|
||||
func TestTunnelCache_DeniedResponseNotCached(t *testing.T) {
|
||||
cache := newTunnelValidationCache()
|
||||
key := newTestKey("acct-1", "100.64.0.10", "svc.example")
|
||||
|
||||
var calls int32
|
||||
validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: false, DeniedReason: "not_in_group"}, nil
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
_, _, err := cache.fetch(context.Background(), key, validate)
|
||||
require.NoError(t, err, "fetch must not error on denied response")
|
||||
}
|
||||
assert.Equal(t, int32(3), atomic.LoadInt32(&calls), "denied responses bypass the cache so policy changes apply immediately")
|
||||
}
|
||||
|
||||
func TestTunnelCache_ConcurrentColdHitsCoalesce(t *testing.T) {
|
||||
cache := newTunnelValidationCache()
|
||||
key := newTestKey("acct-1", "100.64.0.10", "svc.example")
|
||||
|
||||
gate := make(chan struct{})
|
||||
var calls int32
|
||||
validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
<-gate
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"}, nil
|
||||
}
|
||||
|
||||
const workers = 16
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
results := make([]bool, workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
resp, _, err := cache.fetch(context.Background(), key, validate)
|
||||
results[idx] = err == nil && resp.GetValid()
|
||||
}(i)
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
close(gate)
|
||||
wg.Wait()
|
||||
|
||||
for i, ok := range results {
|
||||
assert.Truef(t, ok, "worker %d should observe a successful response", i)
|
||||
}
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "single-flight must collapse concurrent cold fetches into one RPC")
|
||||
}
|
||||
|
||||
func TestTunnelCache_PerAccountIsolation(t *testing.T) {
|
||||
cache := newTunnelValidationCache()
|
||||
keyA := newTestKey("acct-a", "100.64.0.10", "svc.example")
|
||||
keyB := newTestKey("acct-b", "100.64.0.10", "svc.example")
|
||||
|
||||
var callsA, callsB int32
|
||||
validateA := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
|
||||
atomic.AddInt32(&callsA, 1)
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-a", UserId: "user-a"}, nil
|
||||
}
|
||||
validateB := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
|
||||
atomic.AddInt32(&callsB, 1)
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-b", UserId: "user-b"}, nil
|
||||
}
|
||||
|
||||
respA, _, err := cache.fetch(context.Background(), keyA, validateA)
|
||||
require.NoError(t, err)
|
||||
respB, _, err := cache.fetch(context.Background(), keyB, validateB)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "user-a", respA.GetUserId(), "account A response should belong to user-a")
|
||||
assert.Equal(t, "user-b", respB.GetUserId(), "account B response must not be served from account A's cache")
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&callsA), "validateA called exactly once")
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&callsB), "validateB called exactly once")
|
||||
}
|
||||
|
||||
func TestTunnelCache_BoundedSizeEvictsOldest(t *testing.T) {
|
||||
cache := newTunnelValidationCache()
|
||||
cache.maxSize = 2
|
||||
|
||||
validate := func(_ context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-" + req.GetTunnelIp()}, nil
|
||||
}
|
||||
|
||||
keys := []tunnelCacheKey{
|
||||
newTestKey("acct-1", "100.64.0.10", "svc"),
|
||||
newTestKey("acct-1", "100.64.0.11", "svc"),
|
||||
newTestKey("acct-1", "100.64.0.12", "svc"),
|
||||
}
|
||||
for _, k := range keys {
|
||||
_, _, err := cache.fetch(context.Background(), k, validate)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Nil(t, cache.get(keys[0]), "oldest key should be evicted past maxSize")
|
||||
assert.NotNil(t, cache.get(keys[1]), "second-newest must remain cached")
|
||||
assert.NotNil(t, cache.get(keys[2]), "newest must remain cached")
|
||||
}
|
||||
325
proxy/internal/auth/tunnel_lookup_test.go
Normal file
325
proxy/internal/auth/tunnel_lookup_test.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// stubSessionValidator records ValidateTunnelPeer calls and returns the
|
||||
// pre-canned response. Counts let tests assert RPC traffic.
|
||||
type stubSessionValidator struct {
|
||||
respFn func(req *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse
|
||||
respErr error
|
||||
tunnelCalls atomic.Int32
|
||||
}
|
||||
|
||||
func (s *stubSessionValidator) ValidateSession(_ context.Context, _ *proto.ValidateSessionRequest, _ ...grpc.CallOption) (*proto.ValidateSessionResponse, error) {
|
||||
return &proto.ValidateSessionResponse{Valid: false}, nil
|
||||
}
|
||||
|
||||
func (s *stubSessionValidator) ValidateTunnelPeer(_ context.Context, in *proto.ValidateTunnelPeerRequest, _ ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) {
|
||||
s.tunnelCalls.Add(1)
|
||||
if s.respErr != nil {
|
||||
return nil, s.respErr
|
||||
}
|
||||
if s.respFn != nil {
|
||||
return s.respFn(in), nil
|
||||
}
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: false}, nil
|
||||
}
|
||||
|
||||
func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware {
|
||||
t.Helper()
|
||||
mw := NewMiddleware(log.New(), validator, nil)
|
||||
require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false))
|
||||
return mw
|
||||
}
|
||||
|
||||
func newTunnelRequest(remoteAddr string) (*httptest.ResponseRecorder, *http.Request) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "https://svc.example/", nil)
|
||||
r.Host = "svc.example"
|
||||
r.RemoteAddr = remoteAddr
|
||||
return w, r
|
||||
}
|
||||
|
||||
// TestForwardWithTunnelPeer_LocalLookupUnknownIPDeniesFast verifies the
|
||||
// short-circuit: a tunnel IP not in the account's roster never reaches
|
||||
// management's ValidateTunnelPeer.
|
||||
func TestForwardWithTunnelPeer_LocalLookupUnknownIPDeniesFast(t *testing.T) {
|
||||
validator := &stubSessionValidator{}
|
||||
mw := newTunnelMiddleware(t, validator)
|
||||
|
||||
lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
|
||||
return PeerIdentity{}, false
|
||||
})
|
||||
|
||||
w, r := newTunnelRequest("100.64.0.99:55555")
|
||||
r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
|
||||
|
||||
called := false
|
||||
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
|
||||
|
||||
config, _ := mw.getDomainConfig("svc.example")
|
||||
handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
|
||||
|
||||
assert.False(t, handled, "unknown peer must fall through, not forward")
|
||||
assert.False(t, called, "next handler must not run for unknown peer")
|
||||
assert.Equal(t, int32(0), validator.tunnelCalls.Load(), "ValidateTunnelPeer must be skipped on local-lookup miss")
|
||||
}
|
||||
|
||||
// TestForwardWithTunnelPeer_GroupsPropagateToCapturedData verifies the proxy
|
||||
// surfaces the calling peer's group memberships from ValidateTunnelPeerResponse
|
||||
// onto CapturedData so policy-aware middlewares can authorise without an
|
||||
// extra management round-trip.
|
||||
func TestForwardWithTunnelPeer_GroupsPropagateToCapturedData(t *testing.T) {
|
||||
groups := []string{"engineering", "sre"}
|
||||
validator := &stubSessionValidator{
|
||||
respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
|
||||
return &proto.ValidateTunnelPeerResponse{
|
||||
Valid: true,
|
||||
SessionToken: "tok",
|
||||
UserId: "user-1",
|
||||
PeerGroupIds: groups,
|
||||
}
|
||||
},
|
||||
}
|
||||
mw := newTunnelMiddleware(t, validator)
|
||||
|
||||
w, r := newTunnelRequest("100.64.0.10:55555")
|
||||
cd := proxy.NewCapturedData("")
|
||||
r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
|
||||
|
||||
called := false
|
||||
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
|
||||
|
||||
config, _ := mw.getDomainConfig("svc.example")
|
||||
handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
|
||||
|
||||
require.True(t, handled, "valid tunnel-peer response must forward")
|
||||
require.True(t, called, "next handler must run")
|
||||
assert.Equal(t, "user-1", cd.GetUserID(), "user id must propagate from tunnel-peer response")
|
||||
assert.Equal(t, groups, cd.GetUserGroups(), "peer group IDs must propagate from tunnel-peer response")
|
||||
}
|
||||
|
||||
// TestForwardWithTunnelPeer_LocalLookupKnownPeerStillRPCs verifies that a
|
||||
// known tunnel IP still triggers ValidateTunnelPeer for the user-identity
|
||||
// tail (UserID + group access). Phase 3 only short-circuits the deny path.
|
||||
func TestForwardWithTunnelPeer_LocalLookupKnownPeerStillRPCs(t *testing.T) {
|
||||
validator := &stubSessionValidator{
|
||||
respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}
|
||||
},
|
||||
}
|
||||
mw := newTunnelMiddleware(t, validator)
|
||||
|
||||
knownIP := netip.MustParseAddr("100.64.0.10")
|
||||
lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) {
|
||||
if ip == knownIP {
|
||||
return PeerIdentity{PubKey: "pk", TunnelIP: ip, FQDN: "peer.netbird.cloud"}, true
|
||||
}
|
||||
return PeerIdentity{}, false
|
||||
})
|
||||
|
||||
w, r := newTunnelRequest(knownIP.String() + ":55555")
|
||||
r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
|
||||
|
||||
called := false
|
||||
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
|
||||
|
||||
config, _ := mw.getDomainConfig("svc.example")
|
||||
handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
|
||||
|
||||
assert.True(t, handled, "known peer with valid RPC response must forward")
|
||||
assert.True(t, called, "next handler must run on success")
|
||||
assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC must run for the user-identity tail when local lookup confirms the peer")
|
||||
}
|
||||
|
||||
// TestForwardWithTunnelPeer_NoLookupKeepsLegacyPath ensures the existing
|
||||
// behaviour stays intact on the host-level listener (no lookup attached).
|
||||
func TestForwardWithTunnelPeer_NoLookupKeepsLegacyPath(t *testing.T) {
|
||||
validator := &stubSessionValidator{
|
||||
respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}
|
||||
},
|
||||
}
|
||||
mw := newTunnelMiddleware(t, validator)
|
||||
|
||||
w, r := newTunnelRequest("100.64.0.10:55555")
|
||||
called := false
|
||||
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
|
||||
|
||||
config, _ := mw.getDomainConfig("svc.example")
|
||||
handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
|
||||
|
||||
assert.True(t, handled, "host-level path forwards on positive RPC result")
|
||||
assert.True(t, called, "next handler runs on host-level success")
|
||||
assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "host-level path always RPCs (Phase 3 unchanged)")
|
||||
}
|
||||
|
||||
// TestForwardWithTunnelPeer_RPCErrorFallsThrough validates that an RPC
|
||||
// failure still falls through to the next scheme (no false positive).
|
||||
func TestForwardWithTunnelPeer_RPCErrorFallsThrough(t *testing.T) {
|
||||
validator := &stubSessionValidator{respErr: errors.New("management down")}
|
||||
mw := newTunnelMiddleware(t, validator)
|
||||
|
||||
knownIP := netip.MustParseAddr("100.64.0.10")
|
||||
lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) {
|
||||
return PeerIdentity{TunnelIP: ip}, true
|
||||
})
|
||||
|
||||
w, r := newTunnelRequest(knownIP.String() + ":55555")
|
||||
r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
|
||||
|
||||
config, _ := mw.getDomainConfig("svc.example")
|
||||
handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
|
||||
assert.False(t, handled, "RPC error must let the caller try other schemes")
|
||||
assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC was attempted exactly once")
|
||||
}
|
||||
|
||||
// TestForwardWithTunnelPeer_CacheReusesPositiveResponse confirms the
|
||||
// (account, IP, domain) cache prevents repeated RPCs for the same peer.
|
||||
func TestForwardWithTunnelPeer_CacheReusesPositiveResponse(t *testing.T) {
|
||||
validator := &stubSessionValidator{
|
||||
respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}
|
||||
},
|
||||
}
|
||||
mw := newTunnelMiddleware(t, validator)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
w, r := newTunnelRequest("100.64.0.10:55555")
|
||||
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||
config, _ := mw.getDomainConfig("svc.example")
|
||||
handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
|
||||
require.True(t, handled, "iteration %d should forward", i)
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "subsequent forwards must hit the cache, not management")
|
||||
}
|
||||
|
||||
// TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey ensures cache keys
|
||||
// honour account scoping — same tunnel IP on different accounts must not
|
||||
// collide.
|
||||
func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) {
|
||||
validator := &stubSessionValidator{
|
||||
respFn: func(req *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user"}
|
||||
},
|
||||
}
|
||||
mw := NewMiddleware(log.New(), validator, nil)
|
||||
|
||||
require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false))
|
||||
require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false))
|
||||
|
||||
for _, host := range []string{"svc-a.example", "svc-b.example"} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "https://"+host+"/", nil)
|
||||
r.Host = host
|
||||
r.RemoteAddr = "100.64.0.10:55555"
|
||||
config, _ := mw.getDomainConfig(host)
|
||||
handled := mw.forwardWithTunnelPeer(w, r, host, config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
require.True(t, handled, "host %s should forward", host)
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(2), validator.tunnelCalls.Load(), "cache must not collide across accounts even when tunnel IPs match")
|
||||
}
|
||||
|
||||
// TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache
|
||||
// guarantees that the deny-fast path leaves the cache untouched, so a
|
||||
// subsequent request from the same IP after the peerstore catches up
|
||||
// goes through the normal RPC flow.
|
||||
func TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache(t *testing.T) {
|
||||
validator := &stubSessionValidator{
|
||||
respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"}
|
||||
},
|
||||
}
|
||||
mw := newTunnelMiddleware(t, validator)
|
||||
|
||||
knownIP := netip.MustParseAddr("100.64.0.10")
|
||||
known := false
|
||||
lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) {
|
||||
if known && ip == knownIP {
|
||||
return PeerIdentity{TunnelIP: ip}, true
|
||||
}
|
||||
return PeerIdentity{}, false
|
||||
})
|
||||
|
||||
doRequest := func() bool {
|
||||
w, r := newTunnelRequest(knownIP.String() + ":55555")
|
||||
r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
|
||||
config, _ := mw.getDomainConfig("svc.example")
|
||||
return mw.forwardWithTunnelPeer(w, r, "svc.example", config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
}
|
||||
|
||||
require.False(t, doRequest(), "first request must short-circuit")
|
||||
require.Equal(t, int32(0), validator.tunnelCalls.Load(), "short-circuit must not populate the cache")
|
||||
|
||||
known = true
|
||||
require.True(t, doRequest(), "second request with peer in roster must forward via RPC")
|
||||
assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC runs once after peerstore catches up")
|
||||
}
|
||||
|
||||
func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) {
|
||||
mw := NewMiddleware(log.New(), nil, nil)
|
||||
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
|
||||
|
||||
called := false
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://private.svc/", nil)
|
||||
req.Host = "private.svc"
|
||||
req.RemoteAddr = "100.64.0.10:55555"
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.False(t, called)
|
||||
}
|
||||
|
||||
func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) {
|
||||
validator := &stubSessionValidator{
|
||||
respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
|
||||
return &proto.ValidateTunnelPeerResponse{
|
||||
Valid: true,
|
||||
SessionToken: "tok",
|
||||
UserId: "user-1",
|
||||
}
|
||||
},
|
||||
}
|
||||
mw := NewMiddleware(log.New(), validator, nil)
|
||||
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
|
||||
|
||||
called := false
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://private.svc/", nil)
|
||||
req.Host = "private.svc"
|
||||
req.RemoteAddr = "100.64.0.10:55555"
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.True(t, called)
|
||||
}
|
||||
Reference in New Issue
Block a user