diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index cd900d2eb..851245e3e 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -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 } diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go index c0fcefc7d..89ed80ef0 100644 --- a/management/internals/controllers/network_map/controller/repository.go +++ b/management/internals/controllers/network_map/controller/repository.go @@ -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) } diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json new file mode 100644 index 000000000..5f9e98ea7 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json @@ -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" + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json new file mode 100644 index 000000000..cbf729d9f --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json @@ -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" + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json new file mode 100644 index 000000000..d8450505f --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json @@ -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" + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json new file mode 100644 index 000000000..9c5804cdf --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json @@ -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" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json new file mode 100644 index 000000000..73edbecfa --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json @@ -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" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json new file mode 100644 index 000000000..b4645fb90 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json @@ -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" + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json new file mode 100644 index 000000000..8ef26f4cc --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json @@ -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" + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json new file mode 100644 index 000000000..066849bd4 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json @@ -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"] + } + ] +} diff --git a/management/internals/network_map_db/shared_types.go b/management/internals/network_map_db/shared_types.go index 1653289e8..53d2aef59 100644 --- a/management/internals/network_map_db/shared_types.go +++ b/management/internals/network_map_db/shared_types.go @@ -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) diff --git a/management/server/affected_peers_property_test.go b/management/server/affected_peers_property_test.go index f393465bc..b64aeb813 100644 --- a/management/server/affected_peers_property_test.go +++ b/management/server/affected_peers_property_test.go @@ -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{}{} diff --git a/management/server/types/account.go b/management/server/types/account.go index 3c1ca7ea3..522bb8be6 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -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 { diff --git a/management/server/types/account_networkmapdata.go b/management/server/types/account_networkmapdata.go index 5b0239778..8f2e03a10 100644 --- a/management/server/types/account_networkmapdata.go +++ b/management/server/types/account_networkmapdata.go @@ -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, diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go index c4b7c6bc7..5dccfbf30 100644 --- a/management/server/types/account_private_netmap_test.go +++ b/management/server/types/account_private_netmap_test.go @@ -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": {}, diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index 8ca831477..063b2d7e7 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -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 } } diff --git a/shared/management/networkmap/networkmapcompute.go b/shared/management/networkmap/networkmapcompute.go index 067010385..1cf7aeef4 100644 --- a/shared/management/networkmap/networkmapcompute.go +++ b/shared/management/networkmap/networkmapcompute.go @@ -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] diff --git a/shared/management/networkmap/networkmapdata.go b/shared/management/networkmap/networkmapdata.go index 3e8ee1cca..e27605d64 100644 --- a/shared/management/networkmap/networkmapdata.go +++ b/shared/management/networkmap/networkmapdata.go @@ -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 diff --git a/shared/management/networkmap/nmdata/peer.go b/shared/management/networkmap/nmdata/peer.go index 3b40a935b..3ceb1dbc1 100644 --- a/shared/management/networkmap/nmdata/peer.go +++ b/shared/management/networkmap/nmdata/peer.go @@ -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. diff --git a/shared/management/networkmap/nmdata/service.go b/shared/management/networkmap/nmdata/service.go new file mode 100644 index 000000000..63557c51e --- /dev/null +++ b/shared/management/networkmap/nmdata/service.go @@ -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 +} diff --git a/shared/management/networkmap/proxypolicies.go b/shared/management/networkmap/proxypolicies.go new file mode 100644 index 000000000..8d80b347f --- /dev/null +++ b/shared/management/networkmap/proxypolicies.go @@ -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 +}