Compare commits

..

9 Commits

Author SHA1 Message Date
mlsmaycon
2614433b9a [infrastructure] Make the client entrypoint executable regardless of the context
The client image copied the entrypoint with a bare COPY, so its mode came from
whatever the build context held. In a git checkout that is 100755 and the image
works. Building from this module's extracted copy in the module cache — which is
what a suite in another module now does — every file is 0444, because the cache
drops the executable bit, and the container dies on startup with

	exec: "/usr/local/bin/netbird-entrypoint.sh": permission denied

Copy it with --chmod=0755 instead, so the mode is stated rather than inherited.
The proxy Dockerfile already does this for /certs, and the build already requires
BuildKit.

Found by running the terraform provider's suite against images built this way:
the combined server and the proxy came up, and every test needing an agent failed
on the entrypoint.
2026-08-12 06:12:53 +00:00
mlsmaycon
910fef5406 [infrastructure] Assert the cancellation error, not just any error
A non-nil check passes for a lookup that failed for some unrelated reason, which
would let the context handling regress unnoticed. exec.Cmd.Start returns the
context's own error when it is already done, so the wrapped error carries
context.Canceled and can be asserted directly.
2026-08-12 06:02:19 +00:00
mlsmaycon
f4d2b56420 [infrastructure] Resolve the module directory in readonly mode and thread ctx
Two problems with the lookup that finds this module's source for a suite in
another module.

A caller that vendors its dependencies puts the go command in automatic vendor
mode, where `go list -m -f {{.Dir}}` succeeds and reports an EMPTY directory —
vendor/ holds packages, not module source, so there is nothing for it to report.
The harness took that for a missing module and told the caller to run `go mod
download` for a dependency that was already there. Ask in readonly mode instead,
which resolves against the module graph and answers for both a cached module and
a local replacement, without writing to go.mod.

The lookup also ran on context.Background(), so a StartCombined, StartClient or
StartProxy whose context was cancelled could not stop it. repoRoot and moduleDir
now take the caller's context.

The vendor-mode case has a regression test, which needs no network: a temporary
main module with a local replacement, vendored. It fails without the flag with
the misleading "run `go mod download`" error. moduleDir takes the module path as
a parameter so that test can name its own.

The assertions move to testify, matching the repository's guidelines.
2026-08-12 05:58:39 +00:00
mlsmaycon
41c0717f59 [e2e] Let a suite outside this repo use the harness
The harness package documents itself as feature-agnostic — "any suite can ask for
a live management server" — but three details assume the caller lives in this
repo, so the terraform provider's acceptance suite cannot use it and would have to
carry a second harness for the same product instead.

repoRoot walked up from the working directory to the first go.mod and used it as
the Docker build context. From another module that is the caller's own root, where
combined/Dockerfile.multistage does not exist. Require the ancestor to be this
module, and fall back to asking the go tool where this module's source is. For a
dependent that is the extracted module directory of the version it pins, which is
the right context: the server it tests against is then built from the same
revision as the client library it was compiled against.

Geolocation was off unconditionally, in both the container environment and
disableGeoliteUpdate. That is right for agent-network ingest, which does not use
it, but a suite asserting on location-based posture checks needs the database:
management evaluates those rules against it, and a rule it cannot evaluate fails
rather than passing without having been checked. StartCombined now takes options,
with WithGeolocation to keep the download, and WithServerEnv as the general escape
hatch for settings the harness does not model.

StartClient pinned a single network alias and set no hostname, so a second agent
could not start — the alias collides — and the peer's name was whatever the
container got. Both matter to a suite whose fixtures address peers by name:
management records the container hostname at registration, so that is the name the
peer appears under in the API. WithClientName sets alias and hostname together.

All three are additive. StartCombined(ctx) and StartClient(ctx, c, key) still
compile and behave as before, which the existing agent-network suite exercises.

