Merge branch 'main' into agent-network/provider-credential-check

This commit is contained in:
Maycon Santos
2026-08-27 13:36:42 +02:00
committed by GitHub
353 changed files with 25422 additions and 3859 deletions

View File

@@ -730,6 +730,11 @@ jobs:
- name: Install modules
run: go mod tidy
- name: Run Mage
uses: magefile/mage-action@a662bd8c29d8106879588cfff83b2faf6e6f59db # v4.0.0
with:
install-only: true
- name: check git status
run: git --no-pager diff --exit-code
@@ -738,9 +743,7 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
go test -tags=integration -coverprofile=coverage.txt \
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
-timeout 20m ./management/server/http/...
mage integrationtest:all -gotestflags="-coverprofile=coverage.txt"
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'

View File

@@ -124,7 +124,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil)
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
if err != nil {

View File

@@ -146,7 +146,7 @@ func startManagement(t *testing.T, signalAddr string) string {
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg)
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil)
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
require.NoError(t, err)

View File

@@ -763,7 +763,7 @@ func (r *router) addNatRule(pair firewall.RouterPair) error {
exprs = append(exprs, sourceExp...)
exprs = append(exprs, destExp...)
var markValue uint32 = nbnet.PreroutingFwmarkMasquerade
markValue := nbnet.PreroutingFwmarkMasquerade
if pair.Inverse {
markValue = nbnet.PreroutingFwmarkMasqueradeReturn
}

View File

@@ -502,7 +502,7 @@ func toBytes(s string) (int64, error) {
func getFwmark() int {
if nbnet.AdvancedRouting() && runtime.GOOS == "linux" {
return nbnet.ControlPlaneMark
return int(nbnet.ControlPlaneMark)
}
return 0
}

View File

@@ -10,8 +10,6 @@ import (
log "github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
nbnet "github.com/netbirdio/netbird/client/net"
)
// PrepareSenderRawSocketIPv4 creates and configures a raw socket for sending IPv4 packets
@@ -60,14 +58,12 @@ func prepareSenderRawSocket(family int, isIPv4 bool) (net.PacketConn, error) {
return nil, fmt.Errorf("binding to lo interface failed: %w", err)
}
// Set the fwmark on the socket.
err = nbnet.SetSocketOpt(fd)
if err != nil {
if closeErr := syscall.Close(fd); closeErr != nil {
log.Warnf("failed to close raw socket fd: %v", closeErr)
}
return nil, fmt.Errorf("setting fwmark failed: %w", err)
}
// The socket is bound to lo and only ever sends to the local WireGuard
// instance, a destination the local routing table resolves without help, so
// it carries no fwmark. Staying unmarked also keeps these packets out of
// third-party NAT rules that match on marks: such a rule rewriting the
// source would make WireGuard adopt the rewritten address as the peer
// endpoint.
// Convert the file descriptor to a PacketConn.
file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd))

View File

@@ -0,0 +1,77 @@
//go:build linux && !android && privileged
package rawsocket
import (
"net"
"syscall"
"testing"
"golang.org/x/sys/unix"
nbnet "github.com/netbirdio/netbird/client/net"
)
// The sender sockets must stay unmarked: a NAT rule matching on fwmark that
// rewrites the source of an injected packet makes WireGuard adopt the rewritten
// address as the peer endpoint.
func TestSenderRawSocketsCarryNoFwmark(t *testing.T) {
// the mark is only ever applied when advanced routing is available, so
// without it the assertion below would hold for the wrong reason
nbnet.Init()
if !nbnet.AdvancedRouting() {
t.Skip("advanced routing unsupported, the sockets carry no mark either way")
}
tests := []struct {
name string
prepare func() (net.PacketConn, error)
// the proxy treats the IPv6 socket as optional, so a host without IPv6
// is a reason to skip rather than to fail
optional bool
}{
{name: "IPv4", prepare: PrepareSenderRawSocketIPv4},
{name: "IPv6", prepare: PrepareSenderRawSocketIPv6, optional: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
conn, err := tc.prepare()
if err != nil {
if tc.optional {
t.Skipf("prepare raw socket: %v", err)
}
t.Fatalf("prepare raw socket: %v", err)
}
defer func() {
if err := conn.Close(); err != nil {
t.Logf("close raw socket: %v", err)
}
}()
syscallConn, ok := conn.(syscall.Conn)
if !ok {
t.Fatalf("raw socket %T does not expose a syscall conn", conn)
}
raw, err := syscallConn.SyscallConn()
if err != nil {
t.Fatalf("syscall conn: %v", err)
}
var mark int
var markErr error
if err := raw.Control(func(fd uintptr) {
mark, markErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK)
}); err != nil {
t.Fatalf("control: %v", err)
}
if markErr != nil {
t.Fatalf("get SO_MARK: %v", markErr)
}
if mark != 0 {
t.Errorf("SO_MARK = %#x, want 0", mark)
}
})
}
}

View File

@@ -8,7 +8,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/iface/wgaddr"
nbdns "github.com/netbirdio/netbird/dns"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
func TestCreatePTRRecord_IPv4(t *testing.T) {
@@ -136,3 +138,88 @@ func TestAddReverseZone_IPv6(t *testing.T) {
assert.Len(t, reverseZone.Records, 1)
assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type)
}
// TestToDNSConfig_ZoneFlagsPreserved pins the per-zone NonAuthoritative flag
// through the legacy DNSConfig path. A non-authoritative zone is match-only:
// the local resolver falls through to the upstream for an in-zone name it does
// not define. The built-in peer zone is the authoritative one and must stay
// that way, so the flag has to travel per zone rather than be derived.
func TestToDNSConfig_ZoneFlagsPreserved(t *testing.T) {
config := toDNSConfig(&mgmProto.DNSConfig{
ServiceEnable: true,
CustomZones: []*mgmProto.CustomZone{
{
Domain: "netbird.cloud.",
Records: []*mgmProto.SimpleRecord{
{Name: "peer1.netbird.cloud.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.1"},
},
},
{
Domain: "corp.internal.",
NonAuthoritative: true,
SearchDomainDisabled: true,
Records: []*mgmProto.SimpleRecord{
{Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"},
},
},
},
}, wgaddr.Address{
IP: netip.MustParseAddr("100.64.0.1"),
Network: netip.MustParsePrefix("100.64.0.0/16"),
})
zones := make(map[string]nbdns.CustomZone, len(config.CustomZones))
for _, zone := range config.CustomZones {
zones[zone.Domain] = zone
}
peerZone, ok := zones["netbird.cloud."]
require.True(t, ok, "peer zone must survive")
assert.False(t, peerZone.NonAuthoritative, "the built-in peer zone owns the account domain and stays authoritative")
accountZone, ok := zones["corp.internal."]
require.True(t, ok, "account zone must survive")
assert.True(t, accountZone.NonAuthoritative, "an account zone stays match-only, else undefined in-zone names get black-holed")
assert.True(t, accountZone.SearchDomainDisabled)
}
// TestToDNSConfig_SingleZoneForcedAuthoritative pins the compatibility clause
// in toDNSConfig: a config carrying exactly one zone is treated as
// authoritative no matter what the server said, because servers that predate
// the NonAuthoritative field send only the peer FQDN zone.
//
// The clause can only ever downgrade an explicit true to false, so a server
// that legitimately sends a single non-authoritative zone — an account whose
// only zone is a custom one, with no peer records to build the built-in zone
// from — gets that zone's whole apex black-holed on the client. Real accounts
// always carry the peer zone alongside, which is why this is latent. Narrowing
// it needs a way to tell "unset" from "false" on the wire, or the account
// domain passed down here; until then this test states the contract so a
// change to it is deliberate.
func TestToDNSConfig_SingleZoneForcedAuthoritative(t *testing.T) {
config := toDNSConfig(&mgmProto.DNSConfig{
ServiceEnable: true,
CustomZones: []*mgmProto.CustomZone{
{
Domain: "corp.internal.",
NonAuthoritative: true,
Records: []*mgmProto.SimpleRecord{
{Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"},
},
},
},
}, wgaddr.Address{
IP: netip.MustParseAddr("100.64.0.1"),
Network: netip.MustParsePrefix("100.64.0.0/16"),
})
require.NotEmpty(t, config.CustomZones)
assert.Equal(t, "corp.internal.", config.CustomZones[0].Domain)
assert.False(t, config.CustomZones[0].NonAuthoritative,
"a lone zone is forced authoritative for pre-NonAuthoritative servers")
// The reverse zone the config gains afterwards must not feed back into the
// decision: the compat gate counts the zones the server sent.
require.Len(t, config.CustomZones, 2, "a reverse zone is appended for the overlay prefix")
assert.Equal(t, "64.100.in-addr.arpa.", config.CustomZones[1].Domain)
}

View File

@@ -519,7 +519,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil)
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
if err != nil {
return nil, "", err

View File

@@ -109,6 +109,10 @@
// - Does NOT remove result.json (cleaned by ResultHandler after read)
// - Does NOT remove msi.log (kept for debugging)
//
// On Windows the updater copy is often still locked when the daemon it restarted
// runs cleanup, so removing it is retried briefly and otherwise left in place for
// the next update to overwrite rather than reported as a failure.
//
// # Dry-Run Mode
//
// Dry-run mode allows testing the update process without actually installing:

View File

@@ -0,0 +1,67 @@
package installer
import (
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/sys/windows"
)
// lockFile opens path without FILE_SHARE_DELETE, so os.Remove fails the way it does
// while the updater process still holds its own image.
func lockFile(t *testing.T, path string) windows.Handle {
t.Helper()
p, err := windows.UTF16PtrFromString(path)
if err != nil {
t.Fatalf("convert path: %v", err)
}
handle, err := windows.CreateFile(p, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
t.Fatalf("lock %s: %v", path, err)
}
return handle
}
// releaseAfter closes the handle once the delay has passed, standing in for the
// updater process finally exiting.
func releaseAfter(t *testing.T, handle windows.Handle, delay time.Duration) {
t.Helper()
released := make(chan struct{})
t.Cleanup(func() { <-released })
go func() {
defer close(released)
time.Sleep(delay)
if err := windows.CloseHandle(handle); err != nil {
t.Errorf("close handle: %v", err)
}
}()
}
// TestCleanUpInstallerFilesLockedUpdater covers the post-update cleanup race: the
// daemon cleans up at startup while the updater that restarted it is still exiting,
// so the updater image is locked and Windows refuses the delete. Cleanup must wait
// the lock out instead of reporting a failure and leaving the binary behind.
func TestCleanUpInstallerFilesLockedUpdater(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, updaterBinary)
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatalf("write updater: %v", err)
}
releaseAfter(t, lockFile(t, path), 300*time.Millisecond)
u := NewWithDir(tempDir)
if err := u.CleanUpInstallerFiles(); err != nil {
t.Fatalf("cleanup must tolerate a still-locked updater: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("updater binary still present (stat err: %v)", err)
}
}

View File

@@ -152,8 +152,8 @@ func (u *Installer) CleanUpInstallerFiles() error {
var merr *multierror.Error
if err := os.Remove(filepath.Join(u.tempDir, updaterBinary)); err != nil && !os.IsNotExist(err) {
merr = multierror.Append(merr, fmt.Errorf("failed to remove updater binary: %w", err))
if err := removeUpdaterBinary(filepath.Join(u.tempDir, updaterBinary)); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove updater binary: %w", err))
}
entries, err := os.ReadDir(u.tempDir)
@@ -167,10 +167,16 @@ func (u *Installer) CleanUpInstallerFiles() error {
}
name := entry.Name()
// The updater copy is handled above; on Windows its name also matches the
// extension sweep, which would report the same file twice.
if strings.EqualFold(name, updaterBinary) {
continue
}
for _, ext := range binaryExtensions {
if strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) {
if err := os.Remove(filepath.Join(u.tempDir, name)); err != nil {
merr = multierror.Append(merr, fmt.Errorf("failed to remove %s: %w", name, err))
merr = multierror.Append(merr, fmt.Errorf("remove %s: %w", name, err))
}
break
}

View File

@@ -0,0 +1,52 @@
//go:build windows || darwin
package installer
import (
"os"
"path/filepath"
"testing"
)
// TestCleanUpInstallerFiles checks that cleanup removes the updater copy and the
// downloaded installer while leaving the logs and the result file for the daemon.
func TestCleanUpInstallerFiles(t *testing.T) {
tempDir := t.TempDir()
installers := make([]string, 0, len(binaryExtensions))
for _, ext := range binaryExtensions {
installers = append(installers, "netbird_installer."+ext)
}
kept := []string{"installer.log", "result.json"}
for _, name := range append(append([]string{updaterBinary}, installers...), kept...) {
if err := os.WriteFile(filepath.Join(tempDir, name), []byte("x"), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
u := NewWithDir(tempDir)
if err := u.CleanUpInstallerFiles(); err != nil {
t.Fatalf("CleanUpInstallerFiles: %v", err)
}
for _, name := range append([]string{updaterBinary}, installers...) {
if _, err := os.Stat(filepath.Join(tempDir, name)); !os.IsNotExist(err) {
t.Errorf("%s was not removed (stat err: %v)", name, err)
}
}
for _, name := range kept {
if _, err := os.Stat(filepath.Join(tempDir, name)); err != nil {
t.Errorf("%s should have been kept: %v", name, err)
}
}
}
func TestCleanUpInstallerFilesMissingTempDir(t *testing.T) {
u := NewWithDir(filepath.Join(t.TempDir(), "does-not-exist"))
if err := u.CleanUpInstallerFiles(); err != nil {
t.Errorf("a missing temp dir is not a cleanup failure, got: %v", err)
}
}

View File

@@ -0,0 +1,12 @@
package installer
import "os"
// removeUpdaterBinary deletes the updater copy left in the temp dir. On darwin a
// running binary can be unlinked, so no retry is needed.
func removeUpdaterBinary(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

View File

@@ -0,0 +1,45 @@
package installer
import (
"errors"
"os"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
// The updater is the process that restarted the daemon, so when the daemon
// cleans up at startup the updater is often still exiting and Windows refuses
// to delete its locked image. These bound how long cleanup waits for it.
updaterRemoveAttempts = 5
updaterRemoveDelay = 200 * time.Millisecond
)
// removeUpdaterBinary deletes the updater copy left in the temp dir, retrying
// while the still-exiting updater process holds its image. A binary that stays
// locked for the whole window is left in place and reported at info level: the
// next update overwrites it, so it is not worth failing cleanup over.
func removeUpdaterBinary(path string) error {
for attempt := 0; attempt < updaterRemoveAttempts; attempt++ {
if attempt > 0 {
time.Sleep(updaterRemoveDelay)
}
err := os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
}
if !isFileLocked(err) {
return err
}
}
log.Infof("updater binary %s is still locked, leaving it for the next update to overwrite", path)
return nil
}
func isFileLocked(err error) bool {
return errors.Is(err, windows.ERROR_ACCESS_DENIED) || errors.Is(err, windows.ERROR_SHARING_VIOLATION)
}

View File

@@ -0,0 +1,59 @@
package installer
import (
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/sys/windows"
)
func TestRemoveUpdaterBinaryRetriesWhileLocked(t *testing.T) {
path := filepath.Join(t.TempDir(), updaterBinary)
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatalf("write updater: %v", err)
}
releaseAfter(t, lockFile(t, path), updaterRemoveDelay+50*time.Millisecond)
if err := removeUpdaterBinary(path); err != nil {
t.Fatalf("removeUpdaterBinary: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("updater binary still present (stat err: %v)", err)
}
}
// TestRemoveUpdaterBinaryStaysLocked covers an updater that never releases its
// image within the retry window. Cleanup gives up quietly and leaves the file
// behind rather than reporting a failure.
func TestRemoveUpdaterBinaryStaysLocked(t *testing.T) {
path := filepath.Join(t.TempDir(), updaterBinary)
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatalf("write updater: %v", err)
}
handle := lockFile(t, path)
t.Cleanup(func() {
if err := windows.CloseHandle(handle); err != nil {
t.Errorf("close handle: %v", err)
}
})
if err := removeUpdaterBinary(path); err != nil {
t.Fatalf("a permanently locked updater is not a cleanup failure, got: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("locked updater binary should be left in place, stat: %v", err)
}
}
func TestRemoveUpdaterBinaryMissingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), updaterBinary)
if err := removeUpdaterBinary(path); err != nil {
t.Errorf("a missing updater binary is not a failure, got: %v", err)
}
}

110
client/net/fwmark.go Normal file
View File

@@ -0,0 +1,110 @@
package net
import (
"fmt"
"os"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
)
const (
// envFwmarkBase overrides the base of the fwmark range. Container network
// plugins, CNIs and other VPNs claim bits of the mark space for themselves,
// and a rule of theirs matching one of our bits acts on our traffic, so
// hosts running such software may need to move the range out of the way.
envFwmarkBase = "NB_FWMARK_BASE"
// defaultFwmarkBase is the base of the fwmark range used when the
// environment does not override it.
defaultFwmarkBase uint32 = 0x1BD00
// fwmarkOffsetMask is the part of a mark that identifies the individual mark
// within the range, so the base occupies everything above it.
fwmarkOffsetMask uint32 = 0xFF
)
// Offsets of the individual marks within the range.
const (
offsetControlPlane uint32 = 0x00
offsetDataPlaneIn uint32 = 0x10
offsetDataPlaneOut uint32 = 0x11
offsetRedirected uint32 = 0x20
offsetMasquerade uint32 = 0x21
offsetMasqueradeReturn uint32 = 0x22
offsetDataPlaneLower uint32 = 0x10
offsetDataPlaneUpper uint32 = fwmarkOffsetMask
)
var (
fwmarkBase = loadFwmarkBase()
// ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to
// avoid routing loops.
// This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket.
// It doesn't collide with the other marks, as the others are used for data plane traffic only.
ControlPlaneMark = fwmarkBase | offsetControlPlane
// DataPlaneMarkLower is the lowest value for the data plane range
DataPlaneMarkLower = fwmarkBase | offsetDataPlaneLower
// DataPlaneMarkUpper is the highest value for the data plane range
DataPlaneMarkUpper = fwmarkBase | offsetDataPlaneUpper
// DataPlaneMarkIn is the mark for inbound data plane traffic.
DataPlaneMarkIn = fwmarkBase | offsetDataPlaneIn
// DataPlaneMarkOut is the mark for outbound data plane traffic.
DataPlaneMarkOut = fwmarkBase | offsetDataPlaneOut
// PreroutingFwmarkRedirected is applied to packets that were redirected (input -> forward, e.g. by Docker or Podman) for special handling.
PreroutingFwmarkRedirected = fwmarkBase | offsetRedirected
// PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded.
PreroutingFwmarkMasquerade = fwmarkBase | offsetMasquerade
// PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded.
PreroutingFwmarkMasqueradeReturn = fwmarkBase | offsetMasqueradeReturn
)
// IsDataPlaneMark determines if a fwmark is in the data plane range.
func IsDataPlaneMark(fwmark uint32) bool {
return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper
}
func loadFwmarkBase() uint32 {
val := os.Getenv(envFwmarkBase)
if val == "" {
return defaultFwmarkBase
}
base, err := parseFwmarkBase(val)
if err != nil {
log.Warnf("failed to parse %s=%q, using the default range: %v", envFwmarkBase, val, err)
return defaultFwmarkBase
}
log.Infof("using fwmark range %#x-%#x from %s", base, base|fwmarkOffsetMask, envFwmarkBase)
return base
}
// parseFwmarkBase reads a mark range base. The low byte of a mark identifies the
// individual mark within the range, so a base has to leave it free.
func parseFwmarkBase(val string) (uint32, error) {
val = strings.TrimSpace(val)
base, err := strconv.ParseUint(val, 0, 32)
if err != nil {
return 0, fmt.Errorf("not a 32 bit number: %w", err)
}
if base == 0 {
return 0, fmt.Errorf("base must not be zero")
}
if uint32(base)&fwmarkOffsetMask != 0 {
return 0, fmt.Errorf("base %#x must leave the low byte free", base)
}
return uint32(base), nil
}

111
client/net/fwmark_test.go Normal file
View File

@@ -0,0 +1,111 @@
package net
import (
"testing"
)
func TestParseFwmarkBase(t *testing.T) {
tests := []struct {
name string
val string
want uint32
wantErr bool
}{
{name: "hex", val: "0x5A000", want: 0x5A000},
{name: "hex upper case", val: "0X5A000", want: 0x5A000},
{name: "decimal", val: "65536", want: 65536},
{name: "octal", val: "0o400", want: 0o400},
{name: "surrounding space", val: " 0x5A000 ", want: 0x5A000},
{name: "highest usable base", val: "0xFFFFFF00", want: 0xFFFFFF00},
{name: "low byte in use", val: "0x1BD01", wantErr: true},
{name: "zero", val: "0", wantErr: true},
{name: "not a number", val: "wireguard", wantErr: true},
{name: "wider than 32 bit", val: "0x1FFFFFFFF", wantErr: true},
{name: "negative", val: "-0x100", wantErr: true},
{name: "empty", val: "", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := parseFwmarkBase(tc.val)
if tc.wantErr {
if err == nil {
t.Fatalf("parseFwmarkBase(%q) = %#x, want an error", tc.val, got)
}
return
}
if err != nil {
t.Fatalf("parseFwmarkBase(%q): %v", tc.val, err)
}
if got != tc.want {
t.Errorf("parseFwmarkBase(%q) = %#x, want %#x", tc.val, got, tc.want)
}
})
}
}
// The marks have to stay inside the range the base defines, otherwise a host
// that moved the range to dodge a collision would still emit the old values.
func TestMarksStayWithinTheRange(t *testing.T) {
lower, upper := fwmarkBase, fwmarkBase|fwmarkOffsetMask
marks := map[string]uint32{
"ControlPlaneMark": ControlPlaneMark,
"DataPlaneMarkLower": DataPlaneMarkLower,
"DataPlaneMarkUpper": DataPlaneMarkUpper,
"DataPlaneMarkIn": DataPlaneMarkIn,
"DataPlaneMarkOut": DataPlaneMarkOut,
"PreroutingFwmarkRedirected": PreroutingFwmarkRedirected,
"PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade,
"PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn,
}
for name, mark := range marks {
if mark < lower || mark > upper {
t.Errorf("%s = %#x, outside the range %#x-%#x", name, mark, lower, upper)
}
}
// the control plane mark must stay out of the data plane range, the netflow
// conntrack path tells them apart by it
if IsDataPlaneMark(ControlPlaneMark) {
t.Errorf("ControlPlaneMark %#x is inside the data plane range", ControlPlaneMark)
}
for name, mark := range map[string]uint32{
"DataPlaneMarkIn": DataPlaneMarkIn,
"DataPlaneMarkOut": DataPlaneMarkOut,
"PreroutingFwmarkRedirected": PreroutingFwmarkRedirected,
"PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade,
"PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn,
} {
if !IsDataPlaneMark(mark) {
t.Errorf("%s = %#x is outside the data plane range %#x-%#x", name, mark, DataPlaneMarkLower, DataPlaneMarkUpper)
}
}
}
func TestDefaultMarksAreUnchanged(t *testing.T) {
tests := map[string]struct {
got uint32
want uint32
}{
"ControlPlaneMark": {ControlPlaneMark, 0x1BD00},
"DataPlaneMarkLower": {DataPlaneMarkLower, 0x1BD10},
"DataPlaneMarkUpper": {DataPlaneMarkUpper, 0x1BDFF},
"DataPlaneMarkIn": {DataPlaneMarkIn, 0x1BD10},
"DataPlaneMarkOut": {DataPlaneMarkOut, 0x1BD11},
"PreroutingFwmarkRedirected": {PreroutingFwmarkRedirected, 0x1BD20},
"PreroutingFwmarkMasquerade": {PreroutingFwmarkMasquerade, 0x1BD21},
"PreroutingFwmarkMasqueradeReturn": {PreroutingFwmarkMasqueradeReturn, 0x1BD22},
}
if fwmarkBase != defaultFwmarkBase {
t.Skipf("%s is set, the defaults do not apply", envFwmarkBase)
}
for name, tc := range tests {
if tc.got != tc.want {
t.Errorf("%s = %#x, want %#x", name, tc.got, tc.want)
}
}
}

