include legacy path in golden test

This commit is contained in:
pascal
2026-08-19 16:28:35 +02:00
parent 93a0b914fa
commit 9324608d4e
18 changed files with 909 additions and 56 deletions

View File

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

View File

@@ -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/<peerID>.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)
}

View File

@@ -34,6 +34,7 @@
"CustomZones": [
{
"Domain": "corp.internal.",
"NonAuthoritative": true,
"Records": [
{
"Name": "db.corp.internal.",

View File

@@ -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"}
]

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
//go:build nmapequiv
package legacynmap
import (

View File

@@ -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 (

View File

@@ -1,5 +1,3 @@
//go:build nmapequiv
package legacynmap
import (

View File

@@ -1,5 +1,3 @@
//go:build nmapequiv
package legacynmap
import (

View File

@@ -1,5 +1,3 @@
//go:build nmapequiv
package legacynmap
import (

View File

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

View File

@@ -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)

View File

@@ -1,5 +1,3 @@
//go:build nmapequiv
package legacynmap
import (

View File

@@ -1,5 +1,3 @@
//go:build nmapequiv
package legacynmap
import (

View File

@@ -1,5 +1,3 @@
//go:build nmapequiv
package legacynmap
import (

View File

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

View File

@@ -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,