The options configure a container environment and a config file, both assembled
before anything starts, so options_test.go checks them without Docker. A wiring
mistake would otherwise surface as a puzzling failure minutes into a container
run — and the argument order in the config format string is exactly the kind of
thing worth pinning, since a misplaced verb there fails the server's startup.
2026-08-12 05:45:54 +00:00
Viktor Liu
052cf5a748 [client] Derive Windows SSH privilege checks from the token and group membership (#6966) 2026-08-11 18:16:37 +02:00
Viktor Liu
95a458801c [doc] Point translation contributions at Crowdin (#7161) 2026-08-11 16:02:09 +02:00
Viktor Liu
14f9f8ce22 Fix Crowdin export paths and align import and export with repo bundle state (#7162) 2026-08-11 16:01:33 +02:00
Maycon Santos
f805c149d9 [management] Record reverse proxy usage for activity accounting (#7116)
People who only ever reach private services through the reverse proxy were
invisible to activity accounting. Active users are counted from user.LastLogin
or from the LastSeen of a peer they own, and neither column was written on the
proxy paths — so a person signing in via SSO to a proxied service, or a peer
serving one over the mesh, never showed up in the 24 hour numbers.
Both writes now happen where the proxy already authenticates:
- GenerateSessionToken stamps LastLogin after the session token is signed,
  the same column and the same way the dashboard and device login paths do.
- ValidateTunnelPeer stamps the calling peer's LastSeen, the column its owner
  activates through.
The policy lives in a new reverseproxy/activity manager rather than in the gRPC
service, matching the module layout the other reverse proxy domains use. It
skips what can never count — service users, embedded proxy peers and WASM
clients — and throttles peer writes to once an hour, well inside the window
accounting asks about and far above the proxy's five minute tunnel cache.
The peer write is a single indexed UPDATE that touches only
peer_status_last_seen. Connected and SessionStartedAt are left alone so the
session-ownership fencing MarkPeerConnectedIfNewerSession relies on is never
disturbed, and the timestamp comes from the database clock rather than the
caller, for the same reason the other status writers take it from there. The
caller's cutoff travels into the statement's WHERE, so concurrent requests for
one peer collapse into a single write instead of each acting on its own stale
read, and a peer that was never seen — NULL last seen, since Status is an
embedded pointer — still records its first activity.
Nothing outside the reverse proxy changes behaviour: the only addition
elsewhere is the RefreshPeerLastSeen store method the manager calls.
2026-08-11 15:54:39 +02:00
Viktor Liu
99048e2bf2 [infrastructure] Add Crowdin configuration for UI translation sync (#7155) 2026-08-11 14:24:39 +02:00
206 changed files with 5328 additions and 15120 deletions

View File

@@ -112,6 +112,7 @@ aligns with our security standards and design expectations.
- [Test suite](#test-suite)
- [Checklist before submitting a PR](#checklist-before-submitting-a-pr)
- [When we close a PR](#when-we-close-a-pr)
- [Translations](#translations)
- [Other project repositories](#other-project-repositories)
- [Contributor License Agreement](#contributor-license-agreement)
@@ -612,6 +613,17 @@ A closed PR is not a rejected idea. Take it back to the
[discussion](https://github.com/netbirdio/netbird/discussions), settle the
approach, and reopen the work from there.
## Translations
Desktop UI translations are not contributed through pull requests. Translate on
[Crowdin](https://crowdin.com/project/netbird) instead: no ticket needed, just
join the project and pick your language. Crowdin syncs with this repository and
opens the service PRs itself, so hand-edited locale files would conflict with
the next sync. Style, terminology, and review guidance live in
[client/ui/i18n/TRANSLATING.md](client/ui/i18n/TRANSLATING.md). To request a
language the project does not offer yet, ask on the Crowdin project page or in
a [discussion](https://github.com/netbirdio/netbird/discussions).
## Other project repositories
NetBird project is composed of 3 main repositories:

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, nil)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config)
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

@@ -21,8 +21,8 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/proto"
nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain"

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, nil)
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg)
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

@@ -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, nil)
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
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

@@ -232,3 +232,4 @@ 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, nil)
networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
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

View File

@@ -243,7 +243,7 @@ func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfi
// prepareCommandEnv prepares environment variables for command execution on Windows
func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, session ssh.Session) []string {
username, domain := s.parseUsername(localUser.Username)
username, domain := parseUsername(localUser.Username)
userEnv, err := s.getUserEnvironment(logger, username, domain)
if err != nil {
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
@@ -383,7 +383,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
return false
}
username, domain := s.parseUsername(localUser.Username)
username, domain := parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
req := PtyExecutionRequest{

View File

@@ -133,7 +133,12 @@ func (s *Server) checkPrivilegedPortAccess(forwardType string, port uint32, resu
return nil
}
if result.User != nil && isPrivilegedUsername(result.User.Username) {
// Only uid 0 may bind below the threshold, which is the kernel's own rule and
// is asked directly rather than through isPrivilegedOrUnknown: that helper
// reports an account it cannot evaluate as privileged, which is safe for a
// refusal and unsafe for a grant such as this one. Windows has returned
// above, so Uid here is a Unix uid and never a SID.
if result.User != nil && result.User.Uid == "0" {
return nil
}

View File

@@ -0,0 +1,16 @@
//go:build !windows
package server
// isProcessElevated is only meaningful on Windows; other platforms use the
// effective UID check in isCurrentProcessPrivileged.
func isProcessElevated() bool {
return false
}
// isWindowsAccountPrivilegedOrUnknown is only reachable on Windows. Report
// privileged on other platforms so a caller refusing privileged accounts fails
// closed.
func isWindowsAccountPrivilegedOrUnknown(string) bool {
return true
}

View File

@@ -0,0 +1,228 @@
//go:build windows
package server
import (
"fmt"
"strings"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
var (
netapi32 = windows.NewLazySystemDLL("netapi32.dll")
procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups")
)
const (
// lgIncludeIndirect makes NetUserGetLocalGroups also return local groups
// the user belongs to through a global group.
lgIncludeIndirect = 0x1
maxPreferredLength = 0xFFFFFFFF
)
// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0.
type localGroupUsersInfo0 struct {
name *uint16
}
// isProcessElevated reports whether the current process token is elevated
// (TokenElevation): true for elevated administrators, the built-in
// Administrator, administrators with UAC disabled, and SYSTEM; false for
// standard users and administrators running with a UAC-filtered token.
func isProcessElevated() bool {
return windows.GetCurrentProcessToken().IsElevated()
}
// isWindowsAccountPrivilegedOrUnknown reports whether the account is privileged
// on this machine: a well-known service account, a built-in Administrator
// (RID 500), or a member of the local Administrators group, directly or through
// nested groups.
//
// An account whose privilege cannot be determined counts as privileged, which
// is why the name says "or unknown". That is fail-closed for a caller that
// refuses privileged accounts, and fail-open for a caller that grants something
// to them, so only the former may use this.
func isWindowsAccountPrivilegedOrUnknown(username string) bool {
sid, _, _, err := windows.LookupSID("", username)
if err != nil {
log.Warnf("privilege check: SID lookup for %q failed, treating as privileged: %v", username, err)
return true
}
if isPrivilegedUserSID(sid) {
return true
}
member, err := isLocalAdminsMember(username)
if err != nil {
log.Warnf("privilege check: cannot determine Administrators membership for %q, treating as privileged: %v", username, err)
return true
}
return member
}
// isPrivilegedUserSID reports whether the SID itself identifies a privileged
// principal, without consulting group membership.
func isPrivilegedUserSID(sid *windows.SID) bool {
wellKnown := []windows.WELL_KNOWN_SID_TYPE{
windows.WinLocalSystemSid,
windows.WinLocalServiceSid,
windows.WinNetworkServiceSid,
windows.WinBuiltinAdministratorsSid,
}
for _, sidType := range wellKnown {
if sid.IsWellKnown(sidType) {
return true
}
}
return isBuiltinAdministratorSID(sid)
}
// isBuiltinAdministratorSID reports whether the SID is a machine or domain
// built-in Administrator account (S-1-5-21-...-500). RID 500 is reserved for
// that account; it can be renamed but cannot be removed from the
// Administrators group.
func isBuiltinAdministratorSID(sid *windows.SID) bool {
if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
return false
}
count := sid.SubAuthorityCount()
if count < 2 || sid.SubAuthority(0) != 21 {
return false
}
return sid.SubAuthority(uint32(count-1)) == 500
}
// isLocalAdminsMember reports whether the account is a member of the local
// Administrators group.
//
// Local accounts are checked against the local SAM, which is authoritative for
// them and, unlike a token, cannot under-report: UAC filters the tokens of
// local administrators, and a filtered token carries Administrators as
// deny-only, which a membership check on the token would read as "not a
// member". Domain accounts are exempt from that filtering, so for them an S4U
// token is preferred because its group list is LSA's transitive expansion and
// therefore covers nested and universal groups plus the machine's own local
// groups. NetUserGetLocalGroups expands only one global-group hop but needs no
// logon, so it serves as the fallback when no token can be obtained.
func isLocalAdminsMember(username string) (bool, error) {
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
if err != nil {
return false, fmt.Errorf("create Administrators SID: %w", err)
}
account, domain := parseUsername(username)
if NewPrivilegeDropper().isLocalUser(domain) {
return localGroupsContainSID(account, adminSid)
}
member, s4uErr := s4uTokenIsMember(account, domain, adminSid)
if s4uErr == nil {
return member, nil
}
log.Debugf("privilege check: S4U membership check for %q failed, falling back to local group enumeration: %v", username, s4uErr)
member, err = localGroupsContainSID(buildUserCpn(account, domain), adminSid)
if err != nil {
return false, fmt.Errorf("S4U check: %w; local group enumeration: %w", s4uErr, err)
}
return member, nil
}
// s4uTokenIsMember obtains an S4U token for the account and checks whether the
// given SID is enabled in it.
func s4uTokenIsMember(account, domain string, sid *windows.SID) (bool, error) {
token, err := generateS4UUserToken(log.NewEntry(log.StandardLogger()), account, domain)
if err != nil {
return false, err
}
defer func() {
if err := windows.CloseHandle(token); err != nil {
log.Debugf("close S4U token: %v", err)
}
}()
return windows.Token(token).IsMember(sid)
}
// localGroupsContainSID reports whether the wanted group is among the local
// groups the account belongs to, directly or through a global group.
//
// The wanted SID is resolved to its group name once and compared against the
// enumerated names. Well-known SIDs resolve from a static table, so that lookup
// needs no domain controller, and it keeps the comparison correct for a renamed
// or localized group because both sides then carry the new name. Resolving each
// enumerated name back to a SID instead would add a lookup per group that can
// block until it times out while a domain controller is unreachable, and cannot
// change the outcome: the names enumerated here are local groups of this
// machine, whose names are unique, so a name match identifies the group.
//
// A failure to resolve the wanted SID is returned rather than reported as
// "not a member", so a privilege check built on this fails closed.
func localGroupsContainSID(username string, want *windows.SID) (bool, error) {
wantName, _, _, err := want.LookupAccount("")
if err != nil {
return false, fmt.Errorf("resolve group SID %s to a name: %w", want, err)
}
groups, err := netUserGetLocalGroups(username)
if err != nil {
return false, err
}
for _, group := range groups {
if strings.EqualFold(group, wantName) {
return true, nil
}
}
return false, nil
}
// netUserGetLocalGroups returns the names of the local groups the account is a
// member of, including indirect membership through global groups.
func netUserGetLocalGroups(username string) ([]string, error) {
name16, err := windows.UTF16PtrFromString(username)
if err != nil {
return nil, fmt.Errorf("convert username: %w", err)
}
var buf *byte
var entriesRead, totalEntries uint32
status, _, _ := procNetUserGetLocalGroups.Call(
0, // local server
uintptr(unsafe.Pointer(name16)),
0, // level 0: LOCALGROUP_USERS_INFO_0
lgIncludeIndirect,
uintptr(unsafe.Pointer(&buf)),
maxPreferredLength,
uintptr(unsafe.Pointer(&entriesRead)),
uintptr(unsafe.Pointer(&totalEntries)),
)
if status != 0 {
return nil, fmt.Errorf("NetUserGetLocalGroups for %q: status %d", username, status)
}
if buf == nil {
return nil, nil
}
defer func() {
if err := windows.NetApiBufferFree(buf); err != nil {
log.Debugf("free NetApi buffer: %v", err)
}
}()
// MAX_PREFERRED_LENGTH makes the API allocate as much as it needs, so a
// short read is not expected. Report it rather than silently returning a
// subset of the account's groups.
if entriesRead != totalEntries {
return nil, fmt.Errorf("NetUserGetLocalGroups for %q returned %d of %d groups", username, entriesRead, totalEntries)
}
entries := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buf)), entriesRead)
groups := make([]string, 0, entriesRead)
for _, entry := range entries {
groups = append(groups, windows.UTF16PtrToString(entry.name))
}
return groups, nil
}

View File

@@ -0,0 +1,293 @@
//go:build windows
package server
import (
"os/user"
"testing"
"unsafe"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// filterNormalAccount limits NetUserEnum to normal user accounts.
const filterNormalAccount = 0x2
// TOKEN_ELEVATION_TYPE values.
const (
tokenElevationTypeDefault = 1
tokenElevationTypeFull = 2
tokenElevationTypeLimited = 3
)
// tokenElevationType reads TokenElevationType from a token.
func tokenElevationType(token windows.Token) (uint32, error) {
var elevationType, returnedLen uint32
err := windows.GetTokenInformation(token, windows.TokenElevationType,
(*byte)(unsafe.Pointer(&elevationType)), uint32(unsafe.Sizeof(elevationType)), &returnedLen)
if err != nil {
return 0, err
}
return elevationType, nil
}
// userInfo0 mirrors USER_INFO_0.
type userInfo0 struct {
name *uint16
}
func mustParseSID(t *testing.T, s string) *windows.SID {
t.Helper()
sid, err := windows.StringToSid(s)
require.NoError(t, err, "parse SID %s", s)
return sid
}
// localAccountNames returns the names of the local user accounts.
func localAccountNames(t *testing.T) []string {
t.Helper()
var buf *byte
var entriesRead, totalEntries, resume uint32
err := windows.NetUserEnum(nil, 0, filterNormalAccount, &buf, maxPreferredLength,
&entriesRead, &totalEntries, &resume)
require.NoError(t, err, "enumerate local users")
t.Cleanup(func() {
require.NoError(t, windows.NetApiBufferFree(buf), "free NetApi buffer")
})
entries := unsafe.Slice((*userInfo0)(unsafe.Pointer(buf)), entriesRead)
names := make([]string, 0, entriesRead)
for _, entry := range entries {
names = append(names, windows.UTF16PtrToString(entry.name))
}
return names
}
// localAccountNameByRID returns the name of the local account carrying the
// given RID. Accounts such as Administrator and Guest can be renamed and are
// localized, so tests must not name them literally.
func localAccountNameByRID(t *testing.T, rid uint32) string {
t.Helper()
for _, name := range localAccountNames(t) {
sid, _, _, err := windows.LookupSID("", name)
if err != nil {
continue
}
if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
continue
}
count := sid.SubAuthorityCount()
if count < 2 || sid.SubAuthority(0) != 21 {
continue
}
if sid.SubAuthority(uint32(count-1)) == rid {
return name
}
}
t.Fatalf("no local account with RID %d", rid)
return ""
}
// wellKnownAccountName resolves a well-known SID to the qualified account name
// the local system uses for it, which is localized.
func wellKnownAccountName(t *testing.T, sidType windows.WELL_KNOWN_SID_TYPE) string {
t.Helper()
sid, err := windows.CreateWellKnownSid(sidType)
require.NoError(t, err, "create well-known SID")
name, domain, _, err := sid.LookupAccount("")
require.NoError(t, err, "resolve %s to an account name", sid)
if domain == "" {
return name
}
return domain + `\` + name
}
func TestIsBuiltinAdministratorSID(t *testing.T) {
tests := []struct {
name string
sid string
want bool
}{
{"machine_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
{"domain_administrator", "S-1-5-21-3390233681-4087452608-412898826-500", true},
{"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
{"guest_account", "S-1-5-21-1111111111-2222222222-3333333333-501", false},
{"domain_admins_group", "S-1-5-21-1111111111-2222222222-3333333333-512", false},
{"system", "S-1-5-18", false},
{"administrators_group", "S-1-5-32-544", false},
{"non_nt_authority", "S-1-1-0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isBuiltinAdministratorSID(mustParseSID(t, tt.sid))
assert.Equal(t, tt.want, result, "RID 500 detection for %s", tt.sid)
})
}
}
func TestIsPrivilegedUserSID(t *testing.T) {
tests := []struct {
name string
sid string
want bool
}{
{"local_system", "S-1-5-18", true},
{"local_service", "S-1-5-19", true},
{"network_service", "S-1-5-20", true},
{"administrators_group", "S-1-5-32-544", true},
{"builtin_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
{"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
{"users_group", "S-1-5-32-545", false},
{"everyone", "S-1-1-0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isPrivilegedUserSID(mustParseSID(t, tt.sid))
assert.Equal(t, tt.want, result, "SID privilege classification for %s", tt.sid)
})
}
}
func TestIsWindowsAccountPrivilegedOrUnknown(t *testing.T) {
tests := []struct {
name string
username string
want bool
}{
{"system", wellKnownAccountName(t, windows.WinLocalSystemSid), true},
{"local_service", wellKnownAccountName(t, windows.WinLocalServiceSid), true},
{"network_service", wellKnownAccountName(t, windows.WinNetworkServiceSid), true},
{"administrators_group", wellKnownAccountName(t, windows.WinBuiltinAdministratorsSid), true},
// The built-in Administrator (RID 500) and Guest (RID 501) accounts
// exist on every Windows installation, though they may be disabled.
{"builtin_administrator", localAccountNameByRID(t, 500), true},
{"guest", localAccountNameByRID(t, 501), false},
// Unresolvable accounts fail closed.
{"nonexistent_user", "netbird-no-such-user", true},
{"empty_username", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isWindowsAccountPrivilegedOrUnknown(tt.username)
assert.Equal(t, tt.want, result, "account privilege classification for %q", tt.username)
})
}
}
func TestIsProcessElevated(t *testing.T) {
elevated := isProcessElevated()
// TokenElevationType is a second, independent view of the same token:
// Full means elevated and Limited means a filtered administrator, while
// Default covers both a standard user and an administrator with no linked
// token (UAC off, the built-in Administrator, SYSTEM), so it implies nothing.
elevationType, err := tokenElevationType(windows.GetCurrentProcessToken())
require.NoError(t, err, "read token elevation type")
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
// Token(0) makes CheckTokenMembership evaluate the caller's own token. It
// counts only enabled SIDs, so a filtered administrator reports false here.
member, err := windows.Token(0).IsMember(adminSid)
require.NoError(t, err, "check own Administrators membership")
t.Logf("elevated=%v elevationType=%d memberOfAdministrators=%v", elevated, elevationType, member)
switch elevationType {
case tokenElevationTypeFull:
assert.True(t, elevated, "a token of elevation type Full must report elevated")
case tokenElevationTypeLimited:
assert.False(t, elevated, "a filtered administrator token must not report elevated")
}
// Administrators enabled in the token means the token wields administrative
// rights, which is what elevation reports.
if member {
assert.True(t, elevated, "token with enabled Administrators membership must report elevated")
}
}
// TestS4UMembershipAgreesWithLocalGroups exercises the S4U token path used
// for domain accounts. S4U logons need the TCB privilege, so the test runs
// only as SYSTEM (which is how CI executes the suite). For local accounts the
// token's Administrators membership must agree with the SAM enumeration.
func TestS4UMembershipAgreesWithLocalGroups(t *testing.T) {
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
require.NoError(t, err, "create SYSTEM SID")
current, err := user.Current()
require.NoError(t, err, "get current user")
if current.Uid != system.String() {
t.Skipf("S4U logon requires SYSTEM (running as %s)", current.Username)
}
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
checked := 0
for _, name := range localAccountNames(t) {
viaToken, err := s4uTokenIsMember(name, ".", adminSid)
if err != nil {
// Disabled or logon-restricted accounts cannot get an S4U logon.
t.Logf("skipping %s: %v", name, err)
continue
}
viaSAM, err := localGroupsContainSID(name, adminSid)
require.NoError(t, err, "enumerate local groups for %s", name)
assert.Equal(t, viaSAM, viaToken, "S4U token and SAM enumeration must agree on Administrators membership for %s", name)
checked++
}
// Ineligible accounts are skipped, so without this the test could report
// success while comparing nothing at all.
require.Positive(t, checked, "no local account completed an S4U logon, so nothing was compared")
t.Logf("checked %d local accounts via S4U", checked)
}
// TestLocalGroupsContainSID_Administrator checks the positive case against the
// built-in Administrator, a member of Administrators on every installation.
func TestLocalGroupsContainSID_Administrator(t *testing.T) {
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
administrator := localAccountNameByRID(t, 500)
member, err := localGroupsContainSID(administrator, adminSid)
require.NoError(t, err, "enumerate local groups for %s", administrator)
assert.True(t, member, "%s is a member of the Administrators group", administrator)
}
// TestLocalGroupsContainSID_UnresolvableGroupFailsClosed covers a wanted SID
// that resolves to no group: the error must surface rather than being reported
// as "not a member", so the privilege check treats the account as privileged.
func TestLocalGroupsContainSID_UnresolvableGroupFailsClosed(t *testing.T) {
unknown := mustParseSID(t, "S-1-5-21-1111111111-2222222222-3333333333-4444")
_, err := localGroupsContainSID(localAccountNameByRID(t, 500), unknown)
require.Error(t, err, "must report an error when the wanted group cannot be identified")
}
func TestLocalGroupsContainSID_Guest(t *testing.T) {
guestsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid)
require.NoError(t, err, "create Guests SID")
adminsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
guest := localAccountNameByRID(t, 501)
inGuests, err := localGroupsContainSID(guest, guestsSid)
require.NoError(t, err, "enumerate local groups for %s", guest)
assert.True(t, inGuests, "%s is a member of the Guests group", guest)
inAdmins, err := localGroupsContainSID(guest, adminsSid)
require.NoError(t, err, "enumerate local groups for %s", guest)
assert.False(t, inAdmins, "%s is not a member of the Administrators group", guest)
}

View File

@@ -239,6 +239,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType string
port uint32
username string
uid string
expectError bool
errorMsg string
skipOnWindows bool
@@ -248,6 +249,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote",
port: 80,
username: "testuser",
uid: "1000",
expectError: true,
errorMsg: "cannot bind to privileged port",
skipOnWindows: true,
@@ -257,6 +259,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "tcpip-forward",
port: 443,
username: "testuser",
uid: "1000",
expectError: true,
errorMsg: "cannot bind to privileged port",
skipOnWindows: true,
@@ -266,6 +269,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote",
port: 8080,
username: "testuser",
uid: "1000",
expectError: false,
},
{
@@ -273,6 +277,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote",
port: 0,
username: "testuser",
uid: "1000",
expectError: false,
},
{
@@ -280,13 +285,35 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote",
port: 22,
username: "root",
uid: "0",
expectError: false,
},
{
// Only uid 0 is privileged, whatever the account is called.
name: "uid 0 under another name may bind a privileged port",
forwardType: "remote",
port: 22,
username: "toor",
uid: "0",
expectError: false,
skipOnWindows: true,
},
{
name: "account named root without uid 0 may not",
forwardType: "remote",
port: 22,
username: "root",
uid: "1000",
expectError: true,
errorMsg: "cannot bind to privileged port",
skipOnWindows: true,
},
{
name: "local forward privileged port allowed for non-root",
forwardType: "local",
port: 80,
username: "testuser",
uid: "1000",
expectError: false,
},
}
@@ -299,7 +326,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
result := PrivilegeCheckResult{
Allowed: true,
User: &user.User{Username: tt.username},
User: &user.User{Username: tt.username, Uid: tt.uid},
}
err := server.checkPrivilegedPortAccess(tt.forwardType, tt.port, result)
@@ -420,6 +447,13 @@ func TestServer_PortConflictHandling(t *testing.T) {
func TestServer_IsPrivilegedUser(t *testing.T) {
// Windows classification depends on account SIDs and group membership, and
// the accounts involved carry localized, renameable names. It is covered by
// TestIsWindowsAccountPrivileged, which resolves them from well-known SIDs.
if runtime.GOOS == "windows" {
t.Skip("covered by TestIsWindowsAccountPrivileged")
}
tests := []struct {
username string
expected bool
@@ -440,44 +474,16 @@ func TestServer_IsPrivilegedUser(t *testing.T) {
expected: false,
description: "empty username should not be privileged",
},
}
// Add Windows-specific tests
if runtime.GOOS == "windows" {
tests = append(tests, []struct {
username string
expected bool
description string
}{
{
username: "Administrator",
expected: true,
description: "Administrator should be considered privileged on Windows",
},
{
username: "administrator",
expected: true,
description: "administrator should be considered privileged on Windows (case insensitive)",
},
}...)
} else {
// On non-Windows systems, Administrator should not be privileged
tests = append(tests, []struct {
username string
expected bool
description string
}{
{
username: "Administrator",
expected: false,
description: "Administrator should not be privileged on non-Windows systems",
},
}...)
{
username: "Administrator",
expected: false,
description: "Administrator should not be privileged on non-Windows systems",
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
result := isPrivilegedUsername(tt.username)
result := isPrivilegedOrUnknown(tt.username)
assert.Equal(t, tt.expected, result, tt.description)
})
}

View File

@@ -17,7 +17,7 @@ import (
// createSftpCommand creates a Windows SFTP command with user switching.
// The caller must close the returned token handle after starting the process.
func (s *Server) createSftpCommand(targetUser *user.User, sess ssh.Session) (*exec.Cmd, windows.Token, error) {
username, domain := s.parseUsername(targetUser.Username)
username, domain := parseUsername(targetUser.Username)
netbirdPath, err := os.Executable()
if err != nil {

View File

@@ -16,11 +16,6 @@ var (
ErrPrivilegedUserSwitch = errors.New("cannot switch to privileged user - current user lacks required privileges")
)
// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
func isPlatformUnix() bool {
return getCurrentOS() != "windows"
}
// Dependency injection variables for testing - allows mocking dynamic runtime checks
var (
getCurrentUser = currentUserWithGetent
@@ -29,6 +24,9 @@ var (
getIsProcessPrivileged = isCurrentProcessPrivileged
getEuid = os.Geteuid
getProcessElevated = isProcessElevated
getWindowsAccountPrivilegedOrUnknown = isWindowsAccountPrivilegedOrUnknown
)
const (
@@ -65,6 +63,13 @@ type PrivilegeCheckResult struct {
RequiresUserSwitching bool
}
// privilegeCheckContext holds all context needed for privilege checking
type privilegeCheckContext struct {
currentUser *user.User
currentUserPrivileged bool
allowRoot bool
}
// CheckPrivileges performs comprehensive privilege checking for all SSH features.
// This is the single source of truth for privilege decisions across the SSH server.
func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult {
@@ -75,7 +80,7 @@ func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult
// Handle empty username case - but still check root access controls
if req.RequestedUsername == "" {
if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot {
if isPrivilegedOrUnknown(context.currentUser.Username) && !context.allowRoot {
return PrivilegeCheckResult{
Allowed: false,
Error: &PrivilegedUserError{Username: context.currentUser.Username},
@@ -135,7 +140,7 @@ func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheck
needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser)
if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot {
if isPrivilegedOrUnknown(resolvedUser.Username) && !ctx.allowRoot {
return PrivilegeCheckResult{
Allowed: false,
Error: &PrivilegedUserError{Username: resolvedUser.Username},
@@ -175,6 +180,42 @@ func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, err
return u, nil
}
// SetAllowRootLogin configures root login access
func (s *Server) SetAllowRootLogin(allow bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.allowRootLogin = allow
}
// userNameLookup performs user lookup with root login permission check
func (s *Server) userNameLookup(username string) (*user.User, error) {
result, err := s.userPrivilegeCheck(username)
if err != nil {
return nil, err
}
return result.User, nil
}
// userPrivilegeCheck performs user lookup with full privilege check result
func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: username,
FeatureSupportsUserSwitch: true,
FeatureName: FeatureSSHLogin,
})
if !result.Allowed {
return result, result.Error
}
return result, nil
}
// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
func isPlatformUnix() bool {
return getCurrentOS() != "windows"
}
// isSameResolvedUser compares two resolved user identities
func isSameResolvedUser(user1, user2 *user.User) bool {
if user1 == nil || user2 == nil {
@@ -183,13 +224,6 @@ func isSameResolvedUser(user1, user2 *user.User) bool {
return user1.Uid == user2.Uid
}
// privilegeCheckContext holds all context needed for privilege checking
type privilegeCheckContext struct {
currentUser *user.User
currentUserPrivileged bool
allowRoot bool
}
// isSameUser checks if two usernames refer to the same user
// SECURITY: This function must be conservative - it should only return true
// when we're certain both usernames refer to the exact same user identity
@@ -253,159 +287,30 @@ func isWindowsSameUser(requestedUsername, currentUsername string) bool {
return strings.EqualFold(reqDomain, curDomain)
}
// SetAllowRootLogin configures root login access
func (s *Server) SetAllowRootLogin(allow bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.allowRootLogin = allow
}
// userNameLookup performs user lookup with root login permission check
func (s *Server) userNameLookup(username string) (*user.User, error) {
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: username,
FeatureSupportsUserSwitch: true,
FeatureName: FeatureSSHLogin,
})
if !result.Allowed {
return nil, result.Error
}
return result.User, nil
}
// userPrivilegeCheck performs user lookup with full privilege check result
func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: username,
FeatureSupportsUserSwitch: true,
FeatureName: FeatureSSHLogin,
})
if !result.Allowed {
return result, result.Error
}
return result, nil
}
// isPrivilegedUsername checks if the given username represents a privileged user across platforms.
// On Unix: root
// On Windows: Administrator, SYSTEM (case-insensitive)
// Handles domain-qualified usernames like "DOMAIN\Administrator" or "user@domain.com"
func isPrivilegedUsername(username string) bool {
// isPrivilegedOrUnknown reports whether the given username represents a
// privileged user, or on Windows an account whose privilege could not be
// determined.
// On Unix: root.
// On Windows: well-known service accounts, built-in Administrator accounts,
// and members of the local Administrators group; handles domain-qualified
// usernames like "DOMAIN\user" or "user@domain.com". An account that cannot be
// resolved or evaluated is reported as privileged.
//
// Use this to refuse privileged accounts, never to grant them anything: the
// undetermined case is safe for a refusal and unsafe for a grant.
func isPrivilegedOrUnknown(username string) bool {
if getCurrentOS() != "windows" {
return username == "root"
}
bareUsername := username
// Handle Windows domain format: DOMAIN\username
if idx := strings.LastIndex(username, `\`); idx != -1 {
bareUsername = username[idx+1:]
}
// Handle email-style format: username@domain.com
if idx := strings.Index(bareUsername, "@"); idx != -1 {
bareUsername = bareUsername[:idx]
}
return isWindowsPrivilegedUser(bareUsername)
}
// isWindowsPrivilegedUser checks if a bare username (domain already stripped) represents a Windows privileged account
func isWindowsPrivilegedUser(bareUsername string) bool {
// common privileged usernames (case insensitive)
privilegedNames := []string{
"administrator",
"admin",
"root",
"system",
"localsystem",
"networkservice",
"localservice",
}
usernameLower := strings.ToLower(bareUsername)
for _, privilegedName := range privilegedNames {
if usernameLower == privilegedName {
return true
}
}
// computer accounts (ending with $) are not privileged by themselves
// They only gain privileges through group membership or specific SIDs
if targetUser, err := lookupUser(bareUsername); err == nil {
return isWindowsPrivilegedSID(targetUser.Uid)
}
return false
}
// isWindowsPrivilegedSID checks if a Windows SID represents a privileged account
func isWindowsPrivilegedSID(sid string) bool {
privilegedSIDs := []string{
"S-1-5-18", // Local System (SYSTEM)
"S-1-5-19", // Local Service (NT AUTHORITY\LOCAL SERVICE)
"S-1-5-20", // Network Service (NT AUTHORITY\NETWORK SERVICE)
"S-1-5-32-544", // Administrators group (BUILTIN\Administrators)
"S-1-5-500", // Built-in Administrator account (local machine RID 500)
}
for _, privilegedSID := range privilegedSIDs {
if sid == privilegedSID {
return true
}
}
// Check for domain administrator accounts (RID 500 in any domain)
// Format: S-1-5-21-domain-domain-domain-500
// This is reliable as RID 500 is reserved for the domain Administrator account
if strings.HasPrefix(sid, "S-1-5-21-") && strings.HasSuffix(sid, "-500") {
return true
}
// Check for other well-known privileged RIDs in domain contexts
// RID 512 = Domain Admins group, RID 516 = Domain Controllers group
if strings.HasPrefix(sid, "S-1-5-21-") {
if strings.HasSuffix(sid, "-512") || // Domain Admins group
strings.HasSuffix(sid, "-516") || // Domain Controllers group
strings.HasSuffix(sid, "-519") { // Enterprise Admins group
return true
}
}
return false
return getWindowsAccountPrivilegedOrUnknown(username)
}
// isCurrentProcessPrivileged checks if the current process is running with elevated privileges.
// On Unix systems, this means running as root (UID 0).
// On Windows, this means running as Administrator or SYSTEM.
// On Windows, this means the process token is elevated (administrators, SYSTEM).
func isCurrentProcessPrivileged() bool {
if getCurrentOS() == "windows" {
return isWindowsElevated()
return getProcessElevated()
}
return getEuid() == 0
}
// isWindowsElevated checks if the current process is running with elevated privileges on Windows
func isWindowsElevated() bool {
currentUser, err := getCurrentUser()
if err != nil {
log.Errorf("failed to get current user for privilege check, assuming non-privileged: %v", err)
return false
}
if isWindowsPrivilegedSID(currentUser.Uid) {
log.Debugf("Windows user switching supported: running as privileged SID %s", currentUser.Uid)
return true
}
if isPrivilegedUsername(currentUser.Username) {
log.Debugf("Windows user switching supported: running as privileged username %s", currentUser.Username)
return true
}
log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid)
return false
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"os/user"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -27,8 +28,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
originalLookupUser := lookupUser
originalGetCurrentOS := getCurrentOS
originalGetEuid := getEuid
// Reset caches to ensure clean test state
originalGetProcessElevated := getProcessElevated
originalGetWindowsAccountPrivilegedOrUnknown := getWindowsAccountPrivilegedOrUnknown
// Set test values - inject platform dependencies
getCurrentUser = func() (*user.User, error) {
@@ -53,16 +54,31 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
return euid
}
// Mock privilege detection based on the test user
getIsProcessPrivileged = func() bool {
// Simulate the Windows token elevation check based on the fixture user:
// the built-in Administrator (RID 500) and SYSTEM run elevated.
getProcessElevated = func() bool {
if currentUser == nil {
return false
}
// Check both username and SID for Windows systems
if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) {
return currentUser.Uid == "S-1-5-18" || strings.HasSuffix(currentUser.Uid, "-500")
}
// Simulate the Windows account classifier for the fixture accounts.
// "root" does not exist on Windows; the real classifier fails closed on
// unresolvable accounts, so it counts as privileged here too.
getWindowsAccountPrivilegedOrUnknown = func(username string) bool {
bare := username
if idx := strings.LastIndex(bare, `\`); idx != -1 {
bare = bare[idx+1:]
}
if idx := strings.Index(bare, "@"); idx != -1 {
bare = bare[:idx]
}
switch strings.ToLower(bare) {
case "administrator", "system", "root":
return true
}
return isPrivilegedUsername(currentUser.Username)
return false
}
// Return cleanup function
@@ -71,10 +87,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
lookupUser = originalLookupUser
getCurrentOS = originalGetCurrentOS
getEuid = originalGetEuid
getIsProcessPrivileged = isCurrentProcessPrivileged
// Reset caches after test
getProcessElevated = originalGetProcessElevated
getWindowsAccountPrivilegedOrUnknown = originalGetWindowsAccountPrivilegedOrUnknown
}
}
@@ -421,6 +435,9 @@ func TestUsedFallback_MeansNoPrivilegeDropping(t *testing.T) {
}
func TestPrivilegedUsernameDetection(t *testing.T) {
// Windows classification is syscall-backed (SID resolution, group
// membership) and is covered by privileges_windows_test.go; here only the
// Unix logic and the platform dispatch are exercised.
tests := []struct {
name string
username string
@@ -432,25 +449,9 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
{"unix_regular_user", "alice", "linux", false},
{"unix_root_capital", "Root", "linux", false}, // Case-sensitive
// Windows tests
// Windows dispatch to the (mocked) account classifier
{"windows_administrator", "Administrator", "windows", true},
{"windows_system", "SYSTEM", "windows", true},
{"windows_admin", "admin", "windows", true},
{"windows_admin_lowercase", "administrator", "windows", true}, // Case-insensitive
{"windows_domain_admin", "DOMAIN\\Administrator", "windows", true},
{"windows_email_admin", "admin@domain.com", "windows", true},
{"windows_regular_user", "alice", "windows", false},
{"windows_domain_user", "DOMAIN\\alice", "windows", false},
{"windows_localsystem", "localsystem", "windows", true},
{"windows_networkservice", "networkservice", "windows", true},
{"windows_localservice", "localservice", "windows", true},
// Computer accounts (these depend on current user context in real implementation)
{"windows_computer_account", "WIN2K19-C2$", "windows", false}, // Computer account by itself not privileged
{"windows_domain_computer", "DOMAIN\\COMPUTER$", "windows", false}, // Domain computer account
// Cross-platform
{"root_on_windows", "root", "windows", true}, // Root should be privileged everywhere
}
for _, tt := range tests {
@@ -459,50 +460,8 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil)
defer cleanup()
result := isPrivilegedUsername(tt.username)
assert.Equal(t, tt.privileged, result)
})
}
}
func TestWindowsPrivilegedSIDDetection(t *testing.T) {
tests := []struct {
name string
sid string
privileged bool
description string
}{
// Well-known system accounts
{"system_account", "S-1-5-18", true, "Local System (SYSTEM)"},
{"local_service", "S-1-5-19", true, "Local Service"},
{"network_service", "S-1-5-20", true, "Network Service"},
{"administrators_group", "S-1-5-32-544", true, "Administrators group"},
{"builtin_administrator", "S-1-5-500", true, "Built-in Administrator"},
// Domain accounts
{"domain_administrator", "S-1-5-21-1234567890-1234567890-1234567890-500", true, "Domain Administrator (RID 500)"},
{"domain_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-512", true, "Domain Admins group"},
{"domain_controllers_group", "S-1-5-21-1234567890-1234567890-1234567890-516", true, "Domain Controllers group"},
{"enterprise_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-519", true, "Enterprise Admins group"},
// Regular users
{"regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1001", false, "Regular domain user"},
{"another_regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1234", false, "Another regular user"},
{"local_user", "S-1-5-21-1234567890-1234567890-1234567890-1000", false, "Local regular user"},
// Groups that are not privileged
{"domain_users", "S-1-5-21-1234567890-1234567890-1234567890-513", false, "Domain Users group"},
{"power_users", "S-1-5-32-547", false, "Power Users group"},
// Invalid SIDs
{"malformed_sid", "S-1-5-invalid", false, "Malformed SID"},
{"empty_sid", "", false, "Empty SID"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isWindowsPrivilegedSID(tt.sid)
assert.Equal(t, tt.privileged, result, "Failed for %s: %s", tt.description, tt.sid)
result := isPrivilegedOrUnknown(tt.username)
assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform)
})
}
}

View File

@@ -91,7 +91,7 @@ func validateUsernameFormat(username string) error {
func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) {
logger.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty)
username, _ := s.parseUsername(localUser.Username)
username, _ := parseUsername(localUser.Username)
if err := validateUsername(username); err != nil {
return nil, nil, fmt.Errorf("invalid username %q: %w", username, err)
}
@@ -102,7 +102,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
// createUserSwitchCommand creates a command with Windows user switching.
// Returns the command and a cleanup function that must be called after starting the process.
func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) {
username, domain := s.parseUsername(localUser.Username)
username, domain := parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
@@ -138,7 +138,7 @@ func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session,
}
// parseUsername extracts username and domain from a Windows username
func (s *Server) parseUsername(fullUsername string) (username, domain string) {
func parseUsername(fullUsername string) (username, domain string) {
// Handle DOMAIN\username format
if idx := strings.LastIndex(fullUsername, `\`); idx != -1 {
domain = fullUsername[:idx]

View File

@@ -2,9 +2,24 @@
A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating).
**Drive an agent with:** *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* — or *"…and review the existing German translation."*
**Translations are managed on Crowdin: <https://crowdin.com/project/netbird>.** Join the project, pick your language, and translate in the editor. Each string carries a context note (the `description` from the source file) telling you what it is and where it shows up, and the project's glossary, style guide, and QA checks mirror this document.
> 💡 **The one habit that matters most:** read each key's `description` before translating it. Labels are terse and ambiguous on their own; the `description` tells you what the string is, where it shows up, what to keep verbatim, and what it actually means.
> 💡 **The one habit that matters most:** read each string's context before translating it. Labels are terse and ambiguous on their own; the context tells you what the string is, where it shows up, what to keep verbatim, and what it actually means.
---
## How contributions flow
```text
i18n/locales/en/common.json ──sync──▶ Crowdin ──service PR──▶ i18n/locales/<code>/common.json
```
- `i18n/locales/en/common.json` is the source of truth. New and changed strings sync to Crowdin automatically (see `crowdin.yml` in the repository root).
- Crowdin opens and updates a service pull request with the translated bundles, keeping the source's file shape and key order. Keys nobody has translated yet are left out of the export; the app falls back to English for them at runtime. Maintainers review and merge that PR.
- Don't hand-edit `i18n/locales/<code>/common.json` in your own PRs: the next sync would conflict with or overwrite your changes. Translate on Crowdin instead.
- Missing your language? Request it on the Crowdin project page or in a [GitHub discussion](https://github.com/netbirdio/netbird/discussions). When a language first ships, a maintainer adds its row to `i18n/locales/_index.json` with `code`, `displayName` (the native name), and `englishName`, which puts it in the app's language picker.
**Prefer translating with an AI agent?** That still works: drive it with *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* as before, but deliver the result to Crowdin instead of a pull request. Download your language's file from the Crowdin editor, let the agent translate it, and upload it back (the editor's offline translation flow). Crowdin runs its QA checks on upload, and the next service PR carries the strings into the repo.
---
@@ -30,25 +45,6 @@ A **business zero-trust VPN** — an encrypted **overlay mesh** between a compan
---
## The files
```
i18n/locales/_index.json shipped-language list
i18n/locales/en/common.json source of truth — message + description
i18n/locales/<code>/common.json a target — message only
```
Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**.
| ✅ Do | ❌ Don't |
|---|---|
| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) |
| Put **only `message`** in target bundles | Copy `description` into a target bundle |
| Give every key a non-empty `message` | Leave keys missing or empty |
| Save valid UTF-8 JSON, no BOM | Add trailing commas or break the JSON |
---
## Hard rules — get these exactly right
These are the usual ways a translation *breaks the app*, not just reads oddly.
@@ -58,7 +54,7 @@ These are the usual ways a translation *breaks the app*, not just reads oddly.
| Copy `{placeholders}` verbatim — `{version}`, `{count}`, `{name}`… | Translate the word inside the braces (`{verbleibend}` breaks it) |
| Reposition a placeholder so the sentence flows | Drop or duplicate a placeholder |
| Preserve every `\n`, leading/trailing space, and trailing `...` | Trim "invisible" spaces or the `...` (they're load-bearing) |
| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the description flags |
| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the context flags |
**Plurals:** the app has only a *one / other* split — the singular key fires only when `count == 1`; the `{count}` key covers everything else (0, 2, 5, 100…). Languages with more than two forms (ru, pl, uk) can't be fully correct here — use the form that fits the widest range (Russian genitive plural: `минут` / `часов` / `дней`). Don't invent extra keys or cram multiple forms into one string. When no single form fits every value — a unit label after a number field, say — reach for a number-agnostic form (an abbreviation, or wording that reads the same for 1 and 100) instead of forcing a plural the *one / other* split can't supply.
@@ -78,13 +74,15 @@ When a brand sits beside a common noun, keep its exact spelling but join them th
> **Use the word that language's IT users actually say.** Translate when a natural, common term exists; keep the English term *only* when the literal translation would be awkward or no one in that field really uses it.
Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing bundles:** match how a term was already rendered for your language rather than re-deciding it.
Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing translation:** match how a term was already rendered for your language rather than re-deciding it.
Two checks before you commit a term:
- **Prefer established localized wording.** If a widely used tool in this space (for example WireGuard) ships your language, its wording for a shared term such as *handshake* is what users already expect — look at the translated app, not just English docs. For generic UI verbs and formal address, follow your OS vendor's style guide (Microsoft / Apple / Google).
- **Watch for false friends.** A literal translation can collide with a *different* established term in your field — confirm your word doesn't already mean something else in this domain before using it.
These tiers are mirrored in the Crowdin project glossary, so the editor highlights them inline. When you settle a new Tier C term for your language, add its translation to the glossary entry so it sticks for everyone who comes after you.
---
## Style
@@ -98,7 +96,7 @@ Two checks before you commit a term:
Where it reads naturally, aim to keep each string **roughly the same length** as the English — the UI is tight and over-long strings can wrap or truncate. It's a soft preference, not a rule: if your language simply needs more words, use them.
A few habits that keep a bundle reading like one product rather than a word-for-word port:
A few habits that keep a translation reading like one product rather than a word-for-word port:
- **Translate meaning, not words.** Render what a string *does*. An idiom or an awkward source phrase should become natural in your language, not a literal calque.
- **Keep one voice within a family.** Sibling strings — the connection states, every settings *help* caption, every "… Failed" title — should share a grammatical form. If one member sounds wrong in that form, re-voice the whole family rather than leave one odd sibling.
@@ -107,27 +105,26 @@ A few habits that keep a bundle reading like one product rather than a word-for-
---
## Procedure
## Reviewing a language
**New language** — read `en/common.json` *with* descriptions → settle your Tier C terms → write `i18n/locales/<code>/common.json` (same keys and order as `en`, `message` only, placeholders & brands preserved) → add a row to `_index.json` (`{"code","displayName"` = native name`,"englishName"}`) → run the QA list. Use the locale-code style the existing entries use (e.g. `fr`, `pt`, `zh-CN`).
**On Crowdin:** proofread in the editor — context, glossary highlights, and QA flags sit inline next to each string.
**Review (de / hu / …)** — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node``Exit Node`, hu `Kilépő csomópont``Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Fix in place, then report what you changed (especially term standardizations) so a native speaker can sanity-check.
**In the repo** — e.g. driving an AI agent with *"Read `i18n/TRANSLATING.md` and review the existing German translation"* — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node``Exit Node`, hu `Kilépő csomópont``Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Report what you found, and apply the fixes **on Crowdin** — direct edits to the locale files are overwritten by the next sync.
---
## QA before you finish
- [ ] Valid JSON · **every `en` key** present, same order · **no `description`** fields
- [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept
- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing bundle for your language)
- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing translation for your language)
- [ ] Buttons & tray short · locale punctuation and capitalization applied
- [ ] New language added to `_index.json`
- [ ] Crowdin QA flags resolved (variables, glossary terms, punctuation)
- [ ] **Tested in the running app**
---
## Test it in the app
A bundle can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens.
A translation can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens.
How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step.

11
crowdin.yml Normal file
View File

@@ -0,0 +1,11 @@
skip_untranslated_strings: true
skip_untranslated_files: true
import_eq_suggestions: true
files:
- source: /client/ui/i18n/locales/en/common.json
translation: /client/ui/i18n/locales/%two_letters_code%/common.json
type: chrome
languages_mapping:
two_letters_code:
zh-CN: zh-CN

View File

@@ -20,5 +20,9 @@ ENV NETBIRD_BIN="/usr/local/bin/netbird" \
NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
# --chmod because the build context is not always a git checkout. A suite in
# another module builds from this module's extracted copy in the module cache,
# where every file is 0444 — the cache drops the executable bit git records — and
# a bare COPY then produces an entrypoint the runtime cannot exec.
COPY --chmod=0755 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY --from=builder /out/netbird /usr/local/bin/netbird

View File

@@ -32,12 +32,36 @@ type Client struct {
container testcontainers.Container
}
// clientOptions is what the ClientOption values assemble.
type clientOptions struct {
name string
}
// ClientOption adjusts how StartClient runs the agent.
type ClientOption func(*clientOptions)
// WithClientName names the agent, which sets both its network alias and its
// container hostname. The hostname matters beyond addressing: the agent reports
// it to management at registration, so it is the name the peer appears under in
// the API.
//
// Required to run more than one agent against the same server — the default name
// is shared, and two containers cannot hold the same alias on one network.
func WithClientName(name string) ClientOption {
return func(o *clientOptions) { o.name = name }
}
// StartClient builds the client image and runs it on the combined server's
// network, joining via the given setup key. The image entrypoint brings the
// daemon up automatically; callers wait for connectivity with WaitConnected /
// WaitProxyPeer.
func StartClient(ctx context.Context, c *Combined, setupKey string) (*Client, error) {
root, err := repoRoot()
func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...ClientOption) (*Client, error) {
o := clientOptions{name: clientAlias}
for _, opt := range opts {
opt(&o)
}
root, err := repoRoot(ctx)
if err != nil {
return nil, err
}
@@ -47,9 +71,13 @@ func StartClient(ctx context.Context, c *Combined, setupKey string) (*Client, er
}
req := testcontainers.ContainerRequest{
Image: clientImage,
Image: clientImage,
// The agent reports the container's hostname to management, so this is
// the name the peer is addressable by in the API as well as on the
// network. The entrypoint takes no hostname flag of its own.
Hostname: o.name,
Networks: []string{c.network.Name},
NetworkAliases: map[string][]string{c.network.Name: {clientAlias}},
NetworkAliases: map[string][]string{c.network.Name: {o.name}},
Env: map[string]string{
"NB_MANAGEMENT_URL": combinedExposedURL,
"NB_SETUP_KEY": setupKey,

View File

@@ -61,11 +61,68 @@ type Combined struct {
workDir string
}
// combinedOptions is what the CombinedOption values assemble.
type combinedOptions struct {
geolocation bool
env map[string]string
}
// CombinedOption adjusts how StartCombined boots the server. The defaults suit a
// suite that only drives the API; the options exist for the ones that need more
// of the product than that.
type CombinedOption func(*combinedOptions)
// WithGeolocation leaves the GeoLite database download enabled. It is off by
// default because the download adds startup latency that most suites get nothing
// for. A suite asserting on location-based posture checks needs it: management
// evaluates those rules against the database, and without it the rule fails
// instead of passing without having been checked.
func WithGeolocation() CombinedOption {
return func(o *combinedOptions) { o.geolocation = true }
}
// WithServerEnv adds environment variables to the combined container, overriding
// the defaults on a key collision. For settings this harness does not model
// directly, so a suite needing one does not have to fork the harness to get it.
func WithServerEnv(env map[string]string) CombinedOption {
return func(o *combinedOptions) {
if o.env == nil {
o.env = map[string]string{}
}
for k, v := range env {
o.env[k] = v
}
}
}
// combinedEnv is the combined container's environment: setup-PAT enabled so the
// caller can mint an admin token through /api/setup, geolocation off unless the
// suite asked for it, and whatever the suite added on top.
func combinedEnv(o combinedOptions) map[string]string {
env := map[string]string{
"NB_SETUP_PAT_ENABLED": "true",
}
if !o.geolocation {
// Skip the GeoLite DB download — it blocks startup and agent-network
// ingest doesn't use geolocation.
env["NB_DISABLE_GEOLOCATION"] = "true"
}
for k, v := range o.env {
env[k] = v
}
return env
}
// StartCombined builds the combined server from its multistage Dockerfile and
// boots it with setup-PAT enabled on a fresh shared network, returning once the
// API is serving. The caller still owns minting the admin PAT via Bootstrap.
func StartCombined(ctx context.Context) (*Combined, error) {
root, err := repoRoot()
func StartCombined(ctx context.Context, opts ...CombinedOption) (*Combined, error) {
var o combinedOptions
for _, opt := range opts {
opt(&o)
}
root, err := repoRoot(ctx)
if err != nil {
return nil, err
}
@@ -88,7 +145,7 @@ func StartCombined(ctx context.Context) (*Combined, error) {
return nil, fmt.Errorf("create work dir: %w", err)
}
cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, containerIssuer)
cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, !o.geolocation, containerIssuer)
if err := os.WriteFile(filepath.Join(workDir, "config.yaml"), []byte(cfg), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container
_ = net.Remove(ctx)
return nil, fmt.Errorf("write combined config: %w", err)
@@ -112,13 +169,8 @@ func StartCombined(ctx context.Context) (*Combined, error) {
ExposedPorts: []string{combinedHTTPPort},
Networks: []string{net.Name},
NetworkAliases: map[string][]string{net.Name: {combinedAlias}},
Env: map[string]string{
"NB_SETUP_PAT_ENABLED": "true",
// Skip the GeoLite DB download — it blocks startup and agent-network
// ingest doesn't use geolocation.
"NB_DISABLE_GEOLOCATION": "true",
},
Cmd: []string{"--config", "/nb/config.yaml"},
Env: combinedEnv(o),
Cmd: []string{"--config", "/nb/config.yaml"},
HostConfigModifier: func(hc *container.HostConfig) {
hc.Binds = append(hc.Binds, workDir+":/nb")
},

View File

@@ -15,6 +15,11 @@ package harness
// server is required to load it — a broken path or malformed file fails startup
// rather than silently falling back to the compiled-in rates, and TestMain then
// fails with the container logs.
//
// disableGeoliteUpdate is a parameter rather than a fixed true because a suite
// that exercises geolocation needs the database: management can only evaluate a
// location rule with GeoLite loaded, and a rule it cannot evaluate fails rather
// than passing vacuously. See WithGeolocation.
const combinedConfigYAML = `server:
listenAddress: ":8080"
exposedAddress: "%s"
@@ -25,7 +30,7 @@ const combinedConfigYAML = `server:
authSecret: "e2e-relay-secret"
dataDir: "/nb/data"
disableAnonymousMetrics: true
disableGeoliteUpdate: true
disableGeoliteUpdate: %t
auth:
issuer: "%s"
store:

161
e2e/harness/options_test.go Normal file
View File

@@ -0,0 +1,161 @@
//go:build e2e
package harness
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The options exist so a suite can ask for a deployment this harness would not
// otherwise give it. What they configure is a container environment and a config
// file, both assembled before anything is started, so they are checkable without
// Docker — which is the point: a wiring mistake here would otherwise only show up
// as a puzzling failure minutes into a container run.
func TestCombinedEnvGeolocation(t *testing.T) {
var off combinedOptions
assert.Equal(t, "true", combinedEnv(off)["NB_DISABLE_GEOLOCATION"],
"geolocation should be off by default")
var on combinedOptions
WithGeolocation()(&on)
assert.NotContains(t, combinedEnv(on), "NB_DISABLE_GEOLOCATION",
"WithGeolocation must leave NB_DISABLE_GEOLOCATION unset, so the server downloads the database")
assert.Equal(t, "true", combinedEnv(on)["NB_SETUP_PAT_ENABLED"],
"the setup PAT must stay enabled whatever else is configured; Bootstrap depends on it")
}
// The config file carries the same decision as the environment variable, and the
// server needs both to agree: disableGeoliteUpdate suppresses the download even
// when geolocation itself is enabled.
func TestCombinedConfigGeolocation(t *testing.T) {
for _, tc := range []struct {
name string
opts []CombinedOption
want string
}{
{name: "default", want: "disableGeoliteUpdate: true"},
{name: "with geolocation", opts: []CombinedOption{WithGeolocation()}, want: "disableGeoliteUpdate: false"},
} {
t.Run(tc.name, func(t *testing.T) {
var o combinedOptions
for _, opt := range tc.opts {
opt(&o)
}
cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, !o.geolocation, containerIssuer)
assert.Contains(t, cfg, tc.want, "geolocation not rendered as expected")
// The issuer is the last verb; a mis-ordered argument list would put
// the boolean here instead and the server would fail to start.
assert.Contains(t, cfg, `issuer: "`+containerIssuer+`"`, "issuer not rendered")
})
}
}
func TestWithServerEnvOverrides(t *testing.T) {
var o combinedOptions
WithServerEnv(map[string]string{"NB_LOG_LEVEL": "debug"})(&o)
WithServerEnv(map[string]string{"NB_SETUP_PAT_ENABLED": "false"})(&o)
env := combinedEnv(o)
assert.Equal(t, "debug", env["NB_LOG_LEVEL"], "added variable missing")
assert.Equal(t, "false", env["NB_SETUP_PAT_ENABLED"], "a suite must be able to override a default")
}
// Two agents on one network cannot share an alias, so the name has to reach both
// the alias and the hostname. The hostname is the one management records, so it is
// also what the peer is addressable by through the API.
func TestWithClientName(t *testing.T) {
o := clientOptions{name: clientAlias}
require.Equal(t, "client", o.name, "unexpected default client name")
WithClientName("peer2")(&o)
assert.Equal(t, "peer2", o.name, "WithClientName did not take")
}
// repoRoot has to recognise this module rather than merely finding a go.mod, or a
// suite in another module gets its own root and a build context without the
// component Dockerfiles in it.
func TestIsModule(t *testing.T) {
dir := t.TempDir()
other := filepath.Join(dir, "go.mod")
require.NoError(t, os.WriteFile(other, []byte("module example.com/other\n\ngo 1.25\n"), 0o600))
assert.False(t, isModule(other, modulePath), "another module's go.mod must not be taken for this repo")
ours := filepath.Join(dir, "ours.mod")
require.NoError(t, os.WriteFile(ours, []byte("// a comment\n\nmodule "+modulePath+"\n\ngo 1.25\n"), 0o600))
assert.True(t, isModule(ours, modulePath), "this repo's go.mod was not recognised")
assert.False(t, isModule(filepath.Join(dir, "absent.mod"), modulePath),
"a missing go.mod must not report a match")
}
// Running from inside the repo, repoRoot finds it by walking up — the module
// lookup is only the fallback, and this asserts the walk still wins so an in-repo
// run never depends on the module cache.
func TestRepoRootFindsThisRepo(t *testing.T) {
root, err := repoRoot(context.Background())
require.NoError(t, err)
assert.True(t, isModule(filepath.Join(root, "go.mod"), modulePath),
"repoRoot returned %s, which is not this module", root)
for _, f := range []string{combinedDockerfile, clientDockerfile} {
_, err := os.Stat(filepath.Join(root, f))
assert.NoError(t, err, "%s is not present under the reported root %s", f, root)
}
}
// A caller that vendors its dependencies puts the go command in automatic vendor
// mode, where `go list -m -f {{.Dir}}` succeeds and reports an EMPTY directory:
// vendor/ holds packages, not module source. Without -mod=readonly the lookup
// would come back empty and the harness would report a missing module for a
// dependency that is present.
func TestModuleDirResolvesUnderVendorMode(t *testing.T) {
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no go tool on PATH")
}
ctx := context.Background()
base := t.TempDir()
dep := filepath.Join(base, "dep")
main := filepath.Join(base, "main")
require.NoError(t, os.MkdirAll(dep, 0o750))
require.NoError(t, os.MkdirAll(main, 0o750))
// A local replacement rather than a real dependency, so this needs no network.
require.NoError(t, os.WriteFile(filepath.Join(dep, "go.mod"),
[]byte("module example.com/dep\n\ngo 1.25\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(dep, "dep.go"),
[]byte("package dep\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(main, "go.mod"),
[]byte("module example.com/main\n\ngo 1.25\n\nrequire example.com/dep v0.0.0\n\nreplace example.com/dep v0.0.0 => ../dep\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(main, "main.go"),
[]byte("package main\n\nimport _ \"example.com/dep\"\n\nfunc main() {}\n"), 0o600))
t.Chdir(main)
vendor := exec.CommandContext(ctx, "go", "mod", "vendor")
out, err := vendor.CombinedOutput()
require.NoError(t, err, "go mod vendor: %s", out)
dir, err := moduleDir(ctx, "example.com/dep")
require.NoError(t, err, "the module must still resolve with a vendor directory present")
assert.Equal(t, dep, dir, "resolved the wrong directory")
}
// A cancelled context has to stop the lookup rather than leaving the caller
// waiting on a subprocess it has already given up on.
func TestModuleDirHonoursContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := moduleDir(ctx, modulePath)
assert.ErrorIs(t, err, context.Canceled, "a cancelled context must stop the lookup")
}

View File

@@ -3,27 +3,82 @@
package harness
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// repoRoot walks up from the working directory to the module root (the
// directory holding go.mod), so the Docker build context is correct no matter
// which package the test runs from.
func repoRoot() (string, error) {
// modulePath is this module, used both to recognise the repo when walking up
// from the working directory and to locate it when the suite lives elsewhere.
const modulePath = "github.com/netbirdio/netbird"
// repoRoot returns the directory the component Dockerfiles are built from.
//
// Walking up from the working directory finds it for any test inside this repo,
// no matter which package it runs from. A suite in another module gets a
// different answer that way — its own module root, where combined/Dockerfile
// does not exist — so the ancestor has to be this module and not merely some
// module. When it is not, the build context is the extracted module directory of
// whichever version that suite depends on, which is the right one: the server it
// tests against is then built from the same revision as the client library it
// was compiled with.
func repoRoot(ctx context.Context) (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil {
if isModule(filepath.Join(dir, "go.mod"), modulePath) {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("go.mod not found above %s", dir)
break
}
dir = parent
}
return moduleDir(ctx, modulePath)
}
// isModule reports whether the go.mod at path declares the given module.
func isModule(path, want string) bool {
b, err := os.ReadFile(path)
if err != nil {
return false
}
for _, line := range strings.Split(string(b), "\n") {
if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "module "); ok {
return strings.TrimSpace(rest) == want
}
}
return false
}
// moduleDir asks the go tool where a module's source is, which for a dependent
// module is its extracted copy in the module cache. The cache is read-only, and
// a Docker build context is only ever read.
//
// -mod=readonly is required rather than cosmetic. A caller that vendors its
// dependencies puts the go command in automatic vendor mode, where this lookup
// succeeds with an EMPTY directory — vendor/ holds packages, not module source,
// so there is nothing to report. Asking in readonly mode resolves against the
// module graph instead, which answers for both a cached module and a local
// replacement, and neither writes to go.mod.
func moduleDir(ctx context.Context, module string) (string, error) {
cmd := exec.CommandContext(ctx, "go", "list", "-mod=readonly", "-m", "-f", "{{.Dir}}", module)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("locate %s: %w", module, err)
}
dir := strings.TrimSpace(string(out))
if dir == "" {
return "", fmt.Errorf("locate %s: the go tool reported no directory; run `go mod download %s`", module, module)
}
if _, err := os.Stat(dir); err != nil {
return "", fmt.Errorf("locate %s: %w", module, err)
}
return dir, nil
}

View File

@@ -43,7 +43,7 @@ type Proxy struct {
// or override any NB_PROXY_* var (e.g. NB_PROXY_TUNNEL_CACHE_TTL for tests that
// need a short authorization-cache window).
func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverrides ...map[string]string) (*Proxy, error) {
root, err := repoRoot()
root, err := repoRoot(ctx)
if err != nil {
return nil, err
}

2
go.mod
View File

@@ -81,7 +81,7 @@ require (
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/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42
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

4
go.sum
View File

@@ -482,8 +482,8 @@ github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6
github.com/netbirdio/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
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-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/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/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

@@ -1,58 +0,0 @@
//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

@@ -1,22 +0,0 @@
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');

View File

@@ -1,25 +0,0 @@
//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

@@ -1,62 +0,0 @@
//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, enable_search_domain, distribution_groups)
VALUES('zone-1','account-1','test-1.com',true,'["group-one-resource-id"]')`)
execQuery(t, ctx,
`insert into zones (id, account_id, domain, enable_search_domain, distribution_groups)
VALUES('zone-2','account-1','test-2.com',false,'["group-two-resources-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')`)
zoneCandidates, err := conn(t, ctx).GetAppliedZoneCandidates(ctx, "account-1")
assert.NoError(t, err)
assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{
DistributionGroups: []string{"group-one-resource-id"},
Zone: nmdata.CustomZone{
Domain: "test-1.com",
SearchDomainDisabled: false,
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,
Records: []nmdata.SimpleRecord{
{Name: "test2.test-2.com", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test3.test-2.com."},
},
},
})
}

View File

@@ -1,39 +0,0 @@
//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

@@ -1,58 +0,0 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"testing"
"github.com/netbirdio/netbird/management/server/types"
"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) {
if engine == string(types.SqliteStoreEngine) {
t.Skip()
}
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

@@ -1,99 +0,0 @@
//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
//go:embed pg_data.sql
var pgData string
//go:embed sqlite_data.sql
var sqliteData 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, pgData)
case "", string(types.SqliteStoreEngine):
engine = string(types.SqliteStoreEngine)
sqlitestore, cleanup = createSqliteTestStore(baseData, sqliteData)
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 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 local time zone, as that's what being returned from sql queries
// pgx returns time in the "local" timezone
// sql with sqlite driver returns time in UTC timezone
func mustParseTime(t string) *time.Time {
tt, err := time.Parse(time.RFC3339Nano, t)
if err != nil {
panic(err)
}
if engine == string(types.SqliteStoreEngine) {
utc := tt.UTC()
return &utc
}
local := tt.Local()
return &local
}

View File

@@ -1,61 +0,0 @@
//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

@@ -1,65 +0,0 @@
//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

@@ -1,33 +0,0 @@
//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

@@ -1,56 +0,0 @@
//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

@@ -1,26 +0,0 @@
//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

@@ -1,163 +0,0 @@
//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,
},
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,
},
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,
},
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

@@ -1,30 +0,0 @@
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+00','"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+00','"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+00','"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

@@ -1,122 +0,0 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"fmt"
"regexp"
"slices"
"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, pgData 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 slices.Concat(strings.Split(baseData, ";"), strings.Split(pgData, ";")) {
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

@@ -1,146 +0,0 @@
//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

@@ -1,61 +0,0 @@
//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

@@ -1,87 +0,0 @@
//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

@@ -1,109 +0,0 @@
//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

@@ -1,30 +0,0 @@
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

@@ -1,49 +0,0 @@
//go:build integration
package networkmap_pgsql
import (
"context"
"fmt"
"runtime"
"slices"
"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, sqliteData 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 slices.Concat(strings.Split(baseData, ";"), strings.Split(sqliteData, ";")) {
if _, err := sqldb.Exec(query); err != nil {
log.Fatalf("error initializing db: %s", err.Error())
}
}
return &networkmap_sqlite.SqliteStore{Db: sqldb}, func() {}
}

View File

@@ -1,57 +0,0 @@
//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"},
})
}