View File

@@ -7,41 +7,6 @@ import (
"net/netip"
)
const (
// ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to
// avoid routing loops.
// This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket.
// It doesn't collide with the other marks, as the others are used for data plane traffic only.
ControlPlaneMark = 0x1BD00
// Data plane marks (0x1BD10 - 0x1BDFF)
// DataPlaneMarkLower is the lowest value for the data plane range
DataPlaneMarkLower = 0x1BD10
// DataPlaneMarkUpper is the highest value for the data plane range
DataPlaneMarkUpper = 0x1BDFF
// DataPlaneMarkIn is the mark for inbound data plane traffic.
DataPlaneMarkIn = 0x1BD10
// DataPlaneMarkOut is the mark for outbound data plane traffic.
DataPlaneMarkOut = 0x1BD11
// PreroutingFwmarkRedirected is applied to packets that are were redirected (input -> forward, e.g. by Docker or Podman) for special handling.
PreroutingFwmarkRedirected = 0x1BD20
// PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded.
PreroutingFwmarkMasquerade = 0x1BD21
// PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded.
PreroutingFwmarkMasqueradeReturn = 0x1BD22
)
// IsDataPlaneMark determines if a fwmark is in the data plane range (0x1BD10-0x1BDFF)
func IsDataPlaneMark(fwmark uint32) bool {
return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper
}
func GetLastIPFromNetwork(network netip.Prefix, fromEnd int) (netip.Addr, error) {
var endIP net.IP
addr := network.Addr().AsSlice()

View File

@@ -21,15 +21,6 @@ func SetSocketMark(conn syscall.Conn) error {
return setRawSocketMark(sysconn)
}
// SetSocketOpt sets the SO_MARK option on the given file descriptor
func SetSocketOpt(fd int) error {
if !AdvancedRouting() {
return nil
}
return setSocketOptInt(fd)
}
func setRawSocketMark(conn syscall.RawConn) error {
var setErr error
@@ -51,5 +42,5 @@ func setRawSocketMark(conn syscall.RawConn) error {
}
func setSocketOptInt(fd int) error {
return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, ControlPlaneMark)
return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, int(ControlPlaneMark))
}

View File

@@ -232,4 +232,3 @@ func toNetIDs(routes []string) []route.NetID {
}
return netIDs
}

View File

@@ -200,7 +200,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil)
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
if err != nil {
return nil, "", err

9
go.mod
View File

@@ -71,17 +71,18 @@ require (
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
github.com/hashicorp/go-version v1.7.0
github.com/jackc/pgx/v5 v5.5.5
github.com/jackc/pgx/v5 v5.10.0
github.com/libdns/route53 v1.5.0
github.com/libp2p/go-netroute v0.4.0
github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81
github.com/magefile/mage v1.17.2
github.com/mdlayher/socket v0.5.1
github.com/mdp/qrterminal/v3 v3.2.1
github.com/miekg/dns v1.1.72
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/moby/moby/api v1.54.1
github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42
github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45
github.com/oapi-codegen/runtime v1.1.2
github.com/okta/okta-sdk-golang/v2 v2.18.0
@@ -236,8 +237,8 @@ require (
github.com/huin/goupnp v1.2.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect

18
go.sum
View File

@@ -341,12 +341,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -413,6 +413,8 @@ github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9
github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/YdXctQ7qbcizZkw5mZ6l8Ogrl1dodBzVJduwI=
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI=
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
@@ -482,8 +484,8 @@ github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVU
github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8=
github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI=
github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8=
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8=
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg=
github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 h1:iJeUvSMC0BTpkw7u4JyWcY4/3dl7fEL9DR/TpKf2+1w=
github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87/go.mod h1:pmsCPx1S0nuZRxCextGpc9AV4hLgGSuTsc4NMuwGeCo=
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8=
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=

View File

@@ -0,0 +1,58 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"testing"
"time"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetAccountSettings(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into accounts (id, settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled,
settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled,
settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled)
values('account-3',null,null,null,null,null,null,null,null,null,null,null)`)
accountSettings, err := conn(t, ctx).GetAccountSettings(ctx, "account-1")
assert.NoError(t, err)
assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{
PeerLoginExpirationEnabled: true,
PeerLoginExpiration: 86400000000000 * time.Nanosecond,
PeerInactivityExpirationEnabled: false,
PeerInactivityExpiration: 86400000000000 * time.Nanosecond,
DNSDomain: "",
IPv6EnabledGroups: []string{"group-one-resource-id"},
RoutingPeerDNSResolutionEnabled: false,
LazyConnectionEnabled: false,
AutoUpdateVersion: "disabled",
AutoUpdateAlways: false,
MetricsPushEnabled: false,
})
accountSettings, err = conn(t, ctx).GetAccountSettings(ctx, "account-2")
assert.NoError(t, err)
assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{
PeerLoginExpirationEnabled: true,
PeerLoginExpiration: 86400000000000 * time.Nanosecond,
PeerInactivityExpirationEnabled: false,
PeerInactivityExpiration: 86400000000000 * time.Nanosecond,
DNSDomain: "",
IPv6EnabledGroups: []string{"group-two-resources-id"},
RoutingPeerDNSResolutionEnabled: false,
LazyConnectionEnabled: false,
AutoUpdateVersion: "disabled",
AutoUpdateAlways: false,
MetricsPushEnabled: false,
})
accountSettings, err = conn(t, ctx).GetAccountSettings(ctx, "account-3")
assert.NoError(t, err)
assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{})
}

View File

@@ -0,0 +1,53 @@
insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups,
settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled,
settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled,
settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled)
VALUES('account-1','network-1','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]',
true, 86400000000000, false,
86400000000000, null, '["group-one-resource-id"]', false,
false, 'disabled', false, false);
insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups,
settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled,
settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled,
settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled)
VALUES('account-2','network-2','{"IP":"110.0.0.0","Mask":"//8AAA=="}','{"IP":"fddf:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',2,null,
true, 86400000000000, false,
86400000000000, null, '["group-two-resources-id"]', false,
false, 'disabled', false, false);
insert into groups (id, account_id, name, resources, public_id) VALUES('group-one-resource-id','account-1','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public');
insert into groups (id, account_id, name, resources, public_id) VALUES('group-two-resources-id','account-1','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','group-two-resources-id-public');
insert into groups (id, account_id, name, resources, public_id) VALUES('group-no-resources-id','account-1','group-3-name', null,'group-no-resources-id-public');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-1','group-one-resource-id');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-2','group-two-resources-id');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-3','group-two-resources-id');
insert into peers (id, account_id, "key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-1','account-1','key-1','ssh-key-1','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"',
false,true,true,'cluster-1.netbird.services',
'0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'DE','Berlin','"46.201.148.187"');
insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-2','account-1','key-2','ssh-key-2','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"',
false,true,true,'cluster-2.netbird.services',
'0.76.1','linux','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0,
'DE','Berlin','"46.201.149.187"');
insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-3','account-1','key-3','ssh-key-3','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"',
false,true,true,'cluster-3.netbird.services',
'0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'DE','Berlin','"46.201.150.187"');

View File

@@ -0,0 +1,25 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"testing"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetDnsSettings(t *testing.T) {
ctx := context.TODO()
settings, err := conn(t, ctx).GetDnsSettings(ctx, "account-1")
assert.NoError(t, err)
assert.Equal(t, settings, nmdata.DNSSettings{
DisabledManagementGroups: []string{"disabled-group-1", "disabled-group-2"},
})
settings, err = conn(t, ctx).GetDnsSettings(ctx, "account-2")
assert.NoError(t, err)
assert.Equal(t, settings, nmdata.DNSSettings{})
}

View File

@@ -0,0 +1,80 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"testing"
"github.com/miekg/dns"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetAppliedZoneCandidatesViaPgxConnection(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups)
VALUES('zone-1','account-1','test-1.com',true,true,'["group-one-resource-id"]')`)
execQuery(t, ctx,
`insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups)
VALUES('zone-2','account-1','test-2.com',true,false,'["group-two-resources-id"]')`)
execQuery(t, ctx,
`insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups)
VALUES('zone-3','account-1','test-3.com',false,true,'["group-one-resource-id"]')`)
execQuery(t, ctx,
`insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-1','account-1','zone-1','test.test-1.com','A',1800,'1.1.1.1')`)
execQuery(t, ctx,
`insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-2','account-1','zone-1','test2.test-1.com','A',1800,'1.1.1.2')`)
execQuery(t, ctx,
`insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-3','account-1','zone-1','test3.test-1.com','CNAME',1800,'test4.test-1.com')`)
execQuery(t, ctx,
`insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-4','account-1','zone-2','test2.test-2.com','CNAME',1800,'test3.test-2.com')`)
execQuery(t, ctx,
`insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-5','account-1','zone-3','test.test-3.com','A',1800,'1.1.1.3')`)
zoneCandidates, err := conn(t, ctx).GetAppliedZoneCandidates(ctx, "account-1")
assert.NoError(t, err)
// Zone domains and record names are fully qualified, and the zone is served
// non-authoritatively — the account-side builder
// (types.buildAppliedZoneCandidates) states the same shape, and both feed the
// one client-facing map, so the two have to agree.
assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{
DistributionGroups: []string{"group-one-resource-id"},
Zone: nmdata.CustomZone{
Domain: "test-1.com.",
SearchDomainDisabled: false,
NonAuthoritative: true,
Records: []nmdata.SimpleRecord{
{Name: "test.test-1.com.", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.1"},
{Name: "test2.test-1.com.", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.2"},
{Name: "test3.test-1.com.", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test4.test-1.com."},
},
},
})
assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{
DistributionGroups: []string{"group-two-resources-id"},
Zone: nmdata.CustomZone{
Domain: "test-2.com.",
SearchDomainDisabled: true,
NonAuthoritative: true,
Records: []nmdata.SimpleRecord{
{Name: "test2.test-2.com.", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test3.test-2.com."},
},
},
})
// A zone an admin switched off reaches no peer.
for _, candidate := range zoneCandidates {
assert.NotEqual(t, "test-3.com.", candidate.Zone.Domain, "disabled zone must not be a candidate")
assert.NotEqual(t, "test-3.com", candidate.Zone.Domain, "disabled zone must not be a candidate")
}
}

View File

@@ -0,0 +1,39 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"database/sql"
"testing"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/stretchr/testify/assert"
)
func TestGetDomains(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into domains (id, account_id, domain, target_cluster)
VALUES('domain-1','account-1','test-1.com','target-1.cluster.local')`)
execQuery(t, ctx,
`insert into domains (id, account_id, domain, target_cluster)
VALUES('domain-2','account-1','test-2.com','target-2.cluster.local')`)
execQuery(t, ctx,
`insert into domains (id, account_id, domain, target_cluster)
VALUES('domain-3','account-1',null,null)`)
domains, err := conn(t, ctx).GetDomains(ctx, "account-1")
assert.NoError(t, err)
assert.Len(t, domains, 2)
assert.Contains(t, domains, networkmapdb.Domain{
Domain: sql.NullString{String: "test-1.com", Valid: true},
TargetCluster: sql.NullString{String: "target-1.cluster.local", Valid: true},
})
assert.Contains(t, domains, networkmapdb.Domain{
Domain: sql.NullString{String: "test-2.com", Valid: true},
TargetCluster: sql.NullString{String: "target-2.cluster.local", Valid: true},
})
}

View File

@@ -0,0 +1,54 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"testing"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetGroups(t *testing.T) {
ctx := context.TODO()
groups, resourceToGroupIdx, err := conn(t, ctx).GetGroups(ctx, "account-1")
assert.NoError(t, err)
assert.Contains(t,
groups,
nmdata.Group{ID: "group-one-resource-id", Name: "group-1-name", PublicID: "group-one-resource-id-public", Resources: []nmdata.Resource{{ID: "host-id-1", Type: "host"}}, Peers: []string{"peer-id-1"}},
)
assert.NotNil(t, resourceToGroupIdx["host-id-1"]["group-one-resource-id"])
assert.Contains(t,
groups,
nmdata.Group{ID: "group-two-resources-id", Name: "group-2-name", PublicID: "group-two-resources-id-public",
Resources: []nmdata.Resource{{ID: "subnet-id-1", Type: "subnet"}, {ID: "host-id-2", Type: "host"}},
Peers: []string{"peer-id-2", "peer-id-3"}},
)
assert.NotNil(t, resourceToGroupIdx["host-id-2"]["group-two-resources-id"])
assert.NotNil(t, resourceToGroupIdx["subnet-id-1"]["group-two-resources-id"])
assert.Contains(t,
groups,
nmdata.Group{ID: "group-no-resources-id", Name: "group-3-name", PublicID: "group-no-resources-id-public"})
}
// Verify handling of empty fields in groups table
// Verify that group's PublicID gets populated on retrieval
// TODO (dmitri) PublicID should not be populated with delta updates,
// which require stable PublicIDs
func TestGetGroupsWithoutExpectedFields(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
"insert into accounts (id) VALUES('random-id')")
execQuery(t, ctx,
"insert into groups (id, account_id) VALUES('g2-test-group-id-1','random-id')")
groups, _, err := conn(t, ctx).GetGroups(ctx, "random-id")
assert.NoError(t, err)
require.Len(t, groups, 1)
assert.NotEmpty(t, groups[0].PublicID)
}

View File

@@ -0,0 +1,99 @@
//go:build integration
package networkmap_pgsql
import (
"context"
_ "embed"
"os"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite"
"github.com/netbirdio/netbird/management/server/types"
)
//go:embed base_data.sql
var baseData string
var (
pgstore *networkmap_pgsql.PgStore
sqlitestore *networkmap_sqlite.SqliteStore
engine string
)
func TestMain(m *testing.M) {
var cleanup func()
kind, _ := os.LookupEnv("NETBIRD_STORE_ENGINE")
switch kind {
case string(types.PostgresStoreEngine):
engine = string(types.PostgresStoreEngine)
pgstore, cleanup = createPGTestStore(baseData)
pgstore.UsingTimeZone(time.UTC)
case "", string(types.SqliteStoreEngine):
engine = string(types.SqliteStoreEngine)
sqlitestore, cleanup = createSqliteTestStore(baseData)
default:
log.Fatalf("unsupported db '%s' in NETBIRD_STORE_ENGINE env var", kind)
}
code := m.Run()
cleanup()
os.Exit(code)
}
func conn(t *testing.T, ctx context.Context) networkmapdb.NetworkMapDBStoreConn {
t.Helper()
switch engine {
case string(types.PostgresStoreEngine):
c, err := pgstore.Pool.Acquire(ctx)
assert.NoError(t, err)
return pgstore.UsingConnection(c.Conn())
case string(types.SqliteStoreEngine):
return sqlitestore.UsingConn()
}
log.Fatalf("unknown db engine kind %s", engine)
return nil
}
func store(t *testing.T) networkmapdb.NetworkMapDBStore {
t.Helper()
switch engine {
case string(types.PostgresStoreEngine):
return pgstore
case string(types.SqliteStoreEngine):
return sqlitestore
}
log.Fatalf("unknown db engine kind %s", engine)
return nil
}
func execQuery(t *testing.T, ctx context.Context, q string) {
t.Helper()
switch engine {
case string(types.PostgresStoreEngine):
_, err := pgstore.Pool.Exec(ctx, q)
assert.NoError(t, err)
case string(types.SqliteStoreEngine):
_, err := sqlitestore.Db.ExecContext(ctx, q)
assert.NoError(t, err)
}
}
// use to parse time in time.RFC3339Nano format
// returns the time in the UTC time zone
func mustParseTime(t string) *time.Time {
tt, err := time.Parse(time.RFC3339Nano, t)
if err != nil {
panic(err)
}
utc := tt.UTC()
return &utc
}

View File

@@ -0,0 +1,61 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetNameServerGroups(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled, "primary", account_id)
VALUES('nsgroup-1','nsgroup-1-public','nsgroup-1','nsgroup-1','[{"IP":"192.168.31.2","NSType":1,"Port":53}]','["group-one-resource-id"]','["test-1.com"]',TRUE,FALSE,TRUE,'account-1')`)
execQuery(t, ctx,
`insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id)
VALUES('nsgroup-2','nsgroup-2-public','nsgroup-2','nsgroup-2','[{"IP":"192.168.32.3","NSType":1,"Port":53}]','["group-one-resource-id","group-no-resources-id"]','["test-1.com","test-2.com"]',TRUE,FALSE,TRUE,'account-1')`)
execQuery(t, ctx,
`insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id)
VALUES('nsgroup-3','nsgroup-3-public',null,null,null,null,null,TRUE,FALSE,FALSE,'account-1')`)
nsgroups, err := conn(t, ctx).GetNameServerGroups(ctx, "account-1")
assert.NoError(t, err)
assert.Contains(t, nsgroups, nmdata.NameServerGroup{
ID: "nsgroup-1",
PublicID: "nsgroup-1-public",
Name: "nsgroup-1",
Description: "nsgroup-1",
NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.31.2"), NSType: 1, Port: 53}},
Groups: []string{"group-one-resource-id"},
Domains: []string{"test-1.com"},
Primary: true,
SearchDomainsEnabled: false,
Enabled: true,
})
assert.Contains(t, nsgroups, nmdata.NameServerGroup{
ID: "nsgroup-2",
PublicID: "nsgroup-2-public",
Name: "nsgroup-2",
Description: "nsgroup-2",
NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.32.3"), NSType: 1, Port: 53}},
Groups: []string{"group-one-resource-id", "group-no-resources-id"},
Domains: []string{"test-1.com", "test-2.com"},
Primary: true,
SearchDomainsEnabled: false,
Enabled: true,
})
assert.Contains(t, nsgroups, nmdata.NameServerGroup{
ID: "nsgroup-3",
PublicID: "nsgroup-3-public",
Primary: false,
SearchDomainsEnabled: false,
Enabled: true,
})
}

View File

@@ -0,0 +1,108 @@
insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups,
settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled,
settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled,
settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled)
VALUES('account-33','network-331','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]',
true, 86400000000000, false,
86400000000000, null, '["33-group-one-resource-id"]', false,
false, 'disabled', false, false);
insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-one-resource-id','account-33','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public');
insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-two-resources-id','account-33','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','33-group-two-resources-id-public');
insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-no-resources-id','account-33','group-3-name', null,'33-group-no-resources-id-public');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-331','33-group-one-resource-id');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-332','33-group-two-resources-id');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-333','33-group-two-resources-id');
insert into peers (id, account_id, "key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-331','account-33','key-331','ssh-key-1','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"',
false,true,true,'cluster-1.netbird.services',
'0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'DE','Berlin','"46.201.148.187"');
insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-332','account-33','key-332','ssh-key-2','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"',
false,true,true,'cluster-2.netbird.services',
'0.76.1','linux','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0,
'DE','Berlin','"46.201.149.187"');
insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-333','account-33','key-333','ssh-key-3','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"',
false,true,true,'cluster-3.netbird.services',
'0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'DE','Berlin','"46.201.150.187"');
insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups)
VALUES('zone-331','account-33','test-331.com',true,true,'["33-group-one-resource-id"]');
insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups)
VALUES('zone-332','account-33','disabled-331.com',false,true,'["33-group-one-resource-id"]');
insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups)
VALUES('zone-333','account-33','search-off-331.com',true,false,'["33-group-two-resources-id"]');
insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-333','account-33','zone-332','test.disabled-331.com','A',1800,'1.1.1.9');
insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-334','account-33','zone-333','test.search-off-331.com','A',1800,'1.1.1.3');
insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-335','account-33','zone-333','alias.search-off-331.com','CNAME',1800,'test.search-off-331.com');
insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-331','account-33','zone-331','test.test-331.com','A',1800,'1.1.1.1');
insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-332','account-33','zone-331','test2.test-331.com','A',1800,'1.1.1.2');
insert into domains (id, account_id, domain, target_cluster)
VALUES('domain-331','account-33','test-331.com','target-1.cluster.local');
insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled, "primary", account_id)
VALUES('nsgroup-331','nsgroup-1-public','nsgroup-1','nsgroup-1','[{"IP":"192.168.31.2","NSType":1,"Port":53}]','["33-group-one-resource-id"]','["test-1.com"]',TRUE,FALSE,TRUE,'account-33');
insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id)
VALUES('nsgroup-332','nsgroup-2-public','nsgroup-2','nsgroup-2','[{"IP":"192.168.32.3","NSType":1,"Port":53}]','["33-group-one-resource-id","33-group-no-resources-id"]','["test-1.com","test-2.com"]',TRUE,FALSE,TRUE,'account-33');
insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled)
VALUES('net-resource-331','account-33','network-331','net-resource-public-1','network-resource-1','network-resource-1','subnet','','"10.0.0.0/16"',TRUE);
insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled)
VALUES('net-resource-332','account-33','network-332','net-resource-public-2','network-resource-2','network-resource-2','domain','test.com','',TRUE);
insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups)
VALUES('test-nr-id-331','account-33','public-id-1','peer-id-331','network-id-1',TRUE,999,TRUE,'["33-group-one-resource-id"]');
insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups)
VALUES('test-nr-id-332','account-33','public-id-2','','network-id-2',TRUE,333,TRUE,'["33-group-two-resources-id","33-group-no-resources-id"]');
insert into networks (id, account_id, public_id) VALUES('network-331','account-33','network-1-public');
insert into networks (id, account_id, public_id) VALUES('network-332','account-33','network-2-public');
insert into policies (id, public_id, account_id, enabled, source_posture_checks)
values('policy-331','policy-1-public','account-33',true,'["posture-checks-1","posture-checks-2"]');
insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
source_resource, destination_resource, ports, port_ranges,
authorized_groups, authorized_user)
values('policy-331-rule-1','policy-331',true,'accept','tcp',true,'["33-group-one-resource-id","33-group-two-resources-id"]','["33-group-one-resource-id","33-group-two-resources-id"]',
'{"ID":"host-id-1","Type":"host"}','{"ID":"domain-331","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]',
'{"33-group-one-resource-id":["user-1", "user-2"]}','user-3');
insert into posture_checks (id, account_id, public_id, checks)
VALUES('posturecheck-331','account-33','posturecheck-1-public',
'{"NBVersionCheck":{"MinVersion":"0.25.0"},
"OSVersionCheck":{"Darwin":{"MinVersion":"12.0"}},
"GeoLocationCheck":{"Locations":[{"CountryCode":"FI","CityName":""}],"Action":"allow"},
"PeerNetworkRangeCheck":{"Action":"deny","Ranges":["192.168.0.1/24"]}}');
insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
peer, peer_groups, network_type, masquerade, metric, enabled,
groups, access_control_groups, skip_auto_apply)
VALUES('route-331','account-33','route-1-public','"172.0.0.0/16"','["test-1.com"]',true,'route-331-net-id','route-1',
'peer-id-331','["33-group-one-resource-id"]',1,true,9999,true,
'["33-group-one-resource-id"]','["33-group-one-resource-id"]',false);
insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
values('service-331','account-33',true,true,'["33-group-one-resource-id"]','test-1.com','test-332.com');

