[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:
Maycon Santos
2026-10-12 13:23:18 +02:00
committed by GitHub
parent 88f1a307e3
commit 57722d3235
7 changed files with 789 additions and 5 deletions
+53
View File
@@ -0,0 +1,53 @@
name: Reverse Proxy E2E
on:
pull_request:
paths:
- '.github/workflows/reverse-proxy-e2e.yml'
- '**/*.go'
- 'e2e/**'
- 'shared/management/**'
- 'combined/Dockerfile*'
- 'proxy/Dockerfile*'
- '.dockerignore'
- 'go.mod'
- 'go.sum'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
reverse-proxy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Cache Docker layers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: /tmp/.reverse-proxy-buildx-cache
key: ${{ runner.os }}-reverse-proxy-e2e-${{ hashFiles('go.sum', 'combined/Dockerfile.multistage', 'proxy/Dockerfile.multistage') }}
restore-keys: |
${{ runner.os }}-reverse-proxy-e2e-
- name: Run reverse-proxy E2E
env:
NB_E2E_BUILDX_CACHE: /tmp/.reverse-proxy-buildx-cache
run: go test -tags e2e -timeout 25m -v ./e2e/reverseproxy/...
+2 -2
View File
@@ -18,8 +18,8 @@ import (
// writeSelfSignedCert generates a self-signed TLS cert/key pair covering the
// given DNS names and writes them as tls.crt / tls.key in dir. The proxy serves
// this for the agent-network endpoint; the client curls with -k, so validity
// chains don't matter — the proxy just needs a usable cert to present.
// this for the agent-network endpoint; test clients either trust the generated
// certificate explicitly or opt out of verification inside the test network.
func writeSelfSignedCert(dir string, dnsNames []string) error {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
+228
View File
@@ -0,0 +1,228 @@
//go:build e2e
package harness
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
httpUpstreamImage = "nginx:alpine"
httpUpstreamAlias = "httpupstream"
httpUpstreamPort = "8080/tcp"
httpUpstreamLogPath = "/tmp/e2e-access.log"
)
// httpUpstreamNginxConf returns request details from a plain HTTP upstream and
// writes every request URI to an unbuffered file. Tests use unique path markers
// to distinguish requests that reached the upstream from proxy-generated
// responses with the same status.
const httpUpstreamNginxConf = `pid /tmp/nginx.pid;
worker_processes 1;
events {}
http {
log_format e2e escape=json '{"uri":"$request_uri"}';
access_log /tmp/e2e-access.log e2e;
server {
listen 8080;
location / {
default_type application/json;
return 200 '{"source":"netbird-e2e-http-upstream","path":"$uri","request_uri":"$request_uri","configured_auth":"$http_x_e2e_proxy_key","authorization":"$http_authorization","cookie":"$http_cookie","netbird_user":"$http_x_netbird_user","netbird_groups":"$http_x_netbird_groups"}';
}
}
}
`
// HTTPUpstream is a plain HTTP server on the combined server's network. It
// reflects selected request properties and records request URIs for tests that
// need to distinguish proxy responses from upstream responses.
type HTTPUpstream struct {
container testcontainers.Container
workDir string
directAddress string
syncSequence atomic.Uint64
URL string
}
// StartHTTPUpstream runs a request-observing nginx server on the shared network.
func StartHTTPUpstream(ctx context.Context, c *Combined) (*HTTPUpstream, error) {
workDir, err := os.MkdirTemp("/tmp", "nb-e2e-http-upstream-*")
if err != nil {
return nil, fmt.Errorf("create HTTP upstream work dir: %w", err)
}
if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e config dir, must be traversable by the container uid
_ = os.RemoveAll(workDir)
return nil, fmt.Errorf("chmod HTTP upstream dir: %w", err)
}
if err := os.WriteFile(filepath.Join(workDir, "nginx.conf"), []byte(httpUpstreamNginxConf), 0o644); err != nil { //nolint:gosec // non-secret e2e config
_ = os.RemoveAll(workDir)
return nil, fmt.Errorf("write HTTP upstream config: %w", err)
}
req := testcontainers.ContainerRequest{
Image: httpUpstreamImage,
ExposedPorts: []string{httpUpstreamPort},
Networks: []string{c.network.Name},
NetworkAliases: map[string][]string{c.network.Name: {httpUpstreamAlias}},
Cmd: []string{"nginx", "-c", "/conf/nginx.conf", "-g", "daemon off;"},
HostConfigModifier: func(hc *container.HostConfig) {
hc.Binds = append(hc.Binds, workDir+":/conf:ro")
},
WaitingFor: wait.ForListeningPort(httpUpstreamPort).WithStartupTimeout(60 * time.Second),
}
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
cleanupHTTPUpstreamStart(ctr, workDir)
return nil, fmt.Errorf("start HTTP upstream container: %w", err)
}
host, err := ctr.Host(ctx)
if err != nil {
cleanupHTTPUpstreamStart(ctr, workDir)
return nil, fmt.Errorf("HTTP upstream container host: %w", err)
}
mapped, err := ctr.MappedPort(ctx, nat.Port(httpUpstreamPort))
if err != nil {
cleanupHTTPUpstreamStart(ctr, workDir)
return nil, fmt.Errorf("HTTP upstream mapped port: %w", err)
}
return &HTTPUpstream{
container: ctr,
workDir: workDir,
directAddress: net.JoinHostPort(host, mapped.Port()),
URL: "http://" + httpUpstreamAlias + ":8080",
}, nil
}
func cleanupHTTPUpstreamStart(ctr testcontainers.Container, workDir string) {
if ctr != nil {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_ = ctr.Terminate(cleanupCtx)
cancel()
}
_ = os.RemoveAll(workDir)
}
// Synchronize waits until the upstream has finalized all requests received
// before a direct barrier request. The fixture runs one nginx worker and writes
// access logs without buffering, so observing the barrier in the file proves
// any earlier proxied request has also been recorded.
func (u *HTTPUpstream) Synchronize(ctx context.Context) error {
marker := fmt.Sprintf("__e2e_upstream_barrier_%d__", u.syncSequence.Add(1))
requestURL := (&url.URL{
Scheme: "http",
Host: u.directAddress,
Path: "/" + marker,
}).String()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return fmt.Errorf("create HTTP upstream barrier request: %w", err)
}
transport := &http.Transport{}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("send HTTP upstream barrier request: %w", err)
}
defer resp.Body.Close()
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
return fmt.Errorf("read HTTP upstream barrier response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP upstream barrier returned status %d", resp.StatusCode)
}
waitCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
for {
count, err := u.RequestCount(waitCtx, marker)
if err != nil {
return err
}
switch {
case count == 1:
return nil
case count > 1:
return fmt.Errorf("HTTP upstream recorded barrier %q %d times", marker, count)
}
select {
case <-waitCtx.Done():
return fmt.Errorf("wait for HTTP upstream barrier: %w", waitCtx.Err())
case <-ticker.C:
}
}
}
// RequestCount returns the number of recorded request URIs containing marker.
func (u *HTTPUpstream) RequestCount(ctx context.Context, marker string) (int, error) {
if marker == "" {
return 0, fmt.Errorf("request marker is empty")
}
reader, err := u.container.CopyFileFromContainer(ctx, httpUpstreamLogPath)
if err != nil {
return 0, fmt.Errorf("read HTTP upstream access log: %w", err)
}
defer reader.Close()
count := 0
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
var entry struct {
URI string `json:"uri"`
}
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
return 0, fmt.Errorf("decode HTTP upstream access log: %w", err)
}
if strings.Contains(entry.URI, marker) {
count++
}
}
if err := scanner.Err(); err != nil {
return 0, fmt.Errorf("scan HTTP upstream access log: %w", err)
}
return count, nil
}
// Logs returns the HTTP upstream container logs for failure diagnostics.
func (u *HTTPUpstream) Logs(ctx context.Context) string {
return containerLogs(ctx, u.container)
}
// Terminate stops the HTTP upstream container and removes its work directory.
func (u *HTTPUpstream) Terminate(ctx context.Context) error {
var err error
if u.container != nil {
err = u.container.Terminate(ctx)
}
if u.workDir != "" {
_ = os.RemoveAll(u.workDir)
}
return err
}
+92 -3
View File
@@ -5,13 +5,20 @@ package harness
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
"github.com/testcontainers/testcontainers-go"
tcexec "github.com/testcontainers/testcontainers-go/exec"
"github.com/testcontainers/testcontainers-go/wait"
@@ -24,6 +31,7 @@ const (
// pulled as a published image; a bare tag is built under that name.
defaultProxyImage = "netbird-reverse-proxy:e2e"
proxyAlias = "proxy"
proxyHTTPSPort = "443/tcp"
// AgentNetworkCluster is the proxy cluster the e2e provider bootstraps and
// the proxy serves. It must equal the management's exposed domain
@@ -34,8 +42,10 @@ const (
// Proxy is a running agent-network gateway (netbird proxy) container.
type Proxy struct {
container testcontainers.Container
workDir string
container testcontainers.Container
workDir string
httpsAddress string
tlsRootCAs *x509.CertPool
}
// StartProxy builds the proxy image and runs it on the combined server's
@@ -63,14 +73,27 @@ func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverride
// MkdirTemp creates the dir 0700; widen it so the non-root proxy container
// can traverse the bind-mounted cert dir on Linux CI runners.
if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e cert dir, must be traversable by the proxy container uid
_ = os.RemoveAll(workDir)
return nil, fmt.Errorf("chmod proxy cert dir: %w", err)
}
if err := writeSelfSignedCert(workDir, []string{"*." + AgentNetworkCluster, AgentNetworkCluster}); err != nil {
_ = os.RemoveAll(workDir)
return nil, err
}
certPEM, err := os.ReadFile(filepath.Join(workDir, "tls.crt"))
if err != nil {
_ = os.RemoveAll(workDir)
return nil, fmt.Errorf("read proxy certificate: %w", err)
}
tlsRootCAs := x509.NewCertPool()
if !tlsRootCAs.AppendCertsFromPEM(certPEM) {
_ = os.RemoveAll(workDir)
return nil, fmt.Errorf("parse proxy certificate")
}
req := testcontainers.ContainerRequest{
Image: proxyImage,
ExposedPorts: []string{proxyHTTPSPort},
Networks: []string{c.network.Name},
NetworkAliases: map[string][]string{c.network.Name: {proxyAlias}},
Env: map[string]string{
@@ -112,10 +135,76 @@ func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverride
Started: true,
})
if err != nil {
_ = os.RemoveAll(workDir)
return nil, fmt.Errorf("start proxy container: %w", err)
}
return &Proxy{container: ctr, workDir: workDir}, nil
host, err := ctr.Host(ctx)
if err != nil {
_ = ctr.Terminate(ctx)
_ = os.RemoveAll(workDir)
return nil, fmt.Errorf("proxy container host: %w", err)
}
mapped, err := ctr.MappedPort(ctx, nat.Port(proxyHTTPSPort))
if err != nil {
_ = ctr.Terminate(ctx)
_ = os.RemoveAll(workDir)
return nil, fmt.Errorf("proxy mapped HTTPS port: %w", err)
}
return &Proxy{
container: ctr,
workDir: workDir,
httpsAddress: net.JoinHostPort(host, mapped.Port()),
tlsRootCAs: tlsRootCAs,
}, nil
}
// HTTPSGet reaches a public service through the proxy's host-mapped HTTPS
// listener while retaining domain as the request Host and TLS server name.
// Connections are not reused so callers can observe mapping removal without
// an existing connection outliving the route.
func (p *Proxy) HTTPSGet(ctx context.Context, domain, path string, headers http.Header) (int, string, error) {
requestURL := &url.URL{Scheme: "https", Host: domain, Path: path}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
if err != nil {
return 0, "", fmt.Errorf("create proxy request: %w", err)
}
req.Header = headers.Clone()
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp", p.httpsAddress)
},
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: p.tlsRootCAs,
ServerName: domain,
},
DisableKeepAlives: true,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
defer transport.CloseIdleConnections()
client := &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
if err != nil {
return 0, "", fmt.Errorf("request public proxy service: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, "", fmt.Errorf("read public proxy response: %w", err)
}
return resp.StatusCode, string(body), nil
}
// ProxyDebugClient is one per-account embedded client the proxy runs, as the
+36
View File
@@ -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.
+42
View File
@@ -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
}
}