mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-16 19:59:07 +02:00
[management] Canonicalize the proxy cluster address where it is stored
The proxy-connect path already computes the canonical form of the address a proxy declares — ValidateDomains returns lowercase punycode — and then throws it away, storing the string as declared. cluster_address is the key every capability, ownership and routing lookup matches on, so one host could sit in that column under two spellings, and the previous commit compensated with LOWER() in the ownership query, which gives up the cluster_address index on a query that runs for every account-scoped proxy connect. Keep the canonical form instead. Connect is the only writer of the column (heartbeats touch last_seen and status), and proxy rows are session state rebuilt on every connect rather than durable config, so the column converges without a migration and the lookups can stay exact and indexed. Folding happens before punycode conversion, not after: idna lowercases the ASCII it produces but does not case-fold the unicode it consumes, so PRÖXY.example.com and pröxy.example.com would otherwise encode to two different labels for one host. The agent network check keeps comparing normalised forms in memory, which costs nothing there — it is a pass over the account's cluster list, not a query — and covers rows written before this landed.
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -495,7 +496,8 @@ func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx c
|
||||
if proxyID == "" {
|
||||
return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy_id is required")
|
||||
}
|
||||
if !isProxyAddressValid(address) {
|
||||
address, ok := canonicalProxyAddress(address)
|
||||
if !ok {
|
||||
return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy address is invalid")
|
||||
}
|
||||
|
||||
@@ -872,16 +874,38 @@ 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
|
||||
}
|
||||
if ip, err := netip.ParseAddr(addr); err == nil {
|
||||
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 {
|
||||
if addr == "" {
|
||||
return false
|
||||
}
|
||||
if net.ParseIP(addr) != nil {
|
||||
return true
|
||||
}
|
||||
_, err := domain.ValidateDomains([]string{addr})
|
||||
return err == nil
|
||||
_, ok := canonicalProxyAddress(addr)
|
||||
return ok
|
||||
}
|
||||
|
||||
// isStreamClosed returns true for errors that indicate normal stream
|
||||
|
||||
@@ -27,3 +27,37 @@ 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},
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user