View File

@@ -0,0 +1,546 @@
{
"Peers": {
"peer-id-331": {
"ID": "peer-id-331",
"Key": "key-331",
"SSHKey": "ssh-key-1",
"DNSLabel": "peer-1",
"UserID": "user-id-1",
"SSHEnabled": true,
"LoginExpirationEnabled": true,
"LastLogin": "2026-08-06T13:25:59.12999Z",
"IP": "10.10.10.1",
"IPv6": "fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940",
"RequiresApproval": false,
"ExtraDNSLabels": [
"extra-peer-1"
],
"Meta": {
"WtVersion": "0.76.0",
"GoOS": "linux",
"OSVersion": "26.4.1",
"KernelVersion": "6.8.0-134-generic",
"NetworkAddresses": [
{
"NetIP": "fe80::8b4c:973f:a76b:3771/64"
},
{
"NetIP": "192.168.16.1/20"
}
],
"Files": [
{
"Path": "/usr/bin/netbird",
"ProcessIsRunning": false
}
],
"Capabilities": [
1,
2
],
"Flags": {
"ServerSSHAllowed": true,
"DisableIPv6": false
},
"SyncMessageVersion": 1
},
"ProxyMeta": {
"Embedded": true,
"Cluster": "cluster-1.netbird.services"
},
"Location": {
"CountryCode": "DE",
"CityName": "Berlin",
"ConnectionIP": "46.201.148.187"
}
},
"peer-id-332": {
"ID": "peer-id-332",
"Key": "key-332",
"SSHKey": "ssh-key-2",
"DNSLabel": "peer-2",
"UserID": "user-id-2",
"SSHEnabled": true,
"LoginExpirationEnabled": true,
"LastLogin": "2026-08-06T14:25:59.12999Z",
"IP": "10.10.100.1",
"IPv6": "fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940",
"RequiresApproval": false,
"ExtraDNSLabels": [
"extra-peer-2"
],
"Meta": {
"WtVersion": "0.76.1",
"GoOS": "linux",
"OSVersion": "26.4.2",
"KernelVersion": "6.8.0-135-generic",
"NetworkAddresses": [
{
"NetIP": "fe81::8b4c:973f:a76b:3771/64"
},
{
"NetIP": "192.168.17.1/20"
}
],
"Files": [
{
"Path": "/usr/bin/netbird",
"ProcessIsRunning": false
}
],
"Capabilities": [
1,
2
],
"Flags": {
"ServerSSHAllowed": true,
"DisableIPv6": false
},
"SyncMessageVersion": 0
},
"ProxyMeta": {
"Embedded": true,
"Cluster": "cluster-2.netbird.services"
},
"Location": {
"CountryCode": "DE",
"CityName": "Berlin",
"ConnectionIP": "46.201.149.187"
}
},
"peer-id-333": {
"ID": "peer-id-333",
"Key": "key-333",
"SSHKey": "ssh-key-3",
"DNSLabel": "peer-3",
"UserID": "user-id-3",
"SSHEnabled": true,
"LoginExpirationEnabled": true,
"LastLogin": "2026-08-06T12:25:59.12999Z",
"IP": "10.10.200.1",
"IPv6": "fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940",
"RequiresApproval": false,
"ExtraDNSLabels": [
"extra-peer-3"
],
"Meta": {
"WtVersion": "0.76.2",
"GoOS": "linux",
"OSVersion": "26.4.3",
"KernelVersion": "6.8.0-136-generic",
"NetworkAddresses": [
{
"NetIP": "fe82::8b4c:973f:a76b:3771/64"
},
{
"NetIP": "192.168.18.1/20"
}
],
"Files": [
{
"Path": "/usr/bin/netbird",
"ProcessIsRunning": false
}
],
"Capabilities": [
1,
2
],
"Flags": {
"ServerSSHAllowed": true,
"DisableIPv6": false
},
"SyncMessageVersion": 1
},
"ProxyMeta": {
"Embedded": true,
"Cluster": "cluster-3.netbird.services"
},
"Location": {
"CountryCode": "DE",
"CityName": "Berlin",
"ConnectionIP": "46.201.150.187"
}
}
},
"Groups": {
"33-group-no-resources-id": {
"ID": "33-group-no-resources-id",
"Name": "group-3-name",
"PublicID": "33-group-no-resources-id-public",
"Peers": null,
"Resources": null
},
"33-group-one-resource-id": {
"ID": "33-group-one-resource-id",
"Name": "group-1-name",
"PublicID": "group-one-resource-id-public",
"Peers": [
"peer-id-331"
],
"Resources": [
{
"ID": "host-id-1",
"Type": "host"
}
]
},
"33-group-two-resources-id": {
"ID": "33-group-two-resources-id",
"Name": "group-2-name",
"PublicID": "33-group-two-resources-id-public",
"Peers": [
"peer-id-332",
"peer-id-333"
],
"Resources": [
{
"ID": "subnet-id-1",
"Type": "subnet"
},
{
"ID": "host-id-2",
"Type": "host"
}
]
}
},
"Policies": [
{
"ID": "policy-331",
"PublicID": "policy-1-public",
"Enabled": true,
"SourcePostureChecks": [
"posture-checks-1",
"posture-checks-2"
],
"Rules": [
{
"ID": "policy-331",
"PolicyID": "policy-331",
"Enabled": true,
"Action": "accept",
"Protocol": "tcp",
"Bidirectional": true,
"Sources": [
"33-group-one-resource-id",
"33-group-two-resources-id"
],
"Destinations": [
"33-group-one-resource-id",
"33-group-two-resources-id"
],
"SourceResource": {
"ID": "host-id-1",
"Type": "host"
},
"DestinationResource": {
"ID": "domain-331",
"Type": "domain"
},
"Ports": [
"8080",
"8443"
],
"PortRanges": [
{
"Start": 8080,
"End": 8090
}
],
"AuthorizedGroups": {
"33-group-one-resource-id": [
"user-1",
"user-2"
]
},
"AuthorizedUser": "user-3"
}
]
}
],
"Routes": [
{
"ID": "route-331",
"AccountID": "account-33",
"PublicID": "route-1-public",
"Network": "172.0.0.0/16",
"Domains": [
"test-1.com"
],
"KeepRoute": true,
"NetID": "route-331-net-id",
"Description": "route-1",
"Peer": "peer-id-331",
"PeerID": "peer-id-331",
"PeerGroups": [
"33-group-one-resource-id"
],
"NetworkType": 1,
"Masquerade": true,
"Metric": 9999,
"Enabled": true,
"Groups": [
"33-group-one-resource-id"
],
"AccessControlGroups": [
"33-group-one-resource-id"
],
"SkipAutoApply": false
}
],
"NameServerGroups": [
{
"ID": "nsgroup-331",
"PublicID": "nsgroup-1-public",
"Name": "nsgroup-1",
"Description": "nsgroup-1",
"NameServers": [
{
"IP": "192.168.31.2",
"NSType": 1,
"Port": 53
}
],
"Groups": [
"33-group-one-resource-id"
],
"Primary": true,
"Domains": [
"test-1.com"
],
"Enabled": true,
"SearchDomainsEnabled": false
},
{
"ID": "nsgroup-332",
"PublicID": "nsgroup-2-public",
"Name": "nsgroup-2",
"Description": "nsgroup-2",
"NameServers": [
{
"IP": "192.168.32.3",
"NSType": 1,
"Port": 53
}
],
"Groups": [
"33-group-one-resource-id",
"33-group-no-resources-id"
],
"Primary": true,
"Domains": [
"test-1.com",
"test-2.com"
],
"Enabled": true,
"SearchDomainsEnabled": false
}
],
"NetworkResources": [
{
"ID": "net-resource-331",
"NetworkID": "network-331",
"AccountID": "account-33",
"PublicID": "net-resource-public-1",
"Name": "network-resource-1",
"Description": "network-resource-1",
"Type": "subnet",
"Address": "",
"Domain": "",
"Prefix": "10.0.0.0/16",
"Enabled": true
},
{
"ID": "net-resource-332",
"NetworkID": "network-332",
"AccountID": "account-33",
"PublicID": "net-resource-public-2",
"Name": "network-resource-2",
"Description": "network-resource-2",
"Type": "domain",
"Address": "",
"Domain": "test.com",
"Prefix": "",
"Enabled": true
}
],
"Network": {
"Identifier": "network-331",
"Net": {
"IP": "100.103.0.0",
"Mask": "//8AAA=="
},
"NetV6": {
"IP": "fdde:e995:fd38:a465::",
"Mask": "//////////8AAAAAAAAAAA=="
},
"Dns": "",
"Serial": 1
},
"DNSSettings": {
"DisabledManagementGroups": [
"disabled-group-1",
"disabled-group-2"
]
},
"AccountSettings": {
"PeerLoginExpirationEnabled": true,
"PeerLoginExpiration": 86400000000000,
"PeerInactivityExpirationEnabled": false,
"PeerInactivityExpiration": 86400000000000,
"DNSDomain": "",
"IPv6EnabledGroups": [
"33-group-one-resource-id"
],
"RoutingPeerDNSResolutionEnabled": false,
"LazyConnectionEnabled": false,
"AutoUpdateVersion": "disabled",
"AutoUpdateAlways": false,
"MetricsPushEnabled": false
},
"PostureChecks": {
"posturecheck-331": {
"ID": "posturecheck-331",
"Checks": {
"NBVersionCheck": {
"MinVersion": "0.25.0"
},
"OSVersionCheck": {
"Android": null,
"Darwin": {
"MinVersion": "12.0"
},
"Ios": null,
"Linux": null,
"Windows": null
},
"GeoLocationCheck": {
"Locations": [
{
"CountryCode": "FI",
"CityName": ""
}
],
"Action": "allow"
},
"PeerNetworkRangeCheck": {
"Action": "deny",
"Ranges": [
"192.168.0.1/24"
]
},
"ProcessCheck": null
}
}
},
"PostureValidation": null,
"AllowedUserIDs": {},
"NetworkXIDToPublicID": {
"network-331": "network-1-public",
"network-332": "network-2-public"
},
"PostureCheckXIDToPublicID": {
"posturecheck-331": "posturecheck-1-public"
},
"ValidatedPeers": {
"peer-id-1": {},
"peer-id-2": {},
"peer-id-3": {}
},
"ResourcePolicies": {},
"Routers": {
"network-id-1": {
"peer-id-331": {
"PublicID": "public-id-1",
"PeerGroups": [
"33-group-one-resource-id"
],
"Masquerade": true,
"Metric": 999,
"Enabled": true
}
},
"network-id-2": {
"peer-id-332": {
"PublicID": "public-id-2",
"PeerGroups": [
"33-group-two-resources-id",
"33-group-no-resources-id"
],
"Masquerade": true,
"Metric": 333,
"Enabled": true
},
"peer-id-333": {
"PublicID": "public-id-2",
"PeerGroups": [
"33-group-two-resources-id",
"33-group-no-resources-id"
],
"Masquerade": true,
"Metric": 333,
"Enabled": true
}
}
},
"GroupIDToUserIDs": {},
"DNSDomain": "",
"ProxyTargetedDomainResourceIDs": {},
"AppliedZoneCandidates": [
{
"DistributionGroups": [
"33-group-one-resource-id"
],
"Zone": {
"Domain": "test-331.com.",
"Records": [
{
"Name": "test.test-331.com.",
"Type": 1,
"Class": "IN",
"TTL": 1800,
"RData": "1.1.1.1"
},
{
"Name": "test2.test-331.com.",
"Type": 1,
"Class": "IN",
"TTL": 1800,
"RData": "1.1.1.2"
}
],
"SearchDomainDisabled": false,
"NonAuthoritative": true
}
},
{
"DistributionGroups": [
"33-group-two-resources-id"
],
"Zone": {
"Domain": "search-off-331.com.",
"Records": [
{
"Name": "test.search-off-331.com.",
"Type": 1,
"Class": "IN",
"TTL": 1800,
"RData": "1.1.1.3"
},
{
"Name": "alias.search-off-331.com.",
"Type": 5,
"Class": "IN",
"TTL": 1800,
"RData": "test.search-off-331.com."
}
],
"SearchDomainDisabled": true,
"NonAuthoritative": true
}
}
],
"PrivateServiceCandidates": null,
"Services": null
}

View File

@@ -0,0 +1,74 @@
//go:build integration
package networkmap_pgsql
import (
"context"
_ "embed"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
"github.com/netbirdio/netbird/management/server/settings"
"github.com/netbirdio/netbird/management/server/types"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
//go:embed network_map_data.sql
var nmapData string
//go:embed network_map_data_golden.json
var goldenNMap string
const EnvUpdateGoldenData = "NMAP_UPDATE_GOLDEN_DATA"
func TestGetNetworkMapData(t *testing.T) {
ctx := context.TODO()
// The two mocks are generated by different mock frameworks, so each needs a
// controller of its own kind.
extraSettingsManager := settings.NewMockManager(gomock.NewController(t))
extraSettingsManager.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil)
peerValidators := integrated_validator.NewMockIntegratedValidator(gomock.NewController(t))
peerValidators.EXPECT().GetValidatedPeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(
map[string]struct{}{
"peer-id-1": {},
"peer-id-2": {},
"peer-id-3": {},
}, nil)
storeImpl := networkmapdb.NetworkMapDBStoreImpl{
Store: store(t),
ExtraSettingsManager: extraSettingsManager,
IntegratedPeerValidator: peerValidators,
}
for _, query := range strings.Split(nmapData, ";") {
if err := store(t).Exec(ctx, query); err != nil {
log.Fatalf("error initializing nmap test: %s", err.Error())
}
}
nmap, err := storeImpl.GetNetworkMapData(ctx, "account-33")
assert.NoError(t, err)
serializedNMap, err := json.MarshalIndent(nmap, "", " ")
assert.NoError(t, err)
if _, ok := os.LookupEnv(EnvUpdateGoldenData); ok {
_, filename, _, _ := runtime.Caller(0)
tosavepath := filepath.Join(filepath.Dir(filename), "network_map_data_golden.json")
err = os.WriteFile(tosavepath, serializedNMap, 0644)
assert.NoError(t, err)
goldenNMap = string(serializedNMap)
}
assert.Equal(t, goldenNMap, string(serializedNMap))
}