View File

@@ -18,7 +18,6 @@ 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/server/account"
@@ -31,8 +30,6 @@ 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"
@@ -64,8 +61,6 @@ type Controller struct {
serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion
perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion
nmdataStore *networkmapdb.NetworkMapDBStoreImpl
}
type bufferUpdate struct {
@@ -83,7 +78,7 @@ 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, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *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) *Controller {
nMetrics, err := newMetrics(metrics.UpdateChannelMetrics())
if err != nil {
log.Fatal(fmt.Errorf("error creating metrics: %w", err))
@@ -104,7 +99,6 @@ 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,
}
}
@@ -153,11 +147,6 @@ 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)
@@ -178,7 +167,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
return nil
}
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validate peers: %v", err)
}
@@ -266,7 +255,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, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
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)
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -287,7 +276,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
}
start = time.Now()
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)
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)
c.metrics.CountToSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -305,261 +294,6 @@ 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)
}
nmData.PrecomputePostureValidation()
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.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.nmdataStore == nil {
return nil
}
nmData, err := c.nmdataStore.GetNetworkMapData(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
}
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{})
if nmData.AccountSettings != nil {
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) *types.NetworkMap {
components := nmData.GetPeerNetworkMapComponents(peerID, peersCustomZone)
if components.IsEmpty() {
return &types.NetworkMap{Network: components.Network}
}
return types.CalculateNetworkMapFromComponents(ctx, components)
}
// 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
@@ -592,10 +326,6 @@ 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)
@@ -611,7 +341,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, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validate peers: %v", err)
}
@@ -698,7 +428,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, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
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)
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -719,7 +449,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
}
start = time.Now()
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)
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)
c.metrics.CountToSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -776,7 +506,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, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validated peers: %v", err)
}
@@ -836,7 +566,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, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
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)
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
Update: update,
@@ -853,7 +583,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, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
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)
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
Update: update,
@@ -913,11 +643,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
if err != nil {
return nil, nil, nil, nil, 0, err
}
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)
return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
@@ -927,7 +653,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
c.injectAllProxyPolicies(ctx, account)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
if err != nil {
return nil, nil, nil, nil, 0, err
}
@@ -964,21 +690,6 @@ 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 {
@@ -1085,15 +796,11 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
}
emptyMap := &types.NetworkMap{
Network: types.TwinNetwork(network),
Network: network.Copy(),
}
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
@@ -1101,7 +808,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
c.injectAllProxyPolicies(ctx, account)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
if err != nil {
return nil, nil, 0, err
}
@@ -1141,21 +848,6 @@ 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)
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 {
@@ -1218,36 +910,20 @@ 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 {
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 {
if len(peers) == 0 {
return int64(network_map.OldForwarderPort)
}
reqVer := semver.Canonical(requiredVersion)
// Check if all peers have the required version or newer
for _, wtVersion := range wtVersions {
for _, peer := range peers {
// Development version is always supported
if version.IsDevelopmentVersion(wtVersion) {
if version.IsDevelopmentVersion(peer.Meta.WtVersion) {
continue
}
peerVersion := semver.Canonical("v" + wtVersion)
peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
if peerVersion == "" {
// If any peer doesn't have version info, return 0
return int64(network_map.OldForwarderPort)
@@ -1381,7 +1057,7 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
groups[groupID] = group.Peers
}
validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,25 @@
// Package activity records that a principal used a reverse proxy service, so
// that activity accounting counts people and devices which reach services
// through the proxy but never touch the dashboard or the management API.
package activity
import (
"context"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/types"
)
// Manager records reverse proxy usage against the timestamps activity
// accounting reads. Both methods are best effort from the caller's point of
// view: a lost record is corrected by the next request, and no authorization
// decision reads them back.
type Manager interface {
// RecordUserLogin records a completed SSO sign-in to a proxied service.
// Service users have no interactive login and are ignored.
RecordUserLogin(ctx context.Context, accountID string, user *types.User) error
// RecordPeerSeen records that a peer reached a private service over the
// mesh, which is what lets its owner count as active. Peers activity
// accounting excludes, and peers already seen recently, are ignored.
RecordPeerSeen(ctx context.Context, accountID string, peer *peer.Peer) error
}

View File

@@ -0,0 +1,66 @@
package manager
import (
"context"
"time"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
)
// peerSeenInterval is how stale a peer's LastSeen must be before reaching a
// private service refreshes it. Positive tunnel validations are cached on the
// proxy for five minutes, so without a floor a busy peer would rewrite its row
// behind every request; an hour still sits well inside the window activity
// accounting asks about.
const peerSeenInterval = time.Hour
type managerImpl struct {
store store.Store
}
// NewManager returns the activity manager backed by the management store.
func NewManager(store store.Store) activity.Manager {
return &managerImpl{store: store}
}
// RecordUserLogin stamps the login the same way the dashboard and device login
// paths do, so a person who only ever reaches proxied services still has a
// login on record.
func (m *managerImpl) RecordUserLogin(ctx context.Context, accountID string, user *types.User) error {
if user == nil || user.IsServiceUser {
return nil
}
return m.store.SaveUserLastLogin(ctx, accountID, user.Id, time.Now().UTC())
}
// RecordPeerSeen stamps LastSeen, the column a peer activates its owner
// through. The peer the caller already holds answers the throttle without a
// query, so a peer seen inside the interval costs nothing to skip; the same
// cutoff goes to the store, which enforces it inside the UPDATE so concurrent
// requests for one peer cannot each write off their own stale read.
func (m *managerImpl) RecordPeerSeen(ctx context.Context, accountID string, peer *peer.Peer) error {
if peer == nil || !countsTowardActivity(peer) {
return nil
}
staleBefore := time.Now().UTC().Add(-peerSeenInterval)
if peer.Status != nil && peer.Status.LastSeen.After(staleBefore) {
return nil
}
_, err := m.store.RefreshPeerLastSeen(ctx, accountID, peer.ID, staleBefore)
return err
}
// countsTowardActivity reports whether the peer represents a device a person
// actually runs. Embedded proxy peers are infrastructure and browser (WASM)
// clients are ephemeral sessions, so activity accounting ignores both and a
// write for them could never count.
func countsTowardActivity(peer *peer.Peer) bool {
return !peer.ProxyMeta.Embedded && peer.Meta.KernelVersion != "wasm"
}

View File

@@ -0,0 +1,149 @@
package manager
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
)
// recordingStore captures the two writes the activity manager makes. The
// embedded interface satisfies the rest and panics if anything else is called,
// which keeps the manager honest about its surface.
type recordingStore struct {
store.Store
logins []loginWrite
seen []seenWrite
}
type loginWrite struct {
accountID string
userID string
at time.Time
}
type seenWrite struct {
accountID string
peerID string
staleBefore time.Time
}
func (s *recordingStore) SaveUserLastLogin(_ context.Context, accountID, userID string, lastLogin time.Time) error {
s.logins = append(s.logins, loginWrite{accountID: accountID, userID: userID, at: lastLogin})
return nil
}
func (s *recordingStore) RefreshPeerLastSeen(_ context.Context, accountID, peerID string, staleBefore time.Time) (bool, error) {
s.seen = append(s.seen, seenWrite{accountID: accountID, peerID: peerID, staleBefore: staleBefore})
return true, nil
}
func TestRecordUserLogin(t *testing.T) {
tests := []struct {
name string
user *types.User
expectWrite bool
}{
{
name: "regular user is recorded",
user: &types.User{Id: "user1", AccountID: "account1"},
expectWrite: true,
},
{
// Activity accounting never counts service users, so a row for one
// would be noise.
name: "service user is ignored",
user: &types.User{Id: "svc1", AccountID: "account1", IsServiceUser: true},
expectWrite: false,
},
{
name: "missing user is ignored",
user: nil,
expectWrite: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := &recordingStore{}
require.NoError(t, NewManager(st).RecordUserLogin(context.Background(), "account1", tt.user))
if !tt.expectWrite {
assert.Empty(t, st.logins, "no login should have been recorded")
return
}
require.Len(t, st.logins, 1, "exactly one login should have been recorded")
assert.Equal(t, "account1", st.logins[0].accountID, "login must be recorded against the service account")
assert.Equal(t, tt.user.Id, st.logins[0].userID, "login must be recorded against the signing-in user")
assert.Equal(t, time.UTC, st.logins[0].at.Location(), "timestamps are written in UTC")
assert.WithinDuration(t, time.Now().UTC(), st.logins[0].at, time.Minute, "login should be stamped now")
})
}
}
func TestRecordPeerSeen(t *testing.T) {
tests := []struct {
name string
peer *peer.Peer
expectWrite bool
}{
{
name: "peer seen long ago is recorded",
peer: &peer.Peer{ID: "peer1", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
expectWrite: true,
},
{
name: "peer never seen is recorded",
peer: &peer.Peer{ID: "peer1", Status: &peer.PeerStatus{}},
expectWrite: true,
},
{
// The throttle. The caller already holds the peer, so skipping a
// recently seen one costs nothing.
name: "peer seen inside the interval is skipped",
peer: &peer.Peer{ID: "peer1", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-10 * time.Minute)}},
expectWrite: false,
},
{
name: "embedded proxy peer is skipped",
peer: &peer.Peer{ID: "peer1", ProxyMeta: peer.ProxyMeta{Embedded: true}, Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
expectWrite: false,
},
{
name: "browser client is skipped",
peer: &peer.Peer{ID: "peer1", Meta: peer.PeerSystemMeta{KernelVersion: "wasm"}, Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
expectWrite: false,
},
{
name: "missing peer is ignored",
peer: nil,
expectWrite: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := &recordingStore{}
require.NoError(t, NewManager(st).RecordPeerSeen(context.Background(), "account1", tt.peer))
if !tt.expectWrite {
assert.Empty(t, st.seen, "no activity should have been recorded")
return
}
require.Len(t, st.seen, 1, "exactly one activity write should have been recorded")
assert.Equal(t, "account1", st.seen[0].accountID, "activity must be recorded against the service account")
assert.Equal(t, tt.peer.ID, st.seen[0].peerID, "activity must be recorded against the calling peer")
assert.Equal(t, time.UTC, st.seen[0].staleBefore.Location(), "cutoffs are passed in UTC")
assert.WithinDuration(t, time.Now().UTC().Add(-peerSeenInterval), st.seen[0].staleBefore, time.Minute,
"the store must enforce the same interval the local check applies")
})
}
}

View File

@@ -1,51 +0,0 @@
package networkmapdb
import (
"context"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
"github.com/netbirdio/netbird/management/server/settings"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
type NetworkMapDBStore interface { //nolint:revive // established name across the codebase
BeginTx(ctx context.Context) (NetworkMapDBStoreConn, error)
}
type NetworkMapDBStoreConn interface { //nolint:revive // established name across the codebase
GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error)
GetDomains(ctx context.Context, accountId string) ([]Domain, error)
GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error)
GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error)
GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error)
GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error)
GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error)
GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error)
GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error)
GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error)
GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error)
GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error)
GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error)
GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error)
GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error)
GetPrivateServices(ctx context.Context, accountId string) ([]Service, error)
GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error)
CommitTx(ctx context.Context) error
RollbackTx(ctx context.Context) error
}
type NetworkMapDBStoreImpl struct { //nolint:revive // established name across the codebase
store NetworkMapDBStore
integratedPeerValidator integrated_validator.IntegratedValidator
extraSettingsManager settings.Manager
}
func NewNetworkMapDBStoreImpl(store NetworkMapDBStore, integratedPeerValidator integrated_validator.IntegratedValidator, extraSettingsManager settings.Manager) *NetworkMapDBStoreImpl {
return &NetworkMapDBStoreImpl{
store: store,
integratedPeerValidator: integratedPeerValidator,
extraSettingsManager: extraSettingsManager,
}
}

