mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-12 16:39:09 +02:00
[management] Scope the change to the private-capability check
The PR grew past its purpose. What it needs to do is refuse to bootstrap an agent network endpoint onto a cluster that cannot serve it, which is the private capability check on the picked cluster. Everything that accreted around it — canonicalising proxy addresses at connect, refusing another account's cluster or a host another account pinned, withdrawing a claim lost to a concurrent one, folding casing on migrated settings rows — is security work in its own right and moves to follow-up PRs, where each can be reviewed against its own threat rather than as a rider on this one. This restores main's version of every file outside that purpose and reduces the validation to: a cluster the account can see must have a live embedded proxy, and a cluster management holds no row for stays pinnable (address-first). The e2e test and the fixture seeds are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4ed71f8987
commit
5502ea08ac
@@ -13,7 +13,6 @@ import (
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -496,8 +495,7 @@ func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx c
|
||||
if proxyID == "" {
|
||||
return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy_id is required")
|
||||
}
|
||||
address, ok := canonicalProxyAddress(address)
|
||||
if !ok {
|
||||
if !isProxyAddressValid(address) {
|
||||
return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy address is invalid")
|
||||
}
|
||||
|
||||
@@ -571,12 +569,6 @@ func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params
|
||||
proxyRecord, err := s.proxyManager.Connect(ctx, params.proxyID, sessionID, params.address, peerInfo, accountID, caps)
|
||||
if err != nil {
|
||||
cancel()
|
||||
if errors.Is(err, proxy.ErrClusterAddressUnavailable) {
|
||||
// The claim was lost to a concurrent one after validateProxyConnect
|
||||
// saw the address free; the row has been withdrawn. Same answer
|
||||
// as the pre-write check gives, so the proxy treats both alike.
|
||||
return nil, nil, status.Errorf(codes.AlreadyExists, "cluster address %s is already in use", params.address)
|
||||
}
|
||||
if accountID != nil {
|
||||
return nil, nil, status.Errorf(codes.Internal, "failed to register BYOP proxy: %v", err)
|
||||
}
|
||||
@@ -880,44 +872,16 @@ func (s *ProxyServiceServer) snapshotServiceMappings(ctx context.Context, conn *
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
// canonicalProxyAddress validates a proxy address (domain name or IP address)
|
||||
// and returns the form the store keeps.
|
||||
//
|
||||
// cluster_address is the key every capability, ownership and routing lookup
|
||||
// matches on, and this is the only path that writes it, so the address is
|
||||
// canonicalised once here rather than pushing case-insensitivity into each of
|
||||
// those queries: hostnames are case-insensitive and may be unicode, so they
|
||||
// fold to lowercase punycode (what domain.ValidateDomains already computes and
|
||||
// this used to throw away), and an IP literal goes through netip so a mixed
|
||||
// case IPv6 address does not become a second key for the same host.
|
||||
func canonicalProxyAddress(addr string) (string, bool) {
|
||||
if addr == "" {
|
||||
return "", false
|
||||
}
|
||||
// A zoned literal like "fe80::1%eth0" is scoped to one host's interface,
|
||||
// so it cannot identify a cluster others reach; net.ParseIP rejected it
|
||||
// before and netip must not start accepting it.
|
||||
if ip, err := netip.ParseAddr(addr); err == nil {
|
||||
if ip.Zone() != "" {
|
||||
return "", false
|
||||
}
|
||||
return ip.String(), true
|
||||
}
|
||||
// Folded before punycode conversion, not just after: idna maps the ASCII
|
||||
// output to lowercase but does not case-fold the unicode input, so
|
||||
// "PRÖXY.example.com" and "pröxy.example.com" would otherwise encode to
|
||||
// two different labels for one host.
|
||||
canonical, err := domain.ValidateDomains([]string{strings.ToLower(addr)})
|
||||
if err != nil || len(canonical) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return string(canonical[0]), true
|
||||
}
|
||||
|
||||
// isProxyAddressValid validates a proxy address (domain name or IP address)
|
||||
func isProxyAddressValid(addr string) bool {
|
||||
_, ok := canonicalProxyAddress(addr)
|
||||
return ok
|
||||
if addr == "" {
|
||||
return false
|
||||
}
|
||||
if net.ParseIP(addr) != nil {
|
||||
return true
|
||||
}
|
||||
_, err := domain.ValidateDomains([]string{addr})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// isStreamClosed returns true for errors that indicate normal stream
|
||||
|
||||
@@ -27,41 +27,3 @@ func TestIsProxyAddressValid(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalProxyAddress pins the canonical form the store keeps. Every
|
||||
// capability, ownership and routing lookup matches cluster_address exactly, so
|
||||
// one host must have exactly one spelling in that column — which is what lets
|
||||
// those queries stay exact (and keep using the index) instead of folding case
|
||||
// per query.
|
||||
func TestCanonicalProxyAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr string
|
||||
canonical string
|
||||
ok bool
|
||||
}{
|
||||
{name: "lowercase domain unchanged", addr: "eu.proxy.netbird.io", canonical: "eu.proxy.netbird.io", ok: true},
|
||||
{name: "mixed case domain folded", addr: "EU.Proxy.NetBird.io", canonical: "eu.proxy.netbird.io", ok: true},
|
||||
{name: "uppercase domain folded", addr: "BYOP.PROXY.EXAMPLE.COM", canonical: "byop.proxy.example.com", ok: true},
|
||||
{name: "unicode domain punycoded", addr: "pröxy.example.com", canonical: "xn--prxy-6qa.example.com", ok: true},
|
||||
// Same host, and idna alone would encode the two cases to different
|
||||
// labels, so this is the one that proves the fold happens first.
|
||||
{name: "mixed case unicode folds to the same label", addr: "PRÖXY.example.com", canonical: "xn--prxy-6qa.example.com", ok: true},
|
||||
{name: "ipv4 unchanged", addr: "203.0.113.10", canonical: "203.0.113.10", ok: true},
|
||||
{name: "mixed case ipv6 canonicalised", addr: "2001:DB8::1", canonical: "2001:db8::1", ok: true},
|
||||
{name: "empty string rejected", addr: "", ok: false},
|
||||
{name: "space rejected", addr: "eu proxy.example.com", ok: false},
|
||||
// Scoped to one host's interface, so it cannot name a cluster others
|
||||
// reach; net.ParseIP rejected these and netip must not accept them.
|
||||
{name: "zoned ipv6 rejected", addr: "fe80::1%eth0", ok: false},
|
||||
{name: "unzoned link-local ipv6 accepted", addr: "fe80::1", canonical: "fe80::1", ok: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
canonical, ok := canonicalProxyAddress(tt.addr)
|
||||
assert.Equal(t, tt.ok, ok)
|
||||
assert.Equal(t, tt.canonical, canonical)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ package grpc
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/grpc/codes"
|
||||
grpcstatus "google.golang.org/grpc/status"
|
||||
|
||||
@@ -167,27 +166,3 @@ func TestValidateProxyConnect_AuthorizerRunsLast(t *testing.T) {
|
||||
assert.Equal(t, codes.AlreadyExists, st.Code(), "an address conflict must keep its own status")
|
||||
assert.Zero(t, auth.called, "a conflicting address must be rejected before policy runs")
|
||||
}
|
||||
|
||||
// TestRegisterProxyConnection_LostClaimIsAlreadyExists pins the status a proxy
|
||||
// sees when its claim is lost after validateProxyConnect passed: the manager
|
||||
// withdraws the row and reports ErrClusterAddressUnavailable, and the connect
|
||||
// path must answer AlreadyExists — the same code the pre-write check gives —
|
||||
// rather than the Internal it uses for a store failure, so the proxy handles
|
||||
// the two paths to "address taken" identically.
|
||||
func TestRegisterProxyConnection_LostClaimIsAlreadyExists(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr := proxy.NewMockManager(ctrl)
|
||||
mgr.EXPECT().
|
||||
Connect(gomock.Any(), "proxy-1", gomock.Any(), "cluster.example.com", gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(nil, fmt.Errorf("cluster address cluster.example.com: %w", proxy.ErrClusterAddressUnavailable))
|
||||
s := &ProxyServiceServer{proxyManager: mgr}
|
||||
|
||||
_, _, err := s.registerProxyConnection(scopedCtx("acc-1"), proxyConnectParams{proxyID: "proxy-1", address: "cluster.example.com"}, &proxyConnection{})
|
||||
require.Error(t, err)
|
||||
st, ok := grpcstatus.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.AlreadyExists, st.Code(), "a claim lost after the check must read as the address being taken")
|
||||
assert.Contains(t, st.Message(), "already in use")
|
||||
_, tracked := s.connectedProxies.Load("proxy-1")
|
||||
assert.False(t, tracked, "a refused registration must not be tracked as connected")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user