View File

@@ -0,0 +1,65 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetNetworkResources(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled)
VALUES('net-resource-1','account-1','network-1','net-resource-public-1','network-resource-1','network-resource-1','subnet','','"10.0.0.0/16"',TRUE)`)
execQuery(t, ctx,
`insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled)
VALUES('net-resource-2','account-1','network-2','net-resource-public-2','network-resource-2','network-resource-2','domain','test.com','',TRUE)`)
execQuery(t, ctx,
`insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled)
VALUES('net-resource-3','account-1','network-3','net-resource-public-3','network-resource-3','network-resource-3','host','','"10.0.0.1/32"',TRUE)`)
resources, err := conn(t, ctx).GetNetworkResources(ctx, "account-1")
assert.NoError(t, err)
assert.Contains(t, resources, nmdata.NetworkResource{
ID: "net-resource-1",
AccountID: "account-1",
NetworkID: "network-1",
PublicID: "net-resource-public-1",
Name: "network-resource-1",
Description: "network-resource-1",
Type: "subnet",
Domain: "",
Prefix: netip.MustParsePrefix("10.0.0.0/16"),
Enabled: true,
})
assert.Contains(t, resources, nmdata.NetworkResource{
ID: "net-resource-2",
AccountID: "account-1",
NetworkID: "network-2",
PublicID: "net-resource-public-2",
Name: "network-resource-2",
Description: "network-resource-2",
Type: "domain",
Domain: "test.com",
Enabled: true,
})
assert.Contains(t, resources, nmdata.NetworkResource{
ID: "net-resource-3",
AccountID: "account-1",
NetworkID: "network-3",
PublicID: "net-resource-public-3",
Name: "network-resource-3",
Description: "network-resource-3",
Type: "host",
Domain: "",
Prefix: netip.MustParsePrefix("10.0.0.1/32"),
Enabled: true,
})
}

View File

@@ -0,0 +1,33 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"testing"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetNetworkRouters(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups)
VALUES('test-nr-id-1','account-1','public-id-1','peer-id-1','network-id-1',TRUE,999,TRUE,'["group-one-resource-id"]')`)
execQuery(t, ctx,
`insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups)
VALUES('test-nr-id-2','account-1','public-id-2','','network-id-2',TRUE,333,TRUE,'["group-two-resources-id","group-no-resources-id"]')`)
routers, err := conn(t, ctx).GetNetworkRouters(ctx, "account-1")
assert.NoError(t, err)
assert.NotEmpty(t, routers)
assert.Equal(t, routers["network-id-1"],
map[string]*nmdata.NetworkRouter{"peer-id-1": {PublicID: "public-id-1", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: []string{"group-one-resource-id"}}})
assert.Equal(t, routers["network-id-2"],
map[string]*nmdata.NetworkRouter{
"peer-id-2": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}},
"peer-id-3": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}})
}

View File

@@ -0,0 +1,56 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"encoding/json"
"net"
"testing"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetNetwork(t *testing.T) {
ctx := context.TODO()
network, err := conn(t, ctx).GetNetwork(ctx, "account-1")
assert.NoError(t, err)
assert.Equal(t, network, nmdata.Network{
Identifier: "network-1",
Net: mustParseCIDR("100.103.0.0/16"),
NetV6: mustParseCIDR("fdde:e995:fd38:a465::/64"),
Serial: 1,
})
network, err = conn(t, ctx).GetNetwork(ctx, "account-2")
assert.NoError(t, err)
assert.Equal(t, network, nmdata.Network{
Identifier: "network-2",
Net: mustParseCIDR("110.0.0.0/16"),
NetV6: mustParseCIDR("fddf:e995:fd38:a465::/64"),
Serial: 2,
})
}
func mustParseCIDR(s string) net.IPNet {
var toret net.IPNet
_, net, err := net.ParseCIDR(s)
if err != nil {
panic(err)
}
jn, err := json.Marshal(net)
if err != nil {
panic(err)
}
err = json.Unmarshal(jn, &toret)
if err != nil {
panic(err)
}
return toret
}

View File

@@ -0,0 +1,26 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetNetworks(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into networks (id, account_id, public_id) VALUES('network-1','account-1','network-1-public')`)
execQuery(t, ctx,
`insert into networks (id, account_id, public_id) VALUES('network-2','account-1','network-2-public')`)
networksIdx, err := conn(t, ctx).GetNetworkXIDToPublicIdMap(ctx, "account-1")
assert.NoError(t, err)
assert.Equal(t, networksIdx, map[string]string{
"network-1": "network-1-public",
"network-2": "network-2-public",
})
}

View File

@@ -0,0 +1,166 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"net"
"net/netip"
"testing"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetPeers(t *testing.T) {
ctx := context.TODO()
peers, clusterToPeersIdx, err := conn(t, ctx).GetPeers(ctx, "account-1")
assert.NoError(t, err)
// shouldn't be returned in the index, as it's not connected
execQuery(t, ctx,
`insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected)
values('peer-4','account-1','key-4','ssh-key-4',true,false)`)
// shouldn't be returned in the index as it doesn't have cluster set
execQuery(t, ctx,
`insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected)
values('peer-5','account-1','key-5','ssh-key-5',false,true)`)
peer1 := nmdata.Peer{
ID: "peer-id-1",
Key: "key-1",
SSHKey: "ssh-key-1",
DNSLabel: "peer-1",
ExtraDNSLabels: []string{"extra-peer-1"},
UserID: "user-id-1",
SSHEnabled: true,
LoginExpirationEnabled: true,
LastLogin: mustParseTime("2026-08-06T13:25:59.12999+00:00"),
IP: netip.MustParseAddr("10.10.10.1"),
IPv6: netip.MustParseAddr("fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"),
RequiresApproval: false,
Meta: nmdata.PeerSystemMeta{
WtVersion: "0.76.0",
GoOS: "linux",
OSVersion: "26.4.1",
KernelVersion: "6.8.0-134-generic",
NetworkAddresses: []nmdata.NetworkAddress{
{NetIP: netip.MustParsePrefix("fe80::8b4c:973f:a76b:3771/64")},
{NetIP: netip.MustParsePrefix("192.168.16.1/20")},
},
Files: []nmdata.File{
{Path: "/usr/bin/netbird", ProcessIsRunning: false},
},
Capabilities: []int32{1, 2},
Flags: nmdata.Flags{
ServerSSHAllowed: true,
DisableIPv6: false,
},
SyncMessageVersion: 1,
},
ProxyMeta: nmdata.ProxyMeta{
Embedded: true,
Cluster: "cluster-1.netbird.services",
},
Location: nmdata.PeerLocation{
CountryCode: "DE",
CityName: "Berlin",
ConnectionIP: net.ParseIP("46.201.148.187"),
},
}
peer2 := nmdata.Peer{
ID: "peer-id-2",
Key: "key-2",
SSHKey: "ssh-key-2",
DNSLabel: "peer-2",
ExtraDNSLabels: []string{"extra-peer-2"},
UserID: "user-id-2",
SSHEnabled: true,
LoginExpirationEnabled: true,
LastLogin: mustParseTime("2026-08-06T14:25:59.12999+00:00"),
IP: netip.MustParseAddr("10.10.100.1"),
IPv6: netip.MustParseAddr("fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"),
RequiresApproval: false,
Meta: nmdata.PeerSystemMeta{
WtVersion: "0.76.1",
GoOS: "linux",
OSVersion: "26.4.2",
KernelVersion: "6.8.0-135-generic",
NetworkAddresses: []nmdata.NetworkAddress{
{NetIP: netip.MustParsePrefix("fe81::8b4c:973f:a76b:3771/64")},
{NetIP: netip.MustParsePrefix("192.168.17.1/20")},
},
Files: []nmdata.File{
{Path: "/usr/bin/netbird", ProcessIsRunning: false},
},
Capabilities: []int32{1, 2},
Flags: nmdata.Flags{
ServerSSHAllowed: true,
DisableIPv6: false,
},
SyncMessageVersion: 0,
},
ProxyMeta: nmdata.ProxyMeta{
Embedded: true,
Cluster: "cluster-2.netbird.services",
},
Location: nmdata.PeerLocation{
CountryCode: "DE",
CityName: "Berlin",
ConnectionIP: net.ParseIP("46.201.149.187"),
},
}
peer3 := nmdata.Peer{
ID: "peer-id-3",
Key: "key-3",
SSHKey: "ssh-key-3",
DNSLabel: "peer-3",
ExtraDNSLabels: []string{"extra-peer-3"},
UserID: "user-id-3",
SSHEnabled: true,
LoginExpirationEnabled: true,
LastLogin: mustParseTime("2026-08-06T12:25:59.12999+00:00"),
IP: netip.MustParseAddr("10.10.200.1"),
IPv6: netip.MustParseAddr("fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"),
RequiresApproval: false,
Meta: nmdata.PeerSystemMeta{
WtVersion: "0.76.2",
GoOS: "linux",
OSVersion: "26.4.3",
KernelVersion: "6.8.0-136-generic",
NetworkAddresses: []nmdata.NetworkAddress{
{NetIP: netip.MustParsePrefix("fe82::8b4c:973f:a76b:3771/64")},
{NetIP: netip.MustParsePrefix("192.168.18.1/20")},
},
Files: []nmdata.File{
{Path: "/usr/bin/netbird", ProcessIsRunning: false},
},
Capabilities: []int32{1, 2},
Flags: nmdata.Flags{
ServerSSHAllowed: true,
DisableIPv6: false,
},
SyncMessageVersion: 1,
},
ProxyMeta: nmdata.ProxyMeta{
Embedded: true,
Cluster: "cluster-3.netbird.services",
},
Location: nmdata.PeerLocation{
CountryCode: "DE",
CityName: "Berlin",
ConnectionIP: net.ParseIP("46.201.150.187"),
},
}
assert.Contains(t, peers, peer1)
assert.Contains(t, peers, peer2)
assert.Contains(t, peers, peer3)
assert.Equal(t, clusterToPeersIdx, map[string][]*nmdata.Peer{
"cluster-1.netbird.services": {&peer1},
"cluster-2.netbird.services": {&peer2},
"cluster-3.netbird.services": {&peer3},
})
}

View File

@@ -0,0 +1,121 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"fmt"
"regexp"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/google/uuid"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
gormstore "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/testutil"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func createPGTestStore(baseData string) (*networkmap_pgsql.PgStore, func()) {
_, tmpdsn, err := testutil.CreatePostgresTestContainer()
if err != nil {
log.Fatalf("error starting postres container %v", err)
}
var db *gorm.DB
for i := range 5 {
db, err = gorm.Open(postgres.Open(tmpdsn), &gorm.Config{})
if err == nil {
break
}
if i < 5 {
waitTime := time.Duration(100*(i+1)) * time.Millisecond
time.Sleep(waitTime)
continue
}
log.Fatalf("error connecting to postres db %v", err)
}
var cleanup func()
dsn, cleanup, err := createRandomDB(tmpdsn, db)
sqlDB, _ := db.DB()
if sqlDB != nil {
sqlDB.Close()
}
if err != nil {
log.Fatalf("error creating postres db %v", err)
}
_, err = gormstore.NewPostgresqlStoreForTests(context.TODO(), dsn, nil, false)
if err != nil {
log.Fatalf("error running migrations %v", err)
}
ctx := context.TODO()
pgstore, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
if err != nil {
log.Fatal("error creating postgres store %w", err)
}
for _, query := range strings.Split(baseData, ";") {
if _, err := pgstore.Pool.Exec(ctx, query); err != nil {
log.Fatalf("error initializing db: %s", err.Error())
}
}
return pgstore, cleanup
}
func createRandomDB(dsn string, db *gorm.DB) (string, func(), error) {
dbName := fmt.Sprintf("test_db_%s", strings.ReplaceAll(uuid.New().String(), "-", "_"))
if err := db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)).Error; err != nil {
return "", nil, fmt.Errorf("failed to create database: %v", err)
}
originalDSN := dsn
cleanup := func() {
var dropDB *gorm.DB
var err error
dropDB, err = gorm.Open(postgres.Open(originalDSN), &gorm.Config{
SkipDefaultTransaction: true,
PrepareStmt: false,
})
if err != nil {
log.Errorf("failed to connect for dropping database %s: %v", dbName, err)
return
}
defer func() {
if sqlDB, _ := dropDB.DB(); sqlDB != nil {
sqlDB.Close()
}
}()
if sqlDB, _ := dropDB.DB(); sqlDB != nil {
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(0)
sqlDB.SetConnMaxLifetime(time.Second)
}
err = dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName)).Error
if err != nil {
log.Errorf("failed to drop database %s: %v", dbName, err)
}
}
return replaceDBName(dsn, dbName), cleanup, nil
}
func replaceDBName(dsn, newDBName string) string {
re := regexp.MustCompile(`(?P<pre>[:/@])(?P<dbname>[^/?]+)(?P<post>\?|$)`)
return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`)
}

View File

@@ -0,0 +1,146 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"testing"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetPolicies(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
values('policy-1','policy-1-public','account-1',true,'["posture-checks-1","posture-checks-2"]')`)
execQuery(t, ctx,
`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
source_resource, destination_resource, ports, port_ranges,
authorized_groups, authorized_user)
values('policy-1-rule-1','policy-1',true,'accept','tcp',true,'["group-one-resource-id","group-two-resources-id"]','["group-one-resource-id","group-two-resources-id"]',
'{"ID":"host-id-1","Type":"host"}','{"ID":"domain-1","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]',
'{"group-one-resource-id":["user-1", "user-2"]}','user-3')`)
execQuery(t, ctx,
`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
values('policy-2','policy-2-public','account-1',true,'["posture-checks-3","posture-checks-4"]')`)
execQuery(t, ctx,
`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
source_resource, destination_resource, ports, port_ranges,
authorized_groups, authorized_user)
values('policy-2-rule-1','policy-2',true,'accept','tcp',true,'["group-one-resource-id"]','["group-two-resources-id"]',
'{"ID":"host-id-3","Type":"host"}','{"ID":"domain-3","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]',
'{"group-one-resource-id":["user-6", "user-7"]}','user-8')`)
// policy with a rule with null fields
execQuery(t, ctx,
`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
values('policy-3','policy-3-public','account-1',true,null)`)
execQuery(t, ctx,
`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
source_resource, destination_resource, ports, port_ranges,
authorized_groups, authorized_user)
values('policy-3-rule-1','policy-3',true,null,null,null,null,null,null,null,null,null,null,null)`)
// policy with a disabled rule, destination resource and groups should not be in indexes
execQuery(t, ctx,
`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
values('policy-4','policy-4-public','account-1',true,null)`)
execQuery(t, ctx,
`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
source_resource, destination_resource, ports, port_ranges,
authorized_groups, authorized_user)
values('policy-4-rule-1','policy-4',false,null,null,null,null,'["group-two-resources-id"]',
null,'{"ID":"domain-3","Type":"domain"}',null,null,null,null)`)
policies, policyToDestinationResourceIdx, policyToDestinationGroupIdx, err := conn(t, ctx).GetPolicies(ctx, "account-1")
assert.NoError(t, err)
assert.Contains(t, policies, nmdata.Policy{
ID: "policy-1",
PublicID: "policy-1-public",
Enabled: true,
SourcePostureChecks: []string{"posture-checks-1", "posture-checks-2"},
Rules: []*nmdata.PolicyRule{
{
ID: "policy-1",
PolicyID: "policy-1",
Enabled: true,
Action: "accept",
Protocol: "tcp",
Bidirectional: true,
Sources: []string{"group-one-resource-id", "group-two-resources-id"},
Destinations: []string{"group-one-resource-id", "group-two-resources-id"},
SourceResource: nmdata.Resource{ID: "host-id-1", Type: "host"},
DestinationResource: nmdata.Resource{ID: "domain-1", Type: "domain"},
Ports: []string{"8080", "8443"},
PortRanges: []nmdata.RulePortRange{{Start: 8080, End: 8090}},
AuthorizedGroups: map[string][]string{"group-one-resource-id": {"user-1", "user-2"}},
AuthorizedUser: "user-3",
},
},
})
assert.Contains(t, policies, nmdata.Policy{
ID: "policy-2",
PublicID: "policy-2-public",
Enabled: true,
SourcePostureChecks: []string{"posture-checks-3", "posture-checks-4"},
Rules: []*nmdata.PolicyRule{
{
ID: "policy-2",
PolicyID: "policy-2",
Enabled: true,
Action: "accept",
Protocol: "tcp",
Bidirectional: true,
Sources: []string{"group-one-resource-id"},
Destinations: []string{"group-two-resources-id"},
SourceResource: nmdata.Resource{ID: "host-id-3", Type: "host"},
DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"},
Ports: []string{"8080", "8443"},
PortRanges: []nmdata.RulePortRange{{Start: 8080, End: 8090}},
AuthorizedGroups: map[string][]string{"group-one-resource-id": {"user-6", "user-7"}},
AuthorizedUser: "user-8",
},
},
})
assert.Contains(t, policies, nmdata.Policy{
ID: "policy-3",
PublicID: "policy-3-public",
Enabled: true,
SourcePostureChecks: nil,
Rules: []*nmdata.PolicyRule{
{
ID: "policy-3",
PolicyID: "policy-3",
Enabled: true,
},
},
})
assert.Contains(t, policies, nmdata.Policy{
ID: "policy-4",
PublicID: "policy-4-public",
Enabled: true,
SourcePostureChecks: nil,
Rules: []*nmdata.PolicyRule{
{
ID: "policy-4",
PolicyID: "policy-4",
Enabled: false,
Destinations: []string{"group-two-resources-id"},
DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"},
},
},
})
assert.Equal(t, policyToDestinationGroupIdx, map[string]map[string]any{
"policy-1": {"group-one-resource-id": struct{}{}, "group-two-resources-id": struct{}{}},
"policy-2": {"group-two-resources-id": struct{}{}},
})
assert.Equal(t, policyToDestinationResourceIdx, map[string]map[string]any{
"policy-1": {"domain-1": struct{}{}},
"policy-2": {"domain-3": struct{}{}},
})
}

View File

@@ -0,0 +1,61 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetPostureChecks(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into posture_checks (id, account_id, public_id, checks)
VALUES('posturecheck-1','account-1','posturecheck-1-public',
'{"NBVersionCheck":{"MinVersion":"0.25.0"},
"OSVersionCheck":{"Darwin":{"MinVersion":"12.0"}},
"GeoLocationCheck":{"Locations":[{"CountryCode":"FI","CityName":""}],"Action":"allow"},
"PeerNetworkRangeCheck":{"Action":"deny","Ranges":["192.168.0.1/24"]}}')`)
execQuery(t, ctx,
`insert into posture_checks (id, account_id, public_id, checks)
VALUES('posturecheck-2','account-1','posturecheck-2-public',
'{"NBVersionCheck":{"MinVersion":"0.25.0"},
"OSVersionCheck":{"Android":{"MinVersion":"0"}},
"GeoLocationCheck":{"Locations":[{"CountryCode":"US","CityName":"Harker Heights"}],"Action":"allow"},
"PeerNetworkRangeCheck":{"Action":"allow","Ranges":["0.0.0.0/0"]}}')`)
execQuery(t, ctx,
`insert into posture_checks (id, account_id, public_id, checks)
VALUES('posturecheck-3','account-1','posturecheck-3-public', null)`)
postureChecks, idToPublicIDIdx, err := conn(t, ctx).GetPostureChecks(ctx, "account-1")
assert.NoError(t, err)
assert.Equal(t, idToPublicIDIdx, map[string]string{
"posturecheck-1": "posturecheck-1-public",
"posturecheck-2": "posturecheck-2-public",
"posturecheck-3": "posturecheck-3-public",
})
assert.Contains(t, postureChecks, nmdata.PostureChecks{
ID: "posturecheck-1",
Checks: nmdata.ChecksDefinition{
NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
OSVersionCheck: &nmdata.OSVersionCheck{Darwin: &nmdata.MinVersionCheck{MinVersion: "12.0"}},
GeoLocationCheck: &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "FI"}}, Action: "allow"},
PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "deny", Ranges: []netip.Prefix{netip.MustParsePrefix("192.168.0.1/24")}},
}})
assert.Contains(t, postureChecks, nmdata.PostureChecks{
ID: "posturecheck-2",
Checks: nmdata.ChecksDefinition{
NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
OSVersionCheck: &nmdata.OSVersionCheck{Android: &nmdata.MinVersionCheck{MinVersion: "0"}},
GeoLocationCheck: &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "US", CityName: "Harker Heights"}}, Action: "allow"},
PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "allow", Ranges: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}},
}})
assert.Contains(t, postureChecks, nmdata.PostureChecks{
ID: "posturecheck-3"})
}