View File

@@ -1,252 +0,0 @@
package networkmapdb
import (
"context"
"fmt"
"strings"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error) {
tx, err := s.store.BeginTx(ctx)
if err != nil {
return nil, err
}
acctSettings, err := tx.GetAccountSettings(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get account settings: %w", err))
}
dnsZones, err := tx.GetAppliedZoneCandidates(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get applied zone candidates: %w", err))
}
groups, resourceToGroupIdx, err := tx.GetGroups(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get groups: %w", err))
}
nsGroups, err := tx.GetNameServerGroups(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get nameserver groups: %w", err))
}
networkResources, err := tx.GetNetworkResources(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network resources: %w", err))
}
routers, err := tx.GetNetworkRouters(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network routers: %w", err))
}
network, err := tx.GetNetwork(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network: %w", err))
}
peers, proxyPeers, err := tx.GetPeers(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get peers: %w", err))
}
policies, policyToDestinationResourceIdx, policyToDestinationGroupIdx, err := tx.GetPolicies(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get policies: %w", err))
}
postureChecks, postureCheckXIDToPublicID, err := tx.GetPostureChecks(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get posture checks: %w", err))
}
routes, err := tx.GetRoutes(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get routes: %w", err))
}
networkXIDToPublicID, err := tx.GetNetworkXIDToPublicIdMap(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network xid to public id map: %w", err))
}
allowedUserIds, groupsToUserIds, err := tx.GetAllowedUsers(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get allowed users: %w", err))
}
dnsSettings, err := tx.GetDnsSettings(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get dns settings: %w", err))
}
domains, err := tx.GetDomains(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, err)
}
services, err := tx.GetPrivateServices(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, err)
}
proxyTargetedDomainResourceIDs, err := tx.GetProxyTargetedDomainResourceIDs(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get proxy targeted domain resources: %w", err))
}
resourcePolicies := make(map[string][]*nmdata.Policy)
for _, resource := range networkResources {
if !resource.Enabled {
continue
}
networkResourceGroups := resourceToGroupIdx[resource.ID]
for _, policy := range policies {
if !policy.Enabled {
continue
}
if _, ok := policyToDestinationResourceIdx[policy.ID][resource.ID]; ok {
resourcePolicies[resource.ID] = append(resourcePolicies[resource.ID], &policy) // TODO (dmitri) maybe use public id?
continue
}
if groupIds, ok := policyToDestinationGroupIdx[policy.ID]; ok {
for networkResourceGroup := range networkResourceGroups {
if _, ok := groupIds[networkResourceGroup]; ok {
resourcePolicies[resource.ID] = append(resourcePolicies[resource.ID], &policy)
break
}
}
}
}
}
if err = tx.CommitTx(ctx); err != nil {
log.WithContext(ctx).Warnf("failed to commit network map read transaction: %v", err)
}
toret := networkmap.NetworkMapData{
AccountSettings: &acctSettings,
DNSSettings: &dnsSettings,
Network: &network,
Peers: toMap(peers, func(p nmdata.Peer) string { return p.ID }),
Groups: toMap(groups, func(g nmdata.Group) string { return g.ID }),
Policies: toSliceOfPtrs(policies),
ResourcePolicies: resourcePolicies,
Routes: toSliceOfPtrs(routes),
Routers: routers,
NameServerGroups: toSliceOfPtrs(nsGroups),
NetworkResources: toSliceOfPtrs(networkResources),
PostureChecks: toMap(postureChecks, func(pc nmdata.PostureChecks) string { return pc.ID }),
AllowedUserIDs: allowedUserIds,
GroupIDToUserIDs: groupsToUserIds,
NetworkXIDToPublicID: networkXIDToPublicID, // TODO (dmitri) maybe we can switch to public ids everywhere?
AppliedZoneCandidates: dnsZones,
PrivateServiceCandidates: buildPrivateServiceCandidates(services, domains, proxyPeers),
PostureCheckXIDToPublicID: postureCheckXIDToPublicID,
ProxyTargetedDomainResourceIDs: proxyTargetedDomainResourceIDs,
}
extraSettings, err := s.extraSettingsManager.GetExtraSettings(ctx, accountId)
if err != nil {
return nil, err
}
toret.ValidatedPeers, err = s.integratedPeerValidator.GetValidatedPeers(ctx, accountId, maps.Values(toret.Groups), maps.Values(toret.Peers), extraSettings)
if err != nil {
return nil, err
}
return &toret, nil
}
func rollbackAndReturnError(ctx context.Context, tx NetworkMapDBStoreConn, err error) (*networkmap.NetworkMapData, error) {
if errr := tx.RollbackTx(ctx); errr != nil {
log.WithContext(ctx).Warnf("failed to rollback network map read transaction: %v", errr)
}
return nil, err
}
func toMap[T any](all []T, id func(t T) string) map[string]*T {
toret := make(map[string]*T, len(all))
for _, t := range all {
toret[id(t)] = &t
}
return toret
}
func toSliceOfPtrs[T any](all []T) []*T {
toret := make([]*T, 0, len(all))
for _, t := range all {
toret = append(toret, &t)
}
return toret
}
func serviceDomainZone(svc Service, ds []Domain) string {
if domainFromSuffix(svc.Domain.String, svc.ProxyCluster.String) {
return svc.ProxyCluster.String
}
var zoneName string
for _, domain := range ds {
if domain.TargetCluster.String != svc.ProxyCluster.String {
continue
}
if domainFromSuffix(svc.Domain.String, domain.Domain.String) && len(domain.Domain.String) > len(zoneName) {
zoneName = domain.Domain.String
}
}
return zoneName
}
func domainFromSuffix(domain, suffix string) bool {
if suffix == "" {
return false
}
return domain == suffix || strings.HasSuffix(domain, "."+suffix)
}
func buildPrivateServiceCandidates(svcs []Service, domains []Domain, proxyPeersByCluster map[string][]*nmdata.Peer) []networkmap.PrivateServiceCandidate {
var out []networkmap.PrivateServiceCandidate
if len(proxyPeersByCluster) == 0 {
return out
}
for _, svc := range svcs {
if !svc.Enabled.Bool || !svc.Private.Bool {
continue
}
if len(svc.AccessGroups) == 0 {
continue
}
domainZone := serviceDomainZone(svc, domains)
if domainZone == "" {
continue
}
var records []nmdata.SimpleRecord
for _, proxyPeer := range proxyPeersByCluster[svc.ProxyCluster.String] {
if !proxyPeer.IP.IsValid() {
continue
}
records = append(records, nmdata.SimpleRecord{
Name: dns.Fqdn(svc.Domain.String),
Type: int(dns.TypeA),
Class: "IN",
TTL: 5,
RData: proxyPeer.IP.String(),
})
}
if len(records) == 0 {
continue
}
out = append(out, networkmap.PrivateServiceCandidate{
AccessGroups: svc.AccessGroups,
Zone: nmdata.CustomZone{
Domain: dns.Fqdn(domainZone),
Records: records,
NonAuthoritative: true,
SearchDomainDisabled: true,
},
})
}
return out
}

View File

@@ -1,61 +0,0 @@
package networkmap_pgsql
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetAccountSettingsQuery = `
select settings_peer_login_expiration_enabled as peer_login_expiration_enabled,
settings_peer_login_expiration as peer_login_expiration,
settings_peer_inactivity_expiration_enabled as peer_inactivity_expiration_enabled,
settings_peer_inactivity_expiration as peer_inactivity_expiration,
settings_dns_domain as dns_domain,
settings_ipv6_enabled_groups as ipv6_enabled_groups,
settings_routing_peer_dns_resolution_enabled as routing_peer_dns_resolution_enabled,
settings_lazy_connection_enabled as lazy_connection_enabled,
settings_auto_update_version as auto_update_version,
settings_auto_update_always as auto_update_always,
settings_metrics_push_enabled as metrics_push_enabled
from accounts
where id=$1
`
)
func (pgc *PgStoreConn) GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error) {
rows, err := pgc.Conn.Query(ctx, GetAccountSettingsQuery, accountId)
if err != nil {
return nmdata.AccountSettingsInfo{}, err
}
settings, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[networkmapdb.Account])
if err != nil {
return nmdata.AccountSettingsInfo{}, err
}
settingsInfo := nmdata.AccountSettingsInfo{
PeerLoginExpirationEnabled: settings.PeerLoginExpirationEnabled.Bool,
PeerLoginExpiration: time.Duration(settings.PeerLoginExpiration.Int64),
PeerInactivityExpirationEnabled: settings.PeerInactivityExpirationEnabled.Bool,
PeerInactivityExpiration: time.Duration(settings.PeerInactivityExpiration.Int64),
DNSDomain: settings.DNSDomain.String,
RoutingPeerDNSResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled.Bool,
LazyConnectionEnabled: settings.LazyConnectionEnabled.Bool,
AutoUpdateVersion: settings.AutoUpdateVersion.String,
AutoUpdateAlways: settings.AutoUpdateAlways.Bool,
MetricsPushEnabled: settings.MetricsPushEnabled.Bool,
}
if settings.IPv6EnabledGroups != nil {
if err := json.Unmarshal(settings.IPv6EnabledGroups, &settingsInfo.IPv6EnabledGroups); err != nil {
return nmdata.AccountSettingsInfo{}, err
}
}
return settingsInfo, nil
}

View File

@@ -1,33 +0,0 @@
package networkmap_pgsql
import (
"context"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap"
)
const (
GetAccountZonesQuery = `
select zones.id as id, domain, not enable_search_domain as search_domain_disabled, distribution_groups,
r.name as record_name, r.type as record_type, 'IN' record_class, r.ttl as record_ttl, r.content as record_rdata
from zones
left join records as r on r.zone_id = zones.id
where zones.account_id=$1
`
)
func (pgc *PgStoreConn) GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error) {
rows, err := pgc.Conn.Query(ctx, GetAccountZonesQuery, accountId)
if err != nil {
return nil, err
}
zones, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Zone])
if err != nil {
return nil, err
}
return networkmapdb.ZonesToAppliedZoneCandidates(zones)
}

View File

@@ -1,45 +0,0 @@
package networkmap_pgsql
import (
"context"
"encoding/json"
"github.com/jackc/pgx/v5"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetDnsSettingsQuery = `
select dns_settings_disabled_management_groups
from accounts
where id=$1
`
)
func (pgc *PgStoreConn) GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error) {
rows, err := pgc.Conn.Query(ctx, GetDnsSettingsQuery, accountId)
if err != nil {
return nmdata.DNSSettings{}, err
}
return pgx.CollectOneRow(rows, rowToDnsSettings)
}
func rowToDnsSettings(row pgx.CollectableRow) (nmdata.DNSSettings, error) {
var value nmdata.DNSSettings
var settings json.RawMessage
if err := row.Scan(&settings); err != nil {
return value, err
}
if settings == nil {
return nmdata.DNSSettings{}, nil
}
if err := json.Unmarshal(settings, &value.DisabledManagementGroups); err != nil {
return value, err
}
return value, nil
}

