[proxy] Enforce per-target HTTP access actions (#8115)

Apply target access before service authentication: inherit, bypass, or block. Keep route selection and auth decisions on the same snapshot; reject stale or unavailable required middleware.

Validation: full auth/proxy race tests and scoped lint passed. Runtime regression tests remain with this layer.
This commit is contained in:
Maycon Santos
2026-10-12 12:31:57 +02:00
committed by GitHub
parent a7a2575bad
commit 1161616c02
16 changed files with 2692 additions and 640 deletions
+172
View File
@@ -0,0 +1,172 @@
package proxy
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/metric/noop"
"github.com/netbirdio/netbird/proxy/internal/auth"
proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics"
internalproxy "github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp"
proxytypes "github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/shared/management/proto"
)
const (
targetAccessAccountID = "test-account-1"
targetAccessCluster = "test.proxy.io"
targetAccessDomain = "target-access.test.proxy.io"
targetAccessHeader = "X-Test-Key"
targetAccessSecret = "valid-secret"
)
type targetAccessRequest struct {
path string
headers http.Header
}
type targetAccessUpstream struct {
mu sync.Mutex
requests []targetAccessRequest
}
func (u *targetAccessUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
u.mu.Lock()
u.requests = append(u.requests, targetAccessRequest{
path: r.URL.Path,
headers: r.Header.Clone(),
})
u.mu.Unlock()
w.WriteHeader(http.StatusOK)
}
func (u *targetAccessUpstream) snapshot() []targetAccessRequest {
u.mu.Lock()
defer u.mu.Unlock()
return append([]targetAccessRequest(nil), u.requests...)
}
func newTargetAccessRuntime(t *testing.T) (*Server, http.Handler) {
t.Helper()
logger := quietLifecycleLogger()
meter, err := proxymetrics.New(t.Context(), noop.Meter{})
require.NoError(t, err)
reverseProxy := internalproxy.NewReverseProxy(http.DefaultTransport, "auto", nil, logger)
authMiddleware := auth.NewMiddleware(logger, nil, nil)
srv := &Server{
ctx: t.Context(),
Logger: logger,
proxy: reverseProxy,
auth: authMiddleware,
meter: meter,
mainRouter: nbtcp.NewRouter(logger, nil, &net.TCPAddr{}),
portRouters: make(map[uint16]*portRouter),
svcPorts: make(map[proxytypes.ServiceID][]uint16),
lastMappings: make(map[proxytypes.ServiceID]*proto.ProxyMapping),
crowdsecServices: make(map[proxytypes.ServiceID]bool),
removePeer: func(context.Context, proxytypes.AccountID, roundtrip.ServiceKey) error {
return nil
},
}
return srv, authMiddleware.Protect(reverseProxy)
}
func applyTargetAccessMapping(t *testing.T, srv *Server, mapping *proto.ProxyMapping) {
t.Helper()
require.NoError(t, srv.updateMapping(t.Context(), mapping))
srv.storeMapping(mapping)
}
func targetAccessRequestTo(handler http.Handler, path, remoteAddr string, headers http.Header) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "http://"+targetAccessDomain+path, nil)
req.Host = targetAccessDomain
req.RemoteAddr = remoteAddr
for name, values := range headers {
for _, value := range values {
req.Header.Add(name, value)
}
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
return recorder
}
func TestModifyHTTPMappingFromEmptyPathsRegistersRoute(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-Test-Upstream", "reached")
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(upstream.Close)
runtime, handler := newTargetAccessRuntime(t)
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
router := nbtcp.NewRouter(runtime.Logger, func(proxytypes.AccountID) (proxytypes.DialContextFunc, error) {
return func(context.Context, string, string) (net.Conn, error) {
return nil, net.ErrClosed
}, nil
}, listener.Addr())
router.SetFallback(nbtcp.Route{
Type: nbtcp.RouteTCP,
AccountID: "fallback-account",
ServiceID: "fallback-service",
Target: "unreachable.test:443",
})
runtime.mainRouter = router
httpServer := &http.Server{
Handler: handler,
TLSConfig: selfSignedTLSConfig(t),
ReadHeaderTimeout: time.Second,
}
t.Cleanup(func() { _ = httpServer.Close() })
go func() { _ = httpServer.ServeTLS(router.HTTPListener(), "", "") }()
go feedRouterFromListener(t.Context(), listener, router, runtime.Logger, proxytypes.AccountID(targetAccessAccountID))
previous := &proto.ProxyMapping{
Id: "empty-to-routable",
AccountId: targetAccessAccountID,
Domain: targetAccessDomain,
}
runtime.storeMapping(previous)
replacement := &proto.ProxyMapping{
Id: previous.GetId(),
AccountId: previous.GetAccountId(),
Domain: previous.GetDomain(),
Path: []*proto.PathMapping{{
Path: "/",
Target: upstream.URL,
}},
}
require.NoError(t, runtime.modifyMapping(t.Context(), replacement))
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, //nolint:gosec
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, listener.Addr().String())
},
}
t.Cleanup(transport.CloseIdleConnections)
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
response, err := client.Get("https://" + targetAccessDomain + "/ready")
require.NoError(t, err, "first target must register the domain with the SNI router")
t.Cleanup(func() { _ = response.Body.Close() })
assert.Equal(t, http.StatusNoContent, response.StatusCode)
assert.Equal(t, "reached", response.Header.Get("X-Test-Upstream"))
}