inject proxy policies on nmdata path

This commit is contained in:
pascal
2026-08-19 15:47:59 +02:00
parent fa286ac778
commit 2e2d0d54dc
21 changed files with 594 additions and 212 deletions

View File

@@ -27,8 +27,6 @@ func allPeerMaps(t *testing.T, manager *DefaultAccountManager, accountID string)
account, err := manager.Store.GetAccount(ctx, accountID)
require.NoError(t, err)
account.InjectProxyPolicies(ctx)
validated := make(map[string]struct{}, len(account.Peers))
for id := range account.Peers {
validated[id] = struct{}{}

View File

@@ -1554,176 +1554,6 @@ func (a *Account) GetProxyPeers() map[string][]*nbpeer.Peer {
return proxyPeers
}
func (a *Account) InjectProxyPolicies(ctx context.Context) {
if len(a.Services) == 0 {
return
}
proxyPeersByCluster := a.GetProxyPeers()
if len(proxyPeersByCluster) == 0 {
return
}
for _, service := range a.Services {
if !service.Enabled {
continue
}
a.injectServiceProxyPolicies(ctx, service, proxyPeersByCluster)
}
}
func (a *Account) injectServiceProxyPolicies(ctx context.Context, service *service.Service, proxyPeersByCluster map[string][]*nbpeer.Peer) {
proxyPeers := proxyPeersByCluster[service.ProxyCluster]
for _, target := range service.Targets {
if !target.Enabled {
continue
}
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
}
// A service's AccessGroups can name groups that no longer exist — persisted
// services and the agent-network synthesiser both carry the ids verbatim from
// their own state. An unresolvable source authorises nothing, so drop it here
// rather than let the network-map assembly resolve it to a nil group.
sources := a.existingGroupIDs(svc.AccessGroups)
if len(sources) == 0 {
return
}
for _, proxyPeer := range proxyPeers {
a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer, sources))
}
}
// existingGroupIDs returns the subset of groupIDs that resolve to a group in the account,
// preserving the input order.
func (a *Account) existingGroupIDs(groupIDs []string) []string {
out := make([]string, 0, len(groupIDs))
for _, groupID := range groupIDs {
if _, ok := a.Groups[groupID]; ok {
out = append(out, groupID)
}
}
return out
}
func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer, accessGroups []string) *Policy {
policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
sources := append([]string(nil), 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) {
port, ok := a.resolveTargetPort(ctx, target)
if !ok {
return
}
path := ""
if target.Path != nil {
path = *target.Path
}
for _, proxyPeer := range proxyPeers {
policy := a.createProxyPolicy(service, target, proxyPeer, port, path)
a.Policies = append(a.Policies, policy)
}
}
func (a *Account) resolveTargetPort(ctx context.Context, target *service.Target) (uint16, bool) {
if target.Port != 0 {
return target.Port, true
}
switch target.Protocol {
case "https", "tls":
return 443, true
case "http":
return 80, true
default:
log.WithContext(ctx).Warnf("unsupported protocol %s for proxy target %s, skipping policy injection", target.Protocol, target.TargetId)
return 0, false
}
}
func (a *Account) createProxyPolicy(svc *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port uint16, path string) *Policy {
policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, path)
protocol := PolicyRuleProtocolTCP
if svc.Mode == service.ModeUDP {
protocol = PolicyRuleProtocolUDP
}
return &Policy{
ID: policyID,
Name: fmt.Sprintf("Proxy Access to %s", svc.Name),
Enabled: true,
Rules: []*PolicyRule{
{
ID: policyID,
PolicyID: policyID,
Name: fmt.Sprintf("Allow access to %s", svc.Name),
Enabled: true,
SourceResource: Resource{
ID: proxyPeer.ID,
Type: ResourceTypePeer,
},
DestinationResource: Resource{
ID: target.TargetId,
Type: ResourceType(target.TargetType),
},
Bidirectional: false,
Protocol: protocol,
Action: PolicyTrafficActionAccept,
PortRanges: []RulePortRange{
{
Start: port,
End: port,
},
},
},
},
}
}
// filterZoneRecordsForPeers filters DNS records to only include peers to connect.
// AAAA records are excluded when the requesting peer lacks IPv6 capability.
func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord {

View File

@@ -4,6 +4,7 @@ import (
"github.com/miekg/dns"
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"
@@ -112,10 +113,54 @@ func (a *Account) toNetworkMapData(
nmd.ProxyTargetedDomainResourceIDs = a.proxyTargetedDomainResourceIDs()
nmd.AppliedZoneCandidates = buildAppliedZoneCandidates(accountZones)
nmd.PrivateServiceCandidates = a.buildPrivateServiceCandidates()
nmd.Services = TwinServices(a.Services)
return nmd
}
// TwinServices converts reverse-proxy services to their slim nmdata twins.
// Exported for the network-map controller, which hands the store-backed twin
// the same services the account carries.
func TwinServices(services []*service.Service) []*nmdata.Service {
if len(services) == 0 {
return nil
}
out := make([]*nmdata.Service, 0, len(services))
for _, svc := range services {
if svc == nil {
continue
}
targets := make([]*nmdata.ServiceTarget, 0, len(svc.Targets))
for _, t := range svc.Targets {
if t == nil {
continue
}
path := ""
if t.Path != nil {
path = *t.Path
}
targets = append(targets, &nmdata.ServiceTarget{
Enabled: t.Enabled,
Path: path,
Port: t.Port,
Protocol: t.Protocol,
TargetID: t.TargetId,
TargetType: string(t.TargetType),
})
}
out = append(out, &nmdata.Service{
ID: svc.ID,
Enabled: svc.Enabled,
Private: svc.Private,
Mode: svc.Mode,
ProxyCluster: svc.ProxyCluster,
AccessGroups: svc.AccessGroups,
Targets: targets,
})
}
return out
}
func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
if p == nil {
return nil
@@ -141,7 +186,7 @@ func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
IPv6: p.IPv6,
RequiresApproval: p.Status != nil && p.Status.RequiresApproval,
ExtraDNSLabels: p.ExtraDNSLabels,
ProxyMeta: nmdata.ProxyMeta{Embedded: p.ProxyMeta.Embedded},
ProxyMeta: nmdata.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster},
Meta: nmdata.PeerSystemMeta{
WtVersion: p.Meta.WtVersion,
GoOS: p.Meta.GoOS,

View File

@@ -18,7 +18,6 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
account.Peers["proxy-peer"].Meta.WtVersion = "0.50.0"
ctx := context.Background()
account.InjectProxyPolicies(ctx)
validated := map[string]struct{}{
"user-peer": {},

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net"
"net/netip"
"strings"
"testing"
"github.com/miekg/dns"
@@ -1051,6 +1052,7 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
Identifier: "net-1",
Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
},
Settings: &Settings{},
Peers: map[string]*nbpeer.Peer{
"user-peer": {
ID: "user-peer",
@@ -1101,41 +1103,25 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
},
}
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")
found := findPolicy(injectedPolicies(account), "private-access-svc-1-proxy-peer")
require.NotNil(t, found, "expected synthesised private-access policy in the twin store")
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")
assert.Equal(t, string(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)
nm := account.GetPeerNetworkMapFromComponents(ctx, "proxy-peer", nbdns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, 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")
assert.Contains(t, netmapPeerIDs(nm.Peers), "user-peer", "proxy peer should see the user peer as an ACL peer")
var inboundRules []*FirewallRule
for _, r := range firewallRules {
for _, r := range nm.FirewallRules {
if r.Direction == FirewallRuleDirectionIN && r.PeerIP == userPeerIP.String() {
inboundRules = append(inboundRules, r)
}
@@ -1144,29 +1130,23 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
}
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")
}
@@ -1229,10 +1209,27 @@ func privateServiceTestAccount(t *testing.T) *Account {
}
}
// injectedPolicies returns the twin's policies with the synthesised proxy ACLs
// already in place, the way the per-peer computation sees them.
func injectedPolicies(account *Account) []*nmdata.Policy {
nmd := account.toNetworkMapData(nil, nil, nil, nil, nil)
nmd.InjectProxyPolicies()
return nmd.Policies
}
func findPolicy(policies []*nmdata.Policy, id string) *nmdata.Policy {
for _, p := range policies {
if p != nil && p.ID == id {
return p
}
}
return nil
}
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 {
for _, p := range injectedPolicies(account) {
if p != nil && strings.HasPrefix(p.ID, prefix) {
return true
}
}