View File

@@ -1,25 +0,0 @@
package networkmap_pgsql
import (
"context"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
)
const (
GetDomainsQuery = `
select domain, target_cluster
from domains
where account_id=$1 and domain<>'' and target_cluster<>''
`
)
func (pgc *PgStoreConn) GetDomains(ctx context.Context, accountId string) ([]networkmapdb.Domain, error) {
rows, err := pgc.Conn.Query(ctx, GetDomainsQuery, accountId)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Domain])
}

View File

@@ -1,64 +0,0 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"encoding/json"
"reflect"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetGroupsQuery = `
select id, name, public_id, resources,
(
select array_agg(group_peers.peer_id)
from group_peers
where group_peers.group_id = groups.id and group_peers.account_id=$1
) as peers
from groups where account_id=$1
`
)
// we also return a resource-to-group index.
// an alternative is to add json indexes, query this directly. Not sure how expensive
// json indexes are. TODO (dmitri) verify and maybe change the implementation here.
func (pgc *PgStoreConn) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error) {
rows, err := pgc.Conn.Query(ctx, GetGroupsQuery, accountId)
if err != nil {
return nil, nil, err
}
groups, err := pgx.CollectRows(rows, pgx.RowToStructByName[group])
toret := make([]nmdata.Group, 0, len(groups))
resourceToGroupIdx := make(map[string]map[string]any)
for _, g := range groups {
dg := nmdata.Group{}
err := networkmapdb.FromSqlTypesToSharedTypes(
reflect.ValueOf(&g), reflect.ValueOf(&dg))
if err != nil {
return nil, nil, err
}
toret = append(toret, dg)
for _, resource := range dg.Resources {
if _, ok := resourceToGroupIdx[resource.ID]; !ok {
resourceToGroupIdx[resource.ID] = make(map[string]any)
}
resourceToGroupIdx[resource.ID][g.ID] = struct{}{}
}
}
return toret, resourceToGroupIdx, err
}
type group struct {
ID string
Name sql.NullString
PublicID sql.NullString
Resources json.RawMessage
Peers []string
}

View File

@@ -1,31 +0,0 @@
package networkmap_pgsql
import (
"context"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetNameserversQuery = `
select id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled
from name_server_groups
where account_id=$1
`
)
func (pgc *PgStoreConn) GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error) {
rows, err := pgc.Conn.Query(ctx, GetNameserversQuery, accountId)
if err != nil {
return nil, err
}
nsgroups, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.NameserverGroup])
if err != nil {
return nil, err
}
return networkmapdb.ConvertAllToSharedTypes[networkmapdb.NameserverGroup, nmdata.NameServerGroup](nsgroups)
}

View File

@@ -1,39 +0,0 @@
package networkmap_pgsql
import (
"context"
"reflect"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetNetworkQuery = `
select network_identifier as identifier, network_net as net, network_net_v6 as net_v6, network_dns as dns, network_serial as serial
from accounts
where id=$1
`
)
func (pgc *PgStoreConn) GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error) {
rows, err := pgc.Conn.Query(ctx, GetNetworkQuery, accountId)
if err != nil {
return nmdata.Network{}, err
}
n, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[networkmapdb.AccountNetwork])
if err != nil {
return nmdata.Network{}, err
}
toret := nmdata.Network{}
err = networkmapdb.FromSqlTypesToSharedTypes(
reflect.ValueOf(&n), reflect.ValueOf(&toret))
if err != nil {
return nmdata.Network{}, err
}
return toret, nil
}

View File

@@ -1,31 +0,0 @@
package networkmap_pgsql
import (
"context"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetNetworkResourcesQuery = `
select id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled
from network_resources
where account_id=$1
`
)
func (pgc *PgStoreConn) GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error) {
rows, err := pgc.Conn.Query(ctx, GetNetworkResourcesQuery, accountId)
if err != nil {
return nil, err
}
netresorces, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Networkresource])
if err != nil {
return nil, err
}
return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Networkresource, nmdata.NetworkResource](netresorces)
}

View File

@@ -1,80 +0,0 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"reflect"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetNetworkRouterQuery = `
select public_id, peer, network_id, masquerade, metric, enabled, peer_groups,
(
select array_agg(group_peers.peer_id)
from group_peers
where group_peers.account_id=$1 and group_peers.group_id in (select json_array_elements_text(peer_groups::json))
) as peers_via_groups
from network_routers
where account_id=$1
`
)
func (pgc *PgStoreConn) GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error) {
rows, err := pgc.Conn.Query(ctx, GetNetworkRouterQuery, accountId)
if err != nil {
return nil, err
}
routers, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkrouter])
if err != nil {
return nil, err
}
toret := make(map[string]map[string]*nmdata.NetworkRouter)
for _, router := range routers {
if !router.Enabled.Bool {
continue
}
networkId := router.NetworkID.String
if networkId == "" {
return nil, fmt.Errorf("router with public_id %s doesn't have network_id set", router.PublicID.String)
}
nmdatarouter := nmdata.NetworkRouter{}
err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&router), reflect.ValueOf(&nmdatarouter))
if err != nil {
return nil, err
}
if toret[networkId] == nil {
toret[networkId] = make(map[string]*nmdata.NetworkRouter)
}
if router.Peer.String != "" {
toret[networkId][router.Peer.String] = &nmdatarouter
continue
}
for _, peerId := range router.PeersViaGroups {
toret[networkId][peerId] = &nmdatarouter
}
}
return toret, nil
}
type networkrouter struct {
PublicID sql.NullString
NetworkID sql.NullString `nmap:"skip"`
Peer sql.NullString `nmap:"skip"`
PeerGroups json.RawMessage
PeersViaGroups []string `nmap:"skip"`
Masquerade sql.NullBool
Metric sql.NullInt64
Enabled sql.NullBool
}

View File

@@ -1,36 +0,0 @@
package networkmap_pgsql
import (
"context"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
)
const (
GetNetworksQuery = `
select id, public_id
from networks where account_id=$1
`
)
func (pgc *PgStoreConn) GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error) {
rows, err := pgc.Conn.Query(ctx, GetNetworksQuery, accountId)
if err != nil {
return nil, err
}
networks, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Network])
if err != nil {
return nil, err
}
toret := make(map[string]string)
for _, n := range networks {
if n.PublicID.Valid {
toret[n.ID] = n.PublicID.String
}
}
return toret, nil
}

View File

@@ -1,34 +0,0 @@
package networkmap_pgsql
import (
"context"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetPeersQuery = `
select 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
from peers
where account_id = $1
`
)
func (pgc *PgStoreConn) GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) {
rows, err := pgc.Conn.Query(ctx, GetPeersQuery, accountId)
if err != nil {
return nil, nil, err
}
peers, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Peer])
if err != nil {
return nil, nil, err
}
return networkmapdb.ConvertToNmdataPeers(peers)
}

View File

@@ -1,98 +0,0 @@
package networkmap_pgsql
import (
"context"
"fmt"
"reflect"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
)
const (
pgMaxConnections = 30
pgMinConnections = 1
pgMaxConnLifetime = 60 * time.Minute
pgHealthCheckPeriod = 1 * time.Minute
)
var _ networkmapdb.NetworkMapDBStore = &PgStore{}
type PgStore struct {
Pool *pgxpool.Pool
}
type PgStoreConn struct {
Conn pgInterface
}
type pgInterface interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
}
var _ networkmapdb.NetworkMapDBStoreConn = &PgStoreConn{}
func NewPostgresqlStore(ctx context.Context, dsn string) (*PgStore, error) {
pool, err := connectToPgDb(ctx, dsn)
if err != nil {
return nil, err
}
return &PgStore{Pool: pool}, nil
}
func (p *PgStore) UsingConnection(c *pgx.Conn) networkmapdb.NetworkMapDBStoreConn {
return &PgStoreConn{Conn: c}
}
func (p *PgStore) BeginTx(ctx context.Context) (networkmapdb.NetworkMapDBStoreConn, error) {
tx, err := p.Pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
return nil, err
}
return &PgStoreConn{Conn: tx}, nil
}
func (c *PgStoreConn) RollbackTx(ctx context.Context) error {
tx, ok := c.Conn.(pgx.Tx)
if !ok {
return fmt.Errorf("expected an pgx.Tx got %s", reflect.TypeOf(c.Conn).Kind())
}
return tx.Rollback(ctx)
}
func (c *PgStoreConn) CommitTx(ctx context.Context) error {
tx, ok := c.Conn.(pgx.Tx)
if !ok {
return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(c.Conn).Kind())
}
return tx.Commit(ctx)
}
func connectToPgDb(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("unable to parse database config: %w", err)
}
config.MaxConns = pgMaxConnections
config.MinConns = pgMinConnections
config.MaxConnLifetime = pgMaxConnLifetime
config.HealthCheckPeriod = pgHealthCheckPeriod
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("unable to create connection pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("unable to ping database: %w", err)
}
return pool, nil
}

View File

@@ -1,34 +0,0 @@
package networkmap_pgsql
import (
"context"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetPoliciesQuery = `
select p.id, p.public_id, p.enabled, p.source_posture_checks, pr.enabled as rule_enabled, pr.action, pr.protocol, pr.bidirectional,
pr.sources, pr.destinations, pr.source_resource, pr.destination_resource, pr.ports, pr.port_ranges,
pr.authorized_groups, pr.authorized_user
from policies as p
left join policy_rules as pr on p.id = pr.policy_id
where account_id=$1
`
)
func (pgc *PgStoreConn) GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) {
rows, err := pgc.Conn.Query(ctx, GetPoliciesQuery, accountId)
if err != nil {
return nil, nil, nil, err
}
policies, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Policy])
if err != nil {
return nil, nil, nil, err
}
return networkmapdb.ConvertToNmdataPolicy(policies)
}

View File

@@ -1,44 +0,0 @@
package networkmap_pgsql
import (
"context"
"reflect"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetPostureChecksQuery = `
select id, public_id, checks
from posture_checks
where account_id=$1
`
)
func (pgc *PgStoreConn) GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error) {
rows, err := pgc.Conn.Query(ctx, GetPostureChecksQuery, accountId)
if err != nil {
return nil, nil, err
}
checks, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.PostureChecks])
if err != nil {
return nil, nil, err
}
toret := make([]nmdata.PostureChecks, 0, len(checks))
idToPublicIDIdx := make(map[string]string)
for _, c := range checks {
checks := nmdata.PostureChecks{}
err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&c), reflect.ValueOf(&checks))
if err != nil {
return nil, nil, err
}
toret = append(toret, checks)
idToPublicIDIdx[checks.ID] = c.PublicID.String
}
return toret, idToPublicIDIdx, nil
}

View File

@@ -1,33 +0,0 @@
package networkmap_pgsql
import (
"context"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetRoutesQuery = `
select id, account_id, public_id, network, domains, keep_route, net_id, description,
peer, peer as peer_id, peer_groups, network_type, masquerade, metric, enabled,
groups, access_control_groups, skip_auto_apply
from routes
where account_id=$1
`
)
func (pgc *PgStoreConn) GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error) {
rows, err := pgc.Conn.Query(ctx, GetRoutesQuery, accountId)
if err != nil {
return nil, err
}
routes, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Route])
if err != nil {
return nil, err
}
return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Route, nmdata.Route](routes)
}

View File

@@ -1,51 +0,0 @@
package networkmap_pgsql
import (
"context"
"github.com/jackc/pgx/v5"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
)
const (
GetServicesQuery = `
select enabled, private, array (select json_array_elements_text(access_groups::json)) as access_groups, proxy_cluster, domain
from services
where account_id=$1
`
GetProxyTargetedDomainResourcesQuery = `
select t.target_id
from targets as t
join services as s on s.id = t.service_id
where s.account_id=$1 and s.enabled and not coalesce(s.terminated, false)
and t.enabled and t.target_type='domain' and t.target_id is not null
`
)
func (pgc *PgStoreConn) GetPrivateServices(ctx context.Context, accountId string) ([]networkmapdb.Service, error) {
rows, err := pgc.Conn.Query(ctx, GetServicesQuery, accountId)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Service])
}
func (pgc *PgStoreConn) GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error) {
rows, err := pgc.Conn.Query(ctx, GetProxyTargetedDomainResourcesQuery, accountId)
if err != nil {
return nil, err
}
ids, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return nil, err
}
toret := make(map[string]struct{}, len(ids))
for _, id := range ids {
toret[id] = struct{}{}
}
return toret, nil
}

View File

@@ -1,60 +0,0 @@
package networkmap_pgsql
import (
"context"
"github.com/jackc/pgx/v5"
)
const (
GetAllowedUserIdsQuery = `
select id, array (select json_array_elements_text(auto_groups::json)) as auto_groups
from users
where account_id=$1 and not blocked and not is_service_user
`
GetAllGroupIdQuery = `
select array_agg(id) from groups
where account_id=$1 and name='All'
`
)
func (pgc *PgStoreConn) GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error) {
rows, err := pgc.Conn.Query(ctx, GetAllowedUserIdsQuery, accountId)
if err != nil {
return nil, nil, err
}
users, err := pgx.CollectRows(rows, pgx.RowToStructByName[user])
if err != nil {
return nil, nil, err
}
rows, err = pgc.Conn.Query(ctx, GetAllGroupIdQuery, accountId)
if err != nil {
return nil, nil, err
}
allGroupIds, err := pgx.CollectOneRow(rows, pgx.RowTo[[]string])
if err != nil {
return nil, nil, err
}
userIdIdx := make(map[string]struct{})
groupIdToUserIds := make(map[string][]string)
for _, user := range users {
userIdIdx[user.ID] = struct{}{}
for _, groupId := range user.AutoGroups {
groupIdToUserIds[groupId] = append(groupIdToUserIds[groupId], user.ID)
}
for _, allgid := range allGroupIds {
groupIdToUserIds[allgid] = append(groupIdToUserIds[allgid], user.ID)
}
}
return userIdIdx, groupIdToUserIds, nil
}
type user struct {
ID string
AutoGroups []string
}

View File

@@ -1,428 +0,0 @@
package networkmapdb
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"reflect"
"github.com/miekg/dns"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
var ErrDnsUnsupportedRecordType = errors.New("unsupported record type")
// The order of fields in these structs is important.
// Mapping of results of sqlite queries relies on the order
// of the fields in these structs, when a query or a struct changes,
// corresponding changes must be made to its counterpart.
type Account struct {
PeerLoginExpirationEnabled sql.NullBool
PeerLoginExpiration sql.NullInt64
PeerInactivityExpirationEnabled sql.NullBool
PeerInactivityExpiration sql.NullInt64
DNSDomain sql.NullString
IPv6EnabledGroups []byte `nmap:"json"`
RoutingPeerDNSResolutionEnabled sql.NullBool
LazyConnectionEnabled sql.NullBool
AutoUpdateVersion sql.NullString
AutoUpdateAlways sql.NullBool
MetricsPushEnabled sql.NullBool
}
type Domain struct {
Domain sql.NullString
TargetCluster sql.NullString
}
type Service struct {
Enabled sql.NullBool
Private sql.NullBool
AccessGroups []string
ProxyCluster sql.NullString
Domain sql.NullString
}
type Zone struct {
Id string `nmap:"skip"`
Domain sql.NullString
SearchDomainDisabled sql.NullBool
DistributionGroups []byte `nmap:"skip,json"`
RecordName sql.NullString `nmap:"skip"`
RecordType sql.NullString `nmap:"skip"`
RecordClass sql.NullString `nmap:"skip"`
RecordTTL sql.NullInt64 `nmap:"skip"`
RecordRData sql.NullString `nmap:"skip"`
}
type NameserverGroup struct {
ID string
PublicID sql.NullString
Name sql.NullString
Description sql.NullString
NameServers []byte `nmap:"json"`
Groups []byte `nmap:"json"`
Primary sql.NullBool
Domains []byte `nmap:"json"`
Enabled sql.NullBool
SearchDomainsEnabled sql.NullBool
}
type Networkresource struct {
ID string
NetworkID sql.NullString
AccountID sql.NullString
PublicID sql.NullString
Name sql.NullString
Description sql.NullString
Type sql.NullString
Domain sql.NullString
Prefix []byte `nmap:"json"`
Enabled sql.NullBool
}
type AccountNetwork struct {
Identifier sql.NullString
Net []byte `nmap:"json"`
NetV6 []byte `nmap:"json"`
Dns sql.NullString
Serial sql.NullInt64
}
type Network struct {
ID string
PublicID sql.NullString
}
type Policy struct {
ID string
PublicID sql.NullString
Enabled sql.NullBool
SourcePostureChecks []byte `nmap:"json"`
RuleEnabled sql.NullBool `nmap:"skip"`
Action sql.NullString `nmap:"skip"`
Protocol sql.NullString `nmap:"skip"`
Bidirectional sql.NullBool `nmap:"skip"`
Sources []byte `nmap:"skip,json"`
Destinations []byte `nmap:"skip,json"`
SourceResource []byte `nmap:"skip,json"`
DestinationResource []byte `nmap:"skip,json"`
Ports []byte `nmap:"skip,json"`
PortRanges []byte `nmap:"skip,json"`
AuthorizedGroups []byte `nmap:"skip,json"`
AuthorizedUser sql.NullString `nmap:"skip"`
}
// Depending on db interface LastLogin contains time in different formats:
// for sqlite/sql.NullTime the time in UTC
// for pgx the time is in the local timezone
// TODO add support for creating struct fields from denormalized fields
type Peer struct {
ID string
Key sql.NullString
SSHKey sql.NullString
DNSLabel sql.NullString
ExtraDNSLabels []byte `nmap:"json"`
UserID sql.NullString
SSHEnabled sql.NullBool
LoginExpirationEnabled sql.NullBool
LastLogin sql.NullTime
IP []byte `nmap:"json"`
IPv6 []byte `nmap:"json"`
PeerStatusRequiresApproval sql.NullBool `nmap:"map_to:RequiresApproval"`
PeerStatusConnected sql.NullBool `nmap:"skip"`
ProxyMetaEmbedded sql.NullBool `nmap:"skip"`
ProxyMetaCluster sql.NullString `nmap:"skip"`
MetaWtVersion sql.NullString `nmap:"skip"`
MetaGoOS sql.NullString `nmap:"skip"`
MetaOSVersion sql.NullString `nmap:"skip"`
MetaKernelVersion sql.NullString `nmap:"skip"`
MetaNetworkAddresses []byte `nmap:"skip,json"`
MetaFiles []byte `nmap:"skip,json"`
MetaCapabilities []byte `nmap:"skip,json"`
MetaFlags []byte `nmap:"skip,json"`
MetaSyncMessageVersion sql.NullInt64 `nmap:"skip"`
LocationCountryCode sql.NullString `nmap:"skip"`
LocationCityName sql.NullString `nmap:"skip"`
LocationConnectionIp []byte `nmap:"skip,json"`
}
type PostureChecks struct {
ID string
PublicID sql.NullString `nmap:"skip"`
Checks []byte `nmap:"json"`
}
type Route struct {
ID string
AccountID sql.NullString
PublicID sql.NullString
Network []byte `nmap:"json"`
Domains []byte `nmap:"json"`
KeepRoute sql.NullBool
NetID sql.NullString
Description sql.NullString
Peer sql.NullString
PeerID sql.NullString
PeerGroups []byte `nmap:"json"`
NetworkType sql.NullInt64
Masquerade sql.NullBool
Metric sql.NullInt64
Enabled sql.NullBool
Groups []byte `nmap:"json"`
AccessControlGroups []byte `nmap:"json"`
SkipAutoApply sql.NullBool
}
func RecordTypeAndRdata(t, rdata string) (int, string, error) {
switch t {
case "A":
return int(dns.TypeA), rdata, nil
case "AAAA":
return int(dns.TypeAAAA), rdata, nil
case "CNAME":
return int(dns.TypeCNAME), dns.Fqdn(rdata), nil
default:
return 0, "", fmt.Errorf("record type: %s %w", t, ErrDnsUnsupportedRecordType)
}
}
func ZonesToAppliedZoneCandidates(zones []Zone) ([]networkmap.AppliedZoneCandidate, error) {
toret := make([]networkmap.AppliedZoneCandidate, 0, len(zones))
currentZoneId := ""
for _, z := range zones {
if !z.RecordType.Valid {
continue
}
zone := nmdata.CustomZone{}
err := FromSqlTypesToSharedTypes(
reflect.ValueOf(&z), reflect.ValueOf(&zone))
if err != nil {
return nil, err
}
var distributionGroups []string
if err := json.Unmarshal(z.DistributionGroups, &distributionGroups); err != nil {
return nil, err
}
if z.Id != currentZoneId {
zone.Records = []nmdata.SimpleRecord{}
toret = append(toret, AppliedZoneCandidateFromZone(zone, distributionGroups))
currentZoneId = z.Id
}
rtype, rdata, err := RecordTypeAndRdata(z.RecordType.String, z.RecordRData.String)
if err != nil {
if errors.Is(err, ErrDnsUnsupportedRecordType) {
continue
}
return nil, err
}
lastZone := &toret[len(toret)-1]
lastZone.Zone.Records = append(lastZone.Zone.Records, nmdata.SimpleRecord{
Name: z.RecordName.String,
Class: z.RecordClass.String,
TTL: int(z.RecordTTL.Int64),
RData: rdata,
Type: rtype,
})
}
return toret, nil
}
func AppliedZoneCandidateFromZone(z nmdata.CustomZone, distributionGroups []string) networkmap.AppliedZoneCandidate {
return networkmap.AppliedZoneCandidate{
DistributionGroups: distributionGroups,
Zone: z,
}
}
func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) {
toret := make([]nmdata.Peer, 0, len(peers))
clusterToPeerIdx := make(map[string][]*nmdata.Peer)
for _, p := range peers {
dp := nmdata.Peer{}
err := FromSqlTypesToSharedTypes(
reflect.ValueOf(&p), reflect.ValueOf(&dp))
if err != nil {
return nil, nil, err
}
if p.ProxyMetaEmbedded.Valid {
dp.ProxyMeta.Embedded = p.ProxyMetaEmbedded.Bool
}
// This is only used to build private service candidates, not connected peers are skipped
if dp.ProxyMeta.Embedded && p.PeerStatusConnected.Bool {
clusterToPeerIdx[p.ProxyMetaCluster.String] = append(clusterToPeerIdx[p.ProxyMetaCluster.String], &dp)
}
if p.MetaWtVersion.Valid {
dp.Meta.WtVersion = p.MetaWtVersion.String
}
if p.MetaSyncMessageVersion.Valid {
dp.Meta.SyncMessageVersion = int(p.MetaSyncMessageVersion.Int64)
}
if p.MetaGoOS.Valid {
dp.Meta.GoOS = p.MetaGoOS.String
}
if p.MetaOSVersion.Valid {
dp.Meta.OSVersion = p.MetaOSVersion.String
}
if p.MetaKernelVersion.Valid {
dp.Meta.KernelVersion = p.MetaKernelVersion.String
}
if p.LocationCountryCode.Valid {
dp.Location.CountryCode = p.LocationCountryCode.String
}
if p.LocationCityName.Valid {
dp.Location.CityName = p.LocationCityName.String
}
if p.LocationConnectionIp != nil {
err := json.Unmarshal(p.LocationConnectionIp, &dp.Location.ConnectionIP)
if err != nil {
return toret, nil, err
}
}
if p.MetaFiles != nil {
err := json.Unmarshal(p.MetaFiles, &dp.Meta.Files)
if err != nil {
return toret, nil, err
}
}
if p.MetaCapabilities != nil {
err := json.Unmarshal(p.MetaCapabilities, &dp.Meta.Capabilities)
if err != nil {
return toret, nil, err
}
}
if p.MetaFlags != nil {
err := json.Unmarshal(p.MetaFlags, &dp.Meta.Flags)
if err != nil {
return toret, nil, err
}
}
if p.MetaNetworkAddresses != nil {
err := json.Unmarshal(p.MetaNetworkAddresses, &dp.Meta.NetworkAddresses)
if err != nil {
return toret, nil, err
}
}
toret = append(toret, dp)
}
return toret, clusterToPeerIdx, nil
}
func ConvertToNmdataPolicy(policies []Policy) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) {
toret := make([]nmdata.Policy, 0, len(policies))
policyToDestinationResourceIdx := make(map[string]map[string]any) // policy id to destination resource id
policyToDestinationGroupIdx := make(map[string]map[string]any) // policy id to destination group id
for _, p := range policies {
policy := nmdata.Policy{}
err := FromSqlTypesToSharedTypes(
reflect.ValueOf(&p), reflect.ValueOf(&policy))
if err != nil {
return nil, nil, nil, err
}
var policyRule *nmdata.PolicyRule
pr := func() *nmdata.PolicyRule {
if policyRule != nil {
return policyRule
}
policyRule = &nmdata.PolicyRule{}
return policyRule
}
if p.RuleEnabled.Valid {
pr().Enabled = p.RuleEnabled.Bool
}
if p.Action.Valid {
pr().Action = p.Action.String
}
if p.Protocol.Valid {
pr().Protocol = p.Protocol.String
}
if p.Bidirectional.Valid {
pr().Bidirectional = p.Bidirectional.Bool
}
if len(p.Sources) > 0 {
err := json.Unmarshal([]byte(p.Sources), &pr().Sources)
if err != nil {
return toret, nil, nil, err
}
}
if len(p.Destinations) > 0 {
err := json.Unmarshal([]byte(p.Destinations), &pr().Destinations)
if err != nil {
return toret, nil, nil, err
}
if p.RuleEnabled.Valid && p.RuleEnabled.Bool {
for _, dst := range pr().Destinations {
if _, ok := policyToDestinationGroupIdx[p.ID]; !ok {
policyToDestinationGroupIdx[p.ID] = make(map[string]any)
}
policyToDestinationGroupIdx[p.ID][dst] = struct{}{}
}
}
}
if len(p.SourceResource) > 0 {
err := json.Unmarshal([]byte(p.SourceResource), &pr().SourceResource)
if err != nil {
return toret, nil, nil, err
}
}
if len(p.DestinationResource) > 0 {
err := json.Unmarshal([]byte(p.DestinationResource), &pr().DestinationResource)
if err != nil {
return toret, nil, nil, err
}
if p.RuleEnabled.Valid && p.RuleEnabled.Bool {
if _, ok := policyToDestinationResourceIdx[p.ID]; !ok {
policyToDestinationResourceIdx[p.ID] = make(map[string]any)
}
policyToDestinationResourceIdx[p.ID][pr().DestinationResource.ID] = struct{}{}
}
}
if len(p.Ports) > 0 {
err := json.Unmarshal([]byte(p.Ports), &pr().Ports)
if err != nil {
return toret, nil, nil, err
}
}
if len(p.PortRanges) > 0 {
err := json.Unmarshal([]byte(p.PortRanges), &pr().PortRanges)
if err != nil {
return toret, nil, nil, err
}
}
if len(p.AuthorizedGroups) > 0 {
err := json.Unmarshal([]byte(p.AuthorizedGroups), &pr().AuthorizedGroups)
if err != nil {
return toret, nil, nil, err
}
}
if p.AuthorizedUser.Valid {
pr().AuthorizedUser = p.AuthorizedUser.String
}
if policyRule != nil {
policyRule.ID = p.ID
policyRule.PolicyID = p.ID
policy.Rules = []*nmdata.PolicyRule{policyRule}
}
toret = append(toret, policy)
}
return toret, policyToDestinationResourceIdx, policyToDestinationGroupIdx, nil
}

