From 9324608d4ee0396216ee833ede2c33e16844a0fc Mon Sep 17 00:00:00 2001 From: pascal Date: Wed, 19 Aug 2026 16:28:35 +0200 Subject: [PATCH] include legacy path in golden test --- .../network_map/nmaptest/legacyaccount.go | 543 ++++++++++++++++++ .../network_map/nmaptest/runner.go | 61 +- .../cases/dns-config/golden/peer-a.json | 1 + .../testdata/cases/dns-config/nmdata.json | 1 + .../golden/proxy-peer.json | 59 ++ .../golden/router-peer.json | 79 +++ .../types/legacynmap/account_components.go | 2 - management/server/types/legacynmap/aliases.go | 13 - .../types/legacynmap/component_types.go | 2 - .../server/types/legacynmap/converters.go | 2 - .../server/types/legacynmap/copied_funcs.go | 2 - management/server/types/legacynmap/doc.go | 17 +- .../types/legacynmap/equivalence_test.go | 13 +- .../types/legacynmap/firewall_helpers.go | 2 - .../types/legacynmap/networkmap_components.go | 2 - .../server/types/legacynmap/proto_legacy.go | 2 - .../server/types/legacynmap/proxy_policies.go | 150 +++++ shared/management/networkmap/proxypolicies.go | 14 +- 18 files changed, 909 insertions(+), 56 deletions(-) create mode 100644 management/internals/controllers/network_map/nmaptest/legacyaccount.go create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json create mode 100644 management/server/types/legacynmap/proxy_policies.go diff --git a/management/internals/controllers/network_map/nmaptest/legacyaccount.go b/management/internals/controllers/network_map/nmaptest/legacyaccount.go new file mode 100644 index 000000000..d6a653f7a --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/legacyaccount.go @@ -0,0 +1,543 @@ +package nmaptest + +import ( + "context" + "strings" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" + "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" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/types/legacynmap" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/netbirdio/netbird/shared/management/proto" + sharedtypes "github.com/netbirdio/netbird/shared/management/types" +) + +// legacyInput is the account and the four derived arguments main's computation +// took alongside it. The controller resolved them from the account before +// calling; the twin carries them as fields, so the fixture is the source for +// both halves. +type legacyInput struct { + account *types.Account + accountZones []*zones.Zone + validatedPeers map[string]struct{} + resourcePolicies map[string][]*types.Policy + routers map[string]map[string]*routerTypes.NetworkRouter + groupIDToUserIDs map[string][]string +} + +// legacyInputFromData rebuilds the Account the fixture stands for. A fixture is +// the value the store returns, and the store's twins carry exactly the state +// the computation reads, so inverting them reproduces the account main would +// have loaded — which is what lets one expectation measure all three paths. +// +// The inverse is only defined for what a twin carries: fields the builders drop +// (peer names, policy descriptions, user records behind AllowedUserIDs) come +// back as the zero value or a minimal stand-in, because no path reads them. +func legacyInputFromData(accountID string, nmData *networkmap.NetworkMapData) legacyInput { + account := &types.Account{ + Id: accountID, + Network: accountNetwork(nmData.Network), + Settings: accountSettings(nmData.AccountSettings), + DNSSettings: types.DNSSettings{DisabledManagementGroups: nmData.DNSSettings.DisabledManagementGroups}, + Peers: make(map[string]*nbpeer.Peer, len(nmData.Peers)), + Groups: make(map[string]*types.Group, len(nmData.Groups)), + Policies: make([]*types.Policy, 0, len(nmData.Policies)), + Routes: make(map[nbroute.ID]*nbroute.Route, len(nmData.Routes)), + NameServerGroups: make(map[string]*nbdns.NameServerGroup, len(nmData.NameServerGroups)), + NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(nmData.NetworkResources)), + PostureChecks: make([]*posture.Checks, 0, len(nmData.PostureChecks)), + Users: make(map[string]*types.User, len(nmData.AllowedUserIDs)), + Services: accountServices(nmData.Services), + } + + for id, p := range nmData.Peers { + account.Peers[id] = accountPeer(id, p) + } + for id, g := range nmData.Groups { + account.Groups[id] = accountGroup(id, g) + } + + policiesByID := make(map[string]*types.Policy, len(nmData.Policies)) + for _, p := range nmData.Policies { + policy := accountPolicy(p) + if policy == nil { + continue + } + account.Policies = append(account.Policies, policy) + policiesByID[policy.ID] = policy + } + + for _, r := range nmData.Routes { + route := accountRoute(r) + if route != nil { + account.Routes[route.ID] = route + } + } + for _, nsg := range nmData.NameServerGroups { + group := accountNSG(nsg) + if group != nil { + account.NameServerGroups[group.ID] = group + } + } + for _, res := range nmData.NetworkResources { + if resource := accountNetworkResource(res); resource != nil { + account.NetworkResources = append(account.NetworkResources, resource) + } + } + for id, pc := range nmData.PostureChecks { + if check := accountPostureChecks(id, pc, nmData.PostureCheckXIDToPublicID[id]); check != nil { + account.PostureChecks = append(account.PostureChecks, check) + } + } + for xid, publicID := range nmData.NetworkXIDToPublicID { + account.Networks = append(account.Networks, &networkTypes.Network{ID: xid, PublicID: publicID}) + } + // The twin keeps only the ids of the users a peer may be shared with; the + // legacy side derives the same set from the account's user records, so a + // bare non-blocked regular user per id is enough. + for userID := range nmData.AllowedUserIDs { + account.Users[userID] = &types.User{Id: userID} + } + + // Main's network-map controller synthesised the reverse-proxy ACLs onto the + // account and only then derived the resource-policy map, so the frozen copy + // has to be fed in that order to stand for what main produced. + account.Policies = append(account.Policies, legacynmap.SynthesizeProxyPolicies(account)...) + + return legacyInput{ + account: account, + accountZones: accountZones(nmData.AppliedZoneCandidates), + validatedPeers: nmData.ValidatedPeers, + resourcePolicies: account.GetResourcePoliciesMap(), + routers: accountRouters(nmData.Routers), + groupIDToUserIDs: nmData.GroupIDToUserIDs, + } +} + +// computeLegacy runs the fixture through main's frozen path and its own proto +// encoder, the one comparison surface the three modes share. +func computeLegacy(t *testing.T, ctx context.Context, legacy legacyInput, peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64) *proto.NetworkMap { + t.Helper() + + require.NotNil(t, legacy.account, "legacy mode needs an account rebuilt from the fixture") + peer := legacy.account.Peers[peerID] + require.NotNil(t, peer, "target peer %q not in rebuilt account", peerID) + + nm := legacynmap.GetPeerNetworkMapFromComponents( + legacy.account, ctx, peerID, legacyCustomZone(zone), legacy.accountZones, legacy.validatedPeers, + legacy.resourcePolicies, legacy.routers, nil, legacy.groupIDToUserIDs, + ) + require.NotNil(t, nm, "legacy path returned no network map for peer %q", peerID) + + return legacynmap.ToProtoNetworkMap( + ctx, peer, nm, dnsDomain, legacy.account.Settings, nil, &cache.DNSConfigCache{}, dnsFwdPort, + ) +} + +// legacyCustomZone converts the peers custom zone the runner computes once for +// every mode into the shape main's path took. +func legacyCustomZone(z nmdata.CustomZone) nbdns.CustomZone { + zoneRecords := make([]nbdns.SimpleRecord, 0, len(z.Records)) + for _, r := range z.Records { + zoneRecords = append(zoneRecords, nbdns.SimpleRecord{ + Name: r.Name, + Type: r.Type, + Class: r.Class, + TTL: r.TTL, + RData: r.RData, + }) + } + return nbdns.CustomZone{ + Domain: z.Domain, + Records: zoneRecords, + SearchDomainDisabled: z.SearchDomainDisabled, + NonAuthoritative: z.NonAuthoritative, + } +} + +func accountNetwork(n *nmdata.Network) *types.Network { + if n == nil { + return nil + } + return &types.Network{ + Identifier: n.Identifier, + Net: n.Net, + NetV6: n.NetV6, + Dns: n.Dns, + Serial: uint64(n.Serial), + } +} + +func accountSettings(s *nmdata.AccountSettingsInfo) *types.Settings { + if s == nil { + return nil + } + return &types.Settings{ + PeerLoginExpirationEnabled: s.PeerLoginExpirationEnabled, + PeerLoginExpiration: s.PeerLoginExpiration, + PeerInactivityExpirationEnabled: s.PeerInactivityExpirationEnabled, + PeerInactivityExpiration: s.PeerInactivityExpiration, + DNSDomain: s.DNSDomain, + IPv6EnabledGroups: s.IPv6EnabledGroups, + RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled, + LazyConnectionEnabled: s.LazyConnectionEnabled, + AutoUpdateVersion: s.AutoUpdateVersion, + AutoUpdateAlways: s.AutoUpdateAlways, + MetricsPushEnabled: s.MetricsPushEnabled, + } +} + +func accountPeer(id string, p *nmdata.Peer) *nbpeer.Peer { + if p == nil { + return nil + } + networkAddresses := make([]nbpeer.NetworkAddress, 0, len(p.Meta.NetworkAddresses)) + for _, na := range p.Meta.NetworkAddresses { + networkAddresses = append(networkAddresses, nbpeer.NetworkAddress{NetIP: na.NetIP}) + } + files := make([]nbpeer.File, 0, len(p.Meta.Files)) + for _, f := range p.Meta.Files { + files = append(files, nbpeer.File{Path: f.Path, ProcessIsRunning: f.ProcessIsRunning}) + } + return &nbpeer.Peer{ + ID: id, + Key: p.Key, + SSHKey: p.SSHKey, + DNSLabel: p.DNSLabel, + UserID: p.UserID, + SSHEnabled: p.SSHEnabled, + LoginExpirationEnabled: p.LoginExpirationEnabled, + LastLogin: p.LastLogin, + IP: p.IP, + IPv6: p.IPv6, + ExtraDNSLabels: p.ExtraDNSLabels, + ProxyMeta: nbpeer.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster}, + // Connected is what SynthesizePrivateServiceZones gates its records on, + // and a fixture peer stands for a peer the store returned, so it is one + // the account would have reported connected. + Status: &nbpeer.PeerStatus{RequiresApproval: p.RequiresApproval, Connected: true}, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: p.Meta.WtVersion, + GoOS: p.Meta.GoOS, + OSVersion: p.Meta.OSVersion, + KernelVersion: p.Meta.KernelVersion, + NetworkAddresses: networkAddresses, + Files: files, + Capabilities: p.Meta.Capabilities, + SyncMessageVersion: p.Meta.SyncMessageVersion, + Flags: nbpeer.Flags{ + ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed, + DisableIPv6: p.Meta.Flags.DisableIPv6, + }, + }, + Location: nbpeer.Location{ + CountryCode: p.Location.CountryCode, + CityName: p.Location.CityName, + ConnectionIP: p.Location.ConnectionIP, + }, + } +} + +func accountGroup(id string, g *nmdata.Group) *types.Group { + if g == nil { + return nil + } + return &types.Group{ + ID: id, + Name: g.Name, + PublicID: g.PublicID, + Peers: g.Peers, + } +} + +func accountPolicy(p *nmdata.Policy) *types.Policy { + if p == nil { + return nil + } + rules := make([]*types.PolicyRule, 0, len(p.Rules)) + for _, r := range p.Rules { + if r == nil { + continue + } + var portRanges []sharedtypes.RulePortRange + if r.PortRanges != nil { + portRanges = make([]sharedtypes.RulePortRange, len(r.PortRanges)) + for i, pr := range r.PortRanges { + portRanges[i] = sharedtypes.RulePortRange{Start: pr.Start, End: pr.End} + } + } + rules = append(rules, &types.PolicyRule{ + ID: r.ID, + PolicyID: r.PolicyID, + Enabled: r.Enabled, + Action: sharedtypes.PolicyTrafficActionType(r.Action), + Protocol: sharedtypes.PolicyRuleProtocolType(r.Protocol), + Bidirectional: r.Bidirectional, + Sources: r.Sources, + Destinations: r.Destinations, + SourceResource: types.Resource{ID: r.SourceResource.ID, Type: sharedtypes.ResourceType(r.SourceResource.Type)}, + DestinationResource: types.Resource{ID: r.DestinationResource.ID, Type: sharedtypes.ResourceType(r.DestinationResource.Type)}, + Ports: r.Ports, + PortRanges: portRanges, + AuthorizedGroups: r.AuthorizedGroups, + AuthorizedUser: r.AuthorizedUser, + }) + } + return &types.Policy{ + ID: p.ID, + PublicID: p.PublicID, + Enabled: p.Enabled, + SourcePostureChecks: p.SourcePostureChecks, + Rules: rules, + } +} + +func accountRoute(r *nmdata.Route) *nbroute.Route { + if r == nil { + return nil + } + return &nbroute.Route{ + ID: nbroute.ID(r.ID), + AccountID: r.AccountID, + PublicID: r.PublicID, + Network: r.Network, + Domains: r.Domains, + KeepRoute: r.KeepRoute, + NetID: nbroute.NetID(r.NetID), + Description: r.Description, + Peer: r.Peer, + PeerID: r.PeerID, + PeerGroups: r.PeerGroups, + NetworkType: nbroute.NetworkType(r.NetworkType), + Masquerade: r.Masquerade, + Metric: r.Metric, + Enabled: r.Enabled, + Groups: r.Groups, + AccessControlGroups: r.AccessControlGroups, + SkipAutoApply: r.SkipAutoApply, + } +} + +func accountNSG(n *nmdata.NameServerGroup) *nbdns.NameServerGroup { + if n == nil { + return nil + } + nameServers := make([]nbdns.NameServer, 0, len(n.NameServers)) + for _, ns := range n.NameServers { + nameServers = append(nameServers, nbdns.NameServer{ + IP: ns.IP, + NSType: nbdns.NameServerType(ns.NSType), + Port: ns.Port, + }) + } + return &nbdns.NameServerGroup{ + ID: n.ID, + PublicID: n.PublicID, + Name: n.Name, + Description: n.Description, + NameServers: nameServers, + Groups: n.Groups, + Primary: n.Primary, + Domains: n.Domains, + Enabled: n.Enabled, + SearchDomainsEnabled: n.SearchDomainsEnabled, + } +} + +func accountNetworkResource(r *nmdata.NetworkResource) *resourceTypes.NetworkResource { + if r == nil { + return nil + } + return &resourceTypes.NetworkResource{ + ID: r.ID, + NetworkID: r.NetworkID, + AccountID: r.AccountID, + PublicID: r.PublicID, + Name: r.Name, + Description: r.Description, + Type: resourceTypes.NetworkResourceType(r.Type), + Address: r.Address, + Domain: r.Domain, + Prefix: r.Prefix, + Enabled: r.Enabled, + } +} + +func accountPostureChecks(id string, pc *nmdata.PostureChecks, publicID string) *posture.Checks { + if pc == nil { + return nil + } + out := &posture.Checks{ID: id, PublicID: publicID} + def := pc.Checks + if def.NBVersionCheck != nil { + out.Checks.NBVersionCheck = &posture.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion} + } + if def.OSVersionCheck != nil { + oc := &posture.OSVersionCheck{} + if def.OSVersionCheck.Android != nil { + oc.Android = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion} + } + if def.OSVersionCheck.Darwin != nil { + oc.Darwin = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion} + } + if def.OSVersionCheck.Ios != nil { + oc.Ios = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion} + } + if def.OSVersionCheck.Linux != nil { + oc.Linux = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion} + } + if def.OSVersionCheck.Windows != nil { + oc.Windows = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion} + } + out.Checks.OSVersionCheck = oc + } + if def.GeoLocationCheck != nil { + gc := &posture.GeoLocationCheck{Action: def.GeoLocationCheck.Action} + for _, loc := range def.GeoLocationCheck.Locations { + gc.Locations = append(gc.Locations, posture.Location{CountryCode: loc.CountryCode, CityName: loc.CityName}) + } + out.Checks.GeoLocationCheck = gc + } + if def.PeerNetworkRangeCheck != nil { + out.Checks.PeerNetworkRangeCheck = &posture.PeerNetworkRangeCheck{ + Action: def.PeerNetworkRangeCheck.Action, + Ranges: def.PeerNetworkRangeCheck.Ranges, + } + } + if def.ProcessCheck != nil { + procs := make([]posture.Process, 0, len(def.ProcessCheck.Processes)) + for _, p := range def.ProcessCheck.Processes { + procs = append(procs, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath}) + } + out.Checks.ProcessCheck = &posture.ProcessCheck{Processes: procs} + } + return out +} + +func accountServices(services []*nmdata.Service) []*service.Service { + if len(services) == 0 { + return nil + } + out := make([]*service.Service, 0, len(services)) + for _, svc := range services { + if svc == nil { + continue + } + targets := make([]*service.Target, 0, len(svc.Targets)) + for _, t := range svc.Targets { + if t == nil { + continue + } + target := &service.Target{ + Enabled: t.Enabled, + Port: t.Port, + Protocol: t.Protocol, + TargetId: t.TargetID, + TargetType: service.TargetType(t.TargetType), + } + if t.Path != "" { + path := t.Path + target.Path = &path + } + targets = append(targets, target) + } + out = append(out, &service.Service{ + ID: svc.ID, + Enabled: svc.Enabled, + Private: svc.Private, + Mode: svc.Mode, + ProxyCluster: svc.ProxyCluster, + AccessGroups: svc.AccessGroups, + Targets: targets, + }) + } + return out +} + +// accountZones inverts buildAppliedZoneCandidates. Records come back with the +// record type the builder mapped them from; a candidate only ever carries the +// three types it converts. +func accountZones(candidates []networkmap.AppliedZoneCandidate) []*zones.Zone { + if len(candidates) == 0 { + return nil + } + out := make([]*zones.Zone, 0, len(candidates)) + for _, candidate := range candidates { + zoneRecords := make([]*records.Record, 0, len(candidate.Zone.Records)) + for _, r := range candidate.Zone.Records { + recordType, ok := zoneRecordType(r.Type) + if !ok { + continue + } + zoneRecords = append(zoneRecords, &records.Record{ + Name: strings.TrimSuffix(r.Name, "."), + Type: recordType, + Content: r.RData, + TTL: r.TTL, + }) + } + out = append(out, &zones.Zone{ + ID: candidate.Zone.Domain, + Domain: strings.TrimSuffix(candidate.Zone.Domain, "."), + Enabled: true, + EnableSearchDomain: !candidate.Zone.SearchDomainDisabled, + DistributionGroups: candidate.DistributionGroups, + Records: zoneRecords, + }) + } + return out +} + +func zoneRecordType(recordType int) (records.RecordType, bool) { + switch uint16(recordType) { + case dns.TypeA: + return records.RecordTypeA, true + case dns.TypeAAAA: + return records.RecordTypeAAAA, true + case dns.TypeCNAME: + return records.RecordTypeCNAME, true + default: + return "", false + } +} + +func accountRouters(routers map[string]map[string]*nmdata.NetworkRouter) map[string]map[string]*routerTypes.NetworkRouter { + if len(routers) == 0 { + return nil + } + out := make(map[string]map[string]*routerTypes.NetworkRouter, len(routers)) + for networkID, inner := range routers { + converted := make(map[string]*routerTypes.NetworkRouter, len(inner)) + for peerID, router := range inner { + if router == nil { + continue + } + converted[peerID] = &routerTypes.NetworkRouter{ + NetworkID: networkID, + PublicID: router.PublicID, + Peer: peerID, + PeerGroups: router.PeerGroups, + Masquerade: router.Masquerade, + Metric: router.Metric, + Enabled: router.Enabled, + } + } + out[networkID] = converted + } + return out +} diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go index c4c84b9d1..4ee4ed62e 100644 --- a/management/internals/controllers/network_map/nmaptest/runner.go +++ b/management/internals/controllers/network_map/nmaptest/runner.go @@ -3,9 +3,11 @@ // with a NetworkMapData fixture — the value NetworkMapDBStoreImpl returns for // one account — then runs the production per-peer pipeline the controller // uses, PeersCustomZone → GetPeerNetworkMapComponents → proto conversion, in -// both wire shapes: the legacy full map (grpc.ToSyncResponse) and the -// component envelope expanded client-side (grpc.ToComponentSyncResponse → -// networkmap.EnvelopeToNetworkMap). +// both wire shapes: the full map (grpc.ToSyncResponse) and the component +// envelope expanded client-side (grpc.ToComponentSyncResponse → +// networkmap.EnvelopeToNetworkMap). A third mode inverts the fixture back into +// the Account it stands for and runs main's frozen path over it (legacynmap), +// so every case is pinned to what main shipped as well. // // The expectation files are the point of the framework. They state what the // output should be, so a failing case means the code disagrees with the @@ -21,12 +23,14 @@ // mocked store returns, using Go field names; zero values may be omitted and // applyFixtureDefaults fills the boilerplate) and golden/.json. // -// There is ONE expectation per peer, shared by every mode. The modes are not -// different computations: CalculateNetworkMapFromComponents is -// components.Calculate, and both sides assemble the proto with the same -// encode helpers, so the only variable is what the envelope round-trip did to -// the components in transit. Any difference between modes is therefore a -// round-trip fidelity defect, and a shared expectation is what exposes it. +// There is ONE expectation per peer, shared by every mode, because all three +// must arrive at the same client-facing map. Full and envelope are not even +// different computations — CalculateNetworkMapFromComponents is +// components.Calculate and both assemble the proto with the same encode +// helpers — so the only variable between them is what the envelope round-trip +// did in transit, and a difference there is a round-trip fidelity defect. +// Legacy is a different computation, main's, reached from a rebuilt account; +// a difference there is this tree having drifted from what main shipped. // Results are canonicalized before comparison, since repeated proto fields // come from map iteration. package nmaptest @@ -39,6 +43,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "testing" @@ -69,12 +74,17 @@ const ( // into a NetworkMapEnvelope (grpc.ToComponentSyncResponse) and the map is // expanded the way the client engine does (networkmap.EnvelopeToNetworkMap). ModeEnvelope Mode = "envelope" + // ModeLegacy is main's frozen path: the fixture is inverted back into the + // Account it stands for and run through legacynmap, the copy of what main + // shipped. It is the outside measurement — the other two modes share this + // tree's computation, so only this one can catch the whole tree drifting. + ModeLegacy Mode = "legacy" defaultAccountID = "account" defaultDNSDomain = "netbird.test" ) -var defaultModes = []Mode{ModeFull, ModeEnvelope} +var defaultModes = []Mode{ModeFull, ModeEnvelope, ModeLegacy} // Case is one nmap-generation scenario: store data for a single account, the // peers whose network maps are computed, and the directory holding one expected @@ -190,13 +200,22 @@ func RunCase(t *testing.T, c Case) { } } + // Built before any mode runs: the first per-peer computation injects the + // synthesised proxy ACLs into the twin's policies, and the legacy side + // synthesises its own, so inverting a twin that already carries them would + // hand the legacy path each ACL twice. + var legacy legacyInput + if slices.Contains(c.Modes, ModeLegacy) { + legacy = legacyInputFromData(c.AccountID, nmData) + } + for _, peerID := range c.Peers { peer := nmData.Peers[peerID] require.NotNil(t, peer, "case %s: target peer %q not in fixture", c.Name, peerID) for _, mode := range c.Modes { t.Run(peerID+"/"+string(mode), func(t *testing.T) { - got := computeMode(t, ctx, mode, nmData, peerID, zone, dnsDomain, dnsFwdPort) + got := computeMode(t, ctx, mode, nmData, peerID, zone, dnsDomain, dnsFwdPort, legacy) canonicalize(got) compareGolden(t, filepath.Join(c.GoldenDir, peerID+".json"), got, mode) }) @@ -207,13 +226,15 @@ func RunCase(t *testing.T, c Case) { // computeMode produces the peer's proto.NetworkMap the way the controller does // for that wire shape. func computeMode(t *testing.T, ctx context.Context, mode Mode, nmData *networkmap.NetworkMapData, - peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64) *proto.NetworkMap { + peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64, legacy legacyInput) *proto.NetworkMap { t.Helper() peer := nmData.Peers[peerID] require.NotNil(t, peer, "target peer %q not in fixture", peerID) switch mode { + case ModeLegacy: + return computeLegacy(t, ctx, legacy, peerID, zone, dnsDomain, dnsFwdPort) case ModeFull: nmap := controller.NetworkMapFromData(ctx, nmData, peerID, zone) return mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, nmap, dnsDomain, nil, @@ -254,11 +275,11 @@ func requireEnvelopeSafeKeys(t *testing.T, nmData *networkmap.NetworkMapData, ca // code does not produce what this case says it should, so it is reported as a // failure and not quietly absorbed. // -// The full mode is compared verbatim, identifiers included, so the expectation -// pins real ids and stays readable. Other modes have identifiers erased on both -// sides first, because the envelope currently rewrites them — a tracked defect -// that TestIDSpaceMatches asserts against on its own, so it does not have to -// drown out every other case here. +// The full and legacy modes are compared verbatim, identifiers included, so the +// expectation pins real ids and stays readable. The envelope mode has +// identifiers erased on both sides first, because it currently rewrites them — +// a tracked defect that TestIDSpaceMatches asserts against on its own, so it +// does not have to drown out every other case here. // Nothing here writes to testdata. Expectation files are authored by hand and // only ever change through a reviewed edit, so there is no mode in which a run // can create or replace one. When a file is missing the computed map is printed @@ -266,7 +287,7 @@ func requireEnvelopeSafeKeys(t *testing.T, nmData *networkmap.NetworkMapData, ca func compareGolden(t *testing.T, path string, got *proto.NetworkMap, mode Mode) { t.Helper() - if mode != ModeFull { + if mode == ModeEnvelope { normalizeIDSpace(got) canonicalize(got) } @@ -282,14 +303,14 @@ func compareGolden(t *testing.T, path string, got *proto.NetworkMap, mode Mode) want := &proto.NetworkMap{} require.NoError(t, protojson.Unmarshal(raw, want), "parse expectation %s", path) canonicalize(want) - if mode != ModeFull { + if mode == ModeEnvelope { normalizeIDSpace(want) canonicalize(want) } if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" { t.Errorf("mode %s does not produce what %s expects (-want +got):\n%s\n"+ - "Both modes run the same computation on the same components, so they must produce the same map. "+ + "Every mode has to deliver the same client-facing map for the same account state. "+ "The expectation file is the committed statement of correct output — fix the code, or change the "+ "expectation deliberately if the intended behaviour really moved.", mode, path, diff) } diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json index f14ff1973..a06f3c42d 100644 --- a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json @@ -34,6 +34,7 @@ "CustomZones": [ { "Domain": "corp.internal.", + "NonAuthoritative": true, "Records": [ { "Name": "db.corp.internal.", diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json index 1581127a5..6efb3835b 100644 --- a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json @@ -42,6 +42,7 @@ "DistributionGroups": ["grp-dev"], "Zone": { "Domain": "corp.internal.", + "NonAuthoritative": true, "Records": [ {"Name": "db.corp.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.5"} ] diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json new file mode 100644 index 000000000..150fb6a09 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json @@ -0,0 +1,59 @@ +{ + "Serial": "32", + "peerConfig": { + "address": "100.64.0.99/10", + "sshConfig": {}, + "fqdn": "proxy-peer.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=", + "allowedIps": [ + "100.64.0.9/32" + ], + "sshConfig": {}, + "fqdn": "router-peer.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-domain:router-peer", + "Network": "192.0.2.0/32", + "NetworkType": "3", + "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=", + "Metric": "9999", + "Masquerade": true, + "NetID": "app-domain", + "Domains": [ + "app.internal" + ], + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "proxy-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.99" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json new file mode 100644 index 000000000..632531c25 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json @@ -0,0 +1,79 @@ +{ + "Serial": "32", + "peerConfig": { + "address": "100.64.0.9/10", + "sshConfig": {}, + "fqdn": "router-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" + } + ], + "Routes": [ + { + "ID": "res-domain:router-peer", + "Network": "192.0.2.0/32", + "NetworkType": "3", + "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=", + "Metric": "9999", + "Masquerade": true, + "NetID": "app-domain", + "Domains": [ + "app.internal" + ], + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "router-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRules": [ + { + "sourceRanges": [ + "100.64.0.99/32" + ], + "destination": "192.0.2.0/32", + "protocol": "TCP", + "portInfo": { + "range": { + "start": 443, + "end": 443 + } + }, + "isDynamic": true, + "domains": [ + "app.internal" + ], + "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt", + "RouteID": "res-domain:router-peer" + } + ], + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/server/types/legacynmap/account_components.go b/management/server/types/legacynmap/account_components.go index 65527f7dd..5d5b4a9cf 100644 --- a/management/server/types/legacynmap/account_components.go +++ b/management/server/types/legacynmap/account_components.go @@ -1,5 +1,3 @@ -//go:build nmapequiv - package legacynmap import ( diff --git a/management/server/types/legacynmap/aliases.go b/management/server/types/legacynmap/aliases.go index a0162243d..82a18192b 100644 --- a/management/server/types/legacynmap/aliases.go +++ b/management/server/types/legacynmap/aliases.go @@ -1,16 +1,3 @@ -//go:build nmapequiv - -// Package legacynmap is a frozen copy of main's Account → NetworkMapComponents → -// NetworkMap → proto path, used only by the main-vs-branch equivalence test. -// It is build-tagged so it never compiles into production binaries, and it lives -// in its own package so it cannot reach this tree's unexported helpers — a -// divergence can therefore never be hidden by the two sides sharing code. -// -// Delete this package once the nmdata refactor is validated. -// -// Types below are aliased rather than copied because they are byte-identical -// between main and this branch. Anything that drifted is copied instead; see -// converters.go and copied_funcs.go. package legacynmap import ( diff --git a/management/server/types/legacynmap/component_types.go b/management/server/types/legacynmap/component_types.go index 373b0c04a..689ea9abf 100644 --- a/management/server/types/legacynmap/component_types.go +++ b/management/server/types/legacynmap/component_types.go @@ -1,5 +1,3 @@ -//go:build nmapequiv - package legacynmap import ( diff --git a/management/server/types/legacynmap/converters.go b/management/server/types/legacynmap/converters.go index 9578ccdda..12cf4c5b3 100644 --- a/management/server/types/legacynmap/converters.go +++ b/management/server/types/legacynmap/converters.go @@ -1,5 +1,3 @@ -//go:build nmapequiv - package legacynmap import ( diff --git a/management/server/types/legacynmap/copied_funcs.go b/management/server/types/legacynmap/copied_funcs.go index 8f09b4b5e..4477967f5 100644 --- a/management/server/types/legacynmap/copied_funcs.go +++ b/management/server/types/legacynmap/copied_funcs.go @@ -1,5 +1,3 @@ -//go:build nmapequiv - package legacynmap import ( diff --git a/management/server/types/legacynmap/doc.go b/management/server/types/legacynmap/doc.go index 22661d404..e0b0ecd11 100644 --- a/management/server/types/legacynmap/doc.go +++ b/management/server/types/legacynmap/doc.go @@ -1,7 +1,16 @@ -// Package legacynmap holds a frozen copy of main's network-map computation, -// used only by the main-vs-branch proto equivalence test. All real content is -// behind the nmapequiv build tag; this file exists so the package is still valid -// for untagged builds and `go test ./...`. +// Package legacynmap is a frozen copy of main's Account → NetworkMapComponents +// → NetworkMap → proto path. It exists only to measure this tree against main: +// the proto-equivalence test runs it over a production database copy, and the +// nmaptest golden suite runs it as a third mode so every case pins all three +// shapes to one expectation. +// +// It lives in its own package so it cannot reach this tree's unexported +// helpers — a divergence can therefore never be hidden by the two sides +// sharing code. Nothing in production imports it. +// +// Types are aliased rather than copied where they are byte-identical between +// main and this branch. Anything that drifted is copied instead; see +// converters.go and copied_funcs.go. // // Delete this package once the nmdata refactor is validated. package legacynmap diff --git a/management/server/types/legacynmap/equivalence_test.go b/management/server/types/legacynmap/equivalence_test.go index 3fd5514d3..1be3c2539 100644 --- a/management/server/types/legacynmap/equivalence_test.go +++ b/management/server/types/legacynmap/equivalence_test.go @@ -160,6 +160,17 @@ func checkAccount(ctx context.Context, t *testing.T, nmStore *networkmapdb.Netwo routers := account.GetResourceRoutersMap() groupUsers := account.GetActiveGroupUsers() + // The reverse-proxy ACLs are synthesised, never persisted. Both new paths + // derive them inside the twin; main derived them in the controller, onto + // the account, before the resource-policy map. The legacy side therefore + // runs on its own view of the policies — a shallow copy so the account the + // other two paths read stays untouched and cannot double-count them. + legacyAccount := *account + if synth := legacynmap.SynthesizeProxyPolicies(account); len(synth) > 0 { + legacyAccount.Policies = append(slices.Clone(account.Policies), synth...) + } + legacyResourcePolicies := legacyAccount.GetResourcePoliciesMap() + settings := account.Settings if settings == nil { settings = &types.Settings{} @@ -200,7 +211,7 @@ func checkAccount(ctx context.Context, t *testing.T, nmStore *networkmapdb.Netwo // LEGACY PATH — main's frozen copy. legacyNM := legacynmap.GetPeerNetworkMapFromComponents( - account, ctx, peerID, nbdns.CustomZone{}, nil, validated, resourcePolicies, routers, nil, groupUsers, + &legacyAccount, ctx, peerID, nbdns.CustomZone{}, nil, validated, legacyResourcePolicies, routers, nil, groupUsers, ) if legacyNM == nil { t.Fatalf("after %d peers: account=%s peer=%s legacy NetworkMap nil, new non-nil", stats.peersChecked, account.Id, peerID) diff --git a/management/server/types/legacynmap/firewall_helpers.go b/management/server/types/legacynmap/firewall_helpers.go index 289fc5524..d78690f3e 100644 --- a/management/server/types/legacynmap/firewall_helpers.go +++ b/management/server/types/legacynmap/firewall_helpers.go @@ -1,5 +1,3 @@ -//go:build nmapequiv - package legacynmap import ( diff --git a/management/server/types/legacynmap/networkmap_components.go b/management/server/types/legacynmap/networkmap_components.go index 64338d727..3f71dafa5 100644 --- a/management/server/types/legacynmap/networkmap_components.go +++ b/management/server/types/legacynmap/networkmap_components.go @@ -1,5 +1,3 @@ -//go:build nmapequiv - package legacynmap import ( diff --git a/management/server/types/legacynmap/proto_legacy.go b/management/server/types/legacynmap/proto_legacy.go index 6bd673bfd..541f68e82 100644 --- a/management/server/types/legacynmap/proto_legacy.go +++ b/management/server/types/legacynmap/proto_legacy.go @@ -1,5 +1,3 @@ -//go:build nmapequiv - package legacynmap import ( diff --git a/management/server/types/legacynmap/proxy_policies.go b/management/server/types/legacynmap/proxy_policies.go new file mode 100644 index 000000000..e8f8c3969 --- /dev/null +++ b/management/server/types/legacynmap/proxy_policies.go @@ -0,0 +1,150 @@ +package legacynmap + +import ( + "fmt" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + sharedtypes "github.com/netbirdio/netbird/shared/management/types" +) + +// SynthesizeProxyPolicies is main's Account.InjectProxyPolicies, frozen. On +// main the network-map controller called it on the account before computing, +// so a comparison that starts from the account has to apply it too. It returns +// the policies instead of appending them, so the caller can measure the legacy +// path without mutating the account the other paths share. +func SynthesizeProxyPolicies(a *Account) []*Policy { + if len(a.Services) == 0 { + return nil + } + + proxyPeersByCluster := a.GetProxyPeers() + if len(proxyPeersByCluster) == 0 { + return nil + } + + var out []*Policy + for _, svc := range a.Services { + if svc == nil || !svc.Enabled { + continue + } + + proxyPeers := proxyPeersByCluster[svc.ProxyCluster] + for _, target := range svc.Targets { + if target == nil || !target.Enabled { + continue + } + port, ok := legacyTargetPort(target) + if !ok { + continue + } + path := "" + if target.Path != nil { + path = *target.Path + } + for _, proxyPeer := range proxyPeers { + out = append(out, legacyProxyPolicy(svc, target, proxyPeer, port, path)) + } + } + + out = append(out, legacyPrivateServicePolicies(a, svc, proxyPeers)...) + } + return out +} + +func legacyPrivateServicePolicies(a *Account, svc *service.Service, proxyPeers []*nbpeer.Peer) []*Policy { + if !svc.Private || len(svc.AccessGroups) == 0 || len(proxyPeers) == 0 { + return nil + } + + sources := make([]string, 0, len(svc.AccessGroups)) + for _, groupID := range svc.AccessGroups { + if _, ok := a.Groups[groupID]; ok { + sources = append(sources, groupID) + } + } + if len(sources) == 0 { + return nil + } + + out := make([]*Policy, 0, len(proxyPeers)) + for _, proxyPeer := range proxyPeers { + policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID) + out = append(out, &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: append([]string(nil), sources...), + DestinationResource: Resource{ + ID: proxyPeer.ID, + Type: ResourceTypePeer, + }, + Bidirectional: false, + Protocol: PolicyRuleProtocolTCP, + Action: PolicyTrafficActionAccept, + PortRanges: []RulePortRange{ + {Start: 80, End: 80}, + {Start: 443, End: 443}, + }, + }, + }, + }) + } + return out +} + +func legacyProxyPolicy(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 = sharedtypes.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: sharedtypes.ResourceType(target.TargetType), + }, + Bidirectional: false, + Protocol: protocol, + Action: PolicyTrafficActionAccept, + PortRanges: []RulePortRange{{Start: port, End: port}}, + }, + }, + } +} + +func legacyTargetPort(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: + return 0, false + } +} diff --git a/shared/management/networkmap/proxypolicies.go b/shared/management/networkmap/proxypolicies.go index 8d80b347f..7a7c805a6 100644 --- a/shared/management/networkmap/proxypolicies.go +++ b/shared/management/networkmap/proxypolicies.go @@ -113,8 +113,13 @@ func proxyAccessPolicy(svc *nmdata.Service, target *nmdata.ServiceTarget, proxyP } return &nmdata.Policy{ - ID: policyID, - Enabled: true, + ID: policyID, + // The envelope encoder puts public ids on the wire and degrades to an + // empty one when a policy has none. A synthesised policy has no + // persisted row to take a public id from, and its own id is already + // stable and unique, so it serves as both. + PublicID: policyID, + Enabled: true, Rules: []*nmdata.PolicyRule{ { ID: policyID, @@ -135,8 +140,9 @@ func privateAccessPolicy(svc *nmdata.Service, proxyPeer *nmdata.Peer, accessGrou policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID) return &nmdata.Policy{ - ID: policyID, - Enabled: true, + ID: policyID, + PublicID: policyID, + Enabled: true, Rules: []*nmdata.PolicyRule{ { ID: policyID,