[management, client, proxy] add expose NetBird-only services over tunnel peers (#6226)

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:
Maycon Santos
2026-05-25 17:41:50 +02:00
committed by GitHub
parent 0358be2313
commit 7aebdd69dd
84 changed files with 7810 additions and 933 deletions
+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
}