View File

@@ -0,0 +1,87 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetRoutes(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
peer, peer_groups, network_type, masquerade, metric, enabled,
groups, access_control_groups, skip_auto_apply)
VALUES('route-1','account-1','route-1-public','"172.0.0.0/16"','["test-1.com"]',true,'route-1-net-id','route-1',
'peer-id-1','["group-one-resource-id"]',1,true,9999,true,
'["group-one-resource-id"]','["group-one-resource-id"]',false)`)
execQuery(t, ctx,
`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
peer, peer_groups, network_type, masquerade, metric, enabled,
groups, access_control_groups, skip_auto_apply)
VALUES('route-2','account-1','route-2-public','"172.10.0.0/16"','["test-1.com","test-2.com"]',true,'route-2-net-id','route-2',
'peer-id-2','["group-two-resources-id"]',1,true,9999,true,
'["group-two-resources-id"]','["group-two-resources-id"]',false)`)
execQuery(t, ctx,
`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
peer, peer_groups, network_type, masquerade, metric, enabled,
groups, access_control_groups, skip_auto_apply)
VALUES('route-3','account-1','route-3-public',null,null,null,null,'route-3',
null,null,null,null,null,null,null,null,null)`)
routes, err := conn(t, ctx).GetRoutes(ctx, "account-1")
assert.NoError(t, err)
assert.Contains(t, routes, nmdata.Route{
ID: "route-1",
AccountID: "account-1",
PublicID: "route-1-public",
Network: netip.MustParsePrefix("172.0.0.0/16"),
Domains: domain.List{"test-1.com"},
KeepRoute: true,
NetID: "route-1-net-id",
Description: "route-1",
Peer: "peer-id-1",
PeerID: "peer-id-1",
PeerGroups: []string{"group-one-resource-id"},
NetworkType: 1,
Masquerade: true,
Metric: 9999,
Enabled: true,
Groups: []string{"group-one-resource-id"},
AccessControlGroups: []string{"group-one-resource-id"},
SkipAutoApply: false,
})
assert.Contains(t, routes, nmdata.Route{
ID: "route-2",
AccountID: "account-1",
PublicID: "route-2-public",
Network: netip.MustParsePrefix("172.10.0.0/16"),
Domains: domain.List{"test-1.com", "test-2.com"},
KeepRoute: true,
NetID: "route-2-net-id",
Description: "route-2",
Peer: "peer-id-2",
PeerID: "peer-id-2",
PeerGroups: []string{"group-two-resources-id"},
NetworkType: 1,
Masquerade: true,
Metric: 9999,
Enabled: true,
Groups: []string{"group-two-resources-id"},
AccessControlGroups: []string{"group-two-resources-id"},
SkipAutoApply: false,
})
assert.Contains(t, routes, nmdata.Route{
ID: "route-3",
AccountID: "account-1",
PublicID: "route-3-public",
Description: "route-3",
})
}

View File

@@ -0,0 +1,109 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
)
func TestGetPrivateServices(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
values('service-1','account-1',true,true,'["group-one-resource-id"]','test-1.com','test-2.com')`)
execQuery(t, ctx,
`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
values('service-2','account-1',true,true,'["group-one-resource-id","group-two-resources-id"]','test-3.com','test-4.com')`)
execQuery(t, ctx,
`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
values('service-3','account-1',null,null,null,null,null)`)
services, err := conn(t, ctx).GetPrivateServices(ctx, "account-1")
assert.NoError(t, err)
assert.Contains(t, services, networkmapdb.Service{
Enabled: sql.NullBool{Bool: true, Valid: true},
Private: sql.NullBool{Bool: true, Valid: true},
AccessGroups: []string{"group-one-resource-id"},
ProxyCluster: sql.NullString{String: "test-1.com", Valid: true},
Domain: sql.NullString{String: "test-2.com", Valid: true},
})
assert.Contains(t, services, networkmapdb.Service{
Enabled: sql.NullBool{Bool: true, Valid: true},
Private: sql.NullBool{Bool: true, Valid: true},
AccessGroups: []string{"group-one-resource-id", "group-two-resources-id"},
ProxyCluster: sql.NullString{String: "test-3.com", Valid: true},
Domain: sql.NullString{String: "test-4.com", Valid: true},
})
assert.Contains(t, services, networkmapdb.Service{
Enabled: sql.NullBool{Bool: false, Valid: false},
Private: sql.NullBool{Bool: false, Valid: false},
AccessGroups: []string{},
ProxyCluster: sql.NullString{String: "", Valid: false},
Domain: sql.NullString{String: "", Valid: false},
})
}
func TestGetProxyTargetedDomainResourceIDs(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into services (id, account_id, enabled, terminated)
values('service-4','account-1',true,false)`)
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-1','account-1','service-4',true,'domain')`)
// id shouldn't be returned as the taget_type is not "domain"
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-2','account-1','service-4',true,'cluster')`)
// id shouldn't be included as the target is disabled
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-3','account-1','service-4',false,'domain')`)
// id shouldn't be included as the service is disabled
execQuery(t, ctx,
`insert into services (id, account_id, enabled, terminated)
values('service-5','account-1',false,false)`)
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-4','account-1','service-5',false,'domain')`)
// id shouldn't be included as the service is terminated (explicitly)
execQuery(t, ctx,
`insert into services (id, account_id, enabled, terminated)
values('service-6','account-1',true,true)`)
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-5','account-1','service-6',true,'domain')`)
// id shouldn't be included as the service is terminated (implicitly)
execQuery(t, ctx,
`insert into services (id, account_id, enabled, terminated)
values('service-7','account-1',true,null)`)
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-6','account-1','service-7',true,'domain')`)
execQuery(t, ctx,
`insert into services (id, account_id, enabled, terminated)
values('service-8','account-1',true,false)`)
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-7','account-1','service-8',true,'domain')`)
// id shouldn't be returned as the taget_id is null
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values(null,'account-1','service-4',true,'cluster')`)
servtargetedDomains, err := conn(t, ctx).GetProxyTargetedDomainResourceIDs(ctx, "account-1")
assert.NoError(t, err)
assert.Equal(t, servtargetedDomains, map[string]struct{}{
"target-1": {},
"target-6": {},
"target-7": {},
})
}

View File

@@ -0,0 +1,48 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"fmt"
"runtime"
"strings"
networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite"
gormstore "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
log "github.com/sirupsen/logrus"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func createSqliteTestStore(baseData string) (*networkmap_sqlite.SqliteStore, func()) {
storeSqliteFileName := ":memory:"
storeStr := fmt.Sprintf("%s?cache=shared", storeSqliteFileName)
if runtime.GOOS == "windows" {
// Vo avoid `The process cannot access the file because it is being used by another process` on Windows
storeStr = storeSqliteFileName
}
db, err := gorm.Open(sqlite.Open(storeStr), &gorm.Config{})
if err != nil {
log.Fatalf("error initializing db: %s", err.Error())
}
_, err = gormstore.NewSqlStore(context.TODO(), db, types.SqliteStoreEngine, nil, false)
if err != nil {
log.Fatalf("error initializing db: %s", err.Error())
}
sqldb, err := db.DB()
if err != nil {
log.Fatalf("error initializing db: %s", err.Error())
}
for _, query := range strings.Split(baseData, ";") {
if _, err := sqldb.Exec(query); err != nil {
log.Fatalf("error initializing db: %s", err.Error())
}
}
return &networkmap_sqlite.SqliteStore{Db: sqldb}, func() {}
}

View File

@@ -0,0 +1,57 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetAllowedUsers(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
VALUES('user-1','user-1','account-1','["group-one-resource-id"]',false,false)`)
execQuery(t, ctx,
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
VALUES('user-2','user-2','account-1','["group-one-resource-id","group-two-resources-id"]',false,false)`)
execQuery(t, ctx,
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
VALUES('user-3','user-3','account-1','["group-two-resources-id"]',false,false)`)
// shouldn't be included as it's blocked
execQuery(t, ctx,
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
VALUES('user-4','user-4','account-1','["group-two-resources-id"]',true,false)`)
// shouldn't be included as it's a service_user
execQuery(t, ctx,
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
VALUES('user-5','user-5','account-1','["group-two-resources-id"]',false,true)`)
execQuery(t, ctx,
`insert into groups (id, name, account_id)
VALUES('all-group-1','All','account-1')`)
execQuery(t, ctx,
`insert into groups (id, name, account_id)
VALUES('all-group-2','All','account-1')`)
execQuery(t, ctx,
`insert into groups (id, name, account_id)
VALUES('all-group-3','All','account-1')`)
userIdx, groupIdToUserIds, err := conn(t, ctx).GetAllowedUsers(ctx, "account-1")
assert.NoError(t, err)
assert.Equal(t, userIdx, map[string]struct{}{
"user-1": {},
"user-2": {},
"user-3": {},
})
assert.Equal(t, groupIdToUserIds, map[string][]string{
"group-one-resource-id": {"user-1", "user-2"},
"group-two-resources-id": {"user-2", "user-3"},
"all-group-1": {"user-1", "user-2", "user-3"},
"all-group-2": {"user-1", "user-2", "user-3"},
"all-group-3": {"user-1", "user-2", "user-3"},
})
}

10
magefiles/magefile.go Normal file
View File

@@ -0,0 +1,10 @@
//mage:multiline
// Set the general description you want to have displayed with mage -l here.
package main
// mg contains helpful utility functions, like Deps
// Default target to run when none is specified
// If not set, running mage will list available targets
//var Default = Integrationtest.All

74
magefiles/test.go Normal file
View File

