mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-12 17:59:06 +02:00
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>
123 lines
4.7 KiB
Go
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)
|
|
}
|