mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-08 07:51:28 +02:00
feat(dns): derive the mesh DNS apex for zone-based endpoints
The zone a private service's synthesized A record hangs under was derived from the serving proxy's address or from a validated custom domain. A placement-free endpoint matches neither, so the apex came out empty, the service was skipped, and the tenant's hostname resolved to nothing -- with no error logged. Synthesized services now carry their zone explicitly and it is preferred when deriving the apex. The field is in-memory only: these services are built per read and never persisted, and the zone cannot be supplied as a parameter instead because it is captured per account at allocation time, so a single current-config value would misclassify any tenant allocated under a previous one. A blanket "use the parent of the hostname" fallback was rejected: the same empty apex also occurs for a service whose domain has no validated entry for its cluster, and those resolve to nothing deliberately, so a blanket fallback would turn domain validation into a no-op.
This commit is contained in:
@@ -945,6 +945,7 @@ func buildAccountService(
|
|||||||
Name: "agent-network-" + accountID,
|
Name: "agent-network-" + accountID,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
ProxyCluster: cluster,
|
ProxyCluster: cluster,
|
||||||
|
DNSZone: settings.Zone, // empty for legacy rows → unchanged behavior
|
||||||
Mode: rpservice.ModeHTTP,
|
Mode: rpservice.ModeHTTP,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Private: true,
|
Private: true,
|
||||||
|
|||||||
@@ -255,6 +255,13 @@ type Service struct {
|
|||||||
Private bool
|
Private bool
|
||||||
// AccessGroups is the group ID allowlist for inbound peers on private services. Mutually exclusive with bearer SSO.
|
// AccessGroups is the group ID allowlist for inbound peers on private services. Mutually exclusive with bearer SSO.
|
||||||
AccessGroups []string `json:"access_groups,omitempty" gorm:"serializer:json"`
|
AccessGroups []string `json:"access_groups,omitempty" gorm:"serializer:json"`
|
||||||
|
// DNSZone is the parent zone a private service's synthesized mesh A record
|
||||||
|
// hangs under, for the case where that zone cannot be derived from
|
||||||
|
// ProxyCluster or a validated custom domain — i.e. placement-free
|
||||||
|
// agent-network endpoints, which are <subdomain>.<zone>. In-memory only:
|
||||||
|
// set by the agent-network synthesizer on services it builds per read,
|
||||||
|
// never stored and never exposed on the API or the proxy wire.
|
||||||
|
DNSZone string `gorm:"-" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitNewRecord generates a new unique ID and resets metadata for a newly created
|
// InitNewRecord generates a new unique ID and resets metadata for a newly created
|
||||||
@@ -1412,6 +1419,7 @@ func (s *Service) Copy() *Service {
|
|||||||
PortAutoAssigned: s.PortAutoAssigned,
|
PortAutoAssigned: s.PortAutoAssigned,
|
||||||
Private: s.Private,
|
Private: s.Private,
|
||||||
AccessGroups: accessGroups,
|
AccessGroups: accessGroups,
|
||||||
|
DNSZone: s.DNSZone,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1215,6 +1215,17 @@ func TestService_Copy_RoundtripsPrivate(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"grp-admins", "grp-ops"}, svc.AccessGroups)
|
assert.Equal(t, []string{"grp-admins", "grp-ops"}, svc.AccessGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestServiceCopy_PreservesDNSZone — DNSZone is in-memory only, so it is easy
|
||||||
|
// to omit from Copy()'s explicit field list; if it is dropped, a copied
|
||||||
|
// account silently loses its zone apex and the tenant's endpoint resolves to
|
||||||
|
// nothing.
|
||||||
|
func TestServiceCopy_PreservesDNSZone(t *testing.T) {
|
||||||
|
svc := &Service{Domain: "brave-otter.gateway.netbird.ai", DNSZone: "gateway.netbird.ai"}
|
||||||
|
cp := svc.Copy()
|
||||||
|
require.NotNil(t, cp)
|
||||||
|
assert.Equal(t, "gateway.netbird.ai", cp.DNSZone)
|
||||||
|
}
|
||||||
|
|
||||||
func TestService_APIRoundtrip_Private(t *testing.T) {
|
func TestService_APIRoundtrip_Private(t *testing.T) {
|
||||||
enabled := true
|
enabled := true
|
||||||
private := true
|
private := true
|
||||||
|
|||||||
@@ -272,6 +272,12 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
|||||||
|
|
||||||
serviceDomainZone := a.privateServiceDomainZone(svc)
|
serviceDomainZone := a.privateServiceDomainZone(svc)
|
||||||
if serviceDomainZone == "" {
|
if serviceDomainZone == "" {
|
||||||
|
// This service passed every gate above (enabled, private,
|
||||||
|
// AccessGroups, connected proxy peers) and would otherwise have
|
||||||
|
// emitted a record, but its domain matches neither its DNSZone,
|
||||||
|
// its ProxyCluster, nor any validated custom-domain row.
|
||||||
|
log.Debugf("private-zone synth: svc %s domain=%s cluster=%s dns_zone=%q has no matching zone apex, skipping",
|
||||||
|
svc.ID, svc.Domain, svc.ProxyCluster, svc.DNSZone)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,8 +350,18 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
|||||||
}
|
}
|
||||||
|
|
||||||
// privateServiceDomainZone returns the DNS zone name for the given private service domain by
|
// privateServiceDomainZone returns the DNS zone name for the given private service domain by
|
||||||
// looking at the proxy cluster domain then the custom domains.
|
// checking its DNSZone, then the proxy cluster domain, then the custom domains.
|
||||||
func (a *Account) privateServiceDomainZone(svc *service.Service) string {
|
func (a *Account) privateServiceDomainZone(svc *service.Service) string {
|
||||||
|
// Placement-free endpoints (<subdomain>.<zone>) carry their zone
|
||||||
|
// explicitly: it is server config, so it matches neither the serving
|
||||||
|
// proxy's address nor any per-account custom-domain row. Checked first so
|
||||||
|
// the apex stays the zone even once ProxyCluster becomes the tenant
|
||||||
|
// hostname itself (a private managed proxy), which would otherwise make the
|
||||||
|
// apex the full hostname and churn the client's zone set on cutover.
|
||||||
|
if svc.DNSZone != "" && domainFromSuffix(svc.Domain, svc.DNSZone) {
|
||||||
|
return svc.DNSZone
|
||||||
|
}
|
||||||
|
|
||||||
if domainFromSuffix(svc.Domain, svc.ProxyCluster) {
|
if domainFromSuffix(svc.Domain, svc.ProxyCluster) {
|
||||||
return svc.ProxyCluster
|
return svc.ProxyCluster
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,6 +423,39 @@ func TestSynthesizePrivateServiceZones_MixedClusterCustomAndPublic(t *testing.T)
|
|||||||
"only the 4 private custom services surface in the custom zone (public one excluded)")
|
"only the 4 private custom services surface in the custom zone (public one excluded)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSynthesizePrivateServiceZones_ZoneBasedEndpoint_UsesZoneApex — a
|
||||||
|
// zone-based tenant still served by the SHARED proxy has a hostname whose
|
||||||
|
// parent is the zone, matching neither ProxyCluster nor any validated
|
||||||
|
// custom-domain row. Without DNSZone the apex resolves to "" and the service is
|
||||||
|
// skipped entirely, so the tenant's endpoint resolves to nothing.
|
||||||
|
func TestSynthesizePrivateServiceZones_ZoneBasedEndpoint_UsesZoneApex(t *testing.T) {
|
||||||
|
account := privateZoneTestAccount(t)
|
||||||
|
svc := account.Services[0]
|
||||||
|
svc.Domain = "brave-otter.gateway.netbird.ai"
|
||||||
|
svc.DNSZone = "gateway.netbird.ai"
|
||||||
|
// ProxyCluster stays the shared cluster address — the pre-private cohort.
|
||||||
|
|
||||||
|
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||||
|
require.Len(t, zones, 1, "a zone-based endpoint must still produce one zone")
|
||||||
|
assert.Equal(t, "gateway.netbird.ai.", zones[0].Domain, "apex must be the placement-free zone, not the cluster")
|
||||||
|
require.Len(t, zones[0].Records, 1)
|
||||||
|
assert.Equal(t, "brave-otter.gateway.netbird.ai.", zones[0].Records[0].Name)
|
||||||
|
assert.Equal(t, "100.64.0.99", zones[0].Records[0].RData, "still points at the serving proxy peer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSynthesizePrivateServiceZones_UnvalidatedDomain_StillSkipped locks the
|
||||||
|
// scope of the fix: a service matching no cluster suffix, no validated custom
|
||||||
|
// domain, AND carrying no DNSZone must keep resolving to nothing. A blanket
|
||||||
|
// "use the parent domain" fallback would hand it mesh DNS and bypass domain
|
||||||
|
// validation.
|
||||||
|
func TestSynthesizePrivateServiceZones_UnvalidatedDomain_StillSkipped(t *testing.T) {
|
||||||
|
account := privateZoneTestAccount(t)
|
||||||
|
account.Services[0].Domain = "api.unvalidated.example.com"
|
||||||
|
|
||||||
|
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||||
|
assert.Empty(t, zones, "no cluster suffix, no validated Domains row, no DNSZone → no records")
|
||||||
|
}
|
||||||
|
|
||||||
// recordNames returns the record names of a zone for order-independent assertions.
|
// recordNames returns the record names of a zone for order-independent assertions.
|
||||||
func recordNames(zone nbdns.CustomZone) []string {
|
func recordNames(zone nbdns.CustomZone) []string {
|
||||||
names := make([]string, 0, len(zone.Records))
|
names := make([]string, 0, len(zone.Records))
|
||||||
|
|||||||
Reference in New Issue
Block a user