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,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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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