View File

@@ -1,38 +0,0 @@
package networkmapdb
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRecordTypeAndRdata(t *testing.T) {
var tests = []struct {
recordType string
expectedRecordType int
rdata string
expectedRdata string
expectedErr error
}{
{recordType: "A", expectedRecordType: 1, rdata: "test.com", expectedRdata: "test.com", expectedErr: nil},
{recordType: "AAAA", expectedRecordType: 28, rdata: "test.com", expectedRdata: "test.com", expectedErr: nil},
{recordType: "CNAME", expectedRecordType: 5, rdata: "test.com", expectedRdata: "test.com.", expectedErr: nil},
{recordType: "CNAME", expectedRecordType: 5, rdata: "test.com.", expectedRdata: "test.com.", expectedErr: nil},
{recordType: "TypeMX", expectedErr: ErrDnsUnsupportedRecordType},
}
for _, tt := range tests {
t.Run(tt.recordType, func(t *testing.T) {
recordType, rdata, err := RecordTypeAndRdata(tt.recordType, tt.rdata)
if tt.expectedErr != nil {
assert.ErrorIs(t, err, ErrDnsUnsupportedRecordType)
return
}
assert.NoError(t, err)
assert.Equal(t, recordType, tt.expectedRecordType)
assert.Equal(t, rdata, tt.expectedRdata)
})
}
}

View File

@@ -1,253 +0,0 @@
package networkmapdb
import (
"database/sql"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestNullStringSupport(t *testing.T) {
src := withNullString{Name: sql.NullString{String: "string", Valid: true}}
dst := withString{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, withString{Name: "string"}, dst)
src = withNullString{Name: sql.NullString{Valid: false}}
dst = withString{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, withString{Name: ""}, dst)
}
func TestNullBoolSupport(t *testing.T) {
src := withNullBool{TrueOrFalse: sql.NullBool{Bool: true, Valid: true}}
dst := withBool{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, withBool{TrueOrFalse: true}, dst)
}
func TestRawJsonSupport(t *testing.T) {
jb, _ := json.Marshal(embeddedS{Name: "blob-name", SomeField: 1})
src := withRawJson{Blob: json.RawMessage(jb)}
dst := fromJson{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, fromJson{Blob: embeddedS{Name: "blob-name", SomeField: 1}}, dst)
src1 := withRawJson{}
dst1 := fromJson{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src1), reflect.ValueOf(&dst1)))
assert.Equal(t, fromJson{}, dst1)
}
func TestShouldSkipTag(t *testing.T) {
src5 := withSkipTag{Field: "shouldskip"}
dst5 := emptySkipTagTarget{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src5), reflect.ValueOf(&dst5)))
assert.Equal(t, emptySkipTagTarget{}, dst5)
}
func TestMapToTag(t *testing.T) {
src6 := withMapToTag{Field: "fieldvalue"}
dst6 := mapToTagTarget{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src6), reflect.ValueOf(&dst6)))
assert.Equal(t, mapToTagTarget{AnotherField: "fieldvalue"}, dst6)
}
func TestNullableInt64Support(t *testing.T) {
src := withInt64{Field: sql.NullInt64{Int64: int64(1), Valid: true}}
dst := int64Target{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, int64Target{Field: 1}, dst)
}
func TestNullableTimeSupport(t *testing.T) {
now := time.Now()
src := withNullableTime{Field: sql.NullTime{Time: now, Valid: true}}
dst := nullableTimeTarget{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, nullableTimeTarget{Field: now}, dst)
}
func TestNullableTimePointerSupport(t *testing.T) {
now := time.Now()
src := withNullableTime{Field: sql.NullTime{Time: now, Valid: true}}
dst := nullableTimePointerTarget{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, nullableTimePointerTarget{Field: &now}, dst)
}
func TestStringSLiceSupport(t *testing.T) {
src := withStringSlice{Field: []string{"one"}}
dst := withStringSlice{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, withStringSlice{Field: []string{"one"}}, dst)
}
func TestNullStringSLiceSupport(t *testing.T) {
src := withStringSlice{}
dst := withStringSlice{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, withStringSlice{}, dst)
}
func TestWithMultipleFields(t *testing.T) {
now := time.Now()
src := withMultipleFields{
Field1: sql.NullString{String: "aaa", Valid: true},
Field2: sql.NullBool{Bool: true, Valid: true},
Field3: sql.NullTime{Time: now, Valid: true},
Field4: sql.NullInt64{Int64: 1, Valid: true},
Field5: "another",
}
dst := multipleFieldsTarget{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, multipleFieldsTarget{
Field1: "aaa",
Field2: true,
Field3: now,
Field4: 1,
Field5: "another",
}, dst)
}
func TestEmptyPublicIdsFilled(t *testing.T) {
src := withEmptyPublicIds{}
dst := emptyPublicIdTarget{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.NotEmpty(t, dst.PublicID)
assert.NotEmpty(t, dst.PublicId)
}
// only []byte and []uint8 slices with "json" tag are being parsed
func TestByteSliceSupport(t *testing.T) {
src := withByteSlice{
Field: []byte("[\"one\",\"two\",\"three\"]"),
}
dst := byteSliceTarget{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, []string{"one", "two", "three"}, dst.Field)
}
func TestUint8SliceSupport(t *testing.T) {
src := withUint8Slice{
Field: []uint8("[\"one\",\"two\",\"three\"]"),
}
dst := uint8SliceTarget{}
assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.Equal(t, []string{"one", "two", "three"}, dst.Field)
}
type withNullString struct {
Name sql.NullString
}
type withString struct {
Name string
}
type withMultipleFields struct {
Field1 sql.NullString
Field2 sql.NullBool
Field3 sql.NullTime
Field4 sql.NullInt64
Field5 string
}
type multipleFieldsTarget struct {
Field1 string
Field2 bool
Field3 time.Time
Field4 int64
Field5 string
}
type withNullBool struct {
TrueOrFalse sql.NullBool
}
type withBool struct {
TrueOrFalse bool
}
type withRawJson struct {
Blob json.RawMessage
}
type embeddedS struct {
Name string
SomeField int
}
type fromJson struct {
Blob embeddedS
}
type withSkipTag struct {
Field string `nmap:"skip"`
}
type emptySkipTagTarget struct {
Field string
}
type withMapToTag struct {
Field string `nmap:"map_to:AnotherField"`
}
type mapToTagTarget struct {
AnotherField string
}
type withInt64 struct {
Field sql.NullInt64
}
type int64Target struct {
Field int
}
type withNullableTime struct {
Field sql.NullTime
}
type nullableTimeTarget struct {
Field time.Time
}
type nullableTimePointerTarget struct {
Field *time.Time
}
type withStringSlice struct {
Field []string
}
type withEmptyPublicIds struct {
PublicID sql.NullString
PublicId sql.NullString
}
type emptyPublicIdTarget struct {
PublicID string
PublicId string
}
type withByteSlice struct {
Field []byte `nmap:"json"`
}
type byteSliceTarget struct {
Field []string
}
type withUint8Slice struct {
Field []byte `nmap:"json"`
}
type uint8SliceTarget struct {
Field []string
}

View File

@@ -1,47 +0,0 @@
package networkmap_sqlite
import (
"context"
"reflect"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetAccountSettingsQuery = `
select settings_peer_login_expiration_enabled as peer_login_expiration_enabled,
settings_peer_login_expiration as peer_login_expiration,
settings_peer_inactivity_expiration_enabled as peer_inactivity_expiration_enabled,
settings_peer_inactivity_expiration as peer_inactivity_expiration,
settings_dns_domain as dns_domain,
settings_ipv6_enabled_groups as ipv6_enabled_groups,
settings_routing_peer_dns_resolution_enabled as routing_peer_dns_resolution_enabled,
settings_lazy_connection_enabled as lazy_connection_enabled,
settings_auto_update_version as auto_update_version,
settings_auto_update_always as auto_update_always,
settings_metrics_push_enabled as metrics_push_enabled
from accounts
where id=?
`
)
func (sc *SqliteStoreConn) GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error) {
rows, err := sc.Conn.QueryContext(ctx, GetAccountSettingsQuery, accountId)
if err != nil {
return nmdata.AccountSettingsInfo{}, err
}
a, err := CollectOneRowForSqlite[networkmapdb.Account](rows)
if err != nil {
return nmdata.AccountSettingsInfo{}, err
}
settingsInfo := nmdata.AccountSettingsInfo{}
err = networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&a), reflect.ValueOf(&settingsInfo))
if err != nil {
return nmdata.AccountSettingsInfo{}, err
}
return settingsInfo, nil
}

View File

@@ -1,32 +0,0 @@
package networkmap_sqlite
import (
"context"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap"
)
const (
GetAccountZonesQuery = `
select zones.id as id, domain, not enable_search_domain as search_domain_disabled, distribution_groups,
r.name as record_name, r.type as record_type, 'IN' record_class, r.ttl as record_ttl, r.content as record_rdata
from zones
left join records as r on r.zone_id = zones.id
where zones.account_id=?
`
)
func (sc *SqliteStoreConn) GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error) {
rows, err := sc.Conn.QueryContext(ctx, GetAccountZonesQuery, accountId)
if err != nil {
return nil, err
}
zones, err := CollectRowsForSqlite[networkmapdb.Zone](rows)
if err != nil {
return nil, err
}
return networkmapdb.ZonesToAppliedZoneCandidates(zones)
}

View File

@@ -1,42 +0,0 @@
package networkmap_sqlite
import (
"context"
"encoding/json"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetDnsSettingsQuery = `
select dns_settings_disabled_management_groups
from accounts
where id=?
`
)
func (sc *SqliteStoreConn) GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error) {
rows, err := sc.Conn.QueryContext(ctx, GetDnsSettingsQuery, accountId)
if err != nil {
return nmdata.DNSSettings{}, err
}
defer rows.Close()
var value nmdata.DNSSettings
var settings []byte
rows.Next()
if err := rows.Scan(&settings); err != nil {
return value, err
}
if settings == nil {
return nmdata.DNSSettings{}, nil
}
if err := json.Unmarshal(settings, &value.DisabledManagementGroups); err != nil {
return value, err
}
return value, nil
}

View File

@@ -1,24 +0,0 @@
package networkmap_sqlite
import (
"context"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
)
const (
GetDomainsQuery = `
select domain, target_cluster
from domains
where account_id=? and domain<>'' and target_cluster<>''
`
)
func (sc *SqliteStoreConn) GetDomains(ctx context.Context, accountId string) ([]networkmapdb.Domain, error) {
rows, err := sc.Conn.QueryContext(ctx, GetDomainsQuery, accountId)
if err != nil {
return nil, err
}
return CollectRowsForSqlite[networkmapdb.Domain](rows)
}

View File

@@ -1,70 +0,0 @@
package networkmap_sqlite
import (
"context"
"database/sql"
"reflect"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetGroupsQuery = `
select groups.id, groups.name, groups.public_id, groups.resources, gp.peer_id
from groups
left join group_peers gp on gp.group_id=groups.id and gp.account_id=?
where groups.account_id=?
`
)
// we also return a resource-to-group index.
// an alternative is to add json indexes, query this directly. Not sure how expensive
// json indexes are. TODO (dmitri) verify and maybe change the implementation here.
func (sc *SqliteStoreConn) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error) {
rows, err := sc.Conn.QueryContext(ctx, GetGroupsQuery, accountId, accountId)
if err != nil {
return nil, nil, err
}
groups, err := CollectRowsForSqlite[group](rows)
toret := make([]nmdata.Group, 0, len(groups))
resourceToGroupIdx := make(map[string]map[string]any)
for _, g := range groups {
if len(toret) > 0 && toret[len(toret)-1].ID == g.ID && g.PeerID.Valid {
toret[len(toret)-1].Peers = append(toret[len(toret)-1].Peers, g.PeerID.String)
continue
}
dg := nmdata.Group{}
err := networkmapdb.FromSqlTypesToSharedTypes(
reflect.ValueOf(&g), reflect.ValueOf(&dg))
if err != nil {
return nil, nil, err
}
if g.PeerID.Valid {
dg.Peers = append(dg.Peers, g.PeerID.String)
}
toret = append(toret, dg)
for _, resource := range dg.Resources {
if _, ok := resourceToGroupIdx[resource.ID]; !ok {
resourceToGroupIdx[resource.ID] = make(map[string]any)
}
resourceToGroupIdx[resource.ID][g.ID] = struct{}{}
}
}
return toret, resourceToGroupIdx, err
}
type group struct {
ID string
Name sql.NullString
PublicID sql.NullString
Resources []byte `nmap:"json"`
PeerID sql.NullString `nmap:"skip"`
}

View File

@@ -1,30 +0,0 @@
package networkmap_sqlite
import (
"context"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetNameserversQuery = `
select id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled
from name_server_groups
where account_id=?
`
)
func (sc *SqliteStoreConn) GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error) {
rows, err := sc.Conn.QueryContext(ctx, GetNameserversQuery, accountId)
if err != nil {
return nil, err
}
nsgroups, err := CollectRowsForSqlite[networkmapdb.NameserverGroup](rows)
if err != nil {
return nil, err
}
return networkmapdb.ConvertAllToSharedTypes[networkmapdb.NameserverGroup, nmdata.NameServerGroup](nsgroups)
}

View File

@@ -1,38 +0,0 @@
package networkmap_sqlite
import (
"context"
"reflect"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetNetworkQuery = `
select network_identifier as identifier, network_net as net, network_net_v6 as net_v6, network_dns as dns, network_serial as serial
from accounts
where id=?
`
)
func (sc *SqliteStoreConn) GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error) {
rows, err := sc.Conn.QueryContext(ctx, GetNetworkQuery, accountId)
if err != nil {
return nmdata.Network{}, err
}
n, err := CollectOneRowForSqlite[networkmapdb.AccountNetwork](rows)
if err != nil {
return nmdata.Network{}, err
}
toret := nmdata.Network{}
err = networkmapdb.FromSqlTypesToSharedTypes(
reflect.ValueOf(&n), reflect.ValueOf(&toret))
if err != nil {
return nmdata.Network{}, err
}
return toret, nil
}

View File

@@ -1,30 +0,0 @@
package networkmap_sqlite
import (
"context"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetNetworkResourcesQuery = `
select id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled
from network_resources
where account_id=?
`
)
func (sc *SqliteStoreConn) GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error) {
rows, err := sc.Conn.QueryContext(ctx, GetNetworkResourcesQuery, accountId)
if err != nil {
return nil, err
}
netresorces, err := CollectRowsForSqlite[networkmapdb.Networkresource](rows)
if err != nil {
return nil, err
}
return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Networkresource, nmdata.NetworkResource](netresorces)
}

View File

@@ -1,74 +0,0 @@
package networkmap_sqlite
import (
"context"
"database/sql"
"fmt"
"reflect"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetNetworkRouterQuery = `
select public_id, peer, network_id, masquerade, metric, enabled, peer_groups, group_peers.peer_id
from network_routers, json_each(peer_groups)
left join group_peers on group_peers.account_id=? and group_peers.group_id=json_each.value
where network_routers.account_id=?
`
)
func (sc *SqliteStoreConn) GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error) {
rows, err := sc.Conn.QueryContext(ctx, GetNetworkRouterQuery, accountId, accountId)
if err != nil {
return nil, err
}
routers, err := CollectRowsForSqlite[networkrouter](rows)
if err != nil {
return nil, err
}
toret := make(map[string]map[string]*nmdata.NetworkRouter)
for _, router := range routers {
if !router.Enabled.Bool {
continue
}
networkId := router.NetworkID.String
if networkId == "" {
return nil, fmt.Errorf("router with public_id %s doesn't have network_id set", router.PublicID.String)
}
nmdatarouter := nmdata.NetworkRouter{}
err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&router), reflect.ValueOf(&nmdatarouter))
if err != nil {
return nil, err
}
if toret[networkId] == nil {
toret[networkId] = make(map[string]*nmdata.NetworkRouter)
}
if router.Peer.String != "" {
toret[networkId][router.Peer.String] = &nmdatarouter
continue
}
if router.PeerViaGroups.String != "" {
toret[networkId][router.PeerViaGroups.String] = &nmdatarouter
}
}
return toret, nil
}
type networkrouter struct {
PublicID sql.NullString
Peer sql.NullString `nmap:"skip"`
NetworkID sql.NullString `nmap:"skip"`
Masquerade sql.NullBool
Metric sql.NullInt64
Enabled sql.NullBool
PeerGroups []byte `nmap:"json"`
PeerViaGroups sql.NullString `nmap:"skip"`
}

View File

@@ -1,35 +0,0 @@
package networkmap_sqlite
import (
"context"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
)
const (
GetNetworksQuery = `
select id, public_id
from networks where account_id=?
`
)
func (sc *SqliteStoreConn) GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error) {
rows, err := sc.Conn.QueryContext(ctx, GetNetworksQuery, accountId)
if err != nil {
return nil, err
}
networks, err := CollectRowsForSqlite[networkmapdb.Network](rows)
if err != nil {
return nil, err
}
toret := make(map[string]string)
for _, n := range networks {
if n.PublicID.Valid {
toret[n.ID] = n.PublicID.String
}
}
return toret, nil
}

View File

@@ -1,33 +0,0 @@
package networkmap_sqlite
import (
"context"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetPeersQuery = `
select 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
from peers
where account_id = ?
`
)
func (sc *SqliteStoreConn) GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) {
rows, err := sc.Conn.QueryContext(ctx, GetPeersQuery, accountId)
if err != nil {
return nil, nil, err
}
peers, err := CollectRowsForSqlite[networkmapdb.Peer](rows)
if err != nil {
return nil, nil, err
}
return networkmapdb.ConvertToNmdataPeers(peers)
}

View File

@@ -1,33 +0,0 @@
package networkmap_sqlite
import (
"context"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetPoliciesQuery = `
select p.id, p.public_id, p.enabled, p.source_posture_checks, pr.enabled as rule_enabled, pr.action, pr.protocol, pr.bidirectional,
pr.sources, pr.destinations, pr.source_resource, pr.destination_resource, pr.ports, pr.port_ranges,
pr.authorized_groups, pr.authorized_user
from policies as p
left join policy_rules as pr on p.id = pr.policy_id
where account_id=?
`
)
func (sc *SqliteStoreConn) GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) {
rows, err := sc.Conn.QueryContext(ctx, GetPoliciesQuery, accountId)
if err != nil {
return nil, nil, nil, err
}
policies, err := CollectRowsForSqlite[networkmapdb.Policy](rows)
if err != nil {
return nil, nil, nil, err
}
return networkmapdb.ConvertToNmdataPolicy(policies)
}

View File

