feat(private-service): expose NetBird-only services over tunnel peers

Adds a new "private" service mode for the reverse proxy: services
reachable exclusively over the embedded WireGuard tunnel, gated by
per-peer group membership instead of operator auth schemes.

Wire contract
- ProxyMapping.private (field 13): the proxy MUST call
  ValidateTunnelPeer and fail closed; operator schemes are bypassed.
- ProxyCapabilities.private (4) + supports_private_service (5):
  capability gate. Management never streams private mappings to
  proxies that don't claim the capability; the broadcast path applies
  the same filter via filterMappingsForProxy.
- ValidateTunnelPeer RPC: resolves an inbound tunnel IP to a peer,
  checks the peer's groups against service.AccessGroups, and mints
  a session JWT on success. checkPeerGroupAccess fails closed when
  a private service has empty AccessGroups.
- ValidateSession/ValidateTunnelPeer responses now carry
  peer_group_ids + peer_group_names so the proxy can authorise
  policy-aware middlewares without an extra management round-trip.
- ProxyInboundListener + SendStatusUpdate.inbound_listener: per-account
  inbound listener state surfaced to dashboards.
- PathTargetOptions.direct_upstream (11): bypass the embedded NetBird
  client and dial the target via the proxy host's network stack for
  upstreams reachable without WireGuard.

Data model
- Service.Private (bool) + Service.AccessGroups ([]string, JSON-
  serialised). Validate() rejects bearer auth on private services.
  Copy() deep-copies AccessGroups. pgx getServices loads the columns.
- DomainConfig.Private threaded into the proxy auth middleware.
  Request handler routes private services through forwardWithTunnelPeer
  and returns 403 on validation failure.
- Account-level SynthesizePrivateServiceZones (synthetic DNS) and
  injectPrivateServicePolicies (synthetic ACL) gate on
  len(svc.AccessGroups) > 0.

Proxy
- /netbird proxy --private (embedded mode) flag; Config.Private in
  proxy/lifecycle.go.
- Per-account inbound listener (proxy/inbound.go) binding HTTP/HTTPS
  on the embedded NetBird client's WireGuard tunnel netstack.
- proxy/internal/auth/tunnel_cache: ValidateTunnelPeer response cache
  with single-flight de-duplication and per-account eviction.
- Local peerstore short-circuit: when the inbound IP isn't in the
  account roster, deny fast without an RPC.
- proxy/server.go reports SupportsPrivateService=true and redacts the
  full ProxyMapping JSON from info logs (auth_token + header-auth
  hashed values now only at debug level).

Identity forwarding
- ValidateSessionJWT returns user_id, email, method, groups,
  group_names. sessionkey.Claims carries Email + Groups + GroupNames
  so the proxy can stamp identity onto upstream requests without an
  extra management round-trip on every cookie-bearing request.
- CapturedData carries userEmail / userGroups / userGroupNames; the
  proxy stamps X-NetBird-User and X-NetBird-Groups on r.Out from the
  authenticated identity (strips client-supplied values first to
  prevent spoofing).
- AccessLog.UserGroups: access-log enrichment captures the user's
  group memberships at write time so the dashboard can render group
  context without reverse-resolving stale memberships.

OpenAPI/dashboard surface
- ReverseProxyService gains private + access_groups; ReverseProxyCluster
  gains private + supports_private. ReverseProxyTarget target_type
  enum gains "cluster". ServiceTargetOptions gains direct_upstream.
  ProxyAccessLog gains user_groups.
