From 57722d3235723527b28dccf2350b5e7677a83c90 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 12 Oct 2026 13:23:18 +0200 Subject: [PATCH] [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. --- .github/workflows/reverse-proxy-e2e.yml | 53 +++ e2e/harness/cert.go | 4 +- e2e/harness/http_upstream.go | 228 ++++++++++++ e2e/harness/proxy.go | 95 ++++- e2e/reverseproxy/README.md | 36 ++ e2e/reverseproxy/main_test.go | 42 +++ .../target_access_control_test.go | 336 ++++++++++++++++++ 7 files changed, 789 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/reverse-proxy-e2e.yml create mode 100644 e2e/harness/http_upstream.go create mode 100644 e2e/reverseproxy/README.md create mode 100644 e2e/reverseproxy/main_test.go create mode 100644 e2e/reverseproxy/target_access_control_test.go diff --git a/.github/workflows/reverse-proxy-e2e.yml b/.github/workflows/reverse-proxy-e2e.yml new file mode 100644 index 000000000..934a6d4c9 --- /dev/null +++ b/.github/workflows/reverse-proxy-e2e.yml @@ -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/... diff --git a/e2e/harness/cert.go b/e2e/harness/cert.go index c8a28e470..b322ab6d2 100644 --- a/e2e/harness/cert.go +++ b/e2e/harness/cert.go @@ -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 { diff --git a/e2e/harness/http_upstream.go b/e2e/harness/http_upstream.go new file mode 100644 index 000000000..3b6fb7df6 --- /dev/null +++ b/e2e/harness/http_upstream.go @@ -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 +} diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go index ee458908b..6a4251f5e 100644 --- a/e2e/harness/proxy.go +++ b/e2e/harness/proxy.go @@ -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 diff --git a/e2e/reverseproxy/README.md b/e2e/reverseproxy/README.md new file mode 100644 index 000000000..fdd5faca5 --- /dev/null +++ b/e2e/reverseproxy/README.md @@ -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. diff --git a/e2e/reverseproxy/main_test.go b/e2e/reverseproxy/main_test.go new file mode 100644 index 000000000..be211510d --- /dev/null +++ b/e2e/reverseproxy/main_test.go @@ -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() +} diff --git a/e2e/reverseproxy/target_access_control_test.go b/e2e/reverseproxy/target_access_control_test.go new file mode 100644 index 000000000..a74ec22bc --- /dev/null +++ b/e2e/reverseproxy/target_access_control_test.go @@ -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 + } +}