@@ -1,43 +0,0 @@
package networkmap_sqlite
import (
"context"
"reflect"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetPostureChecksQuery = `
select id, public_id, checks
from posture_checks
where account_id=?
`
)
func (sc *SqliteStoreConn) GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error) {
rows, err := sc.Conn.QueryContext(ctx, GetPostureChecksQuery, accountId)
if err != nil {
return nil, nil, err
}
checks, err := CollectRowsForSqlite[networkmapdb.PostureChecks](rows)
if err != nil {
return nil, nil, err
}
toret := make([]nmdata.PostureChecks, 0, len(checks))
idToPublicIDIdx := make(map[string]string)
for _, c := range checks {
checks := nmdata.PostureChecks{}
err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&c), reflect.ValueOf(&checks))
if err != nil {
return nil, nil, err
}
toret = append(toret, checks)
idToPublicIDIdx[checks.ID] = c.PublicID.String
}
return toret, idToPublicIDIdx, nil
}

View File

@@ -1,32 +0,0 @@
package networkmap_sqlite
import (
"context"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetRoutesQuery = `
select id, account_id, public_id, network, domains, keep_route, net_id, description,
peer, peer as peer_id, peer_groups, network_type, masquerade, metric, enabled,
groups, access_control_groups, skip_auto_apply
from routes
where account_id=?
`
)
func (sc *SqliteStoreConn) GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error) {
rows, err := sc.Conn.QueryContext(ctx, GetRoutesQuery, accountId)
if err != nil {
return nil, err
}
routes, err := CollectRowsForSqlite[networkmapdb.Route](rows)
if err != nil {
return nil, err
}
return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Route, nmdata.Route](routes)
}

View File

@@ -1,89 +0,0 @@
package networkmap_sqlite
import (
"context"
"database/sql"
"encoding/json"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
)
const (
GetServicesQuery = `
select enabled, private, access_groups, proxy_cluster, domain
from services
where account_id=?
`
GetProxyTargetedDomainResourcesQuery = `
select t.target_id
from targets as t
join services as s on s.id = t.service_id
where s.account_id=? and s.enabled and not coalesce(s.terminated, false)
and t.enabled and t.target_type='domain' and t.target_id is not null
`
)
func (sc *SqliteStoreConn) GetPrivateServices(ctx context.Context, accountId string) ([]networkmapdb.Service, error) {
rows, err := sc.Conn.QueryContext(ctx, GetServicesQuery, accountId)
if err != nil {
return nil, err
}
services, err := CollectRowsForSqlite[service](rows)
if err != nil {
return nil, err
}
toret := make([]networkmapdb.Service, 0, len(services))
for _, service := range services {
acg := []string{}
if service.AccessGroups != nil {
if err := json.Unmarshal(service.AccessGroups, &acg); err != nil {
return nil, err
}
}
s := networkmapdb.Service{
Enabled: service.Enabled,
Private: service.Private,
AccessGroups: acg,
ProxyCluster: service.ProxyCluster,
Domain: service.Domain,
}
toret = append(toret, s)
}
return toret, nil
}
func (sc *SqliteStoreConn) GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error) {
rows, err := sc.Conn.QueryContext(ctx, GetProxyTargetedDomainResourcesQuery, accountId)
if err != nil {
return nil, err
}
defer rows.Close()
toret := make(map[string]struct{})
for rows.Next() {
var id string
err := rows.Scan(&id)
if err != nil {
return nil, err
}
toret[id] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, err
}
return toret, nil
}
type service struct {
Enabled sql.NullBool
Private sql.NullBool
AccessGroups []byte
ProxyCluster sql.NullString
Domain sql.NullString
}

View File