This commit is contained in:
mlsmaycon
2026-05-20 22:46:18 +02:00
parent 37052fd5bc
commit 167ee08e14
72 changed files with 6584 additions and 2586 deletions
+23 -2
View File
@@ -2178,7 +2178,8 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth,
meta_created_at, meta_certificate_issued_at, meta_status, proxy_cluster,
pass_host_header, rewrite_redirects, session_private_key, session_public_key,
mode, listen_port, port_auto_assigned, source, source_peer, terminated
mode, listen_port, port_auto_assigned, source, source_peer, terminated,
private, access_groups
FROM services WHERE account_id = $1`
const targetsQuery = `SELECT id, account_id, service_id, path, host, port, protocol,
@@ -2193,10 +2194,11 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
services, err := pgx.CollectRows(serviceRows, func(row pgx.CollectableRow) (*rpservice.Service, error) {
var s rpservice.Service
var auth []byte
var accessGroups []byte
var createdAt, certIssuedAt sql.NullTime
var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString
var mode, source, sourcePeer sql.NullString
var terminated, portAutoAssigned sql.NullBool
var terminated, portAutoAssigned, private sql.NullBool
var listenPort sql.NullInt64
err := row.Scan(
&s.ID,
@@ -2219,6 +2221,8 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
&source,
&sourcePeer,
&terminated,
&private,
&accessGroups,
)
if err != nil {
return nil, err
@@ -2230,6 +2234,16 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
}
}
if len(accessGroups) > 0 {
if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil {
return nil, fmt.Errorf("unmarshal access_groups: %w", err)
}
}
if private.Valid {
s.Private = private.Bool
}
s.Meta = rpservice.Meta{}
if createdAt.Valid {
s.Meta.CreatedAt = createdAt.Time
@@ -5826,6 +5840,7 @@ var validCapabilityColumns = map[string]struct{}{
"supports_custom_ports": {},
"require_subdomain": {},
"supports_crowdsec": {},
"private": {},
}
// GetClusterSupportsCustomPorts returns whether any active proxy in the cluster
@@ -5840,6 +5855,12 @@ func (s *SqlStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr s
return s.getClusterCapability(ctx, clusterAddr, "require_subdomain")
}
// GetClusterSupportsPrivate reports whether any active proxy in the cluster
// has the private capability (nil = unreported).
func (s *SqlStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
return s.getClusterCapability(ctx, clusterAddr, "private")
}
// GetClusterSupportsCrowdSec returns whether all active proxies in the cluster
// have CrowdSec configured. Returns nil when no proxy reported the capability.
// Unlike other capabilities that use ANY-true (for rolling upgrades), CrowdSec
@@ -0,0 +1,46 @@
package store
import (
"context"
"os"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
)
func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) {
if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" {
t.Skip("skip CI tests on darwin and windows")
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
account := newAccountWithId(ctx, "account_private_svc", "testuser", "")
require.NoError(t, store.SaveAccount(ctx, account))
svc := &rpservice.Service{
ID: "svc-private",
AccountID: account.Id,
Name: "private-svc",
Domain: "private.example",
ProxyCluster: "cluster.example",
Enabled: true,
Mode: rpservice.ModeHTTP,
Private: true,
AccessGroups: []string{"grp-admins", "grp-ops"},
}
require.NoError(t, store.CreateService(ctx, svc))
loaded, err := store.GetAccount(ctx, account.Id)
require.NoError(t, err)
require.Len(t, loaded.Services, 1)
got := loaded.Services[0]
assert.True(t, got.Private)
assert.Equal(t, []string{"grp-admins", "grp-ops"}, got.AccessGroups)
})
}
+1
View File
@@ -312,6 +312,7 @@ type Store interface {
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error)
+14
View File
@@ -1461,6 +1461,20 @@ func (mr *MockStoreMockRecorder) GetClusterSupportsCustomPorts(ctx, clusterAddr
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsCustomPorts", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsCustomPorts), ctx, clusterAddr)
}
// GetClusterSupportsPrivate mocks base method.
func (m *MockStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetClusterSupportsPrivate", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// GetClusterSupportsPrivate indicates an expected call of GetClusterSupportsPrivate.
func (mr *MockStoreMockRecorder) GetClusterSupportsPrivate(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsPrivate", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsPrivate), ctx, clusterAddr)
}
// GetCustomDomain mocks base method.
func (m *MockStore) GetCustomDomain(ctx context.Context, accountID, domainID string) (*domain.Domain, error) {
m.ctrl.T.Helper()
+161 -1
View File
@@ -32,7 +32,9 @@ import (
)
const (
defaultTTL = 300
defaultTTL = 300
// privateServiceDNSRecordTTL is short so proxy-peer changes propagate quickly to clients.
privateServiceDNSRecordTTL = 5
DefaultPeerLoginExpiration = 24 * time.Hour
DefaultPeerInactivityExpiration = 10 * time.Minute
@@ -254,6 +256,117 @@ func getUniqueHostLabel(name string, peerLabels LookupMap) string {
return ""
}
// SynthesizePrivateServiceZones returns in-memory CustomZones with A records pointing each enabled private service the peer can reach at the cluster's proxy-peer IPs. One zone per cluster (multiple services share); records gated by AccessGroups.
func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZone {
peer, ok := a.Peers[peerID]
if !ok || peer == nil {
return nil
}
if len(a.Services) == 0 {
return nil
}
proxyPeersByCluster := a.GetProxyPeers()
if len(proxyPeersByCluster) == 0 {
return nil
}
peerGroups := a.GetPeerGroups(peerID)
zonesByCluster := map[string]*nbdns.CustomZone{}
for _, svc := range a.Services {
if svc == nil || !svc.Enabled || !svc.Private {
continue
}
if len(svc.AccessGroups) == 0 {
continue
}
if !peerInDistributionGroups(peerGroups, svc.AccessGroups) {
continue
}
proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
if len(proxyPeers) == 0 {
continue
}
zone, exists := zonesByCluster[svc.ProxyCluster]
if !exists {
// NonAuthoritative makes this a match-only zone: queries for
// names without an explicit record fall through to the
// upstream resolver instead of returning NXDOMAIN. Without
// it, adding a single private service would black-hole every
// other name under the cluster apex.
zone = &nbdns.CustomZone{
Domain: dns.Fqdn(svc.ProxyCluster),
Records: []nbdns.SimpleRecord{},
NonAuthoritative: true,
}
zonesByCluster[svc.ProxyCluster] = zone
}
emitted := 0
skippedDisconnected := 0
for _, p := range proxyPeers {
if p == nil || !p.IP.IsValid() {
continue
}
// Only emit a record when the proxy peer is actually
// connected. A disconnected proxy peer's tunnel IP won't
// answer; pointing DNS at it would produce a black hole
// for as long as the record is cached client-side.
if p.Status == nil || !p.Status.Connected {
skippedDisconnected++
continue
}
zone.Records = append(zone.Records, nbdns.SimpleRecord{
Name: dns.Fqdn(svc.Domain),
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: privateServiceDNSRecordTTL,
RData: p.IP.String(),
})
emitted++
}
// Disagreement with the firewall path is the typical
// "domain doesn't reach client but firewall rules do"
// symptom: the synth service is otherwise fine, only the
// proxy peer's persisted Connected flag is wrong (most
// likely the connection reaper marked it disconnected even
// though the gRPC stream is alive).
if emitted == 0 && skippedDisconnected > 0 {
log.Debugf("private-zone synth: svc %s domain=%s cluster=%s emitted_zero proxy_peers=%d all_disconnected=%d (firewall would still fire)",
svc.ID, svc.Domain, svc.ProxyCluster, len(proxyPeers), skippedDisconnected)
}
}
out := make([]nbdns.CustomZone, 0, len(zonesByCluster))
for _, zone := range zonesByCluster {
if len(zone.Records) == 0 {
continue
}
out = append(out, *zone)
}
if len(out) == 0 && len(a.Services) > 0 {
// Targeted diagnostic for the "firewall yes, DNS no" divergence —
// fires only when services exist but synth returns zero zones,
// so accounts without private services produce no noise.
log.Debugf("private-zone synth: peer %s account %s returned 0 zones from %d candidate service(s)",
peerID, a.Id, len(a.Services))
}
return out
}
// peerInDistributionGroups reports whether any of the peer's groups
// matches the service's bearer-auth distribution_groups.
func peerInDistributionGroups(peerGroups LookupMap, distributionGroups []string) bool {
for _, gid := range distributionGroups {
if _, ok := peerGroups[gid]; ok {
return true
}
}
return false
}
func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdns.CustomZone {
var merr *multierror.Error
@@ -1498,6 +1611,53 @@ func (a *Account) injectServiceProxyPolicies(ctx context.Context, service *servi
a.injectTargetProxyPolicies(ctx, service, target, proxyPeers)
}
a.injectPrivateServicePolicies(service, proxyPeers)
}
// injectPrivateServicePolicies synthesises an in-memory ACL: AccessGroups → cluster proxy peers on TCP 80/443.
func (a *Account) injectPrivateServicePolicies(svc *service.Service, proxyPeers []*nbpeer.Peer) {
if !svc.Private {
return
}
if len(svc.AccessGroups) == 0 {
return
}
if len(proxyPeers) == 0 {
return
}
for _, proxyPeer := range proxyPeers {
a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer))
}
}
func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer) *Policy {
policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
sources := append([]string(nil), svc.AccessGroups...)
return &Policy{
ID: policyID,
Name: fmt.Sprintf("Private Access to %s", svc.Name),
Enabled: true,
Rules: []*PolicyRule{
{
ID: policyID,
PolicyID: policyID,
Name: fmt.Sprintf("Allow access groups to reach %s", svc.Name),
Enabled: true,
Sources: sources,
DestinationResource: Resource{
ID: proxyPeer.ID,
Type: ResourceTypePeer,
},
Bidirectional: false,
Protocol: PolicyRuleProtocolTCP,
Action: PolicyTrafficActionAccept,
PortRanges: []RulePortRange{
{Start: 80, End: 80},
{Start: 443, End: 443},
},
},
},
}
}
func (a *Account) injectTargetProxyPolicies(ctx context.Context, service *service.Service, target *service.Target, proxyPeers []*nbpeer.Peer) {
@@ -119,6 +119,7 @@ func (a *Account) GetPeerNetworkMapComponents(
peerGroups := a.GetPeerGroups(peerID)
components.AccountZones = filterPeerAppliedZones(ctx, accountZones, peerGroups)
components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...)
for _, nsGroup := range a.NameServerGroups {
if nsGroup.Enabled {
@@ -0,0 +1,85 @@
package types
import (
"context"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/dns"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
)
func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
account := privateZoneTestAccount(t)
account.Peers["user-peer"].Meta.WtVersion = "0.50.0"
account.Peers["proxy-peer"].Meta.WtVersion = "0.50.0"
ctx := context.Background()
account.InjectProxyPolicies(ctx)
validated := map[string]struct{}{
"user-peer": {},
"proxy-peer": {},
}
t.Run("user-peer update", func(t *testing.T) {
nm := account.GetPeerNetworkMapFromComponents(ctx, "user-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
require.NotNil(t, nm)
zone, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io")
require.True(t, ok)
require.Len(t, zone.Records, 1)
assert.Equal(t, "myapp.eu.proxy.netbird.io.", zone.Records[0].Name)
assert.Equal(t, int(dns.TypeA), zone.Records[0].Type)
assert.Equal(t, "100.64.0.99", zone.Records[0].RData)
assert.Contains(t, netmapPeerIDs(nm.Peers), "proxy-peer")
assertPrivateServiceFirewallRules(t, nm.FirewallRules, "100.64.0.99", FirewallRuleDirectionOUT)
})
t.Run("proxy-peer update", func(t *testing.T) {
nm := account.GetPeerNetworkMapFromComponents(ctx, "proxy-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
require.NotNil(t, nm)
assert.Contains(t, netmapPeerIDs(nm.Peers), "user-peer")
assertPrivateServiceFirewallRules(t, nm.FirewallRules, "100.64.0.10", FirewallRuleDirectionIN)
})
}
func netmapPeerIDs(peers []*nbpeer.Peer) []string {
ids := make([]string, 0, len(peers))
for _, p := range peers {
if p == nil {
continue
}
ids = append(ids, p.ID)
}
return ids
}
func assertPrivateServiceFirewallRules(t *testing.T, rules []*FirewallRule, peerIP string, direction int) {
t.Helper()
wantPorts := map[uint16]bool{80: false, 443: false}
for _, r := range rules {
if r == nil || r.PeerIP != peerIP || r.Direction != direction {
continue
}
if r.Protocol != string(PolicyRuleProtocolTCP) || r.Action != string(PolicyTrafficActionAccept) {
continue
}
switch {
case r.PortRange.Start == r.PortRange.End && r.PortRange.Start != 0:
wantPorts[r.PortRange.Start] = true
case r.Port == "80":
wantPorts[80] = true
case r.Port == "443":
wantPorts[443] = true
}
}
for port, found := range wantPorts {
assert.Truef(t, found, "missing TCP accept rule on port %d for peer %s direction %d", port, peerIP, direction)
}
}
@@ -0,0 +1,256 @@
package types
import (
"context"
"net"
"net/netip"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
)
func privateZoneTestAccount(t *testing.T) *Account {
t.Helper()
return &Account{
Id: "acct-1",
Settings: &Settings{},
Network: &Network{
Identifier: "net-1",
Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
},
Peers: map[string]*nbpeer.Peer{
"user-peer": {
ID: "user-peer",
AccountID: "acct-1",
Key: "user-peer-key",
IP: netip.MustParseAddr("100.64.0.10"),
Status: &nbpeer.PeerStatus{Connected: true},
},
"proxy-peer": {
ID: "proxy-peer",
AccountID: "acct-1",
Key: "proxy-peer-key",
IP: netip.MustParseAddr("100.64.0.99"),
Status: &nbpeer.PeerStatus{Connected: true},
ProxyMeta: nbpeer.ProxyMeta{
Embedded: true,
Cluster: "eu.proxy.netbird.io",
},
},
},
Groups: map[string]*Group{
"grp-admins": {
ID: "grp-admins",
Name: "admins",
Peers: []string{"user-peer"},
},
},
Services: []*service.Service{
{
ID: "svc-1",
AccountID: "acct-1",
Name: "myapp",
Domain: "myapp.eu.proxy.netbird.io",
ProxyCluster: "eu.proxy.netbird.io",
Enabled: true,
Private: true,
Mode: service.ModeHTTP,
AccessGroups: []string{"grp-admins"},
},
},
}
}
func TestSynthesizePrivateServiceZones_PeerInGroup_GetsRecord(t *testing.T) {
account := privateZoneTestAccount(t)
zones := account.SynthesizePrivateServiceZones("user-peer")
require.Len(t, zones, 1, "one cluster should produce one zone")
zone := zones[0]
assert.Equal(t, "eu.proxy.netbird.io.", zone.Domain, "zone apex must be the cluster FQDN")
assert.True(t, zone.NonAuthoritative, "synth zone must be match-only so unrelated sibling names fall through to the upstream resolver")
require.Len(t, zone.Records, 1, "one private service yields one A record")
rec := zone.Records[0]
assert.Equal(t, "myapp.eu.proxy.netbird.io.", rec.Name, "record name is the service FQDN")
assert.Equal(t, int(dns.TypeA), rec.Type, "record type must be A")
assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP")
assert.Equal(t, privateServiceDNSRecordTTL, rec.TTL, "TTL must match the synth-records constant")
assert.Equal(t, nbdns.DefaultClass, rec.Class, "record class must be the package default")
}
func TestSynthesizePrivateServiceZones_PeerNotInGroup_NoRecord(t *testing.T) {
account := privateZoneTestAccount(t)
account.Groups["grp-admins"].Peers = nil
zones := account.SynthesizePrivateServiceZones("user-peer")
assert.Empty(t, zones, "peer outside distribution_groups must not see private-service records")
}
func TestSynthesizePrivateServiceZones_NotPrivate_NoRecord(t *testing.T) {
account := privateZoneTestAccount(t)
account.Services[0].Private = false
zones := account.SynthesizePrivateServiceZones("user-peer")
assert.Empty(t, zones, "non-private service must not produce DNS records")
}
func TestSynthesizePrivateServiceZones_NoAccessGroups_NoRecord(t *testing.T) {
account := privateZoneTestAccount(t)
account.Services[0].AccessGroups = nil
zones := account.SynthesizePrivateServiceZones("user-peer")
assert.Empty(t, zones, "private service without bearer auth must not produce DNS records")
}
func TestSynthesizePrivateServiceZones_NoProxyPeers_NoRecord(t *testing.T) {
account := privateZoneTestAccount(t)
delete(account.Peers, "proxy-peer")
zones := account.SynthesizePrivateServiceZones("user-peer")
assert.Empty(t, zones, "no embedded proxy peer in cluster means no record to emit")
}
func TestSynthesizePrivateServiceZones_DisabledService_NoRecord(t *testing.T) {
account := privateZoneTestAccount(t)
account.Services[0].Enabled = false
zones := account.SynthesizePrivateServiceZones("user-peer")
assert.Empty(t, zones, "disabled service must not produce DNS records")
}
func TestSynthesizePrivateServiceZones_DisconnectedProxyPeer_NoRecord(t *testing.T) {
account := privateZoneTestAccount(t)
account.Peers["proxy-peer"].Status = &nbpeer.PeerStatus{Connected: false}
zones := account.SynthesizePrivateServiceZones("user-peer")
assert.Empty(t, zones, "disconnected proxy peer must not produce a DNS record (would be a black hole)")
}
func TestSynthesizePrivateServiceZones_PartiallyDisconnectedProxyPeers_OnlyConnectedSurface(t *testing.T) {
account := privateZoneTestAccount(t)
account.Peers["proxy-peer-2"] = &nbpeer.Peer{
ID: "proxy-peer-2",
AccountID: "acct-1",
Key: "proxy-peer-2-key",
IP: netip.MustParseAddr("100.64.0.100"),
Status: &nbpeer.PeerStatus{Connected: false},
ProxyMeta: nbpeer.ProxyMeta{Embedded: true, Cluster: "eu.proxy.netbird.io"},
}
zones := account.SynthesizePrivateServiceZones("user-peer")
require.Len(t, zones, 1)
require.Len(t, zones[0].Records, 1, "only the connected proxy peer must surface")
assert.Equal(t, "100.64.0.99", zones[0].Records[0].RData)
}
func TestSynthesizePrivateServiceZones_MultipleProxyPeers_RoundRobin(t *testing.T) {
account := privateZoneTestAccount(t)
account.Peers["proxy-peer-2"] = &nbpeer.Peer{
ID: "proxy-peer-2",
AccountID: "acct-1",
Key: "proxy-peer-2-key",
IP: netip.MustParseAddr("100.64.0.100"),
Status: &nbpeer.PeerStatus{Connected: true},
ProxyMeta: nbpeer.ProxyMeta{Embedded: true, Cluster: "eu.proxy.netbird.io"},
}
zones := account.SynthesizePrivateServiceZones("user-peer")
require.Len(t, zones, 1, "still one cluster yields one zone")
require.Len(t, zones[0].Records, 2, "two proxy peers must produce two A records on the same name")
rdata := []string{zones[0].Records[0].RData, zones[0].Records[1].RData}
assert.ElementsMatch(t, []string{"100.64.0.99", "100.64.0.100"}, rdata, "both proxy peer IPs must surface")
}
// findCustomZone returns the CustomZone whose Domain equals the FQDN
// of want, or a zero value when not found. Tests use it to assert
// that the synth zone reaches dnsUpdate.CustomZones end-to-end.
func findCustomZone(zones []nbdns.CustomZone, want string) (nbdns.CustomZone, bool) {
wantFqdn := dns.Fqdn(want)
for _, z := range zones {
if z.Domain == wantFqdn {
return z, true
}
}
return nbdns.CustomZone{}, false
}
// TestPrivateZone_GetPeerNetworkMapFromComponents_ShipsSynthZone
// covers the components-based builder path. The components builder
// appends SynthesizePrivateServiceZones to AccountZones; the
// CalculateNetworkMapFromComponents step then merges AccountZones
// into dnsUpdate.CustomZones.
func TestPrivateZone_GetPeerNetworkMapFromComponents_ShipsSynthZone(t *testing.T) {
account := privateZoneTestAccount(t)
ctx := context.Background()
validated := map[string]struct{}{
"user-peer": {},
"proxy-peer": {},
}
nm := account.GetPeerNetworkMapFromComponents(ctx, "user-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
require.NotNil(t, nm, "network map must be produced for an in-account peer")
zone, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io")
require.True(t, ok, "shipped CustomZones must include the synth zone for the cluster")
require.Len(t, zone.Records, 1, "exactly one record per private service per connected proxy peer")
rec := zone.Records[0]
assert.Equal(t, "myapp.eu.proxy.netbird.io.", rec.Name, "record name is the service FQDN")
assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP")
}
// TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone
// confirms the negative case the user encountered: a peer whose
// groups don't overlap the policy's distribution_groups gets a
// network map with no synth zone (and the wildcard / peer zones still
// flow through). This is the test mirror of the runtime confusion
// where the user looked at a non-distribution-group peer and assumed
// the synth path was broken.
func TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone(t *testing.T) {
account := privateZoneTestAccount(t)
account.Peers["outsider"] = &nbpeer.Peer{
ID: "outsider",
AccountID: "acct-1",
Key: "outsider-key",
IP: netip.MustParseAddr("100.64.0.20"),
Status: &nbpeer.PeerStatus{Connected: true},
}
ctx := context.Background()
validated := map[string]struct{}{
"user-peer": {},
"proxy-peer": {},
"outsider": {},
}
nm := account.GetPeerNetworkMapFromComponents(ctx, "outsider", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
require.NotNil(t, nm)
_, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io")
assert.False(t, ok, "peer outside the distribution_groups must not see the synth zone")
}
func TestSynthesizePrivateServiceZones_TwoServicesSameCluster_OneZone(t *testing.T) {
account := privateZoneTestAccount(t)
account.Services = append(account.Services, &service.Service{
ID: "svc-2",
AccountID: "acct-1",
Name: "anotherapp",
Domain: "anotherapp.eu.proxy.netbird.io",
ProxyCluster: "eu.proxy.netbird.io",
Enabled: true,
Private: true,
Mode: service.ModeHTTP,
AccessGroups: []string{"grp-admins"},
})
zones := account.SynthesizePrivateServiceZones("user-peer")
require.Len(t, zones, 1, "two services on the same cluster must collapse into one zone")
require.Len(t, zones[0].Records, 2, "two services yield two A records")
names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name}
assert.ElementsMatch(t, []string{"myapp.eu.proxy.netbird.io.", "anotherapp.eu.proxy.netbird.io."}, names, "both service domains must surface")
}
+205 -3
View File
@@ -3,6 +3,7 @@ package types
import (
"context"
"fmt"
"net"
"net/netip"
"testing"
@@ -11,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/zones"
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
@@ -82,9 +84,9 @@ func setupTestAccount() *Account {
},
Groups: map[string]*Group{
"groupAll": {
ID: "groupAll",
Name: "All",
Peers: []string{"peer1", "peer2", "peer3", "peer11", "peer12", "peer21", "peer31", "peer32", "peer41", "peer51", "peer61"},
ID: "groupAll",
Name: "All",
Peers: []string{"peer1", "peer2", "peer3", "peer11", "peer12", "peer21", "peer31", "peer32", "peer41", "peer51", "peer61"},
Issued: GroupIssuedAPI,
},
"group1": {
@@ -1583,3 +1585,203 @@ func Test_filterPeerAppliedZones(t *testing.T) {
})
}
}
func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
ctx := context.Background()
userPeerIP := netip.MustParseAddr("100.64.0.10")
proxyPeerIP := netip.MustParseAddr("100.64.0.99")
account := &Account{
Id: "acct-1",
Network: &Network{
Identifier: "net-1",
Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
},
Peers: map[string]*nbpeer.Peer{
"user-peer": {
ID: "user-peer",
AccountID: "acct-1",
Key: "user-peer-key",
IP: userPeerIP,
},
"proxy-peer": {
ID: "proxy-peer",
AccountID: "acct-1",
Key: "proxy-peer-key",
IP: proxyPeerIP,
ProxyMeta: nbpeer.ProxyMeta{
Embedded: true,
Cluster: "eu.proxy.netbird.io",
},
},
},
Groups: map[string]*Group{
"grp-admins": {
ID: "grp-admins",
Name: "admins",
Peers: []string{"user-peer"},
},
},
Services: []*service.Service{
{
ID: "svc-1",
AccountID: "acct-1",
Name: "myapp",
Domain: "myapp.eu.proxy.netbird.io",
ProxyCluster: "eu.proxy.netbird.io",
Enabled: true,
Private: true,
Mode: service.ModeHTTP,
AccessGroups: []string{"grp-admins"},
Targets: []*service.Target{
{
TargetId: "eu.proxy.netbird.io",
TargetType: service.TargetTypeCluster,
Protocol: "http",
Host: "127.0.0.1",
Port: 8080,
Enabled: true,
},
},
},
},
}
account.InjectProxyPolicies(ctx)
var found *Policy
for _, p := range account.Policies {
if p != nil && p.ID == "private-access-svc-1-proxy-peer" {
found = p
break
}
}
require.NotNil(t, found, "expected synthesised private-access policy in account.Policies")
require.Len(t, found.Rules, 1, "policy should have exactly one rule")
rule := found.Rules[0]
assert.Equal(t, []string{"grp-admins"}, rule.Sources, "sources should be group IDs verbatim")
assert.Equal(t, "proxy-peer", rule.DestinationResource.ID, "destination resource should be the proxy peer ID")
assert.Equal(t, ResourceTypePeer, rule.DestinationResource.Type, "destination resource type should be peer")
validatedPeersMap := map[string]struct{}{
"user-peer": {},
"proxy-peer": {},
}
proxyPeer := account.Peers["proxy-peer"]
aclPeers, firewallRules, _, _ := account.GetPeerConnectionResources(ctx, proxyPeer, validatedPeersMap, nil)
var sawUserAsAclPeer bool
for _, p := range aclPeers {
if p.ID == "user-peer" {
sawUserAsAclPeer = true
break
}
}
assert.True(t, sawUserAsAclPeer, "proxy peer should see the user peer as an ACL peer")
var inboundRules []*FirewallRule
for _, r := range firewallRules {
if r.Direction == FirewallRuleDirectionIN && r.PeerIP == userPeerIP.String() {
inboundRules = append(inboundRules, r)
}
}
assert.NotEmpty(t, inboundRules, "proxy peer should have inbound firewall rules from the user peer")
}
func TestInjectPrivateServicePolicies_NotPrivate_NoPolicy(t *testing.T) {
ctx := context.Background()
account := privateServiceTestAccount(t)
account.Services[0].Private = false
account.InjectProxyPolicies(ctx)
assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "non-private service must not synthesise an access policy")
}
func TestInjectPrivateServicePolicies_EmptyAccessGroups_NoPolicy(t *testing.T) {
ctx := context.Background()
account := privateServiceTestAccount(t)
account.Services[0].AccessGroups = nil
account.InjectProxyPolicies(ctx)
assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "private service with no access groups must not synthesise a policy")
}
func TestInjectPrivateServicePolicies_NoProxyPeers_NoPolicy(t *testing.T) {
ctx := context.Background()
account := privateServiceTestAccount(t)
delete(account.Peers, "proxy-peer")
account.InjectProxyPolicies(ctx)
assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "policy must not synthesise when the cluster has no proxy peers")
}
func privateServiceTestAccount(t *testing.T) *Account {
t.Helper()
return &Account{
Id: "acct-1",
Network: &Network{
Identifier: "net-1",
Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
},
Peers: map[string]*nbpeer.Peer{
"user-peer": {
ID: "user-peer",
AccountID: "acct-1",
Key: "user-peer-key",
IP: netip.MustParseAddr("100.64.0.10"),
},
"proxy-peer": {
ID: "proxy-peer",
AccountID: "acct-1",
Key: "proxy-peer-key",
IP: netip.MustParseAddr("100.64.0.99"),
ProxyMeta: nbpeer.ProxyMeta{
Embedded: true,
Cluster: "eu.proxy.netbird.io",
},
},
},
Groups: map[string]*Group{
"grp-admins": {
ID: "grp-admins",
Name: "admins",
Peers: []string{"user-peer"},
},
},
Services: []*service.Service{
{
ID: "svc-1",
AccountID: "acct-1",
Name: "myapp",
Domain: "myapp.eu.proxy.netbird.io",
ProxyCluster: "eu.proxy.netbird.io",
Enabled: true,
Private: true,
Mode: service.ModeHTTP,
AccessGroups: []string{"grp-admins"},
Targets: []*service.Target{
{
TargetId: "eu.proxy.netbird.io",
TargetType: service.TargetTypeCluster,
Protocol: "http",
Host: "127.0.0.1",
Port: 8080,
Enabled: true,
},
},
},
},
}
}
func hasPrivateAccessPolicy(account *Account, serviceID string) bool {
prefix := "private-access-" + serviceID + "-"
for _, p := range account.Policies {
if p != nil && len(p.ID) > len(prefix) && p.ID[:len(prefix)] == prefix {
return true
}
}
return false
}
+36
View File
@@ -10,6 +10,13 @@ import (
type Manager interface {
GetUser(ctx context.Context, userID string) (*types.User, error)
// GetUserWithGroups returns the user and the *types.Group
// records for the user's AutoGroups, in the same order as
// AutoGroups. Group ids that don't resolve to a stored group
// are skipped from the returned slice (the parallel id list is
// derivable from the returned User). Wraps two store calls
// today; can be optimised to a single JOIN later if needed.
GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error)
}
type managerImpl struct {
@@ -29,6 +36,27 @@ func (m *managerImpl) GetUser(ctx context.Context, userID string) (*types.User,
return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
}
func (m *managerImpl) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
return nil, nil, err
}
if len(user.AutoGroups) == 0 {
return user, nil, nil
}
groupsMap, err := m.store.GetGroupsByIDs(ctx, store.LockingStrengthNone, user.AccountID, user.AutoGroups)
if err != nil {
return user, nil, err
}
groups := make([]*types.Group, 0, len(user.AutoGroups))
for _, id := range user.AutoGroups {
if g, ok := groupsMap[id]; ok && g != nil {
groups = append(groups, g)
}
}
return user, groups, nil
}
func NewManagerMock() Manager {
return &managerMock{}
}
@@ -47,3 +75,11 @@ func (m *managerMock) GetUser(ctx context.Context, userID string) (*types.User,
return nil, errors.New("user not found")
}
}
func (m *managerMock) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
user, err := m.GetUser(ctx, userID)
if err != nil {
return nil, nil, err
}
return user, nil, nil
}