@@ -0,0 +1,74 @@
package main
import (
"errors"
"strings"
"github.com/magefile/mage/mg"
"github.com/magefile/mage/sh"
)
var defaultcli = []string{"test", "-tags=integration", "-timeout=20m"}
type Integrationtest mg.Namespace
func (i Integrationtest) All(gotestflags *string) error {
var errs []error
if err := i.Api(gotestflags); err != nil {
errs = append(errs, err)
}
if err := i.NmapDb(gotestflags); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
func (Integrationtest) NmapDb(gotestflags *string) error {
cli := defaultcli
if gotestflags != nil {
cli = append(cli, strings.Split(*gotestflags, " ")...)
}
cli = append(cli, "./integration_tests/management/network_map_db/...")
return sh.RunV("go", cli...)
}
func (Integrationtest) NmapDbPostgres(gotestflags *string) error {
cli := defaultcli
if gotestflags != nil {
cli = append(cli, strings.Split(*gotestflags, " ")...)
}
cli = append(cli, "./integration_tests/management/network_map_db/...")
return sh.RunWithV(map[string]string{"NETBIRD_STORE_ENGINE": "postgres"}, "go", cli...)
}
func (Integrationtest) NmapDbSqlite(gotestflags *string) error {
cli := defaultcli
if gotestflags != nil {
cli = append(cli, strings.Split(*gotestflags, " ")...)
}
cli = append(cli, "./integration_tests/management/network_map_db/...")
return sh.RunWithV(map[string]string{"NETBIRD_STORE_ENGINE": "sqlite"}, "go", cli...)
}
func (Integrationtest) RegenerateNmapGoldenData(gotestflags *string) error {
cli := defaultcli
if gotestflags != nil {
cli = append(cli, strings.Split(*gotestflags, " ")...)
}
cli = append(cli, "./integration_tests/management/network_map_db/...")
return sh.RunWithV(map[string]string{"NMAP_UPDATE_GOLDEN_DATA": "true", "NETBIRD_STORE_ENGINE": "sqlite"}, "go", cli...)
}
func (Integrationtest) Api(gotestflags *string) error {
cli := defaultcli
if gotestflags != nil {
cli = append(cli, strings.Split(*gotestflags, " ")...)
}
cli = append(cli, "./management/server/http/...")
return sh.RunV("go", cli...)
}

View File

@@ -18,8 +18,10 @@ import (
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/internals/shared/requestbuffer"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
@@ -30,12 +32,16 @@ import (
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/management/server/types"
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/version"
)
const defaultNetworkMapDataBufferInterval = 100 * time.Millisecond
type Controller struct {
repo Repository
metrics *metrics
@@ -61,6 +67,9 @@ type Controller struct {
serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion
perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion
nmdataStore *networkmapdb.NetworkMapDBStoreImpl
nmdataBuffer *requestbuffer.Buffer[*networkmap.NetworkMapData]
}
type bufferUpdate struct {
@@ -78,13 +87,13 @@ type bufferAffectedUpdate struct {
var _ network_map.Controller = (*Controller)(nil)
func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config) *Controller {
func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller {
nMetrics, err := newMetrics(metrics.UpdateChannelMetrics())
if err != nil {
log.Fatal(fmt.Errorf("error creating metrics: %w", err))
}
return &Controller{
c := &Controller{
repo: newRepository(store),
metrics: nMetrics,
accountManagerMetrics: metrics.AccountManagerMetrics(),
@@ -99,7 +108,16 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App
EphemeralPeersManager: ephemeralPeersManager,
serverSupportedSyncMessageVersion: sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion),
perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion),
nmdataStore: nmdataStore,
}
if nmdataStore != nil {
interval := requestbuffer.Interval(ctx, "NB_NETWORK_MAP_DATA_BUFFER_INTERVAL", defaultNetworkMapDataBufferInterval)
log.WithContext(ctx).Infof("set network map data request buffer interval to %s", interval)
c.nmdataBuffer = requestbuffer.New(ctx, "network map data request buffer", interval, c.fetchNetworkMapData)
}
return c
}
func (c *Controller) OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *network_map.UpdateMessage, error) {
@@ -125,12 +143,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 {
@@ -138,7 +156,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 {
@@ -147,6 +184,11 @@ func (c *Controller) CountStreams() int {
func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
log.WithContext(ctx).Tracef("updating peers for account %s from %s", accountID, util.GetCallerName())
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.sendUpdateAccountPeersFromData(ctx, accountID, reason, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
return fmt.Errorf("failed to get account: %v", err)
@@ -167,7 +209,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
return nil
}
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validate peers: %v", err)
}
@@ -255,7 +297,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
// the client merges it into Calculate()'s output the same
// way the legacy server did via NetworkMap.Merge.
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -276,7 +318,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
}
start = time.Now()
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -294,6 +336,290 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
return nil
}
// sendUpdateAccountPeersFromData is the account-free variant of
// sendUpdateAccountPeers: everything is computed from the network-map DB
// store's twin data; only extra settings and validated peers are resolved at
// runtime. Proxy network maps and policy injection, private-service zones,
// group-to-user SSH mappings and forced routing-peer DNS resolution have no
// DB-backed source yet and are omitted.
func (c *Controller) sendUpdateAccountPeersFromData(ctx context.Context, accountID string, reason types.UpdateReason, nmData *networkmap.NetworkMapData) error {
peersToUpdate := c.connectedPeersFromData(nmData, nil)
if len(peersToUpdate) == 0 {
return nil
}
return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, &reason)
}
// sendUpdateForAffectedPeersFromData is the account-free variant of
// sendUpdateForAffectedPeers.
func (c *Controller) sendUpdateForAffectedPeersFromData(ctx context.Context, accountID string, peerIDs []string, nmData *networkmap.NetworkMapData) error {
if len(peerIDs) == 0 {
log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no affected peers")
return nil
}
peersToUpdate := c.connectedPeersFromData(nmData, peerIDs)
if len(peersToUpdate) == 0 {
log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no peers to update (affected peers not found in data or no channels)")
return nil
}
log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: sending network map to %d connected peers", len(peersToUpdate))
return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, nil)
}
// connectedPeersFromData returns the peers with an open update channel. An
// empty affected list means all peers; a non-empty list restricts the result
// to those peer IDs.
func (c *Controller) connectedPeersFromData(nmData *networkmap.NetworkMapData, affected []string) []*nmdata.Peer {
if len(affected) == 0 {
result := make([]*nmdata.Peer, 0, len(nmData.Peers))
for _, peer := range nmData.Peers {
if c.peersUpdateManager.HasChannel(peer.ID) {
result = append(result, peer)
}
}
return result
}
result := make([]*nmdata.Peer, 0, len(affected))
for _, peerID := range affected {
peer := nmData.Peers[peerID]
if peer == nil {
continue
}
if c.peersUpdateManager.HasChannel(peerID) {
result = append(result, peer)
}
}
return result
}
func (c *Controller) sendUpdatesFromData(ctx context.Context, accountID string, nmData *networkmap.NetworkMapData, peersToUpdate []*nmdata.Peer, reason *types.UpdateReason) error {
globalStart := time.Now()
extraSettings, err := c.settingsManager.GetExtraSettings(ctx, accountID)
if err != nil {
return fmt.Errorf("failed to get flow enabled status: %v", err)
}
dnsCache := &cache.DNSConfigCache{}
dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
var wg sync.WaitGroup
semaphore := make(chan struct{}, 10)
for _, peer := range peersToUpdate {
if reason != nil && c.accountManagerMetrics != nil {
c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation))
}
wg.Add(1)
semaphore <- struct{}{}
go func(p *nmdata.Peer) {
defer wg.Done()
defer func() { <-semaphore }()
start := time.Now()
postureChecks := peerPostureChecksFromData(nmData, p.ID)
c.metrics.CountCalcPostureChecksDuration(time.Since(start))
start = time.Now()
peerGroups := maps.Keys(nmData.GetPeerGroups(p.ID))
var update *proto.SyncResponse
commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion))
log.WithContext(ctx).
WithFields(log.Fields{
"sync_message_version": commonSyncMessageVersion,
"server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
"peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion),
}).Debug("common highest sync message version")
if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap {
components := nmData.GetPeerNetworkMapComponents(p.ID, peersCustomZone)
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
start = time.Now()
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, nil, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
Update: update,
MessageType: network_map.MessageTypeNetworkMap,
})
return
}
nmap := NetworkMapFromData(ctx, nmData, p.ID, peersCustomZone, c.accountManagerMetrics)
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
start = time.Now()
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
c.metrics.CountToSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
Update: update,
MessageType: network_map.MessageTypeNetworkMap,
})
}(peer)
}
wg.Wait()
if c.accountManagerMetrics != nil {
c.accountManagerMetrics.CountUpdateAccountPeersDuration(time.Since(globalStart))
}
return nil
}
func (c *Controller) getNetworkMapData(ctx context.Context, accountID string) *networkmap.NetworkMapData {
if c.nmdataBuffer == nil {
return nil
}
nmData, err := c.nmdataBuffer.Get(ctx, accountID)
if err != nil {
log.WithContext(ctx).Errorf("failed to get network map data for account %s, falling back to account-based computation: %v", accountID, err)
return nil
}
return nmData
}
// fetchNetworkMapData reads the twin once per buffer window. Its result is
// shared by every waiter of that window, so the mutating steps run here, before
// it is handed out: the twin the callers see is read-only. Injected proxy
// policies carry no posture checks, so precomputing after the injection yields
// the same validation as precomputing before it.
func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string) (*networkmap.NetworkMapData, error) {
nmData, err := c.nmdataStore.GetNetworkMapData(ctx, accountID)
if err != nil {
return nil, err
}
nmData.Services = c.proxyServicesFromRepo(ctx, accountID)
nmData.InjectProxyPolicies()
nmData.PrecomputePostureValidation()
return nmData, nil
}
func (c *Controller) getDNSDomainFromData(settings *nmdata.AccountSettingsInfo) string {
if settings == nil || settings.DNSDomain == "" {
return c.dnsDomain
}
return settings.DNSDomain
}
func IPv6AllowedPeersFromData(nmData *networkmap.NetworkMapData) map[string]struct{} {
result := make(map[string]struct{})
// An account with no IPv6-enabled group runs no overlay at all, so the
// embedded-proxy carve-out below has nothing to reach and stays shut.
if nmData.AccountSettings == nil || len(nmData.AccountSettings.IPv6EnabledGroups) == 0 {
return result
}
for _, groupID := range nmData.AccountSettings.IPv6EnabledGroups {
group := nmData.Groups[groupID]
if group == nil {
continue
}
for _, peerID := range group.Peers {
result[peerID] = struct{}{}
}
}
for id, p := range nmData.Peers {
if p != nil && p.ProxyMeta.Embedded {
result[id] = struct{}{}
}
}
return result
}
func NetworkMapFromData(ctx context.Context, nmData *networkmap.NetworkMapData, peerID string, peersCustomZone nmdata.CustomZone, metrics *telemetry.AccountManagerMetrics) *types.NetworkMap {
start := time.Now()
components := nmData.GetPeerNetworkMapComponents(peerID, peersCustomZone)
if components.IsEmpty() {
return &types.NetworkMap{Network: components.Network}
}
nm := types.CalculateNetworkMapFromComponents(ctx, components)
if metrics != nil {
objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
metrics.CountNetworkMapObjects(objectCount)
metrics.CountGetPeerNetworkMapDuration(time.Since(start))
}
return nm
}
// peerPostureChecksFromData mirrors getPeerPostureChecks on the twin store. The
// sync response only encodes process-check file paths, so only ProcessCheck is
// converted back to the server posture type.
func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string) []*posture.Checks {
if len(nmData.PostureChecks) == 0 {
return nil
}
peerPostureChecks := make(map[string]*posture.Checks)
for _, policy := range nmData.Policies {
if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
continue
}
if !isPeerInPolicySourceGroupsFromData(nmData, peerID, policy) {
continue
}
for _, checkID := range policy.SourcePostureChecks {
twin := nmData.PostureChecks[checkID]
if twin == nil {
continue
}
peerPostureChecks[checkID] = postureChecksFromTwin(twin)
}
}
return maps.Values(peerPostureChecks)
}
func isPeerInPolicySourceGroupsFromData(nmData *networkmap.NetworkMapData, peerID string, policy *nmdata.Policy) bool {
for _, rule := range policy.Rules {
if rule == nil || !rule.Enabled {
continue
}
for _, groupID := range rule.Sources {
if group := nmData.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) {
return true
}
}
}
return false
}
func postureChecksFromTwin(twin *nmdata.PostureChecks) *posture.Checks {
checks := &posture.Checks{ID: twin.ID}
if twin.Checks.ProcessCheck != nil {
processes := make([]posture.Process, 0, len(twin.Checks.ProcessCheck.Processes))
for _, p := range twin.Checks.ProcessCheck.Processes {
processes = append(processes, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
}
checks.Checks.ProcessCheck = &posture.ProcessCheck{Processes: processes}
}
return checks
}
func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion {
if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok {
return perAccount
@@ -326,6 +652,10 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
return nil
}
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.sendUpdateForAffectedPeersFromData(ctx, accountID, peerIDs, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
return fmt.Errorf("failed to get account: %v", err)
@@ -341,7 +671,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: sending network map to %d connected peers", len(peersToUpdate))
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validate peers: %v", err)
}
@@ -428,7 +758,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
// the client merges it into Calculate()'s output the same
// way the legacy server did via NetworkMap.Merge.
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -449,7 +779,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
}
start = time.Now()
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -506,7 +836,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
return fmt.Errorf("peer %s doesn't exists in account %s", peerId, accountId)
}
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validated peers: %v", err)
}
@@ -566,7 +896,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
// the client merges it into Calculate()'s output the same
// way the legacy server did via NetworkMap.Merge.
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
Update: update,
@@ -583,7 +913,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
nmap.Merge(proxyNetworkMap)
}
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
Update: update,
@@ -643,7 +973,11 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
if err != nil {
return nil, nil, nil, nil, 0, err
}
return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil
return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil
}
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.getValidatedPeerWithComponentsFromData(ctx, accountID, peer, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
@@ -658,7 +992,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
c.injectAllProxyPolicies(ctx, account)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return nil, nil, nil, nil, 0, err
}
@@ -695,6 +1029,21 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil
}
// getValidatedPeerWithComponentsFromData is the account-free variant of
// GetValidatedPeerWithComponents. The proxy network map fragment is omitted
// like on the other nmdata paths.
func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) {
postureChecks := peerPostureChecksFromData(nmData, peer.ID)
dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
components := nmData.GetPeerNetworkMapComponents(peer.ID, peersCustomZone)
dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
return peer, components, nil, postureChecks, dnsFwdPort, nil
}
// BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval.
func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
if len(peerIDs) == 0 {
@@ -801,11 +1150,15 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
}
emptyMap := &types.NetworkMap{
Network: network.Copy(),
Network: types.TwinNetwork(network),
}
return emptyMap, nil, 0, nil
}
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.getValidatedPeerWithMapFromData(ctx, accountID, peerID, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
return nil, nil, 0, err
@@ -813,7 +1166,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
c.injectAllProxyPolicies(ctx, account)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return nil, nil, 0, err
}
@@ -853,6 +1206,21 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
return networkMap, postureChecks, dnsFwdPort, nil
}
// getValidatedPeerWithMapFromData is the account-free variant of
// GetValidatedPeerWithMap. The proxy network map fragment is omitted like on
// the other nmdata paths.
func (c *Controller) getValidatedPeerWithMapFromData(ctx context.Context, accountID string, peerID string, nmData *networkmap.NetworkMapData) (*types.NetworkMap, []*posture.Checks, int64, error) {
postureChecks := peerPostureChecksFromData(nmData, peerID)
dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
networkMap := NetworkMapFromData(ctx, nmData, peerID, peersCustomZone, c.accountManagerMetrics)
dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
return networkMap, postureChecks, dnsFwdPort, nil
}
// GetDNSDomain returns the configured dnsDomain
func (c *Controller) GetDNSDomain(settings *types.Settings) string {
if settings == nil {
@@ -915,20 +1283,36 @@ func (c *Controller) StartWarmup(ctx context.Context) {
// computeForwarderPort checks if all peers in the account have updated to a specific version or newer.
// If all peers have the required version, it returns the new well-known port (22054), otherwise returns 0.
func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
if len(peers) == 0 {
versions := make([]string, 0, len(peers))
for _, peer := range peers {
versions = append(versions, peer.Meta.WtVersion)
}
return computeForwarderPortFromVersions(versions, requiredVersion)
}
func ComputeForwarderPortFromData(peers map[string]*nmdata.Peer, requiredVersion string) int64 {
versions := make([]string, 0, len(peers))
for _, peer := range peers {
versions = append(versions, peer.Meta.WtVersion)
}
return computeForwarderPortFromVersions(versions, requiredVersion)
}
func computeForwarderPortFromVersions(wtVersions []string, requiredVersion string) int64 {
if len(wtVersions) == 0 {
return int64(network_map.OldForwarderPort)
}
reqVer := semver.Canonical(requiredVersion)
// Check if all peers have the required version or newer
for _, peer := range peers {
for _, wtVersion := range wtVersions {
// Development version is always supported
if version.IsDevelopmentVersion(peer.Meta.WtVersion) {
if version.IsDevelopmentVersion(wtVersion) {
continue
}
peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
peerVersion := semver.Canonical("v" + wtVersion)
if peerVersion == "" {
// If any peer doesn't have version info, return 0
return int64(network_map.OldForwarderPort)
@@ -1062,7 +1446,12 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
groups[groupID] = group.Peers
}
validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
extraSettings, err := c.settingsManager.GetExtraSettings(ctx, account.Id)
if err != nil {
return nil, err
}
validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), extraSettings)
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,47 @@
package controller
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
// The account-side builder (types.Account.peerIPv6AllowedSet) is the reference:
// an account with no IPv6-enabled group runs no IPv6 overlay at all, embedded
// proxy peers included — see TestPeerIPv6AllowedEmbeddedProxy. Both builders
// gate the same AAAA records, so the store-backed one has to agree.
func TestIPv6AllowedPeersFromData(t *testing.T) {
data := func(enabledGroups []string) *networkmap.NetworkMapData {
return &networkmap.NetworkMapData{
AccountSettings: &nmdata.AccountSettingsInfo{IPv6EnabledGroups: enabledGroups},
Peers: map[string]*nmdata.Peer{
"peer1": {ID: "peer1"},
"lonely": {ID: "lonely"},
"proxy": {ID: "proxy", ProxyMeta: nmdata.ProxyMeta{Embedded: true, Cluster: "netbird.test"}},
},
Groups: map[string]*nmdata.Group{
"group-devs": {ID: "group-devs", Peers: []string{"peer1"}},
},
}
}
t.Run("embedded proxy allowed when any v6 group exists, without group membership", func(t *testing.T) {
allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"}))
assert.Contains(t, allowed, "proxy", "embedded proxy participates in v6 overlay")
assert.Contains(t, allowed, "peer1", "regular peer in enabled group still allowed")
})
t.Run("embedded proxy denied when no v6 group enabled", func(t *testing.T) {
allowed := IPv6AllowedPeersFromData(data(nil))
assert.NotContains(t, allowed, "proxy", "v6 disabled account-wide denies embedded proxies too")
assert.Empty(t, allowed, "no peer participates in the v6 overlay")
})
t.Run("non-embedded peer outside any enabled group is not pulled in", func(t *testing.T) {
allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"}))
assert.NotContains(t, allowed, "lonely", "embedded-proxy bypass must not leak to regular peers")
})
}

View File

@@ -24,6 +24,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 {
@@ -62,6 +63,10 @@ func (r *repository) SynthesizeAgentNetworkServices(ctx context.Context, account
return agentnetwork.SynthesizeServices(ctx, r.store, accountID)
}
func (r *repository) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) {
return r.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
}
func (r *repository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) {
return r.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID)
}

View File

@@ -0,0 +1,380 @@
package nmaptest
import (
"bytes"
"cmp"
"fmt"
"slices"
"sort"
"strconv"
"strings"
"github.com/netbirdio/netbird/shared/management/proto"
)
// normalizeIDSpace replaces policy and route identifiers with positional
// placeholders so a comparison can reach everything else.
//
// This exists only because the envelope round-trip currently substitutes each
// internal xid with the object's public id, which is a tracked defect and not a
// licence to differ: those identifiers reach the server again inside flow
// events, which resolve them by internal id, so the substitution silently
// breaks flow attribution for component-format peers. TestIDSpaceMatches
// asserts the equality that must eventually hold; this erasure keeps the other
// 40-odd cases reporting on semantics meanwhile. When the id space is unified,
// delete this and the calls to it — every case should still pass.
//
// Cardinality and cross-references survive the erasure: two rules under one
// policy still share a token and a route firewall rule still points at its
// route, so a path that drops a policy, merges two policies, or misattributes a
// rule to the wrong route still fails.
func normalizeIDSpace(nm *proto.NetworkMap) {
if nm == nil {
return
}
policies := newTokenizer("policy")
routes := newTokenizer("route")
for _, i := range orderBy(nm.Routes, routeKeyWithoutID) {
nm.Routes[i].ID = routes.get(nm.Routes[i].ID)
}
for _, i := range orderBy(nm.FirewallRules, firewallKeyWithoutPolicy) {
r := nm.FirewallRules[i]
if len(r.PolicyID) > 0 {
r.PolicyID = []byte(policies.get(string(r.PolicyID)))
}
}
for _, i := range orderBy(nm.RoutesFirewallRules, routeFirewallKeyWithoutIDs) {
r := nm.RoutesFirewallRules[i]
if len(r.PolicyID) > 0 {
r.PolicyID = []byte(policies.get(string(r.PolicyID)))
}
r.RouteID = routes.get(r.RouteID)
}
}
// tokenizer maps identifiers to positional placeholders in order of first use.
type tokenizer struct {
prefix string
seen map[string]string
}
func newTokenizer(prefix string) *tokenizer {
return &tokenizer{prefix: prefix, seen: make(map[string]string)}
}
func (t *tokenizer) get(id string) string {
if id == "" {
return ""
}
if tok, ok := t.seen[id]; ok {
return tok
}
tok := fmt.Sprintf("%s#%d", t.prefix, len(t.seen))
t.seen[id] = tok
return tok
}
// orderBy returns indices sorted by key, so placeholder numbering does not
// depend on the identifiers being erased.
func orderBy[T any](items []T, key func(T) string) []int {
idx := make([]int, len(items))
for i := range idx {
idx[i] = i
}
sort.SliceStable(idx, func(a, b int) bool { return key(items[idx[a]]) < key(items[idx[b]]) })
return idx
}
func routeKeyWithoutID(r *proto.Route) string {
if r == nil {
return ""
}
return fmt.Sprintf("%s|%s|%s|%d|%d|%t|%t|%v",
r.Network, r.NetID, r.Peer, r.Metric, r.NetworkType, r.Masquerade, r.KeepRoute, r.Domains)
}
func firewallKeyWithoutPolicy(r *proto.FirewallRule) string {
if r == nil {
return ""
}
return fmt.Sprintf("%s|%d|%d|%d|%s|%s|%v",
r.PeerIP, r.Direction, r.Action, r.Protocol, r.Port, portInfoKey(r.PortInfo), r.SourcePrefixes) //nolint:staticcheck
}
func routeFirewallKeyWithoutIDs(r *proto.RouteFirewallRule) string {
if r == nil {
return ""
}
return fmt.Sprintf("%s|%d|%d|%s|%v|%v|%t|%d",
r.Destination, r.Protocol, r.Action, portInfoKey(r.PortInfo), r.Domains, r.SourceRanges, r.IsDynamic, r.CustomProtocol)
}
// canonicalize sorts every repeated field of the NetworkMap by a stable key.
// The producing paths iterate Go maps while building these slices, so order
// can differ between runs even when the content is identical; comparing
// without this reports noise.
func canonicalize(nm *proto.NetworkMap) {
if nm == nil {
return
}
slices.SortFunc(nm.RemotePeers, cmpRemotePeer)
slices.SortFunc(nm.OfflinePeers, cmpRemotePeer)
slices.SortFunc(nm.Routes, cmpRoute)
slices.SortFunc(nm.FirewallRules, cmpFirewallRule)
slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule)
slices.SortFunc(nm.ForwardingRules, cmpForwardingRule)
for _, r := range nm.FirewallRules {
slices.SortFunc(r.SourcePrefixes, bytes.Compare)
}
for _, r := range nm.RoutesFirewallRules {
slices.Sort(r.SourceRanges)
}
canonicalizeDNSConfig(nm.DNSConfig)
canonicalizeSSHAuth(nm.SshAuth)
}
func canonicalizeDNSConfig(d *proto.DNSConfig) {
if d == nil {
return
}
for _, g := range d.NameServerGroups {
if g == nil {
continue
}
slices.Sort(g.Domains)
slices.SortFunc(g.NameServers, func(a, b *proto.NameServer) int {
if a == nil || b == nil {
return boolCmp(a == nil, b == nil)
}
if c := cmp.Compare(a.IP, b.IP); c != 0 {
return c
}
if c := cmp.Compare(a.Port, b.Port); c != 0 {
return c
}
return cmp.Compare(a.NSType, b.NSType)
})
}
slices.SortFunc(d.NameServerGroups, func(a, b *proto.NameServerGroup) int {
return cmp.Compare(nsgKey(a), nsgKey(b))
})
for _, z := range d.CustomZones {
if z == nil {
continue
}
slices.SortFunc(z.Records, cmpSimpleRecord)
}
slices.SortFunc(d.CustomZones, func(a, b *proto.CustomZone) int {
if a == nil || b == nil {
return boolCmp(a == nil, b == nil)
}
return cmp.Compare(a.Domain, b.Domain)
})
}
// canonicalizeSSHAuth sorts AuthorizedUsers and re-keys MachineUsers.Indexes
// against the new ordering, preserving which machine user maps to which hashes.
func canonicalizeSSHAuth(s *proto.SSHAuth) {
if s == nil || len(s.AuthorizedUsers) == 0 {
return
}
type hashed struct {
bytes []byte
old uint32
}
entries := make([]hashed, len(s.AuthorizedUsers))
for i, b := range s.AuthorizedUsers {
entries[i] = hashed{bytes: b, old: uint32(i)}
}
slices.SortFunc(entries, func(a, b hashed) int { return bytes.Compare(a.bytes, b.bytes) })
remap := make(map[uint32]uint32, len(entries))
sorted := make([][]byte, len(entries))
for newIdx, e := range entries {
remap[e.old] = uint32(newIdx)
sorted[newIdx] = e.bytes
}
s.AuthorizedUsers = sorted
for _, mu := range s.MachineUsers {
if mu == nil {
continue
}
for i, oldIdx := range mu.Indexes {
if newIdx, ok := remap[oldIdx]; ok {
mu.Indexes[i] = newIdx
}
}
slices.Sort(mu.Indexes)
}
}
func boolCmp(a, b bool) int {
if a == b {
return 0
}
if a {
return 1
}
return -1
}
func nsgKey(g *proto.NameServerGroup) string {
if g == nil {
return ""
}
var parts []string
for _, ns := range g.NameServers {
if ns == nil {
continue
}
parts = append(parts, ns.IP+":"+strconv.FormatInt(ns.Port, 10)+":"+strconv.FormatInt(ns.NSType, 10))
}
slices.Sort(parts)
key := strings.Join(parts, ",")
domains := append([]string(nil), g.Domains...)
slices.Sort(domains)
key += "|" + strings.Join(domains, "|")
if g.Primary {
key += "|P"
}
if g.SearchDomainsEnabled {
key += "|S"
}
return key
}
func cmpSimpleRecord(a, b *proto.SimpleRecord) int {
if a == nil || b == nil {
return boolCmp(a == nil, b == nil)
}
if c := cmp.Compare(a.Name, b.Name); c != 0 {
return c
}
if c := cmp.Compare(a.Type, b.Type); c != 0 {
return c
}
if c := cmp.Compare(a.Class, b.Class); c != 0 {
return c
}
if c := cmp.Compare(a.RData, b.RData); c != 0 {
return c
}
return cmp.Compare(a.TTL, b.TTL)
}
func cmpRemotePeer(a, b *proto.RemotePeerConfig) int {
if a == nil || b == nil {
return boolCmp(a == nil, b == nil)
}
return cmp.Compare(a.WgPubKey, b.WgPubKey)
}
func cmpRoute(a, b *proto.Route) int {
if a == nil || b == nil {
return boolCmp(a == nil, b == nil)
}
if c := cmp.Compare(a.ID, b.ID); c != 0 {
return c
}
if c := cmp.Compare(a.NetID, b.NetID); c != 0 {
return c
}
if c := cmp.Compare(a.Network, b.Network); c != 0 {
return c
}
if c := cmp.Compare(a.Peer, b.Peer); c != 0 {
return c
}
if c := cmp.Compare(a.Metric, b.Metric); c != 0 {
return c
}
return slices.Compare(a.Domains, b.Domains)
}
func cmpFirewallRule(a, b *proto.FirewallRule) int {
if a == nil || b == nil {
return boolCmp(a == nil, b == nil)
}
if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
return c
}
if c := cmp.Compare(a.PeerIP, b.PeerIP); c != 0 { //nolint:staticcheck
return c
}
if c := cmp.Compare(int32(a.Direction), int32(b.Direction)); c != 0 {
return c
}
if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
return c
}
if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
return c
}
if c := cmp.Compare(a.Port, b.Port); c != 0 {
return c
}
return cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo))
}
func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int {
if a == nil || b == nil {
return boolCmp(a == nil, b == nil)
}
if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
return c
}
if c := cmp.Compare(a.RouteID, b.RouteID); c != 0 {
return c
}
if c := cmp.Compare(a.Destination, b.Destination); c != 0 {
return c
}
if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
return c
}
if c := cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo)); c != 0 {
return c
}
if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
return c
}
if c := slices.Compare(a.Domains, b.Domains); c != 0 {
return c
}
if c := slices.Compare(a.SourceRanges, b.SourceRanges); c != 0 {
return c
}
if c := cmp.Compare(a.CustomProtocol, b.CustomProtocol); c != 0 {
return c
}
return boolCmp(a.IsDynamic, b.IsDynamic)
}
func cmpForwardingRule(a, b *proto.ForwardingRule) int {
if a == nil || b == nil {
return boolCmp(a == nil, b == nil)
}
if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
return c
}
return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress)
}
func portInfoKey(pi *proto.PortInfo) string {
if pi == nil {
return ""
}
switch sel := pi.PortSelection.(type) {
case *proto.PortInfo_Port:
return "P" + strconv.FormatUint(uint64(sel.Port), 10)
case *proto.PortInfo_Range_:
if sel.Range == nil {
return "R"
}
return "R" + strconv.FormatUint(uint64(sel.Range.Start), 10) + "-" + strconv.FormatUint(uint64(sel.Range.End), 10)
}
return ""
}

View File

