mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-14 10:49:07 +02:00
Merge branch 'main' into embedded-vnc
This commit is contained in:
@@ -54,6 +54,10 @@ type NetworkMapData struct { //nolint:revive // established name across the code
|
||||
// builder can load because they are never written to the database.
|
||||
Services []*nmdata.Service
|
||||
|
||||
// Domains are the account's registered reverse-proxy domains, used to
|
||||
// resolve the zone apex a private service's records hang under.
|
||||
Domains []nmdata.ProxyDomain
|
||||
|
||||
peerGroupsOnce sync.Once
|
||||
peerGroupsIdx map[string]map[string]struct{}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ type Peer struct {
|
||||
IP netip.Addr
|
||||
IPv6 netip.Addr
|
||||
RequiresApproval bool
|
||||
Connected bool
|
||||
ExtraDNSLabels []string
|
||||
Meta PeerSystemMeta
|
||||
ProxyMeta ProxyMeta
|
||||
@@ -39,6 +40,14 @@ type ProxyMeta struct {
|
||||
Cluster string
|
||||
}
|
||||
|
||||
// ProxyDomain is the slim twin of a registered reverse-proxy domain, carrying
|
||||
// what private-service zone resolution needs: the apex a service domain can sit
|
||||
// under, and the cluster it is registered against.
|
||||
type ProxyDomain struct {
|
||||
Domain string
|
||||
TargetCluster string
|
||||
}
|
||||
|
||||
// PeerSystemMeta is the slim twin of peer.PeerSystemMeta.
|
||||
type PeerSystemMeta struct {
|
||||
WtVersion string
|
||||
|
||||
@@ -9,6 +9,7 @@ type Service struct {
|
||||
Enabled bool
|
||||
Private bool
|
||||
Mode string
|
||||
Domain string
|
||||
ProxyCluster string
|
||||
AccessGroups []string
|
||||
Targets []*ServiceTarget
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package networkmap
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
// privateServiceDNSRecordTTL is short so proxy-peer changes propagate quickly.
|
||||
const privateServiceDNSRecordTTL = 5
|
||||
|
||||
// BuildPrivateServiceCandidates derives the per-service DNS records a private
|
||||
// service publishes, from the twin's own services. It is the counterpart of
|
||||
// InjectProxyPolicies: that one synthesises the ACL half of a private service,
|
||||
// this one the DNS half, and both read nmd.Services so a service added to the
|
||||
// twin after it was loaded — an agent-network service is synthesised in memory
|
||||
// and never persisted — reaches the peer with both halves rather than one.
|
||||
//
|
||||
// The per-peer access-group gate and the merge by apex stay in the components
|
||||
// calculation; this only precomputes what is account-wide.
|
||||
func (nmd *NetworkMapData) BuildPrivateServiceCandidates() {
|
||||
if len(nmd.Services) == 0 {
|
||||
nmd.PrivateServiceCandidates = nil
|
||||
return
|
||||
}
|
||||
|
||||
proxyPeersByCluster := nmd.connectedProxyPeersByCluster()
|
||||
if len(proxyPeersByCluster) == 0 {
|
||||
nmd.PrivateServiceCandidates = nil
|
||||
return
|
||||
}
|
||||
|
||||
var out []PrivateServiceCandidate
|
||||
for _, svc := range nmd.Services {
|
||||
if svc == nil || !svc.Enabled || !svc.Private || len(svc.AccessGroups) == 0 || svc.Domain == "" {
|
||||
continue
|
||||
}
|
||||
proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
|
||||
if len(proxyPeers) == 0 {
|
||||
continue
|
||||
}
|
||||
apex := nmd.privateServiceApex(svc)
|
||||
if apex == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
records := make([]nmdata.SimpleRecord, 0, len(proxyPeers))
|
||||
for _, p := range proxyPeers {
|
||||
records = append(records, nmdata.SimpleRecord{
|
||||
Name: dns.Fqdn(svc.Domain),
|
||||
Type: int(dns.TypeA),
|
||||
Class: "IN",
|
||||
TTL: privateServiceDNSRecordTTL,
|
||||
RData: p.IP.String(),
|
||||
})
|
||||
}
|
||||
|
||||
out = append(out, PrivateServiceCandidate{
|
||||
AccessGroups: svc.AccessGroups,
|
||||
Zone: nmdata.CustomZone{
|
||||
// NonAuthoritative keeps the zone match-only, so names without
|
||||
// an explicit record fall through to the upstream resolver
|
||||
// instead of returning NXDOMAIN for the whole apex.
|
||||
Domain: dns.Fqdn(apex),
|
||||
Records: records,
|
||||
NonAuthoritative: true,
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
nmd.PrivateServiceCandidates = out
|
||||
}
|
||||
|
||||
// privateServiceApex resolves the zone a service's record hangs under: the
|
||||
// cluster when the service sits directly beneath it, otherwise the longest
|
||||
// registered custom domain pointing at that same cluster. A service whose
|
||||
// domain matches no registered apex publishes nothing, since a zone the client
|
||||
// never intercepts cannot answer the query.
|
||||
func (nmd *NetworkMapData) privateServiceApex(svc *nmdata.Service) string {
|
||||
if domainUnderSuffix(svc.Domain, svc.ProxyCluster) {
|
||||
return svc.ProxyCluster
|
||||
}
|
||||
|
||||
apex := ""
|
||||
for _, d := range nmd.Domains {
|
||||
if d.TargetCluster != svc.ProxyCluster {
|
||||
continue
|
||||
}
|
||||
if domainUnderSuffix(svc.Domain, d.Domain) && len(d.Domain) > len(apex) {
|
||||
apex = d.Domain
|
||||
}
|
||||
}
|
||||
return apex
|
||||
}
|
||||
|
||||
func domainUnderSuffix(domain, suffix string) bool {
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
return domain == suffix || strings.HasSuffix(domain, "."+suffix)
|
||||
}
|
||||
|
||||
// connectedProxyPeersByCluster groups the account's embedded proxy peers by the
|
||||
// cluster they serve, keeping only connected ones.
|
||||
func (nmd *NetworkMapData) connectedProxyPeersByCluster() map[string][]*nmdata.Peer {
|
||||
var out map[string][]*nmdata.Peer
|
||||
for _, peer := range nmd.Peers {
|
||||
if peer == nil || !peer.ProxyMeta.Embedded || !peer.Connected || !peer.IP.IsValid() {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = make(map[string][]*nmdata.Peer)
|
||||
}
|
||||
out[peer.ProxyMeta.Cluster] = append(out[peer.ProxyMeta.Cluster], peer)
|
||||
}
|
||||
for _, peers := range out {
|
||||
slices.SortFunc(peers, func(a, b *nmdata.Peer) int { return strings.Compare(a.ID, b.ID) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package networkmap
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
func proxyPeer(id, ip, cluster string, connected bool) *nmdata.Peer {
|
||||
return &nmdata.Peer{
|
||||
ID: id, Key: id + "-key", IP: netip.MustParseAddr(ip), Connected: connected,
|
||||
ProxyMeta: nmdata.ProxyMeta{Embedded: true, Cluster: cluster},
|
||||
}
|
||||
}
|
||||
|
||||
func privateService(id, domain, cluster string, groups ...string) *nmdata.Service {
|
||||
return &nmdata.Service{
|
||||
ID: id, Enabled: true, Private: true, Mode: "http",
|
||||
Domain: domain, ProxyCluster: cluster, AccessGroups: groups,
|
||||
}
|
||||
}
|
||||
|
||||
func twinWithProxy(services ...*nmdata.Service) *NetworkMapData {
|
||||
return &NetworkMapData{
|
||||
Peers: map[string]*nmdata.Peer{
|
||||
"proxy-1": proxyPeer("proxy-1", "100.64.0.99", "eu.proxy.netbird.io", true),
|
||||
},
|
||||
Services: services,
|
||||
}
|
||||
}
|
||||
|
||||
// An agent-network service is synthesised in memory and never persisted, so it
|
||||
// only ever reaches the twin through nmd.Services. Deriving the zone from that
|
||||
// same field is what stops it from arriving with an ACL and no name.
|
||||
func TestBuildPrivateServiceCandidates_SynthesisedServiceGetsAZone(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService(
|
||||
"agent-network-acct-1", "acct-1.agent.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 0,
|
||||
"a service whose domain sits under no registered apex publishes nothing")
|
||||
|
||||
nmd.Domains = []nmdata.ProxyDomain{
|
||||
{Domain: "agent.netbird.io", TargetCluster: "eu.proxy.netbird.io"},
|
||||
}
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 1)
|
||||
got := nmd.PrivateServiceCandidates[0]
|
||||
assert.Equal(t, []string{"grp-admins"}, got.AccessGroups)
|
||||
assert.Equal(t, "agent.netbird.io.", got.Zone.Domain, "apex is the registered domain, not the service FQDN")
|
||||
assert.True(t, got.Zone.NonAuthoritative, "zone stays match-only")
|
||||
assert.True(t, got.Zone.SearchDomainDisabled)
|
||||
require.Len(t, got.Zone.Records, 1)
|
||||
assert.Equal(t, nmdata.SimpleRecord{
|
||||
Name: "acct-1.agent.netbird.io.", Type: 1, Class: "IN", TTL: 5, RData: "100.64.0.99",
|
||||
}, got.Zone.Records[0])
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_ClusterApexNeedsNoRegisteredDomain(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 1)
|
||||
assert.Equal(t, "eu.proxy.netbird.io.", nmd.PrivateServiceCandidates[0].Zone.Domain)
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_LongestRegisteredApexWins(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService("svc-1", "app.sub.example.com", "eu.proxy.netbird.io", "grp-admins"))
|
||||
nmd.Domains = []nmdata.ProxyDomain{
|
||||
{Domain: "example.com", TargetCluster: "eu.proxy.netbird.io"},
|
||||
{Domain: "sub.example.com", TargetCluster: "eu.proxy.netbird.io"},
|
||||
{Domain: "other.com", TargetCluster: "eu.proxy.netbird.io"},
|
||||
}
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 1)
|
||||
assert.Equal(t, "sub.example.com.", nmd.PrivateServiceCandidates[0].Zone.Domain)
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_RegisteredApexOfAnotherClusterIsIgnored(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService("svc-1", "app.example.com", "eu.proxy.netbird.io", "grp-admins"))
|
||||
nmd.Domains = []nmdata.ProxyDomain{
|
||||
{Domain: "example.com", TargetCluster: "us.proxy.netbird.io"},
|
||||
}
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
assert.Empty(t, nmd.PrivateServiceCandidates)
|
||||
}
|
||||
|
||||
// A disconnected proxy peer's tunnel IP does not answer, so publishing it
|
||||
// black-holes the name for as long as a client caches the record.
|
||||
func TestBuildPrivateServiceCandidates_OnlyConnectedProxyPeersSurface(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
|
||||
nmd.Peers["proxy-2"] = proxyPeer("proxy-2", "100.64.0.100", "eu.proxy.netbird.io", false)
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 1)
|
||||
require.Len(t, nmd.PrivateServiceCandidates[0].Zone.Records, 1)
|
||||
assert.Equal(t, "100.64.0.99", nmd.PrivateServiceCandidates[0].Zone.Records[0].RData)
|
||||
|
||||
nmd.Peers["proxy-1"].Connected = false
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
assert.Empty(t, nmd.PrivateServiceCandidates, "no connected proxy peer means no zone at all")
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_SkipsServicesThatGrantNothing(t *testing.T) {
|
||||
cases := map[string]func(*nmdata.Service){
|
||||
"disabled": func(s *nmdata.Service) { s.Enabled = false },
|
||||
"not private": func(s *nmdata.Service) { s.Private = false },
|
||||
"no access groups": func(s *nmdata.Service) { s.AccessGroups = nil },
|
||||
"no domain": func(s *nmdata.Service) { s.Domain = "" },
|
||||
"other cluster": func(s *nmdata.Service) { s.ProxyCluster = "us.proxy.netbird.io" },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svc := privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins")
|
||||
mutate(svc)
|
||||
nmd := twinWithProxy(svc)
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
assert.Empty(t, nmd.PrivateServiceCandidates)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_MultipleConnectedProxyPeersEachGetARecord(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
|
||||
nmd.Peers["proxy-2"] = proxyPeer("proxy-2", "100.64.0.100", "eu.proxy.netbird.io", true)
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 1)
|
||||
records := nmd.PrivateServiceCandidates[0].Zone.Records
|
||||
require.Len(t, records, 2)
|
||||
assert.Equal(t, "100.64.0.99", records[0].RData, "records are ordered by proxy peer id")
|
||||
assert.Equal(t, "100.64.0.100", records[1].RData)
|
||||
}
|
||||
Reference in New Issue
Block a user