mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-18 20:49:56 +00:00
* fix(proxy): gate tunnel-peer fast-path on inbound listener marker
forwardWithTunnelPeer previously accepted any RFC1918 / ULA / CGNAT
source IP, so a public client whose address happened to fall in those
ranges could bypass the configured operator auth scheme by colliding
with a known tunnel IP. The fast-path is now gated on
TunnelLookupFromContext(r.Context()) being present — that context value
is attached only by the per-account inbound (overlay) listener, so the
host-facing listener never enters this branch.
Tests updated to reflect the new requirement: requests that don't
carry the inbound marker now fall through to the regular auth flow.
* fix(proxy): harden inbound listener resource + startup-ctx handling
Three correctness fixes on the per-account inbound path, with tests:
- Close the logrus ErrorLog PipeWriter on tearDown. WriterLevel hands
back an *io.PipeWriter backed by a pipe + scanner goroutine that the
caller owns; the two writers per account (https + plain) were never
closed, leaking the pipe and goroutine on every teardown.
- Run the post-Start hooks on context.Background(). runClientStartup
is launched in a goroutine from AddPeer and was inheriting the
caller's request-scoped ctx, so a cancelled request could abort the
inbound bring-up or fail the management status notification. The
tail is split into notifyClientReady so the contract is testable.
Tests cover the PipeWriter close behaviour and assert the readyHandler
+ NotifyStatus calls receive a non-cancelled background context.
* feat(proxy): short-circuit peer-own-target loops with 421
When a peer that hosts the target of a private service dials its own
service URL the request was being looped through the proxy and back
over WireGuard to the same peer — twice the WG round-trip for no
benefit, with no signal to the caller that something was wrong.
Add isSelfTargetLoop to ReverseProxy.ServeHTTP: when the request
arrived on the per-account overlay listener (IsOverlayOrigin) and the
source tunnel IP matches the target host, refuse the request with 421
Misdirected Request and a body pointing the operator at the backend
directly.
The gate is scoped to overlay origin so requests on the public
listener that happen to share a source IP with the target host are
forwarded normally.
* fix(management): private-service validation + tunnel-IP lookup semantics
- Require an explicit port for L4 cluster targets. validateL4Target
exempted TargetTypeCluster from the port check, but buildPathMappings
serializes every L4 target via net.JoinHostPort(host, port) — port=0
shipped a ":0" upstream. Cluster targets use the same Host/Port
fields, so the same requirement applies.
- GetPeerByIP returns NotFound on a tunnel-IP miss instead of mapping
every error to Internal. The proxy's ValidateTunnelPeer probes IPs
that legitimately aren't in the roster; the miss is expected and now
distinguishable from a real store failure.
- Thread ctx into getClusterCapability's gorm query so a cancelled
request doesn't keep the store busy.
Tests updated for the L4-cluster port requirement and the GetPeerByIP
NotFound path.
* fix(client): include offlinePeers in PeerStateByIP lookup
ReplaceOfflinePeers moves peers into d.offlinePeers but PeerStateByIP
only scanned d.peers. Callers (the local DNS filter via
localPeerConnectivity, embed.Client.IdentityForIP used by the
proxy's tunnel-peer validator) were treating known-but-offline peers
as unknown, which:
- causes the DNS filter to keep returning records pointing at peers
that have no live tunnel, AND
- makes the proxy's local-roster check deny a request from such a
peer rather than letting the cached management RPC carry the
authorisation decision.
Search both slices in PeerStateByIP. Adds a unit test for the IPv4
and IPv6 offline-match paths.
* fix(rest): reject empty Delete path params in reverse-proxy clients
ReverseProxyClustersAPI.Delete and ReverseProxyTokensAPI.Delete passed
the path parameter into url.PathEscape without an empty check.
PathEscape("") returns "" which collapses the request onto the
collection endpoint ("/api/reverse-proxies/clusters/" /
"/api/reverse-proxies/proxy-tokens/"), so a caller bug delete with no
id reached a routable URL with surprising semantics (typically 405).
Short-circuit with a typed error before the request is built. Tests
mount a handler on the collection path that fails the test if hit, so
the regression is impossible to reintroduce silently.
* chore(api,ci,docs,test): private-service schema, proto-check, fixups
Non-functional cleanups and contract/CI hardening around the
private-service work:
API schema (openapi.yml):
- Require a non-empty access_groups and mode=http when private=true,
on both Service and ServiceRequest, mirroring
validatePrivateRequirements. mode stays optional-but-constrained
(empty defaults to http server-side), matching runtime.
CI (proto-version-check.yml):
- Cover renamed .pb.go files (read base via previous_filename).
- Match protoc-gen-go-grpc version headers (optional "- " prefix and
-gen-go-grpc suffix) so grpc-generated files are in scope.
Docs / comments:
- Reword Config field docs to say defaults are applied at Server.Start
(initDefaults), not New.
- Rename the obsolete --private-inbound flag to --private across
comments and the proto doc.
Pre-existing test fixups surfaced by review:
- Repair the integration-tagged validate_session_test.go (SignToken
signature growth + new Manager interface methods).
- Fix the CI-skip boolean precedence so Windows isn't skipped
unconditionally.
- Guard the router.HTTPListener type assertion with comma-ok.
* fix(proxy): background ctx for already-started AddPeer notification
The earlier ctx fix covered the async runClientStartup path but missed
the synchronous branch: when a service is added to an already-started
client, AddPeer called NotifyStatus with the caller's request-scoped
ctx. A cancelled request/stream could drop the connected notification
to management. Use context.Background() here too, matching
notifyClientReady.
Extends TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus to
pass a pre-cancelled caller ctx and assert the notification still ran
on a non-cancelled context.
* use the cmd context for roundtripper
356 lines
14 KiB
Go
356 lines
14 KiB
Go
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("")
|
|
lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
|
|
return PeerIdentity{}, true
|
|
})
|
|
r = r.WithContext(proxy.WithCapturedData(WithTunnelLookup(r.Context(), lookup), 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_NoLookupRefusesFastPath guards the
|
|
// anti-spoof gate: requests that didn't arrive on the per-account
|
|
// inbound listener (no TunnelLookup attached) must never reach
|
|
// management's ValidateTunnelPeer, even when the source IP looks like
|
|
// a tunnel address. A colliding RFC1918 / CGNAT source on the public
|
|
// listener would otherwise impersonate a mesh peer.
|
|
func TestForwardWithTunnelPeer_NoLookupRefusesFastPath(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.False(t, handled, "fast-path must refuse without the inbound marker")
|
|
assert.False(t, called, "next handler must not run")
|
|
assert.Equal(t, int32(0), validator.tunnelCalls.Load(), "ValidateTunnelPeer must not be invoked without the inbound marker")
|
|
}
|
|
|
|
// 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)
|
|
|
|
lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
|
|
return PeerIdentity{}, true
|
|
})
|
|
|
|
for i := 0; i < 4; i++ {
|
|
w, r := newTunnelRequest("100.64.0.10:55555")
|
|
r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
|
|
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))
|
|
|
|
// The fast-path requires the inbound-listener marker on the context.
|
|
// The peerstore lookup itself is account-agnostic at this level
|
|
// (one TunnelLookupFunc per account is attached by inbound.go); a
|
|
// trivial "always hit" lookup is enough to exercise the cache-key
|
|
// branch this test covers.
|
|
lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
|
|
return PeerIdentity{}, true
|
|
})
|
|
|
|
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"
|
|
r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
|
|
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)
|
|
}))
|
|
|
|
// Per-account inbound listener attaches WithTunnelLookup; without it
|
|
// forwardWithTunnelPeer refuses to take the fast-path. Mirror the
|
|
// real flow so this test exercises the post-gating success branch.
|
|
lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
|
|
return PeerIdentity{}, true
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "https://private.svc/", nil)
|
|
req.Host = "private.svc"
|
|
req.RemoteAddr = "100.64.0.10:55555"
|
|
req = req.WithContext(WithTunnelLookup(req.Context(), lookup))
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.True(t, called)
|
|
}
|