@@ -0,0 +1,218 @@
package nmaptest
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"os"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
// LoadNetworkMapData reads a fixture holding the NetworkMapData the store
// would return for one account. Unknown fields are rejected so fixture typos
// fail loudly instead of silently testing a default.
func LoadNetworkMapData(path string) (*networkmap.NetworkMapData, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open fixture: %w", err)
}
defer f.Close()
dec := json.NewDecoder(f)
dec.DisallowUnknownFields()
var nmData networkmap.NetworkMapData
if err := dec.Decode(&nmData); err != nil {
return nil, fmt.Errorf("decode fixture %s: %w", path, err)
}
return &nmData, nil
}
var defaultNetworkNet = func() net.IPNet {
_, ipnet, err := net.ParseCIDR("100.64.0.0/10")
if err != nil {
panic(err)
}
return *ipnet
}()
// applyFixtureDefaults fills the boilerplate a fixture may omit. Map-keyed
// objects inherit their key as ID, peers get a deterministic WG-shaped key
// and their ID as DNS label, PublicIDs default to the internal ID (the
// envelope encoder puts public IDs on the wire and silently degrades on
// empty ones), and a nil ValidatedPeers validates every peer — production
// fills it through the integrated validator, not the store.
func applyFixtureDefaults(nmData *networkmap.NetworkMapData) {
if nmData.Network == nil {
nmData.Network = &nmdata.Network{}
}
if nmData.Network.Identifier == "" {
nmData.Network.Identifier = "network"
}
if nmData.Network.Net.IP == nil {
nmData.Network.Net = defaultNetworkNet
}
if nmData.AccountSettings == nil {
nmData.AccountSettings = &nmdata.AccountSettingsInfo{}
}
if nmData.DNSSettings == nil {
nmData.DNSSettings = &nmdata.DNSSettings{}
}
for id, p := range nmData.Peers {
if p == nil {
continue
}
if p.ID == "" {
p.ID = id
}
if p.Key == "" {
p.Key = derivedWgKey(p.ID)
}
if p.DNSLabel == "" {
p.DNSLabel = p.ID
}
}
for id, g := range nmData.Groups {
if g == nil {
continue
}
if g.ID == "" {
g.ID = id
}
if g.Name == "" {
g.Name = g.ID
}
if g.PublicID == "" {
g.PublicID = g.ID
}
}
for _, policy := range nmData.Policies {
defaultPolicyIDs(policy)
}
resolveResourcePolicyRefs(nmData)
for _, r := range nmData.Routes {
if r != nil && r.PublicID == "" {
r.PublicID = r.ID
}
}
for _, nsg := range nmData.NameServerGroups {
if nsg != nil && nsg.PublicID == "" {
nsg.PublicID = nsg.ID
}
}
for _, res := range nmData.NetworkResources {
if res == nil {
continue
}
if res.PublicID == "" {
res.PublicID = res.ID
}
defaultXIDMapping(&nmData.NetworkXIDToPublicID, res.NetworkID)
}
for networkID, routers := range nmData.Routers {
defaultXIDMapping(&nmData.NetworkXIDToPublicID, networkID)
for _, router := range routers {
if router != nil && router.PublicID == "" {
router.PublicID = networkID
}
}
}
for id, pc := range nmData.PostureChecks {
if pc == nil {
continue
}
if pc.ID == "" {
pc.ID = id
}
defaultXIDMapping(&nmData.PostureCheckXIDToPublicID, pc.ID)
}
if nmData.ValidatedPeers == nil {
nmData.ValidatedPeers = make(map[string]struct{}, len(nmData.Peers))
for id := range nmData.Peers {
nmData.ValidatedPeers[id] = struct{}{}
}
}
}
// resolveResourcePolicyRefs lets a fixture name an account policy by ID in
// ResourcePolicies — {"ID": "pol-x"} with no rules — instead of repeating it.
// The real store puts the same policy pointer in both places, which is what
// resolving the reference reproduces.
func resolveResourcePolicyRefs(nmData *networkmap.NetworkMapData) {
byID := make(map[string]*nmdata.Policy, len(nmData.Policies))
for _, policy := range nmData.Policies {
if policy != nil && policy.ID != "" {
byID[policy.ID] = policy
}
}
for _, policies := range nmData.ResourcePolicies {
for i, policy := range policies {
if policy == nil {
continue
}
if len(policy.Rules) == 0 {
if full, ok := byID[policy.ID]; ok {
policies[i] = full
continue
}
}
defaultPolicyIDs(policy)
}
}
}
func defaultPolicyIDs(policy *nmdata.Policy) {
if policy == nil {
return
}
if policy.PublicID == "" {
policy.PublicID = policy.ID
}
for i, rule := range policy.Rules {
if rule == nil {
continue
}
if rule.PolicyID == "" {
rule.PolicyID = policy.ID
}
if rule.ID == "" {
// Production gives a rule its policy's id (management/server/policy.go:205,
// "when policy can contain multiple rules, need refactor"), so a
// single-rule policy — the only shape the product can create today —
// must be modelled that way or the wire ids come out unrealistic.
rule.ID = policy.ID
if len(policy.Rules) > 1 {
rule.ID = fmt.Sprintf("%s-rule-%d", policy.ID, i)
}
}
}
}
func defaultXIDMapping(m *map[string]string, id string) {
if id == "" {
return
}
if *m == nil {
*m = make(map[string]string)
}
if _, ok := (*m)[id]; !ok {
(*m)[id] = id
}
}
// derivedWgKey returns a deterministic base64 key of 32 bytes, valid for the
// envelope decoder's WG-key identity.
func derivedWgKey(peerID string) string {
sum := sha256.Sum256([]byte(peerID))
return base64.StdEncoding.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,12 @@
package nmaptest_test
import (
"path/filepath"
"testing"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/nmaptest"
)
func TestNetworkMapGolden(t *testing.T) {
nmaptest.RunGoldenDir(t, filepath.Join("testdata", "cases"))
}

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

@@ -0,0 +1,332 @@
// Package nmaptest measures network map generation on the dedicated store
// path against committed expectations. A case stands in for the store load
// 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 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
// expectation and the answer is normally to fix the code; an expectation
// changes only through a deliberate reviewed edit. Nothing in this package
// writes to testdata — there is no flag that records current behaviour into an
// expectation, because that is how a defect becomes the baseline. Cases whose
// expectation encodes correct behaviour the code does not yet deliver stay red
// on purpose.
//
// A case lives in testdata/cases/<name>/ as case.json (manifest: description,
// peers, optional accountID, dnsDomain, modes), nmdata.json (the fixture the
// 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, 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
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/testing/protocmp"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
)
// Mode selects the wire shape a case is verified through. Both end in a
// *proto.NetworkMap, the one comparison surface shared by every path.
type Mode string
const (
// ModeFull is the legacy wire shape: the server runs Calculate and sends
// the expanded map (grpc.ToSyncResponse).
ModeFull Mode = "full"
// ModeEnvelope is the component wire shape: the server encodes components
// 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, 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
// *proto.NetworkMap per peer — shared by every mode.
type Case struct {
Name string
AccountID string
DNSDomain string
Peers []string
Modes []Mode
Data *networkmap.NetworkMapData
GoldenDir string
}
type manifest struct {
Description string
AccountID string
DNSDomain string
Peers []string
Modes []Mode
}
// RunGoldenDir discovers and runs every fixture case under dir. A case is a
// directory containing case.json (manifest), nmdata.json (store fixture) and
// golden/<peerID>.json (expected proto.NetworkMap, protojson).
func RunGoldenDir(t *testing.T, dir string) {
t.Helper()
entries, err := os.ReadDir(dir)
require.NoError(t, err, "read cases dir")
ran := 0
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
caseDir := filepath.Join(dir, entry.Name())
c, err := loadCase(caseDir)
require.NoError(t, err, "load case %s", entry.Name())
ran++
t.Run(entry.Name(), func(t *testing.T) {
RunCase(t, c)
})
}
require.NotZero(t, ran, "no cases found under %s", dir)
}
func loadCase(caseDir string) (Case, error) {
raw, err := os.ReadFile(filepath.Join(caseDir, "case.json"))
if err != nil {
return Case{}, fmt.Errorf("read manifest: %w", err)
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
return Case{}, fmt.Errorf("decode manifest: %w", err)
}
data, err := LoadNetworkMapData(filepath.Join(caseDir, "nmdata.json"))
if err != nil {
return Case{}, err
}
return Case{
Name: filepath.Base(caseDir),
AccountID: m.AccountID,
DNSDomain: m.DNSDomain,
Peers: m.Peers,
Modes: m.Modes,
Data: data,
GoldenDir: filepath.Join(caseDir, "golden"),
}, nil
}
// RunCase computes each target peer's network map through every enabled mode
// and compares the canonicalized result against the peer's expectation file.
// It mirrors the controller's store path: fill fixture defaults, precompute
// posture validation once, then run the per-peer pipeline.
func RunCase(t *testing.T, c Case) {
t.Helper()
require.NotNil(t, c.Data, "case %s: Data is required", c.Name)
require.NotEmpty(t, c.Peers, "case %s: Peers is required", c.Name)
require.NotEmpty(t, c.GoldenDir, "case %s: GoldenDir is required", c.Name)
if c.AccountID == "" {
c.AccountID = defaultAccountID
}
if c.DNSDomain == "" {
c.DNSDomain = defaultDNSDomain
}
if len(c.Modes) == 0 {
c.Modes = defaultModes
}
ctx := context.Background()
nmData := c.Data
applyFixtureDefaults(nmData)
nmData.PrecomputePostureValidation()
dnsDomain := c.DNSDomain
if nmData.AccountSettings.DNSDomain != "" {
dnsDomain = nmData.AccountSettings.DNSDomain
}
zone := networkmap.PeersCustomZone(ctx, c.AccountID, dnsDomain, nmData.Peers, controller.IPv6AllowedPeersFromData(nmData))
dnsFwdPort := controller.ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
for _, mode := range c.Modes {
if mode == ModeEnvelope {
requireEnvelopeSafeKeys(t, nmData, c.Name)
break
}
}
// 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, legacy)
canonicalize(got)
compareGolden(t, filepath.Join(c.GoldenDir, peerID+".json"), got, mode)
})
}
}
}
// 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, 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, nil)
return mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, nmap, dnsDomain, nil,
&cache.DNSConfigCache{}, nmData.AccountSettings, nil, nil, dnsFwdPort).NetworkMap
case ModeEnvelope:
components := nmData.GetPeerNetworkMapComponents(peerID, zone)
peerGroups := maps.Keys(nmData.GetPeerGroups(peerID))
resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, nil,
dnsDomain, nil, nmData.AccountSettings, nil, peerGroups, dnsFwdPort)
res, err := networkmap.EnvelopeToNetworkMap(ctx, resp.NetworkMapEnvelope, peer.Key, dnsDomain)
require.NoError(t, err, "expand envelope")
return res.NetworkMap
default:
t.Fatalf("unknown mode %q", mode)
return nil
}
}
// requireEnvelopeSafeKeys fails fast on peer keys the envelope decoder would
// silently drop: it re-keys peers by base64 of the raw 32-byte WG public key.
func requireEnvelopeSafeKeys(t *testing.T, nmData *networkmap.NetworkMapData, caseName string) {
t.Helper()
for id, p := range nmData.Peers {
if p == nil {
continue
}
raw, err := base64.StdEncoding.DecodeString(p.Key)
if err != nil || len(raw) != 32 {
t.Fatalf("case %s: peer %q Key must be base64 of 32 bytes for mode %q (the envelope decoder drops it otherwise); use a real WireGuard public key or restrict the case to mode %q",
caseName, id, ModeEnvelope, ModeFull)
}
}
}
// compareGolden measures got against the committed expectation file. One
// expectation serves every mode, because the modes run the same computation and
// must therefore agree. The expectation is the authority: a mismatch means the
// code does not produce what this case says it should, so it is reported as a
// failure and not quietly absorbed.
//
// 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
// for the author to read and, if it is genuinely correct, save deliberately.
func compareGolden(t *testing.T, path string, got *proto.NetworkMap, mode Mode) {
t.Helper()
if mode == ModeEnvelope {
normalizeIDSpace(got)
canonicalize(got)
}
raw, err := os.ReadFile(path)
if err != nil {
rendered, mErr := renderNetworkMap(got)
require.NoError(t, mErr)
t.Fatalf("no expectation file %s: %v\nThis case has nothing to measure against — write the "+
"proto.NetworkMap this peer should receive. Mode %s currently produces:\n%s\nRead it before "+
"saving any of it: if the code is wrong, so is this.", path, err, mode, rendered)
}
want := &proto.NetworkMap{}
require.NoError(t, protojson.Unmarshal(raw, want), "parse expectation %s", path)
canonicalize(want)
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"+
"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)
}
}
// renderNetworkMap renders stable protojson: protojson output whitespace is
// deliberately unstable, so it is reformatted through json.Indent.
func renderNetworkMap(nm *proto.NetworkMap) ([]byte, error) {
raw, err := protojson.Marshal(nm)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", " "); err != nil {
return nil, err
}
buf.WriteByte('\n')
return buf.Bytes(), nil
}

View File

@@ -0,0 +1,7 @@
{
"description": "Two groups joined by one allow-all policy; peer-c has SSH enabled so the legacy-SSH path fills SshAuth from AllowedUserIDs.",
"peers": [
"peer-a",
"peer-c"
]
}

View File

@@ -0,0 +1,65 @@
{
"Serial": "5",
"peerConfig": {
"address": "100.64.0.1/10",
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "4deEImv8zGvsyBmmfC2G0eQkbyMzyGuz/YK7pcYETwM=",
"allowedIps": [
"100.64.0.3/32"
],
"sshConfig": {
"sshPubKey": "c3NoLXBlZXItYw=="
},
"fqdn": "peer-c.netbird.test",
"agentVersion": "0.60.0"
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
},
{
"Name": "peer-c.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.3"
}
]
}
],
"ForwarderPort": "22054"
},
"FirewallRules": [
{
"PeerIP": "100.64.0.3",
"Protocol": "ALL",
"PolicyID": "cG9sLWFsbA=="
},
{
"PeerIP": "100.64.0.3",
"Direction": "OUT",
"Protocol": "ALL",
"PolicyID": "cG9sLWFsbA=="
}
],
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,102 @@
{
"Serial": "5",
"peerConfig": {
"address": "100.64.0.3/10",
"sshConfig": {
"sshEnabled": true
},
"fqdn": "peer-c.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
"allowedIps": [
"100.64.0.2/32"
],
"sshConfig": {},
"fqdn": "peer-b.netbird.test",
"agentVersion": "0.60.0"
},
{
"wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
"allowedIps": [
"100.64.0.1/32"
],
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"agentVersion": "0.60.0"
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
},
{
"Name": "peer-b.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.2"
},
{
"Name": "peer-c.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.3"
}
]
}
],
"ForwarderPort": "22054"
},
"FirewallRules": [
{
"PeerIP": "100.64.0.1",
"Protocol": "ALL",
"PolicyID": "cG9sLWFsbA=="
},
{
"PeerIP": "100.64.0.1",
"Direction": "OUT",
"Protocol": "ALL",
"PolicyID": "cG9sLWFsbA=="
},
{
"PeerIP": "100.64.0.2",
"Protocol": "ALL",
"PolicyID": "cG9sLWFsbA=="
},
{
"PeerIP": "100.64.0.2",
"Direction": "OUT",
"Protocol": "ALL",
"PolicyID": "cG9sLWFsbA=="
}
],
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub",
"AuthorizedUsers": [
"u9dHvAXZJKiXITuwP9jD/A=="
],
"machineUsers": {
"*": {
"indexes": [
0
]
}
}
}
}

View File

@@ -0,0 +1,31 @@
{
"Network": {"Serial": 5},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
"peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
"peer-c": {"IP": "100.64.0.3", "SSHEnabled": true, "SSHKey": "ssh-peer-c", "Meta": {"WtVersion": "0.60.0"}}
},
"Groups": {
"grp-dev": {"Peers": ["peer-a", "peer-b"]},
"grp-ops": {"Peers": ["peer-c"]}
},
"Policies": [
{
"ID": "pol-all",
"PublicID": "pol-all-pub",
"Enabled": true,
"Rules": [
{
"Enabled": true,
"Action": "accept",
"Protocol": "all",
"Bidirectional": true,
"Sources": ["grp-dev"],
"Destinations": ["grp-ops"]
}
]
}
],
"AllowedUserIDs": {"user-ops": {}}
}

View File

@@ -0,0 +1,7 @@
{
"description": "Nameserver group and applied custom zones distributed to grp-dev; peer-a (with an extra DNS label) receives them, peer-c is outside that group and receives only the zone distributed to grp-ops. Zone flags travel per zone: both grp-dev zones are match-only (NonAuthoritative), only search-off.internal. disables the search domain, and the built-in peer zone stays authoritative.",
"peers": [
"peer-a",
"peer-c"
]
}

View File

@@ -0,0 +1,115 @@
{
"Serial": "8",
"peerConfig": {
"address": "100.64.0.1/10",
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
"allowedIps": [
"100.64.0.2/32"
],
"sshConfig": {},
"fqdn": "peer-b.netbird.test",
"agentVersion": "0.60.0"
}
],
"DNSConfig": {
"ServiceEnable": true,
"NameServerGroups": [
{
"NameServers": [
{
"IP": "8.8.8.8",
"Port": "53"
}
],
"Primary": true
}
],
"CustomZones": [
{
"Domain": "corp.internal.",
"NonAuthoritative": true,
"Records": [
{
"Name": "db.corp.internal.",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "10.10.0.5"
}
]
},
{
"Domain": "search-off.internal.",
"SearchDomainDisabled": true,
"NonAuthoritative": true,
"Records": [
{
"Name": "alias.search-off.internal.",
"Type": "5",
"Class": "IN",
"TTL": "300",
"RData": "app.search-off.internal."
},
{
"Name": "app.search-off.internal.",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "10.10.0.6"
}
]
},
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
},
{
"Name": "peer-b.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.2"
},
{
"Name": "www.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
}
]
}
],
"ForwarderPort": "22054"
},
"FirewallRules": [
{
"PeerIP": "100.64.0.2",
"Protocol": "ALL",
"PolicyID": "cG9sLW1lc2g="
},
{
"PeerIP": "100.64.0.2",
"Direction": "OUT",
"Protocol": "ALL",
"PolicyID": "cG9sLW1lc2g="
}
],
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,47 @@
{
"Serial": "8",
"peerConfig": {
"address": "100.64.0.3/10",
"sshConfig": {},
"fqdn": "peer-c.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeersIsEmpty": true,
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-c.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.3"
}
]
},
{
"Domain": "ops-only.internal.",
"NonAuthoritative": true,
"Records": [
{
"Name": "tool.ops-only.internal.",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "10.10.0.7"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,74 @@
{
"Network": {"Serial": 8},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"peer-a": {"IP": "100.64.0.1", "ExtraDNSLabels": ["www"], "Meta": {"WtVersion": "0.60.0"}},
"peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
"peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
},
"Groups": {
"grp-dev": {"Peers": ["peer-a", "peer-b"]},
"grp-ops": {"Peers": ["peer-c"]}
},
"Policies": [
{
"ID": "pol-mesh",
"PublicID": "pol-mesh-pub",
"Enabled": true,
"Rules": [
{
"Enabled": true,
"Action": "accept",
"Protocol": "all",
"Bidirectional": true,
"Sources": ["grp-dev"],
"Destinations": ["grp-dev"]
}
]
}
],
"NameServerGroups": [
{
"ID": "nsg-1",
"Name": "dns-primary",
"NameServers": [{"IP": "8.8.8.8", "Port": 53}],
"Groups": ["grp-dev"],
"Primary": true,
"Enabled": true
}
],
"AppliedZoneCandidates": [
{
"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"}
]
}
},
{
"DistributionGroups": ["grp-dev"],
"Zone": {
"Domain": "search-off.internal.",
"NonAuthoritative": true,
"SearchDomainDisabled": true,
"Records": [
{"Name": "app.search-off.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.6"},
{"Name": "alias.search-off.internal.", "Type": 5, "Class": "IN", "TTL": 300, "RData": "app.search-off.internal."}
]
}
},
{
"DistributionGroups": ["grp-ops"],
"Zone": {
"Domain": "ops-only.internal.",
"NonAuthoritative": true,
"Records": [
{"Name": "tool.ops-only.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.7"}
]
}
}
]
}

View File

@@ -0,0 +1,4 @@
{
"description": "Domain network resource: the route carries the domain list and the 192.0.2.0/32 placeholder network with NetworkType 3 (dynamic), and peer-r's route firewall rules must be marked dynamic and repeat the domain. Two ports on the policy must produce one rule per port. A domain resource contributes no DNS custom zone of its own — resolution happens through the routing peer's forwarder.",
"peers": ["peer-a", "peer-r"]
}