@@ -1,143 +0,0 @@
package networkmap_sqlite
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"database/sql"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
)
var ErrNoRows = errors.New("no rows in result set")
type SqliteStore struct {
Db *sql.DB
}
type sqliteInterface interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
type SqliteStoreConn struct {
Conn sqliteInterface
}
func NewSqliteStore(ctx context.Context, storeFile, dataDir string) (*SqliteStore, error) {
dbfile := storeFile
if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" {
dbfile = envFile
}
// Separate file path from any SQLite URI query parameters (e.g., "store.db?mode=rwc")
filePath, query, hasQuery := strings.Cut(dbfile, "?")
connStr := filePath
if filePath != ":memory:" && !filepath.IsAbs(filePath) {
connStr = filepath.Join(dataDir, filePath)
}
// Compose query parameters. User-provided ?_busy_timeout (or its mattn alias
// ?_timeout) overrides our default; otherwise inject 30s so SQLite waits at
// most that long on a lock instead of blocking the only Go-side connection.
// mattn/go-sqlite3 applies PRAGMA from the DSN on every fresh connection, so
// the value survives ConnMaxIdleTime/ConnMaxLifetime recycling. cache=shared
// stays the default on non-Windows for the same reason as before.
parsed, _ := url.ParseQuery(query)
var defaults []string
if parsed.Get("_busy_timeout") == "" && parsed.Get("_timeout") == "" {
defaults = append(defaults, "_busy_timeout=30000")
}
if !hasQuery && runtime.GOOS != "windows" {
// To avoid `The process cannot access the file because it is being used by another process` on Windows
defaults = append(defaults, "cache=shared")
}
parts := defaults
if hasQuery {
parts = append(parts, query)
}
if len(parts) > 0 {
connStr += "?" + strings.Join(parts, "&")
}
db, err := sql.Open("sqlite3", connStr)
if err != nil {
return nil, err
}
return &SqliteStore{Db: db}, nil
}
func (s *SqliteStore) BeginTx(ctx context.Context) (*SqliteStoreConn, error) {
tx, err := s.Db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead})
if err != nil {
return nil, err
}
return &SqliteStoreConn{Conn: tx}, nil
}
func (sc *SqliteStoreConn) RollbackTx(ctx context.Context) error {
tx, ok := sc.Conn.(*sql.Tx)
if !ok {
return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(sc.Conn).Kind())
}
return tx.Rollback()
}
func (sc *SqliteStoreConn) CommitTx(ctx context.Context) error {
tx, ok := sc.Conn.(*sql.Tx)
if !ok {
return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(sc.Conn).Kind())
}
return tx.Commit()
}
func (s *SqliteStore) UsingConn() *SqliteStoreConn {
return &SqliteStoreConn{Conn: s.Db}
}
func CollectOneRowForSqlite[T any](rows *sql.Rows) (T, error) {
defer rows.Close()
var r T
if !rows.Next() {
if err := rows.Err(); err != nil {
return r, err
}
return r, ErrNoRows
}
err := rows.Scan(networkmapdb.StructFields(&r)...)
if err != nil {
return r, err
}
return r, nil
}
func CollectRowsForSqlite[T any](rows *sql.Rows) ([]T, error) {
defer rows.Close()
toret := make([]T, 0)
for rows.Next() {
var r T
err := rows.Scan(networkmapdb.StructFields(&r)...)
if err != nil {
return nil, err
}
toret = append(toret, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
return toret, nil
}

View File

@@ -1,84 +0,0 @@
package networkmap_sqlite
import (
"context"
"database/sql"
"encoding/json"
)
const (
GetAllowedUserIdsQuery = `
select id, auto_groups
from users
where account_id=? and not blocked and not is_service_user
`
GetAllGroupIdQuery = `
select id from groups
where account_id=? and name='All'
`
)
func (sc *SqliteStoreConn) GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error) {
rows, err := sc.Conn.QueryContext(ctx, GetAllowedUserIdsQuery, accountId)
if err != nil {
return nil, nil, err
}
users, err := CollectRowsForSqlite[user](rows)
if err != nil {
return nil, nil, err
}
rows, err = sc.Conn.QueryContext(ctx, GetAllGroupIdQuery, accountId)
if err != nil {
return nil, nil, err
}
allGroupIds, err := collectAllGroupIds(rows)
if err != nil {
return nil, nil, err
}
userIdIdx := make(map[string]struct{})
groupIdToUserIds := make(map[string][]string)
for _, user := range users {
autogroups := make([]string, 0)
if err := json.Unmarshal(user.AutoGroups, &autogroups); err != nil {
return nil, nil, err
}
userIdIdx[user.ID] = struct{}{}
for _, groupId := range autogroups {
groupIdToUserIds[groupId] = append(groupIdToUserIds[groupId], user.ID)
}
for _, allgid := range allGroupIds {
groupIdToUserIds[allgid] = append(groupIdToUserIds[allgid], user.ID)
}
}
return userIdIdx, groupIdToUserIds, nil
}
func collectAllGroupIds(rows *sql.Rows) ([]string, error) {
defer rows.Close()
var toret []string
for rows.Next() {
var id string
err := rows.Scan(&id)
if err != nil {
return nil, err
}
toret = append(toret, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return toret, nil
}
type user struct {
ID string
AutoGroups []byte
}

View File

@@ -1,157 +0,0 @@
package networkmapdb
import (
"database/sql"
"encoding/json"
"errors"
"reflect"
"strings"
"github.com/rs/xid"
)
var ErrNoRows = errors.New("no rows in result set")
const (
NMAP_STRUCT_TAG = "nmap"
NMAP_SKIP = "skip"
NMAP_MAP_TO = "map_to"
NMAP_JSON = "json"
)
type fieldTag struct {
Key string
Value string
}
func tagFromString(t string) fieldTag {
kv := strings.Split(t, ":")
if len(kv) == 1 {
return fieldTag{Key: strings.TrimSpace(kv[0])}
}
return fieldTag{Key: strings.TrimSpace(kv[0]), Value: strings.TrimSpace(kv[1])}
}
func FromSqlTypesToSharedTypes(src reflect.Value, dst reflect.Value) error {
typ := src.Elem().Type()
for i := 0; i < typ.NumField(); i++ {
f := typ.Field(i)
fieldTags := make(map[string]string)
if v := f.Tag.Get(NMAP_STRUCT_TAG); v != "" {
for _, t := range strings.Split(v, ",") {
kv := tagFromString(t)
fieldTags[kv.Key] = kv.Value
}
}
if _, ok := fieldTags[NMAP_SKIP]; ok {
continue
}
if f.PkgPath != "" { // skip unexported fields
continue
}
dstFieldName := f.Name
if override, ok := fieldTags[NMAP_MAP_TO]; ok {
dstFieldName = override
}
dstField := dst.Elem().FieldByName(dstFieldName)
if !dstField.IsValid() {
return errors.New("unsupported type in destination field: " + dstFieldName)
}
srcField := src.Elem().Field(i)
srcFieldType := srcField.Type().String()
switch srcFieldType {
case "string":
s := srcField.Interface().(string)
dstField.SetString(s)
case "sql.NullString":
s := srcField.Interface().(sql.NullString)
if s.Valid {
dstField.SetString(s.String)
}
if (dstFieldName == "PublicId" || dstFieldName == "PublicID") && s.String == "" {
dstField.SetString(xid.New().String()) // TODO (dmitri) this needs to be removed to support delta updates
}
case "sql.NullTime":
s := srcField.Interface().(sql.NullTime)
if s.Valid {
if dstField.Kind() == reflect.Ptr {
t := reflect.ValueOf(&s.Time).Elem()
dstField.Set(t.Addr())
} else {
dstField.Set(reflect.ValueOf(s.Time))
}
}
case "sql.NullBool":
s := srcField.Interface().(sql.NullBool)
if s.Valid {
dstField.SetBool(s.Bool)
}
case "sql.NullInt64":
s := srcField.Interface().(sql.NullInt64)
if s.Valid {
dstField.SetInt(s.Int64)
}
case "json.RawMessage":
s := srcField.Interface().(json.RawMessage)
if len(s) == 0 {
continue
}
if err := json.Unmarshal(s, dstField.Addr().Interface()); err != nil {
return err
}
case "[]byte", "[]uint8":
s := srcField.Interface().([]byte)
if _, ok := fieldTags[NMAP_JSON]; !ok || len(s) == 0 {
continue
}
if err := json.Unmarshal(s, dstField.Addr().Interface()); err != nil {
return err
}
case "[]string":
if srcField.IsNil() {
continue
}
dstv := reflect.MakeSlice(dstField.Type(), srcField.Len(), srcField.Cap())
reflect.Copy(dstv, srcField)
dstField.Set(dstv)
}
}
return nil
}
func StructFields(s any) []any {
src := reflect.ValueOf(s)
toret := make([]any, 0)
typ := src.Elem().Type()
for i := 0; i < typ.NumField(); i++ {
f := typ.Field(i)
if f.PkgPath != "" { // skip unexported fields
continue
}
srcField := src.Elem().Field(i)
toret = append(toret, srcField.Addr().Interface())
}
return toret
}
func ConvertAllToSharedTypes[T any, T1 any](allsrc []T) ([]T1, error) {
toret := make([]T1, 0, len(allsrc))
for _, src := range allsrc {
var dst T1
err := FromSqlTypesToSharedTypes(
reflect.ValueOf(&src), reflect.ValueOf(&dst))
if err != nil {
return nil, err
}
toret = append(toret, dst)
}
return toret, nil
}

View File

@@ -7,7 +7,6 @@ import (
"crypto/tls"
"net/http"
"net/netip"
"os"
"slices"
"time"
@@ -28,9 +27,9 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
proxyactivity "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
proxyactivitymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity/manager"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/activity"
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
@@ -102,22 +101,6 @@ func (s *BaseServer) Store() store.Store {
})
}
func (s *BaseServer) NetworkMapStore() *networkmapdb.NetworkMapDBStoreImpl {
return Create(s, func() *networkmapdb.NetworkMapDBStoreImpl {
dsn := os.Getenv("NETBIRD_NMAP_STORE_DSN") // Todo: this needs to be hoocked up properly
if dsn == "" {
return nil
}
store, err := networkmap_pgsql.NewPostgresqlStore(context.Background(), dsn)
if err != nil {
log.Fatalf("failed to create network map store: %v", err)
}
return networkmapdb.NewNetworkMapDBStoreImpl(store, s.IntegratedValidator(), s.SettingsManager())
})
}
func (s *BaseServer) EventStore() activity.Store {
return Create(s, func() activity.Store {
var err error
@@ -250,6 +233,7 @@ func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer {
proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store())
s.AfterInit(func(s *BaseServer) {
proxyService.SetServiceManager(s.ServiceManager())
proxyService.SetActivityManager(s.ProxyActivityManager())
proxyService.SetProxyController(s.ServiceProxyController())
proxyService.SetAgentNetworkSynthesizer(newAgentNetworkSynthesizer(s.Store()))
proxyService.SetAgentNetworkLimitsService(s.AgentNetworkManager())
@@ -309,6 +293,13 @@ func (s *BaseServer) PKCEVerifierStore() *nbgrpc.PKCEVerifierStore {
})
}
// ProxyActivityManager records reverse proxy usage for activity accounting.
func (s *BaseServer) ProxyActivityManager() proxyactivity.Manager {
return Create(s, func() proxyactivity.Manager {
return proxyactivitymanager.NewManager(s.Store())
})
}
func (s *BaseServer) AccessLogsManager() accesslogs.Manager {
return Create(s, func() accesslogs.Manager {
accessLogManager := accesslogsmanager.NewManager(s.Store(), s.PermissionsManager(), s.GeoLocationManager())

View File

@@ -123,7 +123,7 @@ func (s *BaseServer) EphemeralManager() ephemeral.Manager {
func (s *BaseServer) NetworkMapController() network_map.Controller {
return Create(s, func() network_map.Controller {
return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config, s.NetworkMapStore())
return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config)
})
}

View File

@@ -4,9 +4,10 @@ import (
"encoding/base64"
"strconv"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/types"
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"
)
@@ -83,7 +84,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
enc := newComponentEncoder(c)
enc.indexAllPeers()
routerIdxs := enc.indexRouterPeers(c.RouterPeers)
enc.indexAllNetworkResources()
// Phase 2: gather every policy that any consumer references (peer-pair
// policies + resource-only policies) so encodeResourcePoliciesMap can
@@ -105,6 +105,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
DnsSettings: enc.encodeDNSSettings(c.DNSSettings),
DnsDomain: in.DNSDomain,
CustomZoneDomain: c.CustomZoneDomain,
AgentVersions: enc.agentVersions,
Peers: enc.peers,
RouterPeerIndexes: routerIdxs,
Policies: policies,
@@ -129,7 +130,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
// networkSerial returns c.Network.CurrentSerial() with a nil guard. The
// production path always populates c.Network, but the encoder is exported
// and a hand-built components struct may omit it.
func networkSerial(n *nmdata.Network) uint64 {
func networkSerial(n *types.Network) uint64 {
if n == nil {
return 0
}
@@ -142,15 +143,16 @@ type componentEncoder struct {
peerOrder map[string]uint32
peers []*proto.PeerCompact
networkIdToPublicId map[string]string
agentVersionOrder map[string]uint32
agentVersions []string
}
func newComponentEncoder(c *types.NetworkMapComponents) *componentEncoder {
return &componentEncoder{
components: c,
peerOrder: make(map[string]uint32, len(c.Peers)),
peers: make([]*proto.PeerCompact, 0, len(c.Peers)),
networkIdToPublicId: make(map[string]string),
components: c,
peerOrder: make(map[string]uint32, len(c.Peers)),
peers: make([]*proto.PeerCompact, 0, len(c.Peers)),
agentVersionOrder: make(map[string]uint32),
}
}
@@ -163,7 +165,7 @@ func (e *componentEncoder) indexAllPeers() {
}
}
func (e *componentEncoder) appendPeer(p *nmdata.Peer) uint32 {
func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 {
if idx, ok := e.peerOrder[p.ID]; ok {
return idx
}
@@ -177,7 +179,7 @@ func (e *componentEncoder) appendPeer(p *nmdata.Peer) uint32 {
// (c.RouterPeers may contain peers not in c.Peers when validation rules drop
// them) and returns their wire indexes for the RouterPeerIndexes field. Must
// run before any encoder that resolves peer ids via e.peerOrder.
func (e *componentEncoder) indexRouterPeers(routers map[string]*nmdata.Peer) []uint32 {
func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentPeer) []uint32 {
if len(routers) == 0 {
return nil
}
@@ -191,15 +193,6 @@ func (e *componentEncoder) indexRouterPeers(routers map[string]*nmdata.Peer) []u
return out
}
func (e *componentEncoder) indexAllNetworkResources() {
for _, r := range e.components.NetworkResources {
if !r.Enabled {
continue
}
e.networkIdToPublicId[r.ID] = r.PublicID
}
}
func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
if len(e.components.Groups) == 0 {
return nil
@@ -213,22 +206,10 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
peerIdxs = append(peerIdxs, idx)
}
}
groupCompactResources := func() []*proto.ResourceCompact {
var toret []*proto.ResourceCompact
for _, r := range g.Resources {
if pr := e.resourceToProto(r); pr != nil {
toret = append(toret, pr)
}
}
return toret
}
out = append(out, &proto.GroupCompact{
Id: g.PublicID,
PeerIndexes: peerIdxs,
IsAll: g.IsGroupAll(),
Resources: groupCompactResources(),
})
}
return out
@@ -238,7 +219,7 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
// list and a map from policy pointer to the indexes of its emitted rules in
// that list — used by encodeResourcePoliciesMap to translate
// ResourcePoliciesMap[resourceID][]*Policy into wire-side indexes.
func (e *componentEncoder) encodePolicies(policies []*nmdata.Policy) []*proto.PolicyCompact {
func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.PolicyCompact {
if len(policies) == 0 {
return nil
}
@@ -260,7 +241,7 @@ func (e *componentEncoder) encodePolicies(policies []*nmdata.Policy) []*proto.Po
}
// encodePolicyRule maps a single PolicyRule under pol to a PolicyCompact entry.
func (e *componentEncoder) encodePolicyRule(pol *nmdata.Policy, r *nmdata.PolicyRule) *proto.PolicyCompact {
func (e *componentEncoder) encodePolicyRule(pol *types.Policy, r *types.PolicyRule) *proto.PolicyCompact {
return &proto.PolicyCompact{
Id: pol.PublicID,
Action: networkmap.GetProtoAction(string(r.Action)),
@@ -299,14 +280,14 @@ func (e *componentEncoder) groupPublicXids(src []string) []string {
// only live in ResourcePoliciesMap; without this union step they'd be lost
// from the wire and the client's resource-policy lookup would come back
// empty.
func unionPolicies(policies []*nmdata.Policy, resourcePolicies map[string][]*nmdata.Policy) []*nmdata.Policy {
func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*types.Policy) []*types.Policy {
// Fast path: non-router peers have no resource-only policies, so the
// "union" is identical to `policies`. Skip the dedup map allocation.
if len(resourcePolicies) == 0 {
return policies
}
seen := make(map[string]struct{}, len(policies))
out := make([]*nmdata.Policy, 0, len(policies))
out := make([]*types.Policy, 0, len(policies))
for _, p := range policies {
if p == nil {
continue
@@ -364,31 +345,18 @@ func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) {
// peers array. For other resource types only the type string is shipped
// today (Calculate's resource-typed rule path consults SourceResource only
// for "peer" — other types fall through to group-based lookup).
func (e *componentEncoder) resourceToProto(r nmdata.Resource) *proto.ResourceCompact {
t, ok := proto.ResourceCompactType_value[string(r.Type)]
if !ok || t == 0 || r.ID == "" {
func (e *componentEncoder) resourceToProto(r types.Resource) *proto.ResourceCompact {
if r.ID == "" && r.Type == "" {
return nil
}
if t == int32(proto.ResourceCompactType_peer) {
idx, ok := e.peerOrder[r.ID]
if !ok {
return nil
}
return &proto.ResourceCompact{
Type: proto.ResourceCompactType_peer,
ResourceId: &proto.ResourceCompact_PeerIndex{PeerIndex: idx},
out := &proto.ResourceCompact{Type: string(r.Type)}
if r.Type == types.ResourceTypePeer && r.ID != "" {
if idx, ok := e.peerOrder[r.ID]; ok {
out.PeerIndexSet = true
out.PeerIndex = idx
}
}
publicID, ok := e.networkIdToPublicId[r.ID]
if !ok {
return nil
}
return &proto.ResourceCompact{
Type: proto.ResourceCompactType(t),
ResourceId: &proto.ResourceCompact_Id{Id: publicID},
}
return out
}
// postureCheckSeqs translates a slice of posture-check xids to their
@@ -421,7 +389,7 @@ func (e *componentEncoder) networkPublicId(xid string) (string, bool) {
return id, true
}
func (e *componentEncoder) encodeDNSSettings(s *nmdata.DNSSettings) *proto.DNSSettingsCompact {
func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSettingsCompact {
if s == nil || len(s.DisabledManagementGroups) == 0 {
return nil
}
@@ -436,7 +404,7 @@ func (e *componentEncoder) encodeDNSSettings(s *nmdata.DNSSettings) *proto.DNSSe
return out
}
func (e *componentEncoder) encodeRoutes(routes []*nmdata.Route) []*proto.RouteRaw {
func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteRaw {
if len(routes) == 0 {
return nil
}
@@ -474,7 +442,7 @@ func (e *componentEncoder) encodeRoutes(routes []*nmdata.Route) []*proto.RouteRa
return out
}
func (e *componentEncoder) encodeNameServerGroups(nsgs []*nmdata.NameServerGroup) []*proto.NameServerGroupRaw {
func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup) []*proto.NameServerGroupRaw {
if len(nsgs) == 0 {
return nil
}
@@ -497,7 +465,7 @@ func (e *componentEncoder) encodeNameServerGroups(nsgs []*nmdata.NameServerGroup
return out
}
func encodeNameServers(servers []nmdata.NameServer) []*proto.NameServer {
func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer {
if len(servers) == 0 {
return nil
}
@@ -512,7 +480,7 @@ func encodeNameServers(servers []nmdata.NameServer) []*proto.NameServer {
return out
}
func encodeSimpleRecords(records []nmdata.SimpleRecord) []*proto.SimpleRecord {
func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord {
if len(records) == 0 {
return nil
}
@@ -529,7 +497,7 @@ func encodeSimpleRecords(records []nmdata.SimpleRecord) []*proto.SimpleRecord {
return out
}
func encodeCustomZones(zones []nmdata.CustomZone) []*proto.CustomZone {
func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone {
if len(zones) == 0 {
return nil
}
@@ -545,7 +513,7 @@ func encodeCustomZones(zones []nmdata.CustomZone) []*proto.CustomZone {
return out
}
func (e *componentEncoder) encodeNetworkResources(resources []*nmdata.NetworkResource) []*proto.NetworkResourceRaw {
func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw {
if len(resources) == 0 {
return nil
}
@@ -574,7 +542,7 @@ func (e *componentEncoder) encodeNetworkResources(resources []*nmdata.NetworkRes
return out
}
func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*nmdata.NetworkRouter) map[string]*proto.NetworkRouterList {
func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList {
if len(routersMap) == 0 {
return nil
}
@@ -610,7 +578,7 @@ func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*nm
return out
}
func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*nmdata.Policy) map[string]*proto.PolicyIds {
func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Policy) map[string]*proto.PolicyIds {
if len(rpm) == 0 {
return nil
}
@@ -631,9 +599,6 @@ func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*nmdata.Po
}
ids := make([]string, 0, len(policies))
for _, pol := range policies {
if pol == nil {
continue
}
ids = append(ids, pol.PublicID)
}
if len(ids) == 0 {
@@ -700,7 +665,7 @@ func (e *componentEncoder) encodePostureFailedPeers(m map[string]map[string]stru
// (which shouldn't happen in production but the encoder is exported)
// degrades to login_expiration_enabled = false, which makes
// LoginExpired() return false for every peer.
func toAccountSettingsCompact(s *nmdata.AccountSettingsInfo) *proto.AccountSettingsCompact {
func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettingsCompact {
if s == nil {
return &proto.AccountSettingsCompact{}
}
@@ -710,7 +675,7 @@ func toAccountSettingsCompact(s *nmdata.AccountSettingsInfo) *proto.AccountSetti
}
}
func toAccountNetwork(n *nmdata.Network) *proto.AccountNetwork {
func toAccountNetwork(n *types.Network) *proto.AccountNetwork {
if n == nil {
return nil
}
@@ -726,20 +691,20 @@ func toAccountNetwork(n *nmdata.Network) *proto.AccountNetwork {
return out
}
func toPeerCompact(p *nmdata.Peer) *proto.PeerCompact {
func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact {
pc := &proto.PeerCompact{
WgPubKey: decodeWgKey(p.Key),
SshPubKey: []byte(p.SSHKey),
DnsLabel: p.DNSLabel,
AgentVersion: p.Meta.WtVersion,
AddedWithSsoLogin: p.UserID != "",
AgentVersion: p.AgentVersion,
AddedWithSsoLogin: p.AddedWithSSOLogin,
LoginExpirationEnabled: p.LoginExpirationEnabled,
SshEnabled: p.SSHEnabled,
SupportsIpv6: p.SupportsIPv6(),
SupportsSourcePrefixes: p.SupportsSourcePrefixes(),
ServerSshAllowed: p.Meta.Flags.ServerSSHAllowed,
SupportsIpv6: p.SupportsIPv6,
SupportsSourcePrefixes: p.SupportsSourcePrefixes,
ServerSshAllowed: p.ServerSSHAllowed,
}
if p.LastLogin != nil {
if !p.LastLogin.IsZero() {
pc.LastLoginUnixNano = p.LastLogin.UnixNano()
}
switch {
@@ -788,7 +753,7 @@ func portsToUint32(ports []string) []uint32 {
return out
}
func portRangesToProto(ranges []nmdata.RulePortRange) []*proto.PortInfo_Range {
func portRangesToProto(ranges []types.RulePortRange) []*proto.PortInfo_Range {
if len(ranges) == 0 {
return nil
}

View File

@@ -16,7 +16,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
nbroute "github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -152,66 +152,66 @@ func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool {
}
func newTestComponents() *types.NetworkMapComponents {
peerA := &nmdata.Peer{
ID: "peer-a",
Key: testWgKeyA,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
DNSLabel: "peera",
SSHKey: "ssh-a",
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
peerA := &types.ComponentPeer{
ID: "peer-a",
Key: testWgKeyA,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
DNSLabel: "peera",
SSHKey: "ssh-a",
AgentVersion: "0.40.0",
}
peerB := &nmdata.Peer{
ID: "peer-b",
Key: testWgKeyB,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}),
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}),
DNSLabel: "peerb",
Meta: nmdata.PeerSystemMeta{WtVersion: "0.25.0"},
peerB := &types.ComponentPeer{
ID: "peer-b",
Key: testWgKeyB,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}),
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}),
DNSLabel: "peerb",
AgentVersion: "0.25.0",
}
peerC := &nmdata.Peer{
ID: "peer-c",
Key: testWgKeyC,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
DNSLabel: "peerc",
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
peerC := &types.ComponentPeer{
ID: "peer-c",
Key: testWgKeyC,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
DNSLabel: "peerc",
AgentVersion: "0.40.0",
}
return &types.NetworkMapComponents{
PeerID: "peer-a",
Network: &nmdata.Network{
Network: &types.Network{
Identifier: "net-test",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 7,
},
AccountSettings: &nmdata.AccountSettingsInfo{
AccountSettings: &types.AccountSettingsInfo{
PeerLoginExpirationEnabled: true,
PeerLoginExpiration: 2 * time.Hour,
},
Peers: map[string]*nmdata.Peer{
Peers: map[string]*types.ComponentPeer{
"peer-a": peerA,
"peer-b": peerB,
"peer-c": peerC,
},
Groups: map[string]*nmdata.Group{
"group-src": {PublicID: "1", Name: "Src", Peers: []string{"peer-a"}},
"group-dst": {PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}},
Groups: map[string]*types.ComponentGroup{
"group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}},
"group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}},
},
Policies: []*nmdata.Policy{
Policies: []*types.Policy{
{
ID: "pol-1",
PublicID: "10",
Enabled: true,
Rules: []*nmdata.PolicyRule{{
ID: "rule-1", Enabled: true, Action: string(types.PolicyTrafficActionAccept),
Protocol: string(types.PolicyRuleProtocolTCP), Bidirectional: true,
Rules: []*types.PolicyRule{{
ID: "rule-1", Enabled: true, Action: types.PolicyTrafficActionAccept,
Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true,
Ports: []string{"22", "80"},
PortRanges: []nmdata.RulePortRange{{Start: 8000, End: 8100}},
PortRanges: []types.RulePortRange{{Start: 8000, End: 8100}},
Sources: []string{"group-src"},
Destinations: []string{"group-dst"},
}},
},
},
RouterPeers: map[string]*nmdata.Peer{"peer-c": peerC},
RouterPeers: map[string]*types.ComponentPeer{"peer-c": peerC},
}
}
@@ -304,31 +304,6 @@ func TestEncodeNetworkMapEnvelope_GroupsByAccountPublicId(t *testing.T) {
assert.Len(t, groupByID["2"].PeerIndexes, 2)
}
func TestEncodePolicy(t *testing.T) {
encoder := componentEncoder{peerOrder: map[string]uint32{"peerId": uint32(1234)}, networkIdToPublicId: map[string]string{"domain": "publicDomain", "host": "publicHost", "subnet": "publicSubnet"}}
assert.Equal(t,
encoder.resourceToProto(nmdata.Resource{Type: "peer", ID: "peerId"}),
&proto.ResourceCompact{Type: proto.ResourceCompactType_peer, ResourceId: &proto.ResourceCompact_PeerIndex{PeerIndex: uint32(1234)}})
// verify invalid peer id results in nil
assert.Nil(t,
encoder.resourceToProto(nmdata.Resource{Type: "peer", ID: "boom"}))
assert.Equal(t,
encoder.resourceToProto(nmdata.Resource{Type: "domain", ID: "domain"}),
&proto.ResourceCompact{Type: proto.ResourceCompactType_domain, ResourceId: &proto.ResourceCompact_Id{Id: "publicDomain"}})
assert.Equal(t,
encoder.resourceToProto(nmdata.Resource{Type: "host", ID: "host"}),
&proto.ResourceCompact{Type: proto.ResourceCompactType_host, ResourceId: &proto.ResourceCompact_Id{Id: "publicHost"}})
assert.Equal(t,
encoder.resourceToProto(nmdata.Resource{Type: "subnet", ID: "subnet"}),
&proto.ResourceCompact{Type: proto.ResourceCompactType_subnet, ResourceId: &proto.ResourceCompact_Id{Id: "publicSubnet"}})
// verify invalid resource type results in nil
assert.Nil(t,
encoder.resourceToProto(nmdata.Resource{Type: "boom", ID: "boom"}))
// verify invalid networkresource id results in nil
assert.Nil(t,
encoder.resourceToProto(nmdata.Resource{Type: "host", ID: "boom"}))
}
func TestEncodeNetworkMapEnvelope_PolicyExpansion(t *testing.T) {
c := newTestComponents()
@@ -402,12 +377,12 @@ func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) {
func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) {
c := newTestComponents()
v6Only := &nmdata.Peer{
ID: "peer-v6",
Key: testWgKeyA,
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}),
DNSLabel: "peerv6",
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
v6Only := &types.ComponentPeer{
ID: "peer-v6",
Key: testWgKeyA,
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}),
DNSLabel: "peerv6",
AgentVersion: "0.40.0",
}
c.Peers["peer-v6"] = v6Only
@@ -426,11 +401,11 @@ func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) {
func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) {
c := newTestComponents()
c.Peers["peer-noip"] = &nmdata.Peer{
ID: "peer-noip",
Key: testWgKeyA,
DNSLabel: "peernoip",
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
c.Peers["peer-noip"] = &types.ComponentPeer{
ID: "peer-noip",
Key: testWgKeyA,
DNSLabel: "peernoip",
AgentVersion: "0.40.0",
}
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
@@ -448,7 +423,7 @@ func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) {
func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) {
c := &types.NetworkMapComponents{
Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
}
env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c})
@@ -465,9 +440,9 @@ func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) {
func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) {
c := newTestComponents()
now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
c.Peers["peer-a"].UserID = "user-1"
c.Peers["peer-a"].AddedWithSSOLogin = true
c.Peers["peer-a"].LoginExpirationEnabled = true
c.Peers["peer-a"].LastLogin = &now
c.Peers["peer-a"].LastLogin = now
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
@@ -497,7 +472,7 @@ func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) {
func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) {
c := newTestComponents()
c.Routes = []*nmdata.Route{
c.Routes = []*nbroute.Route{
{
ID: "route-peer",
PublicID: "100",
@@ -544,7 +519,7 @@ func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) {
func TestEncodeNetworkMapEnvelope_RouteWithMissingPeerLeavesIndexUnset(t *testing.T) {
c := newTestComponents()
c.Routes = []*nmdata.Route{{
c.Routes = []*nbroute.Route{{
ID: "route-x",
PublicID: "100",
Peer: "peer-not-in-components",
@@ -564,21 +539,21 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing
// Policy that exists ONLY in ResourcePoliciesMap, not in c.Policies. This
// is the I1 case — without unionPolicies the encoder would silently
// drop it from the wire.
resourceOnlyPolicy := &nmdata.Policy{
resourceOnlyPolicy := &types.Policy{
ID: "pol-resource", PublicID: "99", Enabled: true,
Rules: []*nmdata.PolicyRule{{
ID: "rule-r", Enabled: true, Action: string(types.PolicyTrafficActionAccept),
Protocol: string(types.PolicyRuleProtocolTCP),
Rules: []*types.PolicyRule{{
ID: "rule-r", Enabled: true, Action: types.PolicyTrafficActionAccept,
Protocol: types.PolicyRuleProtocolTCP,
Sources: []string{"group-src"},
Destinations: []string{"group-dst"},
}},
}
c.ResourcePoliciesMap = map[string][]*nmdata.Policy{
c.ResourcePoliciesMap = map[string][]*types.Policy{
"resource-x": {c.Policies[0], resourceOnlyPolicy}, // shared + resource-only
}
// Resource must appear in components.NetworkResources with a seq id —
// encoder uses that to translate the xid map key to uint32.
c.NetworkResources = []*nmdata.NetworkResource{
c.NetworkResources = []*types.ComponentResource{
{ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true},
}
@@ -604,10 +579,10 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing
func TestEncodeNetworkMapEnvelope_NameServerGroups(t *testing.T) {
c := newTestComponents()
c.NameServerGroups = []*nmdata.NameServerGroup{{
c.NameServerGroups = []*nbdns.NameServerGroup{{
ID: "nsg-1", PublicID: "50", Name: "Main", Description: "primary",
NameServers: []nmdata.NameServer{{
IP: netip.MustParseAddr("8.8.8.8"), NSType: int(nbdns.UDPNameServerType), Port: 53,
NameServers: []nbdns.NameServer{{
IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53,
}},
Groups: []string{"group-src", "group-not-persisted"},
Primary: true, Enabled: true,
@@ -646,11 +621,11 @@ func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) {
func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) {
c := newTestComponents()
c.NetworkXIDToPublicID = map[string]string{"net-1": "5"}
c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{
c.RoutersMap = map[string]map[string]*types.ComponentRouter{
"net-1": {
"peer-c": {
PublicID: "200",
Masquerade: true, Metric: 10, Enabled: true,
PublicID: "200",
Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true,
},
},
}
@@ -676,14 +651,14 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) {
// peer_index reference must still resolve.
c := newTestComponents()
delete(c.Peers, "peer-c")
routerPeer := &nmdata.Peer{
routerPeer := &types.ComponentPeer{
ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
DNSLabel: "peerc", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
DNSLabel: "peerc", AgentVersion: "0.40.0",
}
c.RouterPeers = map[string]*nmdata.Peer{"peer-c": routerPeer}
c.RouterPeers = map[string]*types.ComponentPeer{"peer-c": routerPeer}
c.NetworkXIDToPublicID = map[string]string{"net-1": "5"}
c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{
"net-1": {"peer-c": {PublicID: "1", Enabled: true}},
c.RoutersMap = map[string]map[string]*types.ComponentRouter{
"net-1": {"peer-c": {PublicID: "1", Peer: "peer-c", Enabled: true}},
}
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
@@ -716,9 +691,9 @@ func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) {
func TestToProxyPatch_PopulatesAllFields(t *testing.T) {
nm := &types.NetworkMap{
Peers: []*nmdata.Peer{{
Peers: []*types.ComponentPeer{{
ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}),
DNSLabel: "extpeer", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
DNSLabel: "extpeer", AgentVersion: "0.40.0",
}},
FirewallRules: []*types.FirewallRule{{
PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp",
@@ -790,7 +765,7 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
c := &types.NetworkMapComponents{
Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
// AccountSettings deliberately nil
}
@@ -804,8 +779,8 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
func emptyNetworkMapComponents() *types.NetworkMapComponents {
return types.EmptyNetworkMapComponents(
&types.NetworkMapComponents{
PeerID: "peer-id", Peers: map[string]*nmdata.Peer{"peer-id": {}},
Network: &nmdata.Network{
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}},
Network: &types.Network{
Identifier: "net-empty",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 9,

View File

@@ -7,11 +7,11 @@ import (
"github.com/netbirdio/netbird/client/ssh/auth"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/posture"
"github.com/netbirdio/netbird/management/server/types"
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
"github.com/netbirdio/netbird/shared/management/networkmap"
nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -31,14 +31,14 @@ func ToComponentSyncResponse(
config *nbconfig.Config,
httpConfig *nbconfig.HttpServerConfig,
deviceFlowConfig *nbconfig.DeviceAuthorizationFlow,
peer *nmdata.Peer,
peer *nbpeer.Peer,
turnCredentials *Token,
relayCredentials *Token,
components *types.NetworkMapComponents,
proxyPatch *types.NetworkMap,
dnsName string,
checks []*posture.Checks,
settings *nmdata.AccountSettingsInfo,
settings *types.Settings,
extraSettings *types.ExtraSettings,
peerGroups []string,
dnsFwdPort int64,
@@ -145,7 +145,7 @@ func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePr
//
// The full SSH AuthorizedUsers map is still produced by the client when it
// runs Calculate() over the envelope.
func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nmdata.Peer) bool {
func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer) bool {
if c == nil || peer == nil {
return false
}
@@ -170,25 +170,25 @@ func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nmdata.Peer)
// ruleEnablesSSHForPeer returns true when rule is active, targets peer, and
// either explicitly authorises SSH or covers the legacy TCP/22 path while the
// peer itself has SSH enabled locally.
func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peer *nmdata.Peer) bool {
func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *types.PolicyRule, peer *nbpeer.Peer) bool {
if rule == nil || !rule.Enabled {
return false
}
if !peerInDestinations(c, rule, peer.ID) {
return false
}
if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) {
if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH {
return true
}
return peer.SSHEnabled && nmdata.PolicyRuleImpliesLegacySSH(rule)
return peer.SSHEnabled && types.PolicyRuleImpliesLegacySSH(rule)
}
// peerInDestinations reports whether peerID is in any of rule.Destinations'
// groups (or matches DestinationResource if it's a peer-typed resource —
// for non-peer types Calculate falls through to group lookup, so we mirror
// that exactly to avoid silent divergence).
func peerInDestinations(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peerID string) bool {
if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" {
func peerInDestinations(c *types.NetworkMapComponents, rule *types.PolicyRule, peerID string) bool {
if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" {
return rule.DestinationResource.ID == peerID
}
for _, groupID := range rule.Destinations {

View File

@@ -5,8 +5,8 @@ import (
"github.com/stretchr/testify/assert"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
// TestComputeSSHEnabledForPeer covers both Calculate-mirroring branches:
@@ -17,15 +17,16 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
const targetPeerID = "target"
const targetGroupID = "g_dst"
mkComponents := func(rule *nmdata.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nmdata.Peer) {
peer := &nmdata.Peer{ID: targetPeerID, SSHEnabled: sshEnabled}
mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) {
peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled}
group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}}
return &types.NetworkMapComponents{
Peers: map[string]*nmdata.Peer{targetPeerID: peer},
Groups: map[string]*nmdata.Group{targetGroupID: {Name: "dst", Peers: []string{targetPeerID}}},
Policies: []*nmdata.Policy{{
Peers: map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()},
Groups: map[string]*types.ComponentGroup{targetGroupID: group},
Policies: []*types.Policy{{
ID: "p",
Enabled: true,
Rules: []*nmdata.PolicyRule{rule},
Rules: []*types.PolicyRule{rule},
}},
}, peer
}
@@ -33,14 +34,14 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
cases := []struct {
name string
peerSSH bool
rule nmdata.PolicyRule
rule types.PolicyRule
wantEnabled bool
}{
{
name: "explicit-netbird-ssh-activates-regardless-of-peer-ssh",
peerSSH: false,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -48,8 +49,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-tcp-22-with-peer-ssh",
peerSSH: true,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -57,8 +58,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-tcp-22-without-peer-ssh-disabled",
peerSSH: false,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
Destinations: []string{targetGroupID},
},
wantEnabled: false,
@@ -66,8 +67,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-tcp-22022-with-peer-ssh",
peerSSH: true,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22022"},
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22022"},
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -75,8 +76,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-all-protocol-with-peer-ssh",
peerSSH: true,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolALL),
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolALL,
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -84,10 +85,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-port-range-covers-22",
peerSSH: true,
rule: nmdata.PolicyRule{
rule: types.PolicyRule{
Enabled: true,
Protocol: string(types.PolicyRuleProtocolTCP),
PortRanges: []nmdata.RulePortRange{{Start: 20, End: 30}},
Protocol: types.PolicyRuleProtocolTCP,
PortRanges: []types.RulePortRange{{Start: 20, End: 30}},
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -95,8 +96,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "tcp-80-no-ssh",
peerSSH: true,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"80"},
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"80"},
Destinations: []string{targetGroupID},
},
wantEnabled: false,
@@ -104,8 +105,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "disabled-rule-skipped",
peerSSH: true,
rule: nmdata.PolicyRule{
Enabled: false, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
rule: types.PolicyRule{
Enabled: false, Protocol: types.PolicyRuleProtocolNetbirdSSH,
Destinations: []string{targetGroupID},
},
wantEnabled: false,
@@ -113,8 +114,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "peer-not-in-destinations",
peerSSH: true,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
Destinations: []string{"g_other"}, // target not in this group
},
wantEnabled: false,
@@ -122,21 +123,21 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "peer-typed-destination-resource-matches",
peerSSH: false,
rule: nmdata.PolicyRule{
rule: types.PolicyRule{
Enabled: true,
Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
DestinationResource: nmdata.Resource{ID: targetPeerID, Type: string(types.ResourceTypePeer)},
Protocol: types.PolicyRuleProtocolNetbirdSSH,
DestinationResource: types.Resource{ID: targetPeerID, Type: types.ResourceTypePeer},
},
wantEnabled: true,
},
{
name: "non-peer-destination-resource-falls-through-to-groups",
peerSSH: false,
rule: nmdata.PolicyRule{
rule: types.PolicyRule{
Enabled: true,
Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
DestinationResource: nmdata.Resource{ID: targetPeerID, Type: "host"}, // wrong type
Destinations: []string{targetGroupID}, // saved by group fallback
Protocol: types.PolicyRuleProtocolNetbirdSSH,
DestinationResource: types.Resource{ID: targetPeerID, Type: "host"}, // wrong type
Destinations: []string{targetGroupID}, // saved by group fallback
},
wantEnabled: true,
},
@@ -155,16 +156,16 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
// belt-and-suspenders presence guard mirroring Calculate's
// getAllPeersFromGroups invariant.
func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) {
peer := &nmdata.Peer{ID: "missing", SSHEnabled: true}
peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true}
c := &types.NetworkMapComponents{
Peers: map[string]*nmdata.Peer{}, // target peer NOT present
Groups: map[string]*nmdata.Group{
"g": {Peers: []string{"missing"}},
Peers: map[string]*types.ComponentPeer{}, // target peer NOT present
Groups: map[string]*types.ComponentGroup{
"g": {ID: "g", Peers: []string{"missing"}},
},
Policies: []*nmdata.Policy{{
Policies: []*types.Policy{{
ID: "p", Enabled: true,
Rules: []*nmdata.PolicyRule{{
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
Rules: []*types.PolicyRule{{
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
Destinations: []string{"g"},
}},
}},
@@ -178,6 +179,6 @@ func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) {
// exported indirectly via ToComponentSyncResponse and may receive nil
// components on graceful-degrade paths.
func TestComputeSSHEnabledForPeer_NilInputs(t *testing.T) {
assert.False(t, computeSSHEnabledForPeer(nil, &nmdata.Peer{ID: "x"}))
assert.False(t, computeSSHEnabledForPeer(nil, &nbpeer.Peer{ID: "x"}))
assert.False(t, computeSSHEnabledForPeer(&types.NetworkMapComponents{}, nil))
}

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