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
@@ -131,12 +131,12 @@ func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, p
// injectAllProxyPolicies prepares an account for the per-peer network-map // injectAllProxyPolicies prepares an account for the per-peer network-map
// computation. It prepends the in-memory agent-network services synthesised // computation. It prepends the in-memory agent-network services synthesised
// from the account's current provider/policy state to account.Services so // from the account's current provider/policy state to account.Services, so the
// the existing InjectProxyPolicies + injectPrivateServicePolicies walks pick // twin store built from the account carries them alongside the persisted
// them up alongside persisted reverse-proxy services. Synthesised services // reverse-proxy services and synthesises their ACLs. Synthesised services are
// are never persisted; the account is loaded fresh per cycle so re-prepending // never persisted; the account is loaded fresh per cycle so re-prepending is
// is safe and idempotent. Accounts without agent-network providers get an // safe and idempotent. Accounts without agent-network providers get an empty
// empty synth slice — no behaviour change. // synth slice — no behaviour change.
func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.Account) { func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.Account) {
synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, account.Id) synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, account.Id)
if err != nil { if err != nil {
@@ -144,7 +144,26 @@ func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.
} else if len(synth) > 0 { } else if len(synth) > 0 {
account.Services = append(synth, account.Services...) 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 { func (c *Controller) CountStreams() int {
@@ -467,6 +486,8 @@ func (c *Controller) getNetworkMapData(ctx context.Context, accountID string) *n
return nil return nil
} }
nmData.Services = c.proxyServicesFromRepo(ctx, accountID)
return nmData return nmData
} }
@@ -3,9 +3,9 @@ package controller
import ( import (
"context" "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/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/zones" "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/peer"
"github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/types"
@@ -22,6 +22,7 @@ type Repository interface {
// services synthesised from the account's agent-network provider/policy // services synthesised from the account's agent-network provider/policy
// state. Empty for accounts without agent-network providers. // state. Empty for accounts without agent-network providers.
SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error) SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error)
GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error)
} }
type repository struct { type repository struct {
@@ -60,6 +61,10 @@ func (r *repository) SynthesizeAgentNetworkServices(ctx context.Context, account
return agentnetwork.SynthesizeServices(ctx, r.store, accountID) 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) { func (r *repository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) {
return r.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID) return r.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID)
} }
@@ -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"
]
}
@@ -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"
}
]
}
]
}
@@ -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"
]
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
]
}
]
}
@@ -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"
]
}
@@ -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"]
}
]
}
@@ -294,6 +294,7 @@ func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Pee
if p.ProxyMetaEmbedded.Valid { if p.ProxyMetaEmbedded.Valid {
dp.ProxyMeta.Embedded = p.ProxyMetaEmbedded.Bool 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 // This is only used to build private service candidates, not connected peers are skipped
if dp.ProxyMeta.Embedded && p.PeerStatusConnected.Bool { if dp.ProxyMeta.Embedded && p.PeerStatusConnected.Bool {
clusterToPeerIdx[p.ProxyMetaCluster.String] = append(clusterToPeerIdx[p.ProxyMetaCluster.String], &dp) clusterToPeerIdx[p.ProxyMetaCluster.String] = append(clusterToPeerIdx[p.ProxyMetaCluster.String], &dp)
@@ -27,8 +27,6 @@ func allPeerMaps(t *testing.T, manager *DefaultAccountManager, accountID string)
account, err := manager.Store.GetAccount(ctx, accountID) account, err := manager.Store.GetAccount(ctx, accountID)
require.NoError(t, err) require.NoError(t, err)
account.InjectProxyPolicies(ctx)
validated := make(map[string]struct{}, len(account.Peers)) validated := make(map[string]struct{}, len(account.Peers))
for id := range account.Peers { for id := range account.Peers {
validated[id] = struct{}{} validated[id] = struct{}{}
-170
View File
@@ -1554,176 +1554,6 @@ func (a *Account) GetProxyPeers() map[string][]*nbpeer.Peer {
return proxyPeers 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. // filterZoneRecordsForPeers filters DNS records to only include peers to connect.
// AAAA records are excluded when the requesting peer lacks IPv6 capability. // 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 { func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord {
@@ -4,6 +4,7 @@ import (
"github.com/miekg/dns" "github.com/miekg/dns"
nbdns "github.com/netbirdio/netbird/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"
"github.com/netbirdio/netbird/management/internals/modules/zones/records" "github.com/netbirdio/netbird/management/internals/modules/zones/records"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
@@ -112,10 +113,54 @@ func (a *Account) toNetworkMapData(
nmd.ProxyTargetedDomainResourceIDs = a.proxyTargetedDomainResourceIDs() nmd.ProxyTargetedDomainResourceIDs = a.proxyTargetedDomainResourceIDs()
nmd.AppliedZoneCandidates = buildAppliedZoneCandidates(accountZones) nmd.AppliedZoneCandidates = buildAppliedZoneCandidates(accountZones)
nmd.PrivateServiceCandidates = a.buildPrivateServiceCandidates() nmd.PrivateServiceCandidates = a.buildPrivateServiceCandidates()
nmd.Services = TwinServices(a.Services)
return nmd 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 { func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
if p == nil { if p == nil {
return nil return nil
@@ -141,7 +186,7 @@ func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
IPv6: p.IPv6, IPv6: p.IPv6,
RequiresApproval: p.Status != nil && p.Status.RequiresApproval, RequiresApproval: p.Status != nil && p.Status.RequiresApproval,
ExtraDNSLabels: p.ExtraDNSLabels, ExtraDNSLabels: p.ExtraDNSLabels,
ProxyMeta: nmdata.ProxyMeta{Embedded: p.ProxyMeta.Embedded}, ProxyMeta: nmdata.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster},
Meta: nmdata.PeerSystemMeta{ Meta: nmdata.PeerSystemMeta{
WtVersion: p.Meta.WtVersion, WtVersion: p.Meta.WtVersion,
GoOS: p.Meta.GoOS, GoOS: p.Meta.GoOS,
@@ -18,7 +18,6 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
account.Peers["proxy-peer"].Meta.WtVersion = "0.50.0" account.Peers["proxy-peer"].Meta.WtVersion = "0.50.0"
ctx := context.Background() ctx := context.Background()
account.InjectProxyPolicies(ctx)
validated := map[string]struct{}{ validated := map[string]struct{}{
"user-peer": {}, "user-peer": {},
+27 -30
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/netip" "net/netip"
"strings"
"testing" "testing"
"github.com/miekg/dns" "github.com/miekg/dns"
@@ -1051,6 +1052,7 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
Identifier: "net-1", Identifier: "net-1",
Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)}, Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
}, },
Settings: &Settings{},
Peers: map[string]*nbpeer.Peer{ Peers: map[string]*nbpeer.Peer{
"user-peer": { "user-peer": {
ID: "user-peer", ID: "user-peer",
@@ -1101,41 +1103,25 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
}, },
} }
account.InjectProxyPolicies(ctx) found := findPolicy(injectedPolicies(account), "private-access-svc-1-proxy-peer")
require.NotNil(t, found, "expected synthesised private-access policy in the twin store")
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") require.Len(t, found.Rules, 1, "policy should have exactly one rule")
rule := found.Rules[0] rule := found.Rules[0]
assert.Equal(t, []string{"grp-admins"}, rule.Sources, "sources should be group IDs verbatim") 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, "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{}{ validatedPeersMap := map[string]struct{}{
"user-peer": {}, "user-peer": {},
"proxy-peer": {}, "proxy-peer": {},
} }
proxyPeer := account.Peers["proxy-peer"] nm := account.GetPeerNetworkMapFromComponents(ctx, "proxy-peer", nbdns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, nil)
aclPeers, firewallRules, _, _ := account.GetPeerConnectionResources(ctx, proxyPeer, validatedPeersMap, nil)
var sawUserAsAclPeer bool assert.Contains(t, netmapPeerIDs(nm.Peers), "user-peer", "proxy peer should see the user peer as an ACL peer")
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 var inboundRules []*FirewallRule
for _, r := range firewallRules { for _, r := range nm.FirewallRules {
if r.Direction == FirewallRuleDirectionIN && r.PeerIP == userPeerIP.String() { if r.Direction == FirewallRuleDirectionIN && r.PeerIP == userPeerIP.String() {
inboundRules = append(inboundRules, r) inboundRules = append(inboundRules, r)
} }
@@ -1144,29 +1130,23 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
} }
func TestInjectPrivateServicePolicies_NotPrivate_NoPolicy(t *testing.T) { func TestInjectPrivateServicePolicies_NotPrivate_NoPolicy(t *testing.T) {
ctx := context.Background()
account := privateServiceTestAccount(t) account := privateServiceTestAccount(t)
account.Services[0].Private = false account.Services[0].Private = false
account.InjectProxyPolicies(ctx)
assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "non-private service must not synthesise an access policy") assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "non-private service must not synthesise an access policy")
} }
func TestInjectPrivateServicePolicies_EmptyAccessGroups_NoPolicy(t *testing.T) { func TestInjectPrivateServicePolicies_EmptyAccessGroups_NoPolicy(t *testing.T) {
ctx := context.Background()
account := privateServiceTestAccount(t) account := privateServiceTestAccount(t)
account.Services[0].AccessGroups = nil 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") 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) { func TestInjectPrivateServicePolicies_NoProxyPeers_NoPolicy(t *testing.T) {
ctx := context.Background()
account := privateServiceTestAccount(t) account := privateServiceTestAccount(t)
delete(account.Peers, "proxy-peer") 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") 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 { func hasPrivateAccessPolicy(account *Account, serviceID string) bool {
prefix := "private-access-" + serviceID + "-" prefix := "private-access-" + serviceID + "-"
for _, p := range account.Policies { for _, p := range injectedPolicies(account) {
if p != nil && len(p.ID) > len(prefix) && p.ID[:len(prefix)] == prefix { if p != nil && strings.HasPrefix(p.ID, prefix) {
return true return true
} }
} }
@@ -17,6 +17,8 @@ type sshRequirements struct {
// exactly, operating on nmdata twins throughout — no Account reference and no // exactly, operating on nmdata twins throughout — no Account reference and no
// twin↔real conversion, since the produced components hold twins. // twin↔real conversion, since the produced components hold twins.
func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCustomZone nmdata.CustomZone) *types.NetworkMapComponents { func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCustomZone nmdata.CustomZone) *types.NetworkMapComponents {
nmd.InjectProxyPolicies()
forceRoutingPeerDNS := nmd.forcesRoutingPeerDNSResolution(peerID) forceRoutingPeerDNS := nmd.forcesRoutingPeerDNSResolution(peerID)
peer := nmd.Peers[peerID] peer := nmd.Peers[peerID]
@@ -48,8 +48,16 @@ type NetworkMapData struct { //nolint:revive // established name across the code
AppliedZoneCandidates []AppliedZoneCandidate AppliedZoneCandidates []AppliedZoneCandidate
PrivateServiceCandidates []PrivateServiceCandidate 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 peerGroupsOnce sync.Once
peerGroupsIdx map[string]map[string]struct{} peerGroupsIdx map[string]map[string]struct{}
proxyPoliciesOnce sync.Once
} }
// AppliedZoneCandidate is an account-level custom DNS zone reduced to the // AppliedZoneCandidate is an account-level custom DNS zone reduced to the
@@ -36,6 +36,7 @@ type Peer struct {
// ProxyMeta is the slim twin of peer.ProxyMeta. // ProxyMeta is the slim twin of peer.ProxyMeta.
type ProxyMeta struct { type ProxyMeta struct {
Embedded bool Embedded bool
Cluster string
} }
// PeerSystemMeta is the slim twin of peer.PeerSystemMeta. // PeerSystemMeta is the slim twin of peer.PeerSystemMeta.
@@ -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
}
@@ -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
}