mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 17:09:07 +02:00
[proxy] Exercise target access through container E2E (#8117)
Verify access decisions through real management and proxy containers, including upstream delivery and denial. Cover inherited auth, bypass/block, precedence, credential stripping, updates, restrictions, deletion, and private-service rejection. Validation: container E2E and scoped lint passed locally. The workflow builds both components from the checkout.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Reverse-proxy access E2E tests
|
||||
|
||||
These tests run a real combined management server, reverse proxy, and HTTP
|
||||
upstream in isolated Docker containers. Services are configured through the
|
||||
management API, then requests go through the proxy's HTTPS listener. No external
|
||||
identity provider, public DNS record, or API credential is needed.
|
||||
|
||||
Run from the repository root with Docker and Go available:
|
||||
|
||||
```bash
|
||||
go test -tags e2e -timeout 25m -v ./e2e/reverseproxy/...
|
||||
```
|
||||
|
||||
The harness builds the combined server and proxy from the current checkout.
|
||||
To test existing images, pass full image references:
|
||||
|
||||
```bash
|
||||
NB_E2E_COMBINED_IMAGE=local/netbird-combined:target-access \
|
||||
NB_E2E_PROXY_IMAGE=local/netbird-proxy:target-access \
|
||||
go test -tags e2e -timeout 25m -v ./e2e/reverseproxy/...
|
||||
```
|
||||
|
||||
Rebuild locally tagged images after backend changes. Both components must
|
||||
support target access actions; using an older management image does not test
|
||||
the feature.
|
||||
|
||||
The suite checks inherited service authentication, anonymous bypass, blocked
|
||||
paths, longest-prefix precedence, credential stripping, live action updates,
|
||||
service restrictions, deletion, and private-service bypass rejection. Upstream
|
||||
request markers distinguish successful forwarding from proxy-generated
|
||||
responses and verify that denied requests never reach the backend.
|
||||
|
||||
The `Reverse Proxy E2E` workflow runs this package for relevant pull requests
|
||||
and manual dispatches. It also remains part of the nightly `./e2e/...` suite.
|
||||
Dashboard browser coverage lives in the dashboard repository: it creates and
|
||||
edits services through the UI, then probes their real proxy behavior.
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build e2e
|
||||
|
||||
// Package reverseproxy contains container-based tests for public reverse-proxy
|
||||
// services. A real combined management server is shared across package tests;
|
||||
// each test owns and cleans up its proxy and service resources.
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
)
|
||||
|
||||
var srv *harness.Combined
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(run(m))
|
||||
}
|
||||
|
||||
func run(m *testing.M) int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
srv, err = harness.StartCombined(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: start combined server: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer func() { _ = srv.Terminate(context.Background()) }()
|
||||
|
||||
if _, err := srv.Bootstrap(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: bootstrap admin PAT: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
return m.Run()
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//go:build e2e
|
||||
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
const (
|
||||
targetAccessDomain = "target-access." + harness.AgentNetworkCluster
|
||||
targetAccessHeader = "X-E2E-Proxy-Key"
|
||||
targetAccessSecret = "e2e-target-access-secret"
|
||||
targetAccessEchoPrefix = "/e2e/proxy-echo/"
|
||||
targetAccessApplicationAuth = "Bearer e2e-application-token"
|
||||
targetAccessApplicationCookie = "application_session=e2e-visible"
|
||||
)
|
||||
|
||||
type targetAccessEcho struct {
|
||||
Source string `json:"source"`
|
||||
Path string `json:"path"`
|
||||
RequestURI string `json:"request_uri"`
|
||||
ConfiguredAuth string `json:"configured_auth"`
|
||||
Authorization string `json:"authorization"`
|
||||
Cookie string `json:"cookie"`
|
||||
NetBirdUser string `json:"netbird_user"`
|
||||
NetBirdGroups string `json:"netbird_groups"`
|
||||
}
|
||||
|
||||
func TestTargetAccessControl_PrivateServiceRejectsBypass(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
group, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-private-target-access"})
|
||||
require.NoError(t, err, "create private-service access group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), group.Id) })
|
||||
|
||||
request := targetAccessServiceRequest(t, "http://upstream.invalid:80",
|
||||
api.ServiceTargetAccessActionBypass,
|
||||
api.ServiceTargetAccessActionBlock,
|
||||
nil,
|
||||
)
|
||||
private := true
|
||||
accessGroups := []string{group.Id}
|
||||
request.Domain = "private-target-access." + harness.AgentNetworkCluster
|
||||
request.Name = "E2E private target access rejection"
|
||||
request.Private = &private
|
||||
request.AccessGroups = &accessGroups
|
||||
|
||||
created, err := srv.API().ReverseProxyServices.Create(ctx, request)
|
||||
if created != nil {
|
||||
t.Cleanup(func() { _ = srv.API().ReverseProxyServices.Delete(context.Background(), created.Id) })
|
||||
}
|
||||
assert.Nil(t, created)
|
||||
assert.ErrorContains(t, err, "bypass access_action is not supported for private services")
|
||||
}
|
||||
|
||||
func TestTargetAccessControl_PublicServiceLifecycle(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
upstream, err := harness.StartHTTPUpstream(ctx, srv)
|
||||
require.NoError(t, err, "start HTTP upstream")
|
||||
t.Cleanup(func() { _ = upstream.Terminate(context.Background()) })
|
||||
|
||||
proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-target-access-proxy")
|
||||
require.NoError(t, err, "mint global proxy token")
|
||||
// Cluster direct-upstream targets require a private-capable proxy, while the
|
||||
// service itself remains public through request.Private below.
|
||||
px, err := harness.StartProxy(ctx, srv, proxyToken)
|
||||
require.NoError(t, err, "start reverse proxy")
|
||||
t.Cleanup(func() { _ = px.Terminate(context.Background()) })
|
||||
|
||||
request := targetAccessServiceRequest(t, upstream.URL,
|
||||
api.ServiceTargetAccessActionBypass,
|
||||
api.ServiceTargetAccessActionBlock,
|
||||
nil,
|
||||
)
|
||||
created, err := srv.API().ReverseProxyServices.Create(ctx, request)
|
||||
require.NoError(t, err, "create service with per-target access actions")
|
||||
removed := false
|
||||
t.Cleanup(func() {
|
||||
if !removed {
|
||||
_ = srv.API().ReverseProxyServices.Delete(context.Background(), created.Id)
|
||||
}
|
||||
})
|
||||
|
||||
validAuth := targetAccessForwardedHeaders(targetAccessSecret)
|
||||
wrongAuth := http.Header{targetAccessHeader: []string{"wrong-secret"}}
|
||||
spoofedBypass := targetAccessForwardedHeaders("wrong-secret")
|
||||
|
||||
// Wait for the service snapshot before issuing unique one-shot markers. A
|
||||
// marker used while polling could reach an older mapping and make an updated
|
||||
// block look as though it forwarded after convergence.
|
||||
requireProxyStatus(t, ctx, px, targetAccessPath("", "warm-created"), nil,
|
||||
http.StatusUnauthorized, "wait for created service")
|
||||
requireProxyAttempt(t, ctx, px, upstream, "", "inherit-missing", nil,
|
||||
http.StatusUnauthorized, 0, "inherit target without service credentials")
|
||||
requireProxyAttempt(t, ctx, px, upstream, "", "inherit-wrong", wrongAuth,
|
||||
http.StatusUnauthorized, 0, "inherit target with invalid service credentials")
|
||||
requireProxyEcho(t, ctx, px, upstream, "", "inherit-valid", validAuth,
|
||||
"inherit target with valid service credentials")
|
||||
requireProxyEcho(t, ctx, px, upstream, "/public", "bypass-initial", spoofedBypass,
|
||||
"bypass target without valid service credentials")
|
||||
requireProxyAttempt(t, ctx, px, upstream, "/public/private", "nested-missing", nil,
|
||||
http.StatusUnauthorized, 0, "nested inherit target overrides bypass parent")
|
||||
requireProxyEcho(t, ctx, px, upstream, "/public/private", "nested-valid", validAuth,
|
||||
"nested inherit target accepts valid service credentials")
|
||||
requireProxyAttempt(t, ctx, px, upstream, "/blocked", "block-initial", validAuth,
|
||||
http.StatusForbidden, 0, "block target even with valid service credentials")
|
||||
|
||||
request = targetAccessServiceRequest(t, upstream.URL,
|
||||
api.ServiceTargetAccessActionBlock,
|
||||
api.ServiceTargetAccessActionBypass,
|
||||
nil,
|
||||
)
|
||||
_, err = srv.API().ReverseProxyServices.Update(ctx, created.Id, request)
|
||||
require.NoError(t, err, "swap target access actions")
|
||||
requireProxyStatus(t, ctx, px, targetAccessPath("/public", "warm-actions-updated"), validAuth,
|
||||
http.StatusForbidden, "wait for target action update")
|
||||
requireProxyAttempt(t, ctx, px, upstream, "/public", "public-updated-block", validAuth,
|
||||
http.StatusForbidden, 0, "updated public target is blocked")
|
||||
requireProxyEcho(t, ctx, px, upstream, "/blocked", "blocked-updated-bypass", spoofedBypass,
|
||||
"updated blocked target bypasses authentication")
|
||||
|
||||
blockedCIDRs := []string{"0.0.0.0/0", "::/0"}
|
||||
request = targetAccessServiceRequest(t, upstream.URL,
|
||||
api.ServiceTargetAccessActionBlock,
|
||||
api.ServiceTargetAccessActionBypass,
|
||||
&api.AccessRestrictions{BlockedCidrs: &blockedCIDRs},
|
||||
)
|
||||
_, err = srv.API().ReverseProxyServices.Update(ctx, created.Id, request)
|
||||
require.NoError(t, err, "add connection restriction")
|
||||
requireProxyStatus(t, ctx, px, targetAccessPath("/blocked", "warm-restricted"), nil,
|
||||
http.StatusForbidden, "wait for connection restriction")
|
||||
requireProxyAttempt(t, ctx, px, upstream, "/blocked", "blocked-restricted", spoofedBypass,
|
||||
http.StatusForbidden, 0, "connection restriction runs before bypass")
|
||||
|
||||
// Clear the restriction and prove the route is live immediately before
|
||||
// deleting it, so a stale 403 cannot make the removal check pass.
|
||||
request = targetAccessServiceRequest(t, upstream.URL,
|
||||
api.ServiceTargetAccessActionBlock,
|
||||
api.ServiceTargetAccessActionBypass,
|
||||
&api.AccessRestrictions{},
|
||||
)
|
||||
_, err = srv.API().ReverseProxyServices.Update(ctx, created.Id, request)
|
||||
require.NoError(t, err, "clear connection restriction")
|
||||
requireProxyStatus(t, ctx, px, targetAccessPath("/blocked", "warm-restriction-cleared"), nil,
|
||||
http.StatusOK, "wait for cleared connection restriction")
|
||||
requireProxyEcho(t, ctx, px, upstream, "/blocked", "blocked-restriction-cleared", spoofedBypass,
|
||||
"bypass route is live before removal")
|
||||
|
||||
require.NoError(t, srv.API().ReverseProxyServices.Delete(ctx, created.Id), "delete service")
|
||||
removed = true
|
||||
requireProxyStatus(t, ctx, px, targetAccessPath("/blocked", "warm-deleted"), nil,
|
||||
http.StatusNotFound, "wait for service deletion")
|
||||
requireProxyAttempt(t, ctx, px, upstream, "/blocked", "blocked-deleted", nil,
|
||||
http.StatusNotFound, 0, "deleted service is removed from the proxy")
|
||||
}
|
||||
|
||||
func targetAccessServiceRequest(
|
||||
t *testing.T,
|
||||
upstreamURL string,
|
||||
publicAction, blockedAction api.ServiceTargetAccessAction,
|
||||
restrictions *api.AccessRestrictions,
|
||||
) api.ServiceRequest {
|
||||
t.Helper()
|
||||
|
||||
u, err := url.Parse(upstreamURL)
|
||||
require.NoError(t, err)
|
||||
host, portText, err := net.SplitHostPort(u.Host)
|
||||
require.NoError(t, err)
|
||||
port, err := strconv.Atoi(portText)
|
||||
require.NoError(t, err)
|
||||
|
||||
rootPath := "/"
|
||||
publicPath := "/public"
|
||||
nestedPath := "/public/private"
|
||||
blockedPath := "/blocked"
|
||||
directUpstream := true
|
||||
mode := api.ServiceRequestModeHttp
|
||||
private := false
|
||||
inherit := api.ServiceTargetAccessActionInherit
|
||||
|
||||
target := func(path *string, action *api.ServiceTargetAccessAction) api.ServiceTarget {
|
||||
return api.ServiceTarget{
|
||||
AccessAction: action,
|
||||
Enabled: true,
|
||||
Host: &host,
|
||||
Options: &api.ServiceTargetOptions{DirectUpstream: &directUpstream},
|
||||
Path: path,
|
||||
Port: port,
|
||||
Protocol: api.ServiceTargetProtocolHttp,
|
||||
TargetId: harness.AgentNetworkCluster,
|
||||
TargetType: api.ServiceTargetTargetTypeCluster,
|
||||
}
|
||||
}
|
||||
targets := []api.ServiceTarget{
|
||||
target(&rootPath, &inherit),
|
||||
target(&publicPath, &publicAction),
|
||||
target(&nestedPath, &inherit),
|
||||
target(&blockedPath, &blockedAction),
|
||||
}
|
||||
headerAuths := []api.HeaderAuthConfig{{
|
||||
Enabled: true,
|
||||
Header: targetAccessHeader,
|
||||
Value: targetAccessSecret,
|
||||
}}
|
||||
|
||||
return api.ServiceRequest{
|
||||
AccessRestrictions: restrictions,
|
||||
Auth: &api.ServiceAuthConfig{HeaderAuths: &headerAuths},
|
||||
Domain: targetAccessDomain,
|
||||
Enabled: true,
|
||||
Mode: &mode,
|
||||
Name: "E2E target access control",
|
||||
Private: &private,
|
||||
Targets: &targets,
|
||||
}
|
||||
}
|
||||
|
||||
func targetAccessForwardedHeaders(authValue string) http.Header {
|
||||
return http.Header{
|
||||
targetAccessHeader: []string{authValue},
|
||||
"Authorization": []string{targetAccessApplicationAuth},
|
||||
"Cookie": []string{"nb_session=e2e-proxy-session; " + targetAccessApplicationCookie},
|
||||
"X-NetBird-User": []string{"spoofed@example.com"},
|
||||
"X-NetBird-Groups": []string{"administrators"},
|
||||
}
|
||||
}
|
||||
|
||||
func targetAccessPath(targetPrefix, marker string) string {
|
||||
return targetPrefix + targetAccessEchoPrefix + marker
|
||||
}
|
||||
|
||||
func requireProxyAttempt(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
px *harness.Proxy,
|
||||
upstream *harness.HTTPUpstream,
|
||||
targetPrefix, marker string,
|
||||
headers http.Header,
|
||||
wantStatus, wantUpstreamRequests int,
|
||||
description string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
path := targetAccessPath(targetPrefix, marker)
|
||||
status, body, requestErr := px.HTTPSGet(ctx, targetAccessDomain, path, headers)
|
||||
syncErr := upstream.Synchronize(ctx)
|
||||
count, countErr := upstream.RequestCount(ctx, marker)
|
||||
if requestErr != nil || syncErr != nil || countErr != nil || status != wantStatus || count != wantUpstreamRequests {
|
||||
t.Fatalf("%s: wanted status=%d upstream_requests=%d, got status=%d upstream_requests=%d body=%q request_error=%v sync_error=%v count_error=%v\n=== proxy logs ===\n%s\n=== upstream logs ===\n%s",
|
||||
description, wantStatus, wantUpstreamRequests, status, count, body, requestErr, syncErr, countErr,
|
||||
px.Logs(context.Background()), upstream.Logs(context.Background()))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func requireProxyEcho(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
px *harness.Proxy,
|
||||
upstream *harness.HTTPUpstream,
|
||||
targetPrefix, marker string,
|
||||
headers http.Header,
|
||||
description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
body := requireProxyAttempt(t, ctx, px, upstream, targetPrefix, marker, headers,
|
||||
http.StatusOK, 1, description)
|
||||
var echo targetAccessEcho
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &echo), "decode HTTP upstream observation")
|
||||
wantPath := targetAccessEchoPrefix + marker
|
||||
assert.Equal(t, "netbird-e2e-http-upstream", echo.Source, "response must come from the test upstream")
|
||||
assert.Equal(t, wantPath, echo.Path, "upstream must receive the routed subpath")
|
||||
assert.Equal(t, wantPath, echo.RequestURI, "upstream request URI must not contain the matched target prefix")
|
||||
assert.Empty(t, echo.ConfiguredAuth, "configured proxy authentication header must be stripped")
|
||||
assert.Equal(t, targetAccessApplicationAuth, echo.Authorization, "application authorization must be preserved")
|
||||
assert.NotContains(t, echo.Cookie, "nb_session=", "proxy session cookie must be stripped")
|
||||
assert.Contains(t, echo.Cookie, targetAccessApplicationCookie, "application cookie must be preserved")
|
||||
assert.Empty(t, echo.NetBirdUser, "client-supplied NetBird identity must be stripped")
|
||||
assert.Empty(t, echo.NetBirdGroups, "client-supplied NetBird groups must be stripped")
|
||||
}
|
||||
|
||||
func requireProxyStatus(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
px *harness.Proxy,
|
||||
path string,
|
||||
headers http.Header,
|
||||
want int,
|
||||
description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
var lastCode int
|
||||
var lastBody string
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
lastCode, lastBody, lastErr = px.HTTPSGet(ctx, targetAccessDomain, path, headers)
|
||||
if lastErr == nil && lastCode == want {
|
||||
return
|
||||
}
|
||||
if !waitBeforeRetry(ctx, time.Second) {
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s: wanted status %d, last status=%d body=%q error=%v\n=== proxy logs ===\n%s",
|
||||
description, want, lastCode, lastBody, lastErr, px.Logs(context.Background()))
|
||||
}
|
||||
|
||||
func waitBeforeRetry(ctx context.Context, delay time.Duration) bool {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user