View File

@@ -0,0 +1,59 @@
{
"Serial": "22",
"peerConfig": {
"address": "100.64.0.1/10",
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
"allowedIps": [
"100.64.0.9/32"
],
"sshConfig": {},
"fqdn": "peer-r.netbird.test",
"agentVersion": "0.60.0"
}
],
"Routes": [
{
"ID": "res-domain:peer-r",
"Network": "192.0.2.0/32",
"NetworkType": "3",
"Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
"Metric": "9999",
"Masquerade": true,
"NetID": "app-domain",
"Domains": [
"app.internal"
],
"keepRoute": true
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,92 @@
{
"Serial": "22",
"peerConfig": {
"address": "100.64.0.9/10",
"sshConfig": {},
"fqdn": "peer-r.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
"allowedIps": [
"100.64.0.1/32"
],
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"agentVersion": "0.60.0"
}
],
"Routes": [
{
"ID": "res-domain:peer-r",
"Network": "192.0.2.0/32",
"NetworkType": "3",
"Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
"Metric": "9999",
"Masquerade": true,
"NetID": "app-domain",
"Domains": [
"app.internal"
],
"keepRoute": true
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-r.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.9"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRules": [
{
"sourceRanges": [
"100.64.0.1/32"
],
"destination": "192.0.2.0/32",
"protocol": "TCP",
"portInfo": {
"port": 443
},
"isDynamic": true,
"domains": [
"app.internal"
],
"PolicyID": "cG9sLWFwcA==",
"RouteID": "res-domain:peer-r"
},
{
"sourceRanges": [
"100.64.0.1/32"
],
"destination": "192.0.2.0/32",
"protocol": "TCP",
"portInfo": {
"port": 80
},
"isDynamic": true,
"domains": [
"app.internal"
],
"PolicyID": "cG9sLWFwcA==",
"RouteID": "res-domain:peer-r"
}
],
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,43 @@
{
"Network": {"Serial": 22},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
"peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
},
"Groups": {
"grp-dev": {"Peers": ["peer-a"]}
},
"Policies": [
{
"ID": "pol-app",
"Enabled": true,
"Rules": [
{
"Enabled": true,
"Action": "accept",
"Protocol": "tcp",
"Ports": ["80", "443"],
"Sources": ["grp-dev"],
"DestinationResource": {"ID": "res-domain", "Type": "domain"}
}
]
}
],
"ResourcePolicies": {"res-domain": [{"ID": "pol-app"}]},
"NetworkResources": [
{
"ID": "res-domain",
"NetworkID": "net-1",
"Name": "app-domain",
"Type": "domain",
"Domain": "app.internal",
"Enabled": true
}
],
"Routers": {
"net-1": {
"peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"description": "Host network resource (single /32) behind one directly-assigned router. peer-a is in the resource policy's source group and must receive one route to 10.10.0.7/32 via peer-r with KeepRoute set and NetID taken from the resource name; peer-r as the router must receive the same route plus a route firewall rule whose SourceRanges are the policy's source peers. A client never gets route firewall rules.",
"peers": ["peer-a", "peer-r"]
}

View File

@@ -0,0 +1,56 @@
{
"Serial": "20",
"peerConfig": {
"address": "100.64.0.1/10",
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
"allowedIps": [
"100.64.0.9/32"
],
"sshConfig": {},
"fqdn": "peer-r.netbird.test",
"agentVersion": "0.60.0"
}
],
"Routes": [
{
"ID": "res-host:peer-r",
"Network": "10.10.0.7/32",
"NetworkType": "1",
"Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
"Metric": "9999",
"Masquerade": true,
"NetID": "web-host",
"keepRoute": true
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,69 @@
{
"Serial": "20",
"peerConfig": {
"address": "100.64.0.9/10",
"sshConfig": {},
"fqdn": "peer-r.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
"allowedIps": [
"100.64.0.1/32"
],
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"agentVersion": "0.60.0"
}
],
"Routes": [
{
"ID": "res-host:peer-r",
"Network": "10.10.0.7/32",
"NetworkType": "1",
"Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
"Metric": "9999",
"Masquerade": true,
"NetID": "web-host",
"keepRoute": true
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-r.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.9"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRules": [
{
"sourceRanges": [
"100.64.0.1/32"
],
"destination": "10.10.0.7/32",
"protocol": "TCP",
"portInfo": {
"port": 443
},
"PolicyID": "cG9sLXdlYg==",
"RouteID": "res-host:peer-r"
}
],
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,43 @@
{
"Network": {"Serial": 20},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
"peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
},
"Groups": {
"grp-dev": {"Peers": ["peer-a"]}
},
"Policies": [
{
"ID": "pol-web",
"Enabled": true,
"Rules": [
{
"Enabled": true,
"Action": "accept",
"Protocol": "tcp",
"Ports": ["443"],
"Sources": ["grp-dev"],
"DestinationResource": {"ID": "res-host", "Type": "host"}
}
]
}
],
"ResourcePolicies": {"res-host": [{"ID": "pol-web"}]},
"NetworkResources": [
{
"ID": "res-host",
"NetworkID": "net-1",
"Name": "web-host",
"Type": "host",
"Prefix": "10.10.0.7/32",
"Enabled": true
}
],
"Routers": {
"net-1": {
"peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"description": "A disabled resource with a valid policy and router must leave no trace: no routes and no route firewall rules for either the client or the router. Disabling a resource is the switch that revokes access without deleting the policy.",
"peers": ["peer-a", "peer-r"]
}

View File

@@ -0,0 +1,34 @@
{
"Serial": "25",
"peerConfig": {
"address": "100.64.0.1/10",
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeersIsEmpty": true,
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,34 @@
{
"Serial": "25",
"peerConfig": {
"address": "100.64.0.9/10",
"sshConfig": {},
"fqdn": "peer-r.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeersIsEmpty": true,
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-r.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.9"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,42 @@
{
"Network": {"Serial": 25},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
"peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
},
"Groups": {
"grp-dev": {"Peers": ["peer-a"]}
},
"Policies": [
{
"ID": "pol-off-resource",
"Enabled": true,
"Rules": [
{
"Enabled": true,
"Action": "accept",
"Protocol": "tcp",
"Ports": ["443"],
"Sources": ["grp-dev"],
"DestinationResource": {"ID": "res-disabled", "Type": "subnet"}
}
]
}
],
"ResourcePolicies": {"res-disabled": [{"ID": "pol-off-resource"}]},
"NetworkResources": [
{
"ID": "res-disabled",
"NetworkID": "net-1",
"Name": "disabled-subnet",
"Type": "subnet",
"Prefix": "10.50.0.0/24"
}
],
"Routers": {
"net-1": {
"peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"description": "An enabled resource with a healthy router but no policy granting access to it must produce nothing anywhere: no route for the client and none for the router either, since access to a resource is only ever created by a policy. The router also gets no route firewall rules despite being a routing peer for the network.",
"peers": ["peer-a", "peer-r"]
}

View File

@@ -0,0 +1,34 @@
{
"Serial": "24",
"peerConfig": {
"address": "100.64.0.1/10",
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeersIsEmpty": true,
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,34 @@
{
"Serial": "24",
"peerConfig": {
"address": "100.64.0.9/10",
"sshConfig": {},
"fqdn": "peer-r.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeersIsEmpty": true,
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-r.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.9"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,26 @@
{
"Network": {"Serial": 24},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
"peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
},
"Groups": {
"grp-dev": {"Peers": ["peer-a"]}
},
"NetworkResources": [
{
"ID": "res-orphan",
"NetworkID": "net-1",
"Name": "orphan-subnet",
"Type": "subnet",
"Prefix": "10.40.0.0/24",
"Enabled": true
}
],
"Routers": {
"net-1": {
"peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"description": "A DISABLED policy granting access to a network resource must grant nothing: no route to 10.90.0.0/24 for peer-a and none for the router either, exactly as if the policy were absent. THE FULL EXPECTATION CURRENTLY FAILS, and should: resource-policy selection never checks policy.Enabled (networkmapcompute.go and networkmap_components.go both test only nil/len(Rules)/Rules[0]), so the legacy path still hands out the route — access survives disabling the policy. The envelope path happens to be correct because the encoder drops disabled policies from the wire. Fix the compute path, do not weaken this expectation.",
"peers": ["peer-a", "peer-r"]
}

View File

@@ -0,0 +1,34 @@
{
"Serial": "39",
"peerConfig": {
"address": "100.64.0.1/10",
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeersIsEmpty": true,
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,34 @@
{
"Serial": "39",
"peerConfig": {
"address": "100.64.0.9/10",
"sshConfig": {},
"fqdn": "peer-r.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeersIsEmpty": true,
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-r.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.9"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,42 @@
{
"Network": {"Serial": 39},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
"peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
},
"Groups": {
"grp-dev": {"Peers": ["peer-a"]}
},
"Policies": [
{
"ID": "pol-revoked",
"Rules": [
{
"Enabled": true,
"Action": "accept",
"Protocol": "tcp",
"Ports": ["5432"],
"Sources": ["grp-dev"],
"DestinationResource": {"ID": "res-db", "Type": "subnet"}
}
]
}
],
"ResourcePolicies": {"res-db": [{"ID": "pol-revoked"}]},
"NetworkResources": [
{
"ID": "res-db",
"NetworkID": "net-1",
"Name": "db-subnet",
"Type": "subnet",
"Prefix": "10.90.0.0/24",
"Enabled": true
}
],
"Routers": {
"net-1": {
"peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"description": "The routing peer for the resource is not in ValidatedPeers — an unapproved peer, which the integrated validator withholds. peer-a must therefore receive no route through it and must not see it as a peer at all: traffic may not be routed through a peer the account has not approved. THE ENVELOPE EXPECTATION CURRENTLY FAILS, and should: component selection puts every routing peer into RouterPeers without checking validation, the encoder indexes them into the envelope's peer table, and the client decoder puts every peer it finds back into its peer map, so the unapproved router reappears client-side with a working route. The full path drops it correctly. Fix the component/encoder path, do not weaken this expectation.",
"peers": ["peer-a"]
}

View File

@@ -0,0 +1,34 @@
{
"Serial": "40",
"peerConfig": {
"address": "100.64.0.1/10",
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeersIsEmpty": true,
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,44 @@
{
"Network": {"Serial": 40},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
"peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
},
"ValidatedPeers": {"peer-a": {}},
"Groups": {
"grp-dev": {"Peers": ["peer-a"]}
},
"Policies": [
{
"ID": "pol-db",
"Enabled": true,
"Rules": [
{
"Enabled": true,
"Action": "accept",
"Protocol": "tcp",
"Ports": ["5432"],
"Sources": ["grp-dev"],
"DestinationResource": {"ID": "res-db", "Type": "subnet"}
}
]
}
],
"ResourcePolicies": {"res-db": [{"ID": "pol-db"}]},
"NetworkResources": [
{
"ID": "res-db",
"NetworkID": "net-1",
"Name": "db-subnet",
"Type": "subnet",
"Prefix": "10.100.0.0/24",
"Enabled": true
}
],
"Routers": {
"net-1": {
"peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"description": "Routing peer group: one router record assigned to a peer group, which the store expands into one entry per member peer sharing the router's settings. peer-a must receive one route per routing peer — same NetID and destination, different route ID and peer — which is what gives the client an HA pair to choose between. Each router must receive only its own route, never its sibling's, plus its own route firewall rule.",
"peers": ["peer-a", "peer-r1", "peer-r2"]
}

View File

@@ -0,0 +1,75 @@
{
"Serial": "23",
"peerConfig": {
"address": "100.64.0.1/10",
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
"allowedIps": [
"100.64.0.11/32"
],
"sshConfig": {},
"fqdn": "peer-r1.netbird.test",
"agentVersion": "0.60.0"
},
{
"wgPubKey": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
"allowedIps": [
"100.64.0.12/32"
],
"sshConfig": {},
"fqdn": "peer-r2.netbird.test",
"agentVersion": "0.60.0"
}
],
"Routes": [
{
"ID": "res-ha:peer-r1",
"Network": "10.30.0.0/24",
"NetworkType": "1",
"Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
"Metric": "9999",
"Masquerade": true,
"NetID": "ha-subnet",
"keepRoute": true
},
{
"ID": "res-ha:peer-r2",
"Network": "10.30.0.0/24",
"NetworkType": "1",
"Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
"Metric": "9999",
"Masquerade": true,
"NetID": "ha-subnet",
"keepRoute": true
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,69 @@
{
"Serial": "23",
"peerConfig": {
"address": "100.64.0.11/10",
"sshConfig": {},
"fqdn": "peer-r1.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
"allowedIps": [
"100.64.0.1/32"
],
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"agentVersion": "0.60.0"
}
],
"Routes": [
{
"ID": "res-ha:peer-r1",
"Network": "10.30.0.0/24",
"NetworkType": "1",
"Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
"Metric": "9999",
"Masquerade": true,
"NetID": "ha-subnet",
"keepRoute": true
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-r1.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.11"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRules": [
{
"sourceRanges": [
"100.64.0.1/32"
],
"destination": "10.30.0.0/24",
"protocol": "TCP",
"portInfo": {
"port": 5432
},
"PolicyID": "cG9sLWhh",
"RouteID": "res-ha:peer-r1"
}
],
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,69 @@
{
"Serial": "23",
"peerConfig": {
"address": "100.64.0.12/10",
"sshConfig": {},
"fqdn": "peer-r2.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
"allowedIps": [
"100.64.0.1/32"
],
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"agentVersion": "0.60.0"
}
],
"Routes": [
{
"ID": "res-ha:peer-r2",
"Network": "10.30.0.0/24",
"NetworkType": "1",
"Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
"Metric": "9999",
"Masquerade": true,
"NetID": "ha-subnet",
"keepRoute": true
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-r2.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.12"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRules": [
{
"sourceRanges": [
"100.64.0.1/32"
],
"destination": "10.30.0.0/24",
"protocol": "TCP",
"portInfo": {
"port": 5432
},
"PolicyID": "cG9sLWhh",
"RouteID": "res-ha:peer-r2"
}
],
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,46 @@
{
"Network": {"Serial": 23},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
"peer-r1": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
"peer-r2": {"IP": "100.64.0.12", "Meta": {"WtVersion": "0.60.0"}}
},
"Groups": {
"grp-dev": {"Peers": ["peer-a"]},
"grp-routers": {"Peers": ["peer-r1", "peer-r2"]}
},
"Policies": [
{
"ID": "pol-ha",
"Enabled": true,
"Rules": [
{
"Enabled": true,
"Action": "accept",
"Protocol": "tcp",
"Ports": ["5432"],
"Sources": ["grp-dev"],
"DestinationResource": {"ID": "res-ha", "Type": "subnet"}
}
]
}
],
"ResourcePolicies": {"res-ha": [{"ID": "pol-ha"}]},
"NetworkResources": [
{
"ID": "res-ha",
"NetworkID": "net-ha",
"Name": "ha-subnet",
"Type": "subnet",
"Prefix": "10.30.0.0/24",
"Enabled": true
}
],
"Routers": {
"net-ha": {
"peer-r1": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true},
"peer-r2": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"description": "Subnet network resource behind one directly-assigned router, with masquerade off and a non-default metric so both reach the wire verbatim, and an all-protocol policy from a two-peer source group. peer-r's route firewall rule must list both source peers; peer-b confirms a second client in the same group gets its own identical route.",
"peers": ["peer-a", "peer-b", "peer-r"]
}

View File

@@ -0,0 +1,55 @@
{
"Serial": "21",
"peerConfig": {
"address": "100.64.0.1/10",
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
"allowedIps": [
"100.64.0.9/32"
],
"sshConfig": {},
"fqdn": "peer-r.netbird.test",
"agentVersion": "0.60.0"
}
],
"Routes": [
{
"ID": "res-subnet:peer-r",
"Network": "10.20.0.0/24",
"NetworkType": "1",
"Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
"Metric": "500",
"NetID": "office-subnet",
"keepRoute": true
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,55 @@
{
"Serial": "21",
"peerConfig": {
"address": "100.64.0.2/10",
"sshConfig": {},
"fqdn": "peer-b.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
"allowedIps": [
"100.64.0.9/32"
],
"sshConfig": {},
"fqdn": "peer-r.netbird.test",
"agentVersion": "0.60.0"
}
],
"Routes": [
{
"ID": "res-subnet:peer-r",
"Network": "10.20.0.0/24",
"NetworkType": "1",
"Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
"Metric": "500",
"NetID": "office-subnet",
"keepRoute": true
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-b.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.2"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,76 @@
{
"Serial": "21",
"peerConfig": {
"address": "100.64.0.9/10",
"sshConfig": {},
"fqdn": "peer-r.netbird.test",
"RoutingPeerDnsResolutionEnabled": true,
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
"allowedIps": [
"100.64.0.2/32"
],
"sshConfig": {},
"fqdn": "peer-b.netbird.test",
"agentVersion": "0.60.0"
},
{
"wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
"allowedIps": [
"100.64.0.1/32"
],
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"agentVersion": "0.60.0"
}
],
"Routes": [
{
"ID": "res-subnet:peer-r",
"Network": "10.20.0.0/24",
"NetworkType": "1",
"Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
"Metric": "500",
"NetID": "office-subnet",
"keepRoute": true
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-r.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.9"
}
]
}
],
"ForwarderPort": "22054"
},
"firewallRulesIsEmpty": true,
"routesFirewallRules": [
{
"sourceRanges": [
"100.64.0.1/32",
"100.64.0.2/32"
],
"destination": "10.20.0.0/24",
"protocol": "ALL",
"portInfo": {},
"PolicyID": "cG9sLXN1Ym5ldA==",
"RouteID": "res-subnet:peer-r"
}
],
"sshAuth": {
"UserIDClaim": "sub"
}
}

View File

@@ -0,0 +1,43 @@
{
"Network": {"Serial": 21},
"AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
"Peers": {
"peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
"peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
"peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
},
"Groups": {
"grp-dev": {"Peers": ["peer-a", "peer-b"]}
},
"Policies": [
{
"ID": "pol-subnet",
"Enabled": true,
"Rules": [
{
"Enabled": true,
"Action": "accept",
"Protocol": "all",
"Sources": ["grp-dev"],
"DestinationResource": {"ID": "res-subnet", "Type": "subnet"}
}
]
}
],
"ResourcePolicies": {"res-subnet": [{"ID": "pol-subnet"}]},
"NetworkResources": [
{
"ID": "res-subnet",
"NetworkID": "net-1",
"Name": "office-subnet",
"Type": "subnet",
"Prefix": "10.20.0.0/24",
"Enabled": true
}
],
"Routers": {
"net-1": {
"peer-r": {"PublicID": "router-direct", "Metric": 500, "Enabled": true}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"description": "Direct peer-to-peer policy via Source/DestinationResource of type peer, no groups involved; peer-a and peer-b see each other, bystander peer-c sees nobody.",
"peers": ["peer-a", "peer-b", "peer-c"]
}

View File

@@ -0,0 +1,64 @@
{
"Serial": "15",
"peerConfig": {
"address": "100.64.0.1/10",
"sshConfig": {},
"fqdn": "peer-a.netbird.test",
"autoUpdate": {}
},
"remotePeers": [
{
"wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
"allowedIps": [
"100.64.0.2/32"
],
"sshConfig": {},
"fqdn": "peer-b.netbird.test",
"agentVersion": "0.60.0"
}
],
"DNSConfig": {
"ServiceEnable": true,
"CustomZones": [
{
"Domain": "netbird.test.",
"Records": [
{
"Name": "peer-a.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.1"
},
{
"Name": "peer-b.netbird.test",
"Type": "1",
"Class": "IN",
"TTL": "300",
"RData": "100.64.0.2"
}
]
}
],
"ForwarderPort": "22054"
},
"FirewallRules": [
{
"PeerIP": "100.64.0.2",
"Protocol": "TCP",
"Port": "443",
"PolicyID": "cG9sLWRpcmVjdA=="
},
{
"PeerIP": "100.64.0.2",
"Direction": "OUT",
"Protocol": "TCP",
"Port": "443",
"PolicyID": "cG9sLWRpcmVjdA=="
}
],
"routesFirewallRulesIsEmpty": true,
"sshAuth": {
"UserIDClaim": "sub"
}
}

Some files were not shown because too many files have changed in this diff Show More