Files
netbird/e2e/agentnetwork/settings_cluster_validation_test.go
T
Claude 083906b84d [management] validate the proxy cluster an agent network bootstraps onto
The agent network gateway service is synthesised as private: agents reach
it over the WireGuard tunnel and are authorised by ValidateTunnelPeer
against the enabled policies' source groups, and its only target is the
cluster itself with DirectUpstream. Only a proxy running embedded in a
netbird client can serve that, which management already reports per
cluster as the `private` capability.

CreateSettings accepted any hostname as proxy_address, so a labeled
bootstrap could pin the account to a cluster that cannot serve its
gateway — another account's BYOP cluster, or one whose proxies are all
centralised. The endpoint assigned at bootstrap is immutable, so the
account is then stuck with a dead gateway until someone deletes and
re-bootstraps the settings row.

Validate the cluster before allocating an endpoint beneath it: a cluster
whose live proxies have reported their capabilities must be one the
account may route through and must be private-capable. A cluster nothing
is connected to is left alone, so claiming an address ahead of the
proxy's first connection keeps working — the same address-first order the
dedicated (self-addressed) path documents, and the one the e2e suite and
self-hosted setups follow.

The e2e coverage drives the real thing: one combined server and two
proxies in the same cluster — a centralised one that makes the cluster
live but unusable, then an embedded one that makes it usable — so both
the refusal and the acceptance are exercised against the same account and
cluster address, with the domains endpoint (the list the dashboard picks
from) as the barrier between starting a proxy and asserting on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:56:07 +00:00

123 lines
4.7 KiB
Go

//go:build e2e
package agentnetwork
import (
"context"
"strings"
"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"
)
// TestSettingsBootstrapValidatesProxyCluster covers the bootstrap-time check
// on the picked cluster, end to end against a real proxy.
//
// The synthesised gateway service is always private: agents reach it over the
// WireGuard tunnel and are authorised by their peer identity. Only a proxy
// running embedded in a netbird client (`netbird proxy --private`) can serve
// that, and management reports it per cluster as the `private` capability —
// the same supports_private flag the dashboard reads to decide which clusters
// it may offer. The endpoint assigned at bootstrap is immutable, so pinning
// to a cluster that cannot serve it has to be refused up front rather than
// leaving the account with a dead gateway.
//
// One combined server, two proxies in the same cluster: the centralised one
// makes the cluster live-but-unusable, the embedded one added afterwards
// makes it usable (the capability is any-true across the cluster's live
// proxies), so both the refusal and the acceptance are exercised against the
// same account and the same cluster address.
func TestSettingsBootstrapValidatesProxyCluster(t *testing.T) {
ctx := context.Background()
fresh, err := harnessStartFresh(ctx, t)
require.NoError(t, err, "start dedicated combined server")
proxyToken, err := fresh.CreateProxyTokenCLI(ctx, "e2e-cluster-validation")
require.NoError(t, err, "mint proxy token via CLI")
const cluster = harness.AgentNetworkCluster
// A centralised proxy: connected and serving the cluster, but not
// embedded in a netbird client, so it cannot authenticate tunnel peers.
central, err := harness.StartProxy(ctx, fresh, proxyToken, map[string]string{
"NB_PROXY_PRIVATE": "false",
})
require.NoError(t, err, "start centralised proxy")
t.Cleanup(func() { _ = central.Terminate(context.Background()) })
waitClusterPrivate(ctx, t, fresh, cluster, false)
_, err = fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
ProxyAddress: ptr(cluster),
})
require.Error(t, err, "bootstrap onto a cluster with no embedded proxy must be refused")
requireClientError(t, err)
assert.Contains(t, err.Error(), "embedded proxy",
"the refusal must name what the cluster is missing: %v", err)
after, err := fresh.GetSettings(ctx)
require.NoError(t, err, "settings must still read after a refused bootstrap")
assert.Empty(t, after.Endpoint, "a refused bootstrap must not assign an endpoint")
assert.Empty(t, after.ProxyAddress, "a refused bootstrap must not pin a cluster")
// Add an embedded proxy to the same cluster: now it can serve a private
// service, and the very same request must go through.
embedded, err := harness.StartProxy(ctx, fresh, proxyToken)
require.NoError(t, err, "start embedded proxy")
t.Cleanup(func() { _ = embedded.Terminate(context.Background()) })
waitClusterPrivate(ctx, t, fresh, cluster, true)
bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
ProxyAddress: ptr(cluster),
})
require.NoError(t, err, "bootstrap onto a private-capable cluster must succeed")
assert.Equal(t, cluster, bootstrapped.ProxyAddress, "the pinned cluster is the requested one")
assert.True(t, strings.HasSuffix(bootstrapped.Endpoint, "."+cluster),
"the endpoint must hang one label beneath the cluster: %s", bootstrapped.Endpoint)
}
// waitClusterPrivate polls the domains endpoint — the list the dashboard picks
// its bootstrap cluster from — until the free domain for clusterAddr reports
// supports_private == want. A proxy's capabilities land when it registers, so
// this is the barrier between starting a proxy and asserting on what
// management thinks its cluster can do.
func waitClusterPrivate(ctx context.Context, t *testing.T, c *harness.Combined, clusterAddr string, want bool) {
t.Helper()
deadline := time.Now().Add(90 * time.Second)
var last string
for time.Now().Before(deadline) {
domains, err := c.API().ReverseProxyDomains.List(ctx)
if err != nil {
last = "list domains: " + err.Error()
} else {
last = "cluster not listed"
for _, d := range domains {
if d.Domain != clusterAddr {
continue
}
if d.SupportsPrivate == nil {
last = "supports_private not reported yet"
break
}
if *d.SupportsPrivate == want {
return
}
last = "supports_private is not the expected value"
break
}
}
if !waitBeforeRetry(ctx, 2*time.Second) {
break
}
}
t.Fatalf("cluster %s never reported supports_private=%v: %s", clusterAddr, want, last)
}