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

@@ -131,12 +131,12 @@ func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, p
// injectAllProxyPolicies prepares an account for the per-peer network-map
// computation. It prepends the in-memory agent-network services synthesised
// from the account's current provider/policy state to account.Services so
// the existing InjectProxyPolicies + injectPrivateServicePolicies walks pick
// them up alongside persisted reverse-proxy services. Synthesised services
// are never persisted; the account is loaded fresh per cycle so re-prepending
// is safe and idempotent. Accounts without agent-network providers get an
// empty synth slice — no behaviour change.
// from the account's current provider/policy state to account.Services, so the
// twin store built from the account carries them alongside the persisted
// reverse-proxy services and synthesises their ACLs. Synthesised services are
// never persisted; the account is loaded fresh per cycle so re-prepending is
// safe and idempotent. Accounts without agent-network providers get an empty
// synth slice — no behaviour change.
func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.Account) {
synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, account.Id)
if err != nil {
@@ -144,7 +144,26 @@ func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.
} else if len(synth) > 0 {
account.Services = append(synth, account.Services...)
}
account.InjectProxyPolicies(ctx)
}
// proxyServicesFromRepo is the store-path counterpart of
// injectAllProxyPolicies: the network-map store reads the policies table, which
// never holds the proxy ACLs, so the twin gets the services they are
// synthesised from — the synthesised agent-network ones first, exactly as the
// account path orders them.
func (c *Controller) proxyServicesFromRepo(ctx context.Context, accountID string) []*nmdata.Service {
persisted, err := c.repo.GetAccountServices(ctx, accountID)
if err != nil {
log.WithContext(ctx).Errorf("failed to get services for account %s: %v", accountID, err)
return nil
}
synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, accountID)
if err != nil {
log.WithContext(ctx).Warnf("synthesise agent-network services for account %s: %v", accountID, err)
}
return types.TwinServices(append(synth, persisted...))
}
func (c *Controller) CountStreams() int {
@@ -467,6 +486,8 @@ func (c *Controller) getNetworkMapData(ctx context.Context, accountID string) *n
return nil
}
nmData.Services = c.proxyServicesFromRepo(ctx, accountID)
return nmData
}

View File

@@ -3,9 +3,9 @@ package controller
import (
"context"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/zones"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
@@ -22,6 +22,7 @@ type Repository interface {
// services synthesised from the account's agent-network provider/policy
// state. Empty for accounts without agent-network providers.
SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error)
GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error)
}
type repository struct {
@@ -60,6 +61,10 @@ func (r *repository) SynthesizeAgentNetworkServices(ctx context.Context, account
return agentnetwork.SynthesizeServices(ctx, r.store, accountID)
}
func (r *repository) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) {
return r.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
}
func (r *repository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) {
return r.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID)
}

View File

@@ -0,0 +1,7 @@
{
"description": "A reverse-proxy service targeting a domain network resource. The synthesised proxy-access ACL is a resource policy too: on the account path the resource-policy map was built after injection, so the routing peer must carry a route firewall rule sourced from the proxy peer for the resource's domain. The store reads the policies table and ResourcePolicies never holds it, so only the synthesis puts it there.",
"peers": [
"router-peer",
"proxy-peer"
]
}

View File

@@ -0,0 +1,44 @@
{
"Network": {"Serial": 32},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"router-peer": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}},
"proxy-peer": {
"IP": "100.64.0.99",
"Meta": {"WtVersion": "0.60.0"},
"ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
}
},
"NetworkResources": [
{
"ID": "res-domain",
"NetworkID": "net-1",
"Name": "app-domain",
"Type": "domain",
"Domain": "app.internal",
"Enabled": true
}
],
"Routers": {
"net-1": {
"router-peer": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
}
},
"ProxyTargetedDomainResourceIDs": {"res-domain": {}},
"Services": [
{
"ID": "svc-1",
"Enabled": true,
"Mode": "http",
"ProxyCluster": "eu.proxy.netbird.io",
"Targets": [
{
"Enabled": true,
"Protocol": "https",
"TargetID": "res-domain",
"TargetType": "domain"
}
]
}
]
}

View File

@@ -0,0 +1,7 @@
{
"description": "A reverse-proxy service targeting a peer. The proxy-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the cluster's embedded proxy peer reach the target on the target's port: proxy-peer gets an OUT rule to app-peer on TCP 8080 and app-peer the matching IN rule. Without the synthesis both maps are empty of each other.",
"peers": [
"proxy-peer",
"app-peer"
]
}

View File

@@ -0,0 +1,63 @@
{
"Serial": "30",
"peerConfig": {
"address": "100.64.0.10/10",
"sshConfig": {},
"fqdn": "app-peer.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
"allowedIps": [
"100.64.0.99/32"
],
"sshConfig": {},
"fqdn": "proxy-peer.netbird.test",
"agentVersion": "0.60.0"
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "app-peer.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.10"
},
{
"Name": "proxy-peer.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.99"
}
]
}
],
"ForwarderPort": "22054"
},
"FirewallRules": [
{
"PeerIP": "100.64.0.99",
"Protocol": "TCP",
"PortInfo": {
"range": {
"start": 8080,
"end": 8080
}
},
"PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt"
}
],
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,64 @@
{
"Serial": "30",
"peerConfig": {
"address": "100.64.0.99/10",
"sshConfig": {},
"fqdn": "proxy-peer.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "/wFxrqMtMwWNZak/f0UDddUkCZMTmxNuiuk4/RGGNcY=",
"allowedIps": [
"100.64.0.10/32"
],
"sshConfig": {},
"fqdn": "app-peer.netbird.test",
"agentVersion": "0.60.0"
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "app-peer.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.10"
},
{
"Name": "proxy-peer.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.99"
}
]
}
],
"ForwarderPort": "22054"
},
"FirewallRules": [
{
"PeerIP": "100.64.0.10",
"Direction": "OUT",
"Protocol": "TCP",
"PortInfo": {
"range": {
"start": 8080,
"end": 8080
}
},
"PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt"
}
],
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,29 @@
{
"Network": {"Serial": 30},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"proxy-peer": {
"IP": "100.64.0.99",
"Meta": {"WtVersion": "0.60.0"},
"ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
},
"app-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
},
"Services": [
{
"ID": "svc-1",
"Enabled": true,
"Mode": "http",
"ProxyCluster": "eu.proxy.netbird.io",
"Targets": [
{
"Enabled": true,
"Port": 8080,
"Protocol": "http",
"TargetID": "app-peer",
"TargetType": "peer"
}
]
}
]
}

View File

@@ -0,0 +1,7 @@
{
"description": "A private reverse-proxy service. The private-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the service's AccessGroups reach the cluster's embedded proxy peer on TCP 80 and 443: user-peer gets OUT rules on both ports and proxy-peer the matching IN rules. Without the synthesis both maps are empty of each other.",
"peers": [
"user-peer",
"proxy-peer"
]
}

View File

@@ -0,0 +1,26 @@
{
"Network": {"Serial": 31},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"user-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}},
"other-peer": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
"proxy-peer": {
"IP": "100.64.0.99",
"Meta": {"WtVersion": "0.60.0"},
"ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
}
},
"Groups": {
"grp-admins": {"Peers": ["user-peer"]}
},
"Services": [
{
"ID": "svc-1",
"Enabled": true,
"Private": true,
"Mode": "http",
"ProxyCluster": "eu.proxy.netbird.io",
"AccessGroups": ["grp-admins", "grp-deleted"]
}
]
}

View File

@@ -294,6 +294,7 @@ func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Pee
if p.ProxyMetaEmbedded.Valid {
dp.ProxyMeta.Embedded = p.ProxyMetaEmbedded.Bool
}
dp.ProxyMeta.Cluster = p.ProxyMetaCluster.String
// This is only used to build private service candidates, not connected peers are skipped
if dp.ProxyMeta.Embedded && p.PeerStatusConnected.Bool {
clusterToPeerIdx[p.ProxyMetaCluster.String] = append(clusterToPeerIdx[p.ProxyMetaCluster.String], &dp)

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
}
}

View File

@@ -17,6 +17,8 @@ type sshRequirements struct {
// exactly, operating on nmdata twins throughout — no Account reference and no
// twin↔real conversion, since the produced components hold twins.
func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCustomZone nmdata.CustomZone) *types.NetworkMapComponents {
nmd.InjectProxyPolicies()
forceRoutingPeerDNS := nmd.forcesRoutingPeerDNSResolution(peerID)
peer := nmd.Peers[peerID]

View File

@@ -48,8 +48,16 @@ type NetworkMapData struct { //nolint:revive // established name across the code
AppliedZoneCandidates []AppliedZoneCandidate
PrivateServiceCandidates []PrivateServiceCandidate
// Services are the account's reverse-proxy services, persisted ones and
// the in-memory ones synthesised from agent-network state. They are the
// source of the proxy ACLs injectProxyPolicies synthesises, which no
// builder can load because they are never written to the database.
Services []*nmdata.Service
peerGroupsOnce sync.Once
peerGroupsIdx map[string]map[string]struct{}
proxyPoliciesOnce sync.Once
}
// AppliedZoneCandidate is an account-level custom DNS zone reduced to the

View File

@@ -36,6 +36,7 @@ type Peer struct {
// ProxyMeta is the slim twin of peer.ProxyMeta.
type ProxyMeta struct {
Embedded bool
Cluster string
}
// PeerSystemMeta is the slim twin of peer.PeerSystemMeta.

View File

@@ -0,0 +1,25 @@
package nmdata
// Service is the slim twin of the reverse-proxy service.Service. It carries
// only the state proxy-policy injection reads: the persisted reverse-proxy
// services and the in-memory ones synthesised from agent-network state, which
// are never written to the database.
type Service struct {
ID string
Enabled bool
Private bool
Mode string
ProxyCluster string
AccessGroups []string
Targets []*ServiceTarget
}
// ServiceTarget is the slim twin of service.Target.
type ServiceTarget struct {
Enabled bool
Path string
Port uint16
Protocol string
TargetID string
TargetType string
}

View File

@@ -0,0 +1,203 @@
package networkmap
import (
"fmt"
"slices"
"strings"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/types"
)
const (
serviceModeUDP = "udp"
privateServicePortHTTP = 80
privateServicePortHTTPS = 443
)
// InjectProxyPolicies synthesises the in-memory ACLs that carry reverse-proxy
// traffic and appends them to the twin's policies. They are never persisted,
// so no builder can load them: a proxy-access policy lets a cluster's proxy
// peers reach each enabled target of a service, and a private-access policy
// lets a private service's AccessGroups reach those proxy peers on HTTP(S).
//
// GetPeerNetworkMapComponents calls it, so every caller of the twin gets the
// same policy set no matter which builder produced it. It runs at most once
// per twin, and is safe to call again to force the synthesis early.
func (nmd *NetworkMapData) InjectProxyPolicies() {
nmd.proxyPoliciesOnce.Do(nmd.injectProxyPolicies)
}
func (nmd *NetworkMapData) injectProxyPolicies() {
if len(nmd.Services) == 0 {
return
}
proxyPeersByCluster := nmd.proxyPeersByCluster()
if len(proxyPeersByCluster) == 0 {
return
}
for _, svc := range nmd.Services {
if svc == nil || !svc.Enabled {
continue
}
proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
for _, target := range svc.Targets {
if target == nil || !target.Enabled {
continue
}
port, ok := resolveTargetPort(target)
if !ok {
continue
}
for _, proxyPeer := range proxyPeers {
nmd.addInjectedPolicy(proxyAccessPolicy(svc, target, proxyPeer, port))
}
}
nmd.injectPrivateServicePolicies(svc, proxyPeers)
}
}
// injectPrivateServicePolicies synthesises AccessGroups → cluster proxy peers on TCP 80/443.
func (nmd *NetworkMapData) injectPrivateServicePolicies(svc *nmdata.Service, proxyPeers []*nmdata.Peer) {
if !svc.Private || len(svc.AccessGroups) == 0 || 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 := nmd.existingGroupIDs(svc.AccessGroups)
if len(sources) == 0 {
return
}
for _, proxyPeer := range proxyPeers {
nmd.addInjectedPolicy(privateAccessPolicy(svc, proxyPeer, sources))
}
}
// addInjectedPolicy appends the policy to the twin's policy set, and to the
// policies of the network resource it targets — mirroring the account path,
// where the resource-policy map was built after injection.
func (nmd *NetworkMapData) addInjectedPolicy(policy *nmdata.Policy) {
nmd.Policies = append(nmd.Policies, policy)
resourceID := policy.Rules[0].DestinationResource.ID
if resourceID == "" {
return
}
for _, resource := range nmd.NetworkResources {
if resource == nil || !resource.Enabled || resource.ID != resourceID {
continue
}
if nmd.ResourcePolicies == nil {
nmd.ResourcePolicies = make(map[string][]*nmdata.Policy)
}
nmd.ResourcePolicies[resourceID] = append(nmd.ResourcePolicies[resourceID], policy)
return
}
}
func proxyAccessPolicy(svc *nmdata.Service, target *nmdata.ServiceTarget, proxyPeer *nmdata.Peer, port uint16) *nmdata.Policy {
policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, target.Path)
protocol := types.PolicyRuleProtocolTCP
if svc.Mode == serviceModeUDP {
protocol = types.PolicyRuleProtocolUDP
}
return &nmdata.Policy{
ID: policyID,
Enabled: true,
Rules: []*nmdata.PolicyRule{
{
ID: policyID,
PolicyID: policyID,
Enabled: true,
SourceResource: nmdata.Resource{ID: proxyPeer.ID, Type: string(types.ResourceTypePeer)},
DestinationResource: nmdata.Resource{ID: target.TargetID, Type: target.TargetType},
Bidirectional: false,
Protocol: string(protocol),
Action: string(types.PolicyTrafficActionAccept),
PortRanges: []nmdata.RulePortRange{{Start: port, End: port}},
},
},
}
}
func privateAccessPolicy(svc *nmdata.Service, proxyPeer *nmdata.Peer, accessGroups []string) *nmdata.Policy {
policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
return &nmdata.Policy{
ID: policyID,
Enabled: true,
Rules: []*nmdata.PolicyRule{
{
ID: policyID,
PolicyID: policyID,
Enabled: true,
Sources: slices.Clone(accessGroups),
DestinationResource: nmdata.Resource{ID: proxyPeer.ID, Type: string(types.ResourceTypePeer)},
Bidirectional: false,
Protocol: string(types.PolicyRuleProtocolTCP),
Action: string(types.PolicyTrafficActionAccept),
PortRanges: []nmdata.RulePortRange{
{Start: privateServicePortHTTP, End: privateServicePortHTTP},
{Start: privateServicePortHTTPS, End: privateServicePortHTTPS},
},
},
},
}
}
func resolveTargetPort(target *nmdata.ServiceTarget) (uint16, bool) {
if target.Port != 0 {
return target.Port, true
}
switch target.Protocol {
case "https", "tls":
return privateServicePortHTTPS, true
case "http":
return privateServicePortHTTP, true
default:
return 0, false
}
}
// proxyPeersByCluster groups the account's embedded proxy peers by the cluster
// they serve. Sorted by peer ID so the synthesised policy order is stable.
func (nmd *NetworkMapData) proxyPeersByCluster() map[string][]*nmdata.Peer {
var proxyPeers map[string][]*nmdata.Peer
for _, peer := range nmd.Peers {
if peer == nil || !peer.ProxyMeta.Embedded {
continue
}
if proxyPeers == nil {
proxyPeers = make(map[string][]*nmdata.Peer)
}
proxyPeers[peer.ProxyMeta.Cluster] = append(proxyPeers[peer.ProxyMeta.Cluster], peer)
}
for _, peers := range proxyPeers {
slices.SortFunc(peers, func(a, b *nmdata.Peer) int { return strings.Compare(a.ID, b.ID) })
}
return proxyPeers
}
// existingGroupIDs returns the subset of groupIDs that resolve to a group,
// preserving the input order.
func (nmd *NetworkMapData) existingGroupIDs(groupIDs []string) []string {
out := make([]string, 0, len(groupIDs))
for _, groupID := range groupIDs {
if _, ok := nmd.Groups[groupID]; ok {
out = append(out, groupID)
}
}
return out
}