Compare commits

..

96 Commits

Author SHA1 Message Date
Dmitri Dolguikh
6b8393c6b0 add GetAppliedZoneCandidatesViaPgxConnection test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 18:43:20 +02:00
Dmitri Dolguikh
316e82337f cleanup query execution in tests
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 18:09:28 +02:00
Dmitri Dolguikh
bacacb8f77 deleted group_test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 16:45:25 +02:00
Dmitri Dolguikh
994e84a686 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types 2026-08-05 16:35:08 +02:00
Dmitri Dolguikh
07809fe923 added test for GetAllowedUsersViaPgxConnection
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 16:34:44 +02:00
pascal
ec3e37456e use Exec instead of Query for inserts and assert every insert error 2026-08-05 16:33:48 +02:00
pascal
c81103dfa6 fix linter comments 2026-08-05 16:21:01 +02:00
Dmitri Dolguikh
ba574dc739 added tests for GetPrivateServicesViaPgxConnection and GetPrivateServicesViaPgxConnection
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 16:06:43 +02:00
Dmitri Dolguikh
067982f772 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 15:26:38 +02:00
Dmitri Dolguikh
ce4f0a7821 added GetAccountSettingsViaPgxConnection test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 15:26:06 +02:00
pascal
3218e2f744 fix client tests 2026-08-05 15:18:19 +02:00
pascal
7e24c27ef1 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types 2026-08-05 15:09:41 +02:00
pascal
06817271bf fix embedded client test 2026-08-05 15:09:29 +02:00
Dmitri Dolguikh
1530739b54 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 14:58:36 +02:00
Dmitri Dolguikh
e745aedc92 added GetRoutesViaPgxConnection test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 14:58:09 +02:00
pascal
4cf3903c83 fix management <-> shared dependencies 2026-08-05 14:48:27 +02:00
pascal
1e14b554a6 fix management <-> shared dependencies 2026-08-05 14:48:18 +02:00
Dmitri Dolguikh
11a3627899 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 14:29:43 +02:00
Dmitri Dolguikh
2442bd6198 tests for GetNameServerGroupsViaPgxConnection and GetPostureChecksViaPgxConnection
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 14:29:07 +02:00
pascal
006cee000f Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types 2026-08-05 13:56:46 +02:00
pascal
c93aa03c0e merge main 2026-08-05 13:56:35 +02:00
Dmitri Dolguikh
efe2eaeb09 added GetDomains test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 12:37:58 +02:00
Dmitri Dolguikh
1926e983fb added GetDnsSettings test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 12:03:36 +02:00
Dmitri Dolguikh
e9b8175915 added GetNetworks test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 11:53:40 +02:00
Dmitri Dolguikh
88f1930450 added account network test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 11:40:23 +02:00
Dmitri Dolguikh
1774a3d9dd add network resource query test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 10:52:39 +02:00
Dmitri Dolguikh
795e06ce83 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types 2026-08-05 10:16:54 +02:00
Dmitri Dolguikh
748f6b3fbb fixes + tests
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 10:16:21 +02:00
pascal
d5adfc799f handle policy with no rules to avoid index panic 2026-08-04 20:57:57 +02:00
pascal
60fe56e442 fix assert equal ordering 2026-08-04 20:56:16 +02:00
pascal
327fa4c0d6 fix group clone 2026-08-04 20:52:16 +02:00
pascal
ed015a7972 skip resource from proto failure 2026-08-04 20:46:10 +02:00
pascal
b43708e31b silently skip resource to proto failure 2026-08-04 20:42:43 +02:00
pascal
ce55b8406b handle not found peer on posture vali 2026-08-04 20:34:30 +02:00
pascal
0456b64ea1 silent skip on non existing groups references 2026-08-04 20:28:06 +02:00
pascal
942ee81ec0 add benchmark 2026-08-04 20:19:17 +02:00
Dmitri Dolguikh
33a0e1bc2b adding integration tests
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-04 18:58:29 +02:00
Dmitri Dolguikh
dbfdd04c7b adding network-routers integration query test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-04 16:43:17 +02:00
Dmitri Dolguikh
4d010f60ce fix another import
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-04 15:40:28 +02:00
Dmitri Dolguikh
3272058e56 fix extra setting manager package
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-04 13:34:25 +02:00
Dmitri Dolguikh
70c3feb05b Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-04 13:04:51 +02:00
Dmitri Dolguikh
a0fe80cd99 wire up validated peers
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-04 12:52:09 +02:00
pascal
6fadf8f24a fix nmdata store expanding router peer groups alongside static peer 2026-08-03 23:58:47 +02:00
pascal
ef0032685b fix nmdata store shipping zones for disabled/public services 2026-08-03 23:54:22 +02:00
pascal
5ed38569f7 fix PrivateServiceCandidates nil assumption 2026-08-03 23:19:55 +02:00
pascal
d4e1c8978e add all group to user group lookup 2026-08-03 23:08:43 +02:00
pascal
d5ac70d806 fix missing ForceRoutingPeerDNSResolution on the nmdata store path 2026-08-03 22:50:06 +02:00
pascal
23579e4dd4 Skip the left-join NULL row instead of failing the account load + drop unsupported record types silently to match buildAppliedZoneCandidates 2026-08-03 22:38:12 +02:00
pascal
a00c5164a8 continue on resource policy loop + exclude disabled policies form index 2026-08-03 22:29:47 +02:00
pascal
02138dffdd fix group id mapping 2026-08-03 22:24:28 +02:00
pascal
02ec1f5dcb fix equivalence test 2026-08-03 22:24:11 +02:00
pascal
07d0440e34 fix slice initialization to avoid nil pointer dereference 2026-08-03 21:51:48 +02:00
pascal
2c85d94c6c hookup validated peers 2026-08-03 21:47:41 +02:00
Dmitri Dolguikh
1f190a50cf Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 18:22:34 +02:00
Dmitri Dolguikh
52fdfd5bdc add an posture-check-id to public-id index
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 18:21:07 +02:00
Dmitri Dolguikh
e35a0f3318 added PrivateServiceCandidates
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 18:04:11 +02:00
pascal
56c411babd fixed networks query and extended error wrapping 2026-08-03 17:52:33 +02:00
Dmitri Dolguikh
164b2baa4d Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 17:29:42 +02:00
Dmitri Dolguikh
0b29c6ed1a adding buildPrivateServiceCandidates
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 17:29:09 +02:00
pascal
724b61440b use new path on initial sync 2026-08-03 17:27:58 +02:00
pascal
22590ad66e hookup network map store 2026-08-03 17:27:24 +02:00
Dmitri Dolguikh
b8e004ea89 also build proxy-cluster to peer idx
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 14:58:53 +02:00
Dmitri Dolguikh
e2797360f4 support for applied zone candidates
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 14:32:54 +02:00
Dmitri Dolguikh
2f399f1e6e wire up dnssettings
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 14:12:27 +02:00
Dmitri Dolguikh
82c1e18264 renamed allowed_user_ids.go to user.got 2026-08-03 13:54:22 +02:00
Dmitri Dolguikh
ce9023bd27 wire up accountsettings
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 13:51:25 +02:00
Dmitri Dolguikh
7d33356776 support for group to user ids
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 13:47:14 +02:00
Dmitri Dolguikh
993291149c Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 12:20:18 +02:00
Dmitri Dolguikh
98f7ea40a1 added allowed_user_ids call
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 12:19:50 +02:00
pascal
76db9ab94f update integrated validator 2026-08-03 12:11:35 +02:00
Dmitri Dolguikh
9653b15d78 automatically fill empty public ids for view types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 11:34:22 +02:00
Dmitri Dolguikh
206bb1676b build resourcePolicies map
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 10:53:54 +02:00
Dmitri Dolguikh
600b0c752b also return a net resource to groups index from GetGroups call
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-01 11:56:17 +02:00
Dmitri Dolguikh
b02736adc3 cleanup network routers retrieval
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-01 11:54:14 +02:00
Dmitri Dolguikh
5d6117d2c0 adding network_map_data: compute Routers field
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-31 17:56:52 +02:00
Dmitri Dolguikh
c2f8360b00 adding support for bulding of network_map_data
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-31 15:58:40 +02:00
Dmitri Dolguikh
ea1b4d56e8 added support for queries via pgx connection
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-31 15:03:15 +02:00
Dmitri Dolguikh
acbe22b831 small cleanups
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-31 13:41:34 +02:00
Dmitri Dolguikh
3b5c8e2298 started moving pg-specific nmap tests to integration_tests/management/network_map_db/pgsql
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-31 12:29:35 +02:00
Dmitri Dolguikh
2baeb4bc0d clean up sql types conversion test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 19:35:11 +02:00
Dmitri Dolguikh
4bf75fdc97 support for posture-checks
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 18:00:58 +02:00
Dmitri Dolguikh
799f3a3c62 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 16:03:46 +02:00
Dmitri Dolguikh
129736ad61 support for account settings
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 16:03:19 +02:00
pascal
f7be9c4347 legacy network mal and equivalence test 2026-07-30 15:45:15 +02:00
Dmitri Dolguikh
e05cb5264d support for dns custom zones
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 15:34:09 +02:00
Dmitri Dolguikh
5a10561ca1 fix handling of string slices
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 11:31:53 +02:00
Dmitri Dolguikh
42ce83a8f3 added support for account network
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-29 18:57:08 +02:00
Dmitri Dolguikh
e620c86cd4 added support for networkrouters
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-29 18:32:51 +02:00
Dmitri Dolguikh
9dee2d60b9 added support for networkresources
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-29 18:19:34 +02:00
Dmitri Dolguikh
4525014632 support for nameservergroups
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-29 17:59:44 +02:00
Dmitri Dolguikh
23a5c0de4b added support for routes
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-29 17:37:00 +02:00
Dmitri Dolguikh
25e882004f added retrieval of policies
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-28 18:06:28 +02:00
Dmitri Dolguikh
15003258d2 networkmap read-only interface for pgsql
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-28 16:46:45 +02:00
Dmitri Dolguikh
2af3a5fba5 do not send resource policies map over the wire
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-27 19:00:30 +02:00
pascal
a6603a2e0a have explicit network map data type and do calculation from there 2026-07-27 15:17:30 +02:00
pascal
7ed3737cda revert component types 2026-07-24 15:16:55 +02:00
196 changed files with 11782 additions and 7051 deletions

View File

@@ -92,11 +92,6 @@ nfpms:
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
# Names the polkit action for the elevation prompt the app raises when an
# unprivileged user changes a privileged setting; without it the dialog
# shows a raw command line.
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
dependencies:
- netbird (>= 0.75.0)
- libgtk-4-1 (>= 4.14)
@@ -120,11 +115,6 @@ nfpms:
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
# Names the polkit action for the elevation prompt the app raises when an
# unprivileged user changes a privileged setting; without it the dialog
# shows a raw command line.
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
dependencies:
- netbird >= 0.75.0
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)

View File

@@ -27,8 +27,8 @@ import (
const errCloseConnection = "Failed to close connection: %v"
var (
logFileCount uint32
systemInfoFlag bool
logFileCount uint32
systemInfoFlag bool
uploadBundleFlag bool
uploadBundleURLFlag string
uploadBundleInsecureFlag bool

View File

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

View File

@@ -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"
"github.com/netbirdio/netbird/client/proto"
nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/proto"
"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)
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil)
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
require.NoError(t, err)

View File

@@ -1,17 +0,0 @@
package daemonaddr
import "strings"
// CarriesIdentity reports whether the control channel at addr conveys the
// connecting process's identity to the daemon. A Unix socket carries peer
// credentials and a named pipe carries the client's token. Nothing else does, TCP
// included, and there the daemon can authorize a privileged operation for nobody
// at all: see ResolveDaemonAddr, which says as much to anyone still reaching the
// Windows daemon on the address it served before it had a pipe.
//
// A client uses this to tell whether becoming privileged would get it anywhere.
// It answers from the scheme and nothing else, so an address it does not
// recognise counts as carrying no identity.
func CarriesIdentity(addr string) bool {
return strings.HasPrefix(addr, "unix://") || strings.HasPrefix(addr, pipeScheme)
}

View File

@@ -1,29 +0,0 @@
package daemonaddr
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCarriesIdentity(t *testing.T) {
tests := []struct {
addr string
want bool
}{
{"unix:///var/run/netbird.sock", true},
{"unix:///var/run/netbird/default.sock", true},
{"npipe://netbird", true},
{`npipe://\\.\pipe\ProtectedPrefix\Administrators\netbird`, true},
{"tcp://127.0.0.1:41731", false},
{"tcp://localhost:41731", false},
{"", false},
{"/var/run/netbird.sock", false},
}
for _, tt := range tests {
t.Run(tt.addr, func(t *testing.T) {
assert.Equal(t, tt.want, CarriesIdentity(tt.addr), "address %q", tt.addr)
})
}
}

View File

@@ -1,74 +0,0 @@
// Package elevate re-runs this very executable under the operating system's own
// privilege-elevation mechanism and waits for it to finish.
//
// It exists so that a change the daemon restricts to root/administrator can be
// authorized from the GUI, by the user, at the moment they ask for it: Windows
// shows the UAC consent dialog, macOS the system authentication dialog, and
// Linux/FreeBSD the session's polkit agent. The credentials, where any are
// asked for, are collected by the operating system and never pass through
// NetBird.
//
// What the elevated process then does is the caller's business: it is the same
// binary, in a one-shot mode, and it is authorized by the daemon exactly like
// any other privileged caller, from the identity the kernel reports on the
// control channel. Nothing here grants privilege, and the daemon gains no new
// way to be talked into something: elevation only changes who is calling it.
package elevate
import (
"context"
"errors"
log "github.com/sirupsen/logrus"
)
// AppliedMarker is what the elevated process prints on standard output once it has
// done what it was run for.
//
// macOS's AuthorizationExecuteWithPrivileges reports no exit status and does not
// say which process it started, so there this line is the only evidence that the
// change was applied. The other platforms have an exit code and ignore it.
const AppliedMarker = "netbird-elevated: applied"
var (
// ErrDeclined reports that the user dismissed the prompt or did not
// authenticate. Nothing happened and nothing is wrong: a caller undoes its
// optimistic update and stays quiet.
ErrDeclined = errors.New("authorization declined")
// ErrUnavailable reports that this host has no elevation mechanism we can
// drive: no polkit on a Unix desktop, or an executable we decline to run as
// root. A caller falls back to telling the user which command to run.
ErrUnavailable = errors.New("no privilege elevation mechanism available")
)
// Run runs this executable with args under the platform's elevation mechanism
// and waits for it to exit. A non-zero exit is returned as an error, so the
// caller can treat a completed Run as the operation having succeeded.
//
// The args are the caller's own command line, so they cross no privilege
// boundary: only a user who has just authenticated as an administrator can get
// them run at all.
func Run(ctx context.Context, args ...string) error {
self, err := trustedSelf()
if err != nil {
return err
}
return run(ctx, self, args)
}
// Available reports whether Run has a mechanism to use on this host, so a caller
// can offer the prompt only when there is one and otherwise fall back to
// guidance the user can act on. It answers from what is installed, not from what
// the user is allowed to do: an administrator's password may still be required
// and may still not be given, which is ErrDeclined from Run.
func Available() bool {
if _, err := trustedSelf(); err != nil {
// Worth a line: this is also what a build run from a group-writable
// directory hits, and there is nothing in the UI to say why the offer is
// missing.
log.Debugf("not offering privilege elevation: %v", err)
return false
}
return mechanismAvailable()
}

View File

@@ -1,18 +0,0 @@
package elevate
import "strings"
// noOutput stands in for a process that said nothing, so that a report of what it
// said still reads as a sentence.
const noOutput = "no output"
func firstLine(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return noOutput
}
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}

View File

@@ -1,21 +0,0 @@
package elevate
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFirstLine(t *testing.T) {
tests := []struct{ in, want string }{
{in: "", want: noOutput},
{in: " \n ", want: noOutput},
{in: "one line", want: "one line"},
{in: "first\nsecond", want: "first"},
{in: "\nsecond\n", want: "second"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, firstLine(tt.in), "input %q", tt.in)
}
}

View File

@@ -1,359 +0,0 @@
package elevate
import (
"context"
"errors"
"fmt"
"os"
"runtime"
"strings"
"sync"
"syscall"
"unsafe"
"github.com/ebitengine/purego"
log "github.com/sirupsen/logrus"
)
// Authorization Services, reached through purego rather than cgo so the released
// binaries keep building with CGO_ENABLED=0.
//
// The prompt belongs to this process, which is what makes it carry the
// application's name and our own explanation. Going through osascript instead puts
// the very same trampoline behind a dialog attributed to osascript, and means
// handing a shell a command line to re-parse.
//
// # On AuthorizationExecuteWithPrivileges
//
// It is deprecated, and Apple's guidance (Quinn, "BSD Privilege Escalation on
// macOS", developer.apple.com/forums/thread/708765) is "while it still works, it's
// been deprecated for many years. Do not use it in a widely distributed product."
// It is used here anyway, knowingly, because the alternatives Apple offers are for
// *obtaining* ongoing privileges — an installer package, SMAppService, SMJobBless —
// and NetBird already has what they would install: a launchd daemon running as
// root. What is missing is only a way for an unprivileged client to ask it to act.
//
// The way to that without a deprecated call is to authorize the client instead of
// elevating one: the app takes the right with AuthorizationCreate, passes the
// AuthorizationExternalForm to the daemon, and the daemon checks it with
// AuthorizationCopyRights before acting — none of which is deprecated. It is the
// better design and it is where this should end up. It also means the daemon
// accepting an authorization over its control socket, which is a new way to be
// asked for privileged work and wants reviewing as such, so it is deliberately not
// bundled in with the rest of this.
//
// Until then, three things keep the deprecation from being a trap. Every symbol is
// resolved with an error rather than a panic, so a macOS that has dropped this
// function leaves the app offering the user a command instead of crashing on the
// way to a prompt. A failure to run the tool is reported as ErrUnavailable, so the
// fallback is the same one an agent-less Linux session gets. And the whole path
// runs under guard, which turns a panic out of the FFI layer into that same
// fallback.
//
// The trampoline passes on the environment it was given, so what it starts as root
// must be an executable this user's peers cannot influence: that is what
// trustedSelf refuses, and what signing the binary settles for the loader.
const (
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
libSystem = "/usr/lib/libSystem.B.dylib"
// trampoline is what the framework hands the tool to. Present on every macOS,
// and worth confirming before offering a prompt rather than mid-prompt.
trampoline = "/usr/libexec/security_authtrampoline"
)
// rightExecute is the right an administrator holds, and what
// AuthorizationExecuteWithPrivileges requires of us.
const rightExecute = "system.privilege.admin"
// promptKey is kAuthorizationEnvironmentPrompt, which puts a sentence of ours above
// the system's in the dialog. It is about the change rather than the mechanism.
const (
promptKey = "prompt"
promptText = "NetBird needs to change a setting that grants SSH access to this computer."
)
// OSStatus values from SecBase.h that mean something to us; anything else is
// reported as it comes.
const (
errAuthorizationSuccess = 0
errAuthorizationDenied = -60005
errAuthorizationCanceled = -60006
errAuthorizationInteractionNotAllowed = -60007
errAuthorizationToolExecuteFailure = -60031
errAuthorizationToolEnvironmentError = -60032
)
// AuthorizationFlags from Authorization.h.
const (
flagDefaults = 0
flagInteractionAllowed = 1 << 0
flagExtendRights = 1 << 1
flagDestroyRights = 1 << 3
flagPreAuthorize = 1 << 4
)
// authorizationItem mirrors AuthorizationItem: a name, and a value the name gives
// meaning to. 32 bytes on both amd64 and arm64.
type authorizationItem struct {
name *byte
valueLength uintptr
value unsafe.Pointer
// flags is reserved by the API and always zero. Declared because the layout
// is the contract: without it the struct is 24 bytes where C reads 32.
flags uint32 //nolint:unused // part of the C layout
}
// authorizationItemSet mirrors AuthorizationItemSet, which serves as both an
// AuthorizationRights and an AuthorizationEnvironment.
type authorizationItemSet struct {
count uint32
items *authorizationItem
}
var (
authorizationCreate func(rights, environment *authorizationItemSet, flags uint32, authorization *uintptr) int32
authorizationExecuteWithPrivileges func(authorization uintptr, pathToTool string, options uint32, arguments *uintptr, communicationsPipe *uintptr) int32
authorizationFree func(authorization uintptr, flags uint32) int32
fileno func(stream uintptr) int32
fclose func(stream uintptr) int32
loadOnce sync.Once
loadErr error
)
// load resolves the functions once. A framework that cannot be opened, or a symbol
// that is no longer there, leaves the host without a mechanism rather than taking
// the process down with it: see the note on deprecation above.
func load() error {
loadOnce.Do(func() { loadErr = guard("loading Security.framework", resolve) })
return loadErr
}
// guard turns a panic out of the FFI layer into an error, so an API that has
// changed under us costs the user a prompt rather than the window they were
// clicking in. purego panics on a signature it cannot map, and this is the one
// place in the client that calls a deprecated system function.
//
// It catches Go panics, which is what purego raises. A fault inside the framework
// itself is not a panic and not recoverable; the layout the tests pin down is what
// stands between us and that.
func guard(what string, fn func() error) (err error) {
defer func() {
r := recover()
if r == nil {
return
}
log.Errorf("%s panicked: %v", what, r)
err = fmt.Errorf("%w: %s: %v", ErrUnavailable, what, r)
}()
return fn()
}
func resolve() error {
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", securityFramework, err)
}
system, err := purego.Dlopen(libSystem, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", libSystem, err)
}
// purego.RegisterLibFunc panics on a symbol it cannot find, which is not how a
// deprecated function's disappearance should reach the user.
for _, fn := range []struct {
ptr any
handle uintptr
name string
}{
{&authorizationCreate, security, "AuthorizationCreate"},
{&authorizationExecuteWithPrivileges, security, "AuthorizationExecuteWithPrivileges"},
{&authorizationFree, security, "AuthorizationFree"},
{&fileno, system, "fileno"},
{&fclose, system, "fclose"},
} {
symbol, err := purego.Dlsym(fn.handle, fn.name)
if err != nil {
return fmt.Errorf("resolve %s: %w", fn.name, err)
}
if symbol == 0 {
return fmt.Errorf("resolve %s: not present on this system", fn.name)
}
purego.RegisterFunc(fn.ptr, symbol)
}
return nil
}
// run asks the system to run self as root: first for the right, which is what puts
// up the authentication dialog and collects the password or takes the Touch ID,
// then for the tool. The credentials go to the system's authorization trampoline
// and never to us.
//
// The context bounds only our own waiting; the dialog belongs to the system and
// closes when the user answers it.
func run(ctx context.Context, self string, args []string) error {
if err := load(); err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return guard("asking for privileges", func() error {
authorization, err := authorize()
if err != nil {
return err
}
defer authorizationFree(authorization, flagDestroyRights)
return execute(ctx, authorization, self, args)
})
}
func mechanismAvailable() bool {
if err := load(); err != nil {
return false
}
info, err := os.Stat(trampoline)
return err == nil && !info.IsDir()
}
// authorize obtains the right, prompting for it. A dismissed dialog comes back as
// errAuthorizationCanceled and a password given up on as errAuthorizationDenied;
// both are the user's answer rather than a failure.
func authorize() (uintptr, error) {
var pinner runtime.Pinner
defer pinner.Unpin()
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
environment := itemSet(&pinner, promptItem(&pinner))
var authorization uintptr
status := authorizationCreate(rights, environment,
flagDefaults|flagInteractionAllowed|flagPreAuthorize|flagExtendRights, &authorization)
switch status {
case errAuthorizationSuccess:
return authorization, nil
case errAuthorizationCanceled, errAuthorizationDenied:
return 0, ErrDeclined
case errAuthorizationInteractionNotAllowed:
// Nowhere to put a dialog, so there is nobody to ask: a launch daemon, or
// a session with no window server.
return 0, fmt.Errorf("%w: this session cannot show an authorization prompt", ErrUnavailable)
default:
return 0, fmt.Errorf("request %s: OSStatus %d", rightExecute, status)
}
}
// execute runs the tool with the right in hand and waits for it by reading the pipe
// it is given until the tool closes it.
//
// AuthorizationExecuteWithPrivileges reports no exit status and does not say what
// process it started, which is why the one-shot says so itself: what it prints is
// the only evidence that the change was applied.
func execute(ctx context.Context, authorization uintptr, self string, args []string) error {
var pinner runtime.Pinner
defer pinner.Unpin()
argv := make([]uintptr, 0, len(args)+1)
for _, arg := range args {
argv = append(argv, uintptr(unsafe.Pointer(cString(&pinner, arg))))
}
argv = append(argv, 0)
pinner.Pin(&argv[0])
var pipe uintptr
status := authorizationExecuteWithPrivileges(authorization, self, flagDefaults, &argv[0], &pipe)
switch status {
case errAuthorizationSuccess:
case errAuthorizationCanceled:
return ErrDeclined
case errAuthorizationToolExecuteFailure, errAuthorizationToolEnvironmentError:
// The right was granted and the tool still did not start. Nothing the user
// can do about it from here, so point them at the command instead.
return fmt.Errorf("%w: the system would not run %s elevated (OSStatus %d)", ErrUnavailable, self, status)
default:
return fmt.Errorf("run %s elevated: OSStatus %d", self, status)
}
out, err := readPipe(ctx, pipe)
if err != nil {
return err
}
return checkApplied(out)
}
// checkApplied reads the one-shot's report, which stands in for the exit status
// there is no way to ask for here. A run that said nothing did not apply the
// change, whatever else went on.
func checkApplied(out string) error {
if !strings.Contains(out, AppliedMarker) {
return fmt.Errorf("elevated netbird did not report the change as applied: %s", firstLine(out))
}
return nil
}
// readPipe drains the tool's output, which ends when the tool exits and is
// therefore also how we wait for it.
func readPipe(ctx context.Context, pipe uintptr) (string, error) {
if pipe == 0 {
return "", nil
}
defer fclose(pipe)
fd := int(fileno(pipe))
if fd < 0 {
return "", nil
}
var out strings.Builder
buf := make([]byte, 4096)
for {
if err := ctx.Err(); err != nil {
return out.String(), err
}
n, err := syscall.Read(fd, buf)
if n > 0 {
out.Write(buf[:n])
}
switch {
case errors.Is(err, syscall.EINTR):
// A signal landed mid-read, which says nothing about the tool.
continue
case err != nil:
log.Debugf("read the elevated process's output: %v", err)
return out.String(), nil
case n <= 0:
// End of file: the tool closed the pipe, which is how it exiting
// reaches us.
return out.String(), nil
}
}
}
// itemSet builds an AuthorizationItemSet over items, pinned for the call.
func itemSet(pinner *runtime.Pinner, items ...authorizationItem) *authorizationItemSet {
pinner.Pin(&items[0])
set := &authorizationItemSet{count: uint32(len(items)), items: &items[0]}
pinner.Pin(set)
return set
}
// promptItem is the environment entry carrying our sentence for the dialog.
func promptItem(pinner *runtime.Pinner) authorizationItem {
value := []byte(promptText)
pinner.Pin(&value[0])
return authorizationItem{
name: cString(pinner, promptKey),
valueLength: uintptr(len(value)),
value: unsafe.Pointer(&value[0]),
}
}
// cString returns a NUL-terminated copy of s, pinned so the C side may hold it for
// the duration of the call.
func cString(pinner *runtime.Pinner, s string) *byte {
b := append([]byte(s), 0)
pinner.Pin(&b[0])
return &b[0]
}

View File

@@ -1,111 +0,0 @@
package elevate
import (
"errors"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The framework has to load and the symbols have to resolve, or nothing else here
// means anything.
func TestSecurityFrameworkLoads(t *testing.T) {
require.NoError(t, load(), "Security.framework must open")
for name, fn := range map[string]any{
"AuthorizationCreate": authorizationCreate,
"AuthorizationExecuteWithPrivileges": authorizationExecuteWithPrivileges,
"AuthorizationFree": authorizationFree,
"fileno": fileno,
"fclose": fclose,
} {
assert.NotNil(t, fn, "%s must resolve", name)
}
}
// A request with no interaction allowed exercises the whole call — the rights and
// environment structs, and the OSStatus that comes back — without a dialog anybody
// has to answer. What the system decides is its business; that it decides at all is
// what this asserts.
func TestAuthorizationCreateWithoutInteraction(t *testing.T) {
if err := load(); err != nil {
t.Skipf("Security.framework did not open: %v", err)
}
var pinner runtime.Pinner
defer pinner.Unpin()
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
environment := itemSet(&pinner, promptItem(&pinner))
require.EqualValues(t, 1, rights.count, "the rights struct layout must match the C one")
var authorization uintptr
status := authorizationCreate(rights, environment, flagDefaults|flagExtendRights, &authorization)
switch status {
case errAuthorizationSuccess:
// Credentials were already cached for this session.
authorizationFree(authorization, flagDestroyRights)
case errAuthorizationDenied, errAuthorizationInteractionNotAllowed:
// The expected answers when nobody may be asked.
default:
require.Failf(t, "unknown OSStatus", "AuthorizationCreate returned %d, want a status we recognise", status)
}
}
// Asking with a right nobody has must not be mistaken for a declined prompt: the
// caller would report nothing at all.
func TestAuthorizeUnknownRightIsNotDeclined(t *testing.T) {
if err := load(); err != nil {
t.Skipf("Security.framework did not open: %v", err)
}
var pinner runtime.Pinner
defer pinner.Unpin()
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, "io.netbird.right.that.does.not.exist")})
var authorization uintptr
status := authorizationCreate(rights, nil, flagDefaults|flagExtendRights, &authorization)
if status == errAuthorizationSuccess {
authorizationFree(authorization, flagDestroyRights)
}
assert.NotEqual(t, int32(errAuthorizationSuccess), status, "a right that does not exist must not be granted")
}
func TestMechanismAvailable(t *testing.T) {
assert.True(t, mechanismAvailable(), "the trampoline exists on every macOS")
}
// The one-shot's report is what stands in for an exit status here, so a run that
// says nothing must not read as success.
func TestCheckApplied(t *testing.T) {
require.NoError(t, checkApplied(AppliedMarker+"\n"), "the report the one-shot prints")
require.NoError(t, checkApplied("some warning\n"+AppliedMarker+"\n"), "the report after other output")
assert.Error(t, checkApplied(""), "a run that printed nothing did not apply the change")
assert.Error(t, checkApplied("dyld: library not loaded\n"), "output that is not the report")
}
// A panic out of the FFI layer has to reach the caller as "no mechanism", which is
// the outcome that offers the user the command instead of taking the window down.
func TestGuardTurnsAPanicIntoUnavailable(t *testing.T) {
err := guard("pretending to call something", func() error {
panic("purego: signature it cannot map")
})
require.ErrorIs(t, err, ErrUnavailable, "a panic must read as a missing mechanism")
assert.Contains(t, err.Error(), "pretending to call something", "what panicked")
}
// guard wraps every darwin path, so what a caller switches on has to survive it.
func TestGuardPassesErrorsThrough(t *testing.T) {
sentinel := errors.New("the call itself failed")
assert.ErrorIs(t, guard("calling", func() error { return sentinel }), sentinel,
"the error it was given")
assert.ErrorIs(t, guard("calling", func() error { return ErrDeclined }), ErrDeclined,
"a declined prompt stays declined")
assert.NoError(t, guard("calling", func() error { return nil }), "a call that worked")
}

View File

@@ -1,117 +0,0 @@
//go:build linux
package elevate
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
// pkexec exit codes that are about the authorization rather than about the program
// we asked it to run. The manual page reserves both.
const (
// exitDismissed is returned when the user dismissed the authentication
// dialog.
exitDismissed = 126
// exitNotAuthorized is returned when the authorization was not obtained. That
// covers the user saying no as well as pkexec having had nobody to ask: see
// noAgentMarkers.
exitNotAuthorized = 127
)
// exitNotAuthorized covers three different endings that only pkexec's own words
// tell apart, so they are matched here. Read with LC_ALL=C so the words are the
// ones written below.
//
// refusedMarker is a refusal: the user said no, gave up on the password, or holds
// an account that may not elevate at all.
const refusedMarker = "Not authorized"
// noAgentMarkers say pkexec had no way to ask: no agent registered for the
// session, and no controlling terminal for the textual agent it falls back to.
var noAgentMarkers = []string{"authentication agent", "controlling terminal"}
// run asks polkit to run self as root. pkexec hands the request to the session's
// polkit agent, which is what prompts and what collects any password; we see only
// its verdict.
//
// The environment is otherwise deliberately not passed through: pkexec clears it
// bar a small allowlist, and the one-shot needs nothing from it.
func run(ctx context.Context, self string, args []string) error {
pkexec, err := exec.LookPath("pkexec")
if err != nil {
return fmt.Errorf("%w: pkexec is not installed", ErrUnavailable)
}
cmd := exec.CommandContext(ctx, pkexec, append([]string{self}, args...)...)
// C locale so pkexec's own diagnostics are the ones noAgentMarkers knows.
cmd.Env = append(os.Environ(), "LC_ALL=C")
var stderr strings.Builder
cmd.Stderr = &stderr
// The one-shot reports itself on stdout for macOS's sake, where there is no
// exit status to read. Here there is one, so that line is noise.
cmd.Stdout = io.Discard
err = cmd.Run()
if err == nil {
return nil
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
return fmt.Errorf("run pkexec: %w", err)
}
// Matched against everything pkexec said, reported as one line: a complaint
// that is not the first thing printed still has to be recognised, and reading
// it as a refusal would swallow it.
full := stderr.String()
out := firstLine(full)
switch exitErr.ExitCode() {
case exitDismissed:
return ErrDeclined
case exitNotAuthorized:
return notAuthorized(full, out)
default:
return fmt.Errorf("elevated netbird exited with %d: %s", exitErr.ExitCode(), out)
}
}
// notAuthorized sorts out the three endings pkexec reports as exitNotAuthorized.
//
// It also returns that code when the authorization succeeded and it then could
// not run the program, so a refusal has to be recognised rather than assumed:
// reading every one of these as "the user said no" would revert the control in
// silence on a host where elevation is broken.
func notAuthorized(full, out string) error {
switch {
case hasAny(full, noAgentMarkers):
return fmt.Errorf("%w: polkit had no way to ask: %s", ErrUnavailable, out)
case out == noOutput, strings.Contains(full, refusedMarker):
// The user said no, which needs no message; that an account barred from
// elevating altogether lands here too is why the reason is kept.
return fmt.Errorf("%w: %s", ErrDeclined, out)
default:
return fmt.Errorf("pkexec could not run elevated netbird: %s", out)
}
}
func hasAny(s string, markers []string) bool {
for _, marker := range markers {
if strings.Contains(s, marker) {
return true
}
}
return false
}
func mechanismAvailable() bool {
_, err := exec.LookPath("pkexec")
return err == nil
}

View File

@@ -1,110 +0,0 @@
//go:build linux
package elevate
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakePkexec puts a pkexec on PATH that exits with the given code, so the
// mapping from polkit's exit codes onto our errors can be exercised without a
// polkit agent.
func fakePkexec(t *testing.T, exitCode int, stderr string) {
t.Helper()
dir := t.TempDir()
script := fmt.Sprintf("#!/bin/sh\necho %s >&2\nexit %d\n", shellQuote(stderr), exitCode)
require.NoError(t, os.WriteFile(filepath.Join(dir, "pkexec"), []byte(script), 0o700), "write the fake pkexec")
t.Setenv("PATH", dir)
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func TestRunMapsPkexecExitCodes(t *testing.T) {
tests := []struct {
name string
exitCode int
stderr string
wantErr error
}{
{name: "applied", exitCode: 0},
{
name: "dialog dismissed",
exitCode: exitDismissed,
stderr: "Error executing command as another user: Request dismissed",
wantErr: ErrDeclined,
},
{
// What a graphical agent reports for a cancelled prompt. Not a
// failure: the user was asked and answered.
name: "prompt cancelled",
exitCode: exitNotAuthorized,
stderr: "Error executing command as another user: Not authorized",
wantErr: ErrDeclined,
},
{
// The same status, but pkexec never got to ask anybody.
name: "no agent and no terminal to fall back on",
exitCode: exitNotAuthorized,
stderr: "Error creating textual authentication agent: Error opening current controlling terminal for the process (`/dev/tty'): No such device or address",
wantErr: ErrUnavailable,
},
{
// And the same status again once the authorization succeeded and
// pkexec could not run what it had been authorized to run. Reading
// that as a refusal would revert the control in silence on a host
// where elevation is broken.
name: "authorized but not runnable",
exitCode: exitNotAuthorized,
stderr: "Error executing command as another user: No such file or directory",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakePkexec(t, tt.exitCode, tt.stderr)
err := run(context.Background(), "/nonexistent/netbird-ui", []string{"--flag"})
switch {
case tt.wantErr != nil:
require.ErrorIs(t, err, tt.wantErr, "exit %d said %q", tt.exitCode, tt.stderr)
case tt.exitCode == 0:
require.NoError(t, err, "a pkexec that exited cleanly applied the change")
default:
require.Error(t, err, "exit %d said %q", tt.exitCode, tt.stderr)
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
}
})
}
}
// An exit code that is not polkit's is the one-shot's own failure, and has to
// stay distinguishable from a declined prompt: the caller reports it.
func TestRunReportsOneShotFailure(t *testing.T) {
fakePkexec(t, 3, "the one-shot said no")
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
require.Error(t, err, "a one-shot that failed is not a prompt that was answered")
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
}
func TestRunWithoutPkexecIsUnavailable(t *testing.T) {
t.Setenv("PATH", t.TempDir())
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
require.ErrorIs(t, err, ErrUnavailable, "no pkexec means no mechanism")
assert.False(t, mechanismAvailable(), "mechanismAvailable without pkexec on PATH")
}

View File

@@ -1,19 +0,0 @@
//go:build !windows && !darwin && !linux
package elevate
import "context"
// run reports that this platform has no elevation prompt to drive.
//
// The desktop app is the only caller and is not built for any of these: mobile
// and WASM have no local user to ask, and the FreeBSD client ships without a UI.
// pkexec would be the mechanism there, and run_unix.go is what to widen if that
// changes.
func run(context.Context, string, []string) error {
return ErrUnavailable
}
func mechanismAvailable() bool {
return false
}

View File

@@ -1,193 +0,0 @@
package elevate
import (
"context"
"errors"
"fmt"
"runtime"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
// seeMaskNoCloseProcess keeps the started process's handle open in
// hProcess so we can wait for it.
seeMaskNoCloseProcess = 0x00000040
// seeMaskNoAsync makes ShellExecuteExW finish its work before returning,
// which it must when the calling thread does not pump messages.
seeMaskNoAsync = 0x00000100
// seeMaskFlagNoUI suppresses the shell's own error dialogs; the UAC consent
// dialog is not one of them and still appears.
seeMaskFlagNoUI = 0x00000400
// swHide: the one-shot has no window to show.
swHide = 0
// sFalse (S_FALSE) answers CoInitializeEx when COM is already up on this
// thread in the mode we asked for; rpcChangedMode (RPC_E_CHANGED_MODE) when
// it is up in the other one.
sFalse = 1
rpcChangedMode = 0x80010106
)
// shellExecuteInfoW mirrors SHELLEXECUTEINFOW. The field order and Go's own
// padding match the C layout on both 386 and amd64.
type shellExecuteInfoW struct {
cbSize uint32
fMask uint32
hwnd windows.HWND
lpVerb *uint16
lpFile *uint16
lpParameters *uint16
lpDirectory *uint16
nShow int32
hInstApp windows.Handle
lpIDList uintptr
lpClass *uint16
hkeyClass windows.Handle
dwHotKey uint32
hIconOrMonitor windows.Handle
hProcess windows.Handle
}
var (
shell32 = windows.NewLazySystemDLL("shell32.dll")
procShellExecuteEx = shell32.NewProc("ShellExecuteExW")
)
// run starts self elevated with the "runas" verb, which is what raises the UAC
// consent dialog, and waits for it to finish. Windows decides whether consent is
// enough or an administrator's credentials are needed, and collects them itself.
func run(ctx context.Context, self string, args []string) error {
verb, err := windows.UTF16PtrFromString("runas")
if err != nil {
return fmt.Errorf("encode verb: %w", err)
}
file, err := windows.UTF16PtrFromString(self)
if err != nil {
return fmt.Errorf("encode %s: %w", self, err)
}
params, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args))
if err != nil {
return fmt.Errorf("encode arguments: %w", err)
}
info := shellExecuteInfoW{
fMask: seeMaskNoCloseProcess | seeMaskNoAsync | seeMaskFlagNoUI,
hwnd: ownerWindow(),
lpVerb: verb,
lpFile: file,
lpParameters: params,
nShow: swHide,
}
info.cbSize = uint32(unsafe.Sizeof(info))
process, err := shellExecute(&info)
if err != nil {
return err
}
defer func() {
if err := windows.CloseHandle(process); err != nil {
log.Debugf("close elevated process handle: %v", err)
}
}()
return waitForProcess(ctx, process)
}
// shellExecute performs the call itself. ShellExecuteExW wants COM initialised on
// the calling thread, so the goroutine is pinned to one for the duration and COM
// is set up on it; an "already initialised, different mode" answer is fine,
// because then somebody else has done it for us.
func shellExecute(info *shellExecuteInfoW) (windows.Handle, error) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
switch err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); {
case err == nil, isHResult(err, sFalse):
// Ours, or already initialised in the same mode: either way this call
// counts and has to be balanced.
defer windows.CoUninitialize()
case isHResult(err, rpcChangedMode):
// The thread is already in the other apartment model. ShellExecuteExW
// works there too, and there is nothing of ours to balance.
default:
return 0, fmt.Errorf("initialise COM: %w", err)
}
ret, _, lastErr := procShellExecuteEx.Call(uintptr(unsafe.Pointer(info)))
if ret != 0 {
return info.hProcess, nil
}
if errors.Is(lastErr, windows.ERROR_CANCELLED) {
return 0, ErrDeclined
}
return 0, fmt.Errorf("run elevated: %w", lastErr)
}
// ownerWindow returns this process's foreground window, and 0 when the window in
// front belongs to somebody else or cannot be attributed. ShellExecuteExW takes it
// as the parent for the UI it raises, which is what keeps the consent dialog in
// front of the window the user was just clicking in instead of behind it. It is
// also what a remote-desktop session needs to place the dialog at all when the
// secure desktop is switched off.
func ownerWindow() windows.HWND {
hwnd := windows.GetForegroundWindow()
if hwnd == 0 {
return 0
}
var pid uint32
if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil {
log.Debugf("cannot attribute the foreground window, raising the prompt without an owner: %v", err)
return 0
}
if pid != windows.GetCurrentProcessId() {
return 0
}
return hwnd
}
// isHResult reports whether err carries the given HRESULT. CoInitializeEx
// returns its HRESULT as an Errno, so the comparison is on the raw value.
func isHResult(err error, hresult uintptr) bool {
var errno windows.Errno
return errors.As(err, &errno) && uintptr(errno) == hresult
}
func waitForProcess(ctx context.Context, process windows.Handle) error {
// The wait is interruptible so a cancelled context stops us waiting on a
// consent dialog nobody is going to answer. The elevated process is not
// ours to kill, and it either applies the change or does not.
for {
event, err := windows.WaitForSingleObject(process, 250)
if err != nil {
return fmt.Errorf("wait for the elevated process: %w", err)
}
if event == uint32(windows.WAIT_OBJECT_0) {
break
}
if err := ctx.Err(); err != nil {
return err
}
}
var code uint32
if err := windows.GetExitCodeProcess(process, &code); err != nil {
return fmt.Errorf("read the elevated process's exit code: %w", err)
}
if code != 0 {
return fmt.Errorf("elevated netbird exited with %d", code)
}
return nil
}
// mechanismAvailable is true on Windows: UAC prompts for consent when the user
// is an administrator and for an administrator's credentials when they are not,
// so there is always something to ask.
func mechanismAvailable() bool {
return true
}

View File

@@ -1,40 +0,0 @@
package elevate
import (
"fmt"
"os"
"path/filepath"
)
// trustedSelf returns the path of this executable, provided it is one we are
// willing to have run as root.
//
// The check is what keeps elevation from becoming a way to launder someone
// else's code into a root process: the user consents to NetBird being elevated,
// having been shown NetBird's name, so what runs must be the file NetBird was
// installed as and not something a third party could have swapped for it. An
// executable only its owner can write is that; anything wider is refused, and
// the caller falls back to showing the command instead.
//
// The owner writing to their own executable is not part of that threat: code
// running as the user can already prompt them for anything, and could just as
// well ask them to run the command by hand. What matters is that no *other*
// unprivileged account can reach it.
func trustedSelf() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("locate this executable: %w", err)
}
// Resolve symlinks so the checks below apply to the file that would actually
// be executed, not to a link somebody else may control.
resolved, err := filepath.EvalSymlinks(exe)
if err != nil {
return "", fmt.Errorf("resolve %s: %w", exe, err)
}
if err := checkOnlyOwnerWritable(resolved); err != nil {
return "", fmt.Errorf("%w: %s cannot be trusted to run as root: %w", ErrUnavailable, resolved, err)
}
return resolved, nil
}

View File

@@ -1,10 +0,0 @@
package elevate
// adminWriteGIDs are the groups whose write access to an executable does not
// widen who could authorize elevating it.
//
// macOS installs applications as root:admin, mode 0775, /Applications included,
// so requiring owner-only write would reject every normal install. Group admin
// (gid 80) is exactly the set of accounts that can answer the authentication
// dialog, so its write access grants nothing the prompt would not.
var adminWriteGIDs = []uint32{0, 80}

View File

@@ -1,9 +0,0 @@
//go:build !windows && !darwin
package elevate
// adminWriteGIDs are the groups whose write access to an executable does not
// widen who could authorize elevating it. Only root's own group qualifies here:
// a distribution installs into root-owned directories, and there is no
// system-wide administrators group that both writes them and answers polkit.
var adminWriteGIDs = []uint32{0}

View File

@@ -1,146 +0,0 @@
//go:build !windows
package elevate
import (
"bufio"
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
)
// groupFile lists which accounts are in which group, for the membership a user
// private group's name does not state: see groupHasOtherMembers.
const groupFile = "/etc/group"
// checkOnlyOwnerWritable reports an error unless path, and every directory leading
// to it, is owned by either root or this user and writable by nobody who could not
// already act as its owner. A writable directory is as good as a writable file,
// since anything in it can be replaced, so the whole chain is checked.
func checkOnlyOwnerWritable(path string) error {
self := uint32(os.Getuid())
for dir := path; ; dir = filepath.Dir(dir) {
info, err := os.Lstat(dir)
if err != nil {
return fmt.Errorf("stat %s: %w", dir, err)
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("file ownership is unavailable on this platform")
}
if stat.Uid != 0 && stat.Uid != self {
return fmt.Errorf("%s is owned by uid %d, neither root nor this user", dir, stat.Uid)
}
if err := checkWriteBits(dir, info, stat.Uid, stat.Gid); err != nil {
return err
}
if parent := filepath.Dir(dir); parent == dir {
return nil
}
}
}
func checkWriteBits(path string, info os.FileInfo, uid, gid uint32) error {
// On a directory the sticky bit stands in for the write bits: whoever may
// write there still cannot replace an entry they do not own, which is the
// only thing that would matter to us. /tmp is the usual example.
sticky := info.IsDir() && info.Mode()&os.ModeSticky != 0
return writeBitsAllow(path, info.Mode().Perm(), sticky, groupWriteAllowed(uid, gid))
}
// writeBitsAllow decides on the permission bits alone, given whether the group's
// write access has been vouched for.
func writeBitsAllow(path string, perm os.FileMode, sticky, groupAllowed bool) error {
if sticky {
return nil
}
if perm&0o020 != 0 && !groupAllowed {
return fmt.Errorf("%s is writable by a group with members other than its owner (%v)", path, perm)
}
if perm&0o002 != 0 {
return fmt.Errorf("%s is world-writable (%v)", path, perm)
}
return nil
}
// groupWriteAllowed reports whether a group's write access to a file owned by uid
// puts it in reach of anyone who could not already act as that owner.
//
// Two ways it does not. A group in adminWriteGIDs holds the accounts that can
// answer the elevation prompt anyway. And a user private group is how Debian,
// Ubuntu and Fedora ship: their umask of 002 makes a home directory and
// everything built in it group-writable, so refusing that would refuse every
// build not installed from a package.
func groupWriteAllowed(uid, gid uint32) bool {
if slices.Contains(adminWriteGIDs, gid) {
return true
}
group, err := user.LookupGroupId(strconv.FormatUint(uint64(gid), 10))
if err != nil {
log.Debugf("cannot look up group %d, treating it as shared: %v", gid, err)
return false
}
owner, err := user.LookupId(strconv.FormatUint(uint64(uid), 10))
if err != nil {
log.Debugf("cannot look up uid %d, treating its group as shared: %v", uid, err)
return false
}
if group.Name != owner.Username {
return false
}
return !groupHasOtherMembers(groupFile, group.Name, owner.Username)
}
// groupHasOtherMembers reports whether the group lists a member besides owner.
//
// Sharing the owner's name is what a user private group is recognised by, and it
// says nothing about who is in it: a group that has since gained a member is
// still named that way, and that member can write whatever the group can. So the
// membership is read rather than assumed. A group this file does not describe,
// because it comes from LDAP or another NSS source, cannot be answered here and
// leaves the name as the only thing to go on.
func groupHasOtherMembers(path, name, owner string) bool {
file, err := os.Open(path)
if err != nil {
log.Debugf("cannot read %s for the members of group %q: %v", path, name, err)
return false
}
defer func() {
if err := file.Close(); err != nil {
log.Debugf("close %s: %v", path, err)
}
}()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
// name:password:gid:member,member
fields := strings.Split(scanner.Text(), ":")
if len(fields) < 4 || fields[0] != name {
continue
}
for member := range strings.SplitSeq(fields[3], ",") {
if member != "" && member != owner {
return true
}
}
}
if err := scanner.Err(); err != nil {
log.Debugf("read %s: %v", path, err)
}
return false
}

View File

@@ -1,177 +0,0 @@
//go:build !windows
package elevate
import (
"os"
"os/user"
"path/filepath"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ownerOnlyDir is t.TempDir() with the write bits tightened. testing creates its
// numbered directory with 0777 minus the umask, so under the common 002 umask it
// is group-writable and would fail the check under test on its own.
func ownerOnlyDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.Chmod(dir, 0o755), "tighten the temporary directory")
return dir
}
// writeExecutable creates a plain executable file, the shape trustedSelf checks.
func writeExecutable(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "netbird-ui")
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755), "write the executable")
require.NoError(t, os.Chmod(path, 0o755), "set the executable's mode")
return path
}
func TestCheckOnlyOwnerWritableAcceptsOwnerOnly(t *testing.T) {
err := checkOnlyOwnerWritable(writeExecutable(t, ownerOnlyDir(t)))
assert.NoError(t, err, "an owner-only writable executable is trustworthy")
}
func TestCheckOnlyOwnerWritableRejectsWorldWritableFile(t *testing.T) {
path := writeExecutable(t, ownerOnlyDir(t))
require.NoError(t, os.Chmod(path, 0o777), "make the executable world-writable")
assert.Error(t, checkOnlyOwnerWritable(path), "a world-writable executable must be refused")
}
// The permission policy on its own, without a filesystem to arrange: whether the
// group has been vouched for is the only thing that makes group write acceptable.
func TestWriteBitsAllow(t *testing.T) {
tests := []struct {
name string
perm os.FileMode
sticky bool
groupAllowed bool
wantErr bool
}{
{name: "owner only", perm: 0o755},
{name: "group write in a private group", perm: 0o775, groupAllowed: true},
{name: "group write in a shared group", perm: 0o775, wantErr: true},
{name: "world write", perm: 0o777, groupAllowed: true, wantErr: true},
{name: "world write on a sticky directory", perm: 0o777, sticky: true},
{name: "group write on a sticky directory", perm: 0o775, sticky: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := writeBitsAllow("/path", tt.perm, tt.sticky, tt.groupAllowed)
if tt.wantErr {
assert.Error(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
return
}
assert.NoError(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
})
}
}
// A build under a home directory on a distribution with a 002 umask, which is what
// a locally built or tarball-installed binary looks like. Its group has no members
// but its owner, so it is as good as owner-only.
//
// Whether this host is such a distribution is read from the environment rather than
// from groupWriteAllowed: asking the function under test whether to run would let
// it skip its own coverage away if it regressed to refusing everything.
func TestCheckOnlyOwnerWritableAcceptsOwnPrivateGroup(t *testing.T) {
requirePrivatePrimaryGroup(t)
dir := ownerOnlyDir(t)
path := writeExecutable(t, dir)
require.NoError(t, os.Chmod(dir, 0o775), "make the directory group-writable")
require.NoError(t, os.Chmod(path, 0o775), "make the executable group-writable")
err := checkOnlyOwnerWritable(path)
assert.NoError(t, err, "group write in the owner's own private group reaches nobody else")
}
// A group that shares its owner's name but has gained another member is no longer
// private, and its write access reaches an account that could not elevate.
func TestGroupHasOtherMembers(t *testing.T) {
tests := []struct {
name string
entry string
want bool
}{
{name: "no members", entry: "vma:x:1000:"},
{name: "only the owner", entry: "vma:x:1000:vma"},
{name: "another member", entry: "vma:x:1000:bob", want: true},
{name: "the owner and another", entry: "vma:x:1000:vma,bob", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "group")
body := "root:x:0:\n" + tt.entry + "\nsudo:x:27:vma\n"
require.NoError(t, os.WriteFile(path, []byte(body), 0o644), "write the group file")
assert.Equal(t, tt.want, groupHasOtherMembers(path, "vma", "vma"), "entry %q", tt.entry)
})
}
}
// A group file that says nothing about the group leaves the name as the only thing
// to go on, so the private-group allowance stands rather than collapsing on every
// host whose groups come from LDAP.
func TestGroupHasOtherMembersTolerantOfAnUnknownGroup(t *testing.T) {
path := filepath.Join(t.TempDir(), "group")
require.NoError(t, os.WriteFile(path, []byte("root:x:0:\n"), 0o644), "write the group file")
assert.False(t, groupHasOtherMembers(path, "vma", "vma"), "a group the file does not describe")
assert.False(t, groupHasOtherMembers(filepath.Join(t.TempDir(), "absent"), "vma", "vma"),
"no group file at all")
}
// A writable directory is as good as a writable file: whoever can write the
// directory can put a different binary at the same path.
func TestCheckOnlyOwnerWritableRejectsWritableDirectory(t *testing.T) {
dir := filepath.Join(ownerOnlyDir(t), "bin")
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
path := writeExecutable(t, dir)
require.NoError(t, os.Chmod(dir, 0o777), "make the directory world-writable")
assert.Error(t, checkOnlyOwnerWritable(path), "an executable in a world-writable directory must be refused")
}
// A sticky world-writable directory is exempt: the sticky bit is what stops one
// user replacing another's entries. /tmp is why this matters.
func TestCheckOnlyOwnerWritableAcceptsStickyDirectory(t *testing.T) {
dir := filepath.Join(ownerOnlyDir(t), "sticky")
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
path := writeExecutable(t, dir)
require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky), "make the directory sticky and world-writable")
err := checkOnlyOwnerWritable(path)
assert.NoError(t, err, "the sticky bit stops another user replacing the executable")
}
func TestCheckOnlyOwnerWritableRejectsMissingFile(t *testing.T) {
err := checkOnlyOwnerWritable(filepath.Join(ownerOnlyDir(t), "absent"))
assert.Error(t, err, "an executable that is not there must be refused")
}
// requirePrivatePrimaryGroup skips unless this user's primary group is their own,
// which is what the user-private-group allowance is about.
func requirePrivatePrimaryGroup(t *testing.T) {
t.Helper()
self, err := user.Current()
require.NoError(t, err, "look up the test user")
group, err := user.LookupGroupId(strconv.Itoa(os.Getgid()))
require.NoError(t, err, "look up the test user's primary group")
if group.Name != self.Username {
t.Skipf("the test user's primary group is %q, not their own, so there is nothing to assert here", group.Name)
}
if groupHasOtherMembers(groupFile, group.Name, self.Username) {
t.Skipf("group %q has other members, so it is not a private group", group.Name)
}
}

View File

@@ -1,215 +0,0 @@
package elevate
import (
"errors"
"fmt"
"path/filepath"
"slices"
"unsafe"
"golang.org/x/sys/windows"
)
const (
// fileDeleteChild is FILE_DELETE_CHILD, which x/sys does not define: the
// right to delete an entry of a directory without holding DELETE on it.
fileDeleteChild = 0x00000040
// accessAllowedCallbackACEType is an allow ACE with a condition appended to
// the ACCESS_ALLOWED_ACE layout, so its trustee is still at SidStart.
accessAllowedCallbackACEType = 0x9
// The allow ACE types that carry object GUIDs ahead of the trustee, so the
// SID is not at SidStart. They occur on directory-service objects rather
// than files, and are refused rather than skipped: see aceTrustee.
accessAllowedObjectACEType = 0x5
accessAllowedCallbackObjectACEType = 0xB
)
// fileWriteAccess are the rights that let a trustee rewrite or replace a file,
// or take it over and then do so.
const fileWriteAccess = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA |
windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER |
windows.GENERIC_WRITE | windows.GENERIC_ALL
// dirWriteAccess are the rights over a directory that let a trustee replace an
// entry somebody else owns. Creating a new entry is not one of them, which is
// what the Unix sticky bit says in one bit: the root of every volume grants
// BUILTIN\Users the right to add directories under it, and that reaches nothing
// already there.
const dirWriteAccess = fileDeleteChild | windows.DELETE |
windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_ALL
// trustedInstallerSID owns much of what Windows itself installs. x/sys has no
// well-known constant for it.
const trustedInstallerSID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"
// checkOnlyOwnerWritable reports an error unless path, and every directory
// leading to it, is owned by an account that can elevate (or by this user) and
// grants write access to nobody else. A writable directory is as good as a
// writable file, since an entry in it can be replaced, so the whole chain is
// checked.
func checkOnlyOwnerWritable(path string) error {
owners, err := trustedOwners()
if err != nil {
return err
}
writers, err := trustedWriters(owners)
if err != nil {
return err
}
writeAccess := windows.ACCESS_MASK(fileWriteAccess)
for target := path; ; target = filepath.Dir(target) {
if err := checkSecurity(target, writeAccess, owners, writers); err != nil {
return err
}
if parent := filepath.Dir(target); parent == target {
return nil
}
writeAccess = dirWriteAccess
}
}
// trustedOwners are the accounts we accept as the owner of the executable and of
// the directories above it: the ones that can already answer the UAC prompt,
// plus this user, whose own executable is theirs to write. Code running as the
// user could prompt them for anything anyway; what matters is that no *other*
// unprivileged account can reach it.
func trustedOwners() ([]*windows.SID, error) {
self, err := currentUserSID()
if err != nil {
return nil, err
}
owners := []*windows.SID{self}
for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{
windows.WinLocalSystemSid,
windows.WinBuiltinAdministratorsSid,
} {
sid, err := windows.CreateWellKnownSid(wellKnown)
if err != nil {
return nil, fmt.Errorf("build well-known SID %d: %w", wellKnown, err)
}
owners = append(owners, sid)
}
installer, err := windows.StringToSid(trustedInstallerSID)
if err != nil {
return nil, fmt.Errorf("parse TrustedInstaller SID: %w", err)
}
return append(owners, installer), nil
}
// trustedWriters are the trustees whose write access does not widen who could
// decide what runs behind the prompt. The owners, and CREATOR OWNER, which
// resolves to the object's owner and is therefore already vetted.
func trustedWriters(owners []*windows.SID) ([]*windows.SID, error) {
creatorOwner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid)
if err != nil {
return nil, fmt.Errorf("build the CREATOR OWNER SID: %w", err)
}
return append(slices.Clone(owners), creatorOwner), nil
}
func checkSecurity(path string, writeAccess windows.ACCESS_MASK, owners, writers []*windows.SID) error {
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION)
if err != nil {
return fmt.Errorf("read security descriptor of %s: %w", path, err)
}
owner, _, err := sd.Owner()
if err != nil {
return fmt.Errorf("read owner of %s: %w", path, err)
}
if !containsSID(owners, owner) {
return fmt.Errorf("%s is owned by %s, which is neither this user nor an account that can elevate", path, owner)
}
dacl, _, err := sd.DACL()
if err != nil {
return fmt.Errorf("read DACL of %s: %w", path, err)
}
// A NULL DACL grants everyone everything; only an absent security
// descriptor would have got us here without one, and neither is trustworthy.
if dacl == nil {
return fmt.Errorf("%s has no DACL, so it grants write access to everyone", path)
}
return checkDACL(path, dacl, writeAccess, writers)
}
// checkDACL refuses an ACL that grants write access to a trustee outside
// writers.
//
// An allowlist, because the trustees that must not have it cannot be listed: an
// ACE naming an ordinary user account hands that account the same power as one
// naming Everyone, and only the accounts that may hold it are knowable.
func checkDACL(path string, dacl *windows.ACL, writeAccess windows.ACCESS_MASK, writers []*windows.SID) error {
for i := uint32(0); i < uint32(dacl.AceCount); i++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, i, &ace); err != nil {
return fmt.Errorf("read ACE %d of %s: %w", i, path, err)
}
// An inherit-only ACE says what children of this object get, not what
// this object grants.
if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
continue
}
if ace.Mask&writeAccess == 0 {
continue
}
// Only an allow ACE grants anything; a deny ACE narrows what one gave.
if !isAllowACE(ace.Header.AceType) {
continue
}
trustee, err := aceTrustee(ace)
if err != nil {
return fmt.Errorf("read the trustee of ACE %d of %s: %w", i, path, err)
}
if !containsSID(writers, trustee) {
return fmt.Errorf("%s grants write access to %s", path, trustee)
}
}
return nil
}
// isAllowACE reports whether an ACE type grants rights, rather than denying,
// auditing or labelling them.
func isAllowACE(aceType uint8) bool {
switch aceType {
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType,
accessAllowedObjectACEType, accessAllowedCallbackObjectACEType:
return true
default:
return false
}
}
// aceTrustee returns who an allow ACE grants its rights to. An ACE whose trustee
// cannot be located is an error rather than something to skip past: being unable
// to read who is being given write access is a refusal.
func aceTrustee(ace *windows.ACCESS_ALLOWED_ACE) (*windows.SID, error) {
switch ace.Header.AceType {
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType:
//nolint:gosec // SidStart is the first uint32 of the variable-length SID that follows the ACE header.
return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), nil
default:
return nil, errors.New("an object-type allow ACE does not carry its trustee where we can read it")
}
}
func containsSID(sids []*windows.SID, sid *windows.SID) bool {
return slices.ContainsFunc(sids, sid.Equals)
}
func currentUserSID() (*windows.SID, error) {
token := windows.GetCurrentProcessToken()
user, err := token.GetTokenUser()
if err != nil {
return nil, fmt.Errorf("read this process's user: %w", err)
}
return user.User.Sid, nil
}

View File

@@ -1,126 +0,0 @@
package elevate
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// A file the test user created under their own profile, which is what a per-user
// install looks like. The whole chain up to the volume root is walked, so this is
// also what says the walk does not refuse an ordinary Windows installation: the
// root of every volume grants BUILTIN\Users rights that are not ours to worry
// about.
func TestCheckOnlyOwnerWritableAcceptsOwnFile(t *testing.T) {
err := checkOnlyOwnerWritable(writeExecutable(t))
assert.NoError(t, err, "a file the test user owns, under directories only administrators can write")
}
// Write access held by an account that cannot answer the UAC prompt means that
// account decides what runs behind it, whoever the ACE names. The trustees that
// must not have it cannot be listed, so the check names the ones that may.
func TestCheckOnlyOwnerWritableRejectsUntrustedWriters(t *testing.T) {
tests := []struct {
name string
wellKnown windows.WELL_KNOWN_SID_TYPE
}{
{name: "everyone", wellKnown: windows.WinWorldSid},
{name: "authenticated users", wellKnown: windows.WinAuthenticatedUserSid},
{name: "builtin users", wellKnown: windows.WinBuiltinUsersSid},
// A service account, which no denylist of the obvious groups would name
// and which cannot elevate any more than Everyone can.
{name: "local service", wellKnown: windows.WinLocalServiceSid},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := writeExecutable(t)
grantWrite(t, path, tt.wellKnown)
assert.Error(t, checkOnlyOwnerWritable(path),
"write access for %s must be refused", tt.name)
})
}
}
// The masks are the policy: on a file any write reaches its contents, while on a
// directory only deleting or taking over an entry reaches something already
// there. Adding an entry does not, which is why the walk survives a volume root.
func TestWriteAccessMasks(t *testing.T) {
assert.NotZero(t, fileWriteAccess&windows.FILE_WRITE_DATA, "writing a file's data reaches its contents")
assert.NotZero(t, fileWriteAccess&windows.FILE_APPEND_DATA, "appending to a file reaches its contents")
assert.Zero(t, dirWriteAccess&windows.FILE_WRITE_DATA, "adding a file to a directory replaces nothing")
assert.Zero(t, dirWriteAccess&windows.FILE_APPEND_DATA, "adding a subdirectory replaces nothing")
assert.NotZero(t, dirWriteAccess&fileDeleteChild, "deleting an entry replaces it")
assert.NotZero(t, dirWriteAccess&windows.DELETE, "deleting the directory takes its entries with it")
}
func TestIsAllowACE(t *testing.T) {
tests := []struct {
name string
aceType uint8
want bool
}{
{name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, want: true},
{name: "allowed callback", aceType: accessAllowedCallbackACEType, want: true},
{name: "allowed object", aceType: accessAllowedObjectACEType, want: true},
{name: "allowed callback object", aceType: accessAllowedCallbackObjectACEType, want: true},
{name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE},
// SYSTEM_AUDIT_ACE_TYPE, which x/sys does not define: an ACE that records
// access rather than granting it.
{name: "audit", aceType: 0x2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isAllowACE(tt.aceType), "ACE type %#x", tt.aceType)
})
}
}
// writeExecutable creates a plain file under the test's own directory, the shape
// trustedSelf checks.
func writeExecutable(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "netbird-ui.exe")
require.NoError(t, os.WriteFile(path, []byte("MZ"), 0o755), "write the executable")
return path
}
// grantWrite replaces the file's DACL with one that grants a well-known trustee
// everything, keeping the test user's own access so the file stays deletable.
func grantWrite(t *testing.T, path string, wellKnown windows.WELL_KNOWN_SID_TYPE) {
t.Helper()
trustee, err := windows.CreateWellKnownSid(wellKnown)
require.NoError(t, err, "build the trustee SID")
self, err := currentUserSID()
require.NoError(t, err, "read the test user's SID")
acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{
fullControl(self, windows.TRUSTEE_IS_USER),
fullControl(trustee, windows.TRUSTEE_IS_WELL_KNOWN_GROUP),
}, nil)
require.NoError(t, err, "build the ACL")
require.NoError(t, windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil, nil, acl, nil), "set the DACL")
}
func fullControl(sid *windows.SID, trusteeType uint32) windows.EXPLICIT_ACCESS {
return windows.EXPLICIT_ACCESS{
AccessPermissions: windows.GENERIC_ALL,
AccessMode: windows.GRANT_ACCESS,
Trustee: windows.TRUSTEE{
TrusteeForm: windows.TRUSTEE_IS_SID,
TrusteeType: windows.TRUSTEE_TYPE(trusteeType),
TrusteeValue: windows.TrusteeValueFromSID(sid),
},
}
}

View File

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

View File

@@ -91,12 +91,6 @@ func SelfDelegatesTo() (Identity, bool) {
return selfIdentity, true
}
// The values PrivilegedActorKey returns.
const (
ActorKeyAdministrator = "administrator"
ActorKeyRoot = "root"
)
// PrivilegedActor names the principal a privileged operation requires, for use
// in messages shown to the user.
func PrivilegedActor() string {
@@ -106,16 +100,6 @@ func PrivilegedActor() string {
return "root"
}
// PrivilegedActorKey identifies that principal without wording it, for a client
// that writes its own message in the user's language. The words PrivilegedActor
// returns are English, and a translated sentence cannot borrow them.
func PrivilegedActorKey() string {
if runtime.GOOS == "windows" {
return ActorKeyAdministrator
}
return ActorKeyRoot
}
// ElevatedCommand renders a command so that running it grants the privileges the
// operation needs. Windows has no in-line equivalent of sudo, so the command is
// returned unchanged and the user is expected to run it from an elevated

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
[Desktop Entry]
Type=Application
Name=NetBird
Comment=NetBird desktop client
Name=netbird-ui
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui
Icon=netbird-ui
Categories=Utility;Network;
Categories=Development;
Terminal=false
Keywords=netbird;vpn;wireguard;
Keywords=wails
Version=1.0
StartupNotify=false

View File

@@ -1,6 +1,5 @@
[Desktop Entry]
Name=NetBird
Comment=NetBird desktop client
Name=Netbird
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
Icon=netbird
Type=Application

View File

@@ -21,17 +21,8 @@ contents:
dst: "/usr/local/bin/netbird-ui"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.png"
# The name the polkit action's icon_name refers to, which the released packages
# install as /usr/share/pixmaps/netbird.png.
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/netbird.png"
- src: "./build/linux/netbird-ui.desktop"
dst: "/usr/share/applications/netbird-ui.desktop"
# Names the polkit action for the elevation prompt the app raises when an
# unprivileged user changes a privileged setting; without it the dialog shows a
# raw command line.
- src: "./build/linux/polkit/io.netbird.settings.policy"
dst: "/usr/share/polkit-1/actions/io.netbird.settings.policy"
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:

View File

@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<!--
Names the action behind the elevation prompt the desktop app raises for an SSH
setting the daemon restricts to root; without it pkexec's generic dialog offers
the raw command line instead. The argv1 annotation keeps this wording to the
one-shot mode that applies those settings.
auth_admin rather than auth_admin_keep: each of these settings is its own grant
of shell access, so a credential cache would let a second, unasked-for change
ride along on the authorization given the first.
exec.path takes no wildcard and the binary's location depends on the package,
hence one action per path.
-->
<policyconfig>
<vendor>NetBird</vendor>
<vendor_url>https://netbird.io</vendor_url>
<action id="io.netbird.settings.apply-privileged">
<description>Change privileged NetBird settings</description>
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
<icon_name>netbird</icon_name>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/netbird-ui</annotate>
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
</action>
<action id="io.netbird.settings.apply-privileged-local">
<description>Change privileged NetBird settings</description>
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
<icon_name>netbird</icon_name>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/local/bin/netbird-ui</annotate>
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
</action>
</policyconfig>

View File

@@ -22,18 +22,12 @@ const logSaveError = (err: unknown) => console.error("[SettingsContext] save fai
export type AutostartState = { supported: boolean; enabled: boolean };
// GuardedField is a setting the daemon only accepts from root/administrator.
// Turning one on goes through saveGuardedField, which asks the operating system
// for the privileges rather than sending a request that would be refused.
export type GuardedField = "serverSshAllowed" | "enableSshRoot" | "disableSshAuth";
type SettingsContextValue = {
config: Config;
guiVersion: string;
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
saveGuardedField: (k: GuardedField, v: boolean) => Promise<void>;
saveNow: () => Promise<void>;
};
@@ -69,12 +63,6 @@ const useSettingsState = () => {
const [guiVersion, setGuiVersion] = useState<string>("—");
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadedRef = useRef<LoadedConfig | null>(null);
// Set when the daemon's config changed while a save was pending, so the read
// that was skipped to protect the pending edit happens once it is through.
// Without it the form keeps values the daemon no longer has and the next save
// submits them, which for a guarded setting means asking the user to authorize
// a change they never made.
const reloadOwed = useRef(false);
useEffect(() => {
loadedRef.current = loaded;
@@ -85,7 +73,6 @@ const useSettingsState = () => {
// update the daemon then rejected.
const reload = useCallback(
async (profileName: string) => {
reloadOwed.current = false;
try {
const data = await SettingsSvc.GetConfig({ profileName, username });
setLoaded({ profileName, data });
@@ -107,12 +94,7 @@ const useSettingsState = () => {
username,
});
if (cancelled) return;
// A pending edit outranks the daemon's copy until it is saved, so
// the read is owed rather than dropped: see reloadOwed.
if (saveTimer.current) {
reloadOwed.current = true;
return;
}
if (saveTimer.current) return;
setLoaded({ profileName: activeProfileId, data });
} catch (e) {
if (cancelled || !showError) return;
@@ -159,17 +141,12 @@ const useSettingsState = () => {
async (profileName: string, next: Config, preSharedKey?: string) => {
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
try {
const { declined } = await SettingsSvc.SetConfig({
await SettingsSvc.SetConfig({
...next,
...preSharedKeyWrite,
profileName,
username,
});
// The change needed authorization and the user said no, so the
// optimistic update is wrong. Nothing to report: they know.
if (declined || reloadOwed.current) {
await reload(profileName);
}
} catch (e) {
// The optimistic update is wrong now: the daemon refused it
// (a change that needs elevated privileges, an MDM-managed
@@ -229,59 +206,6 @@ const useSettingsState = () => {
[loaded, save],
);
// saveGuardedField applies a setting the daemon restricts to
// root/administrator by having the Go side run the app again under the
// platform's elevation prompt (UAC, the macOS authentication dialog, polkit).
// The prompt is the user's, so the call is made straight from their gesture
// and never from the debounce.
const saveGuardedField = useCallback(
async (k: GuardedField, v: boolean) => {
const cur = loadedRef.current;
if (!cur) return;
// Flush what the debounce still owes, before the optimistic update
// below joins it: a later save carrying the guarded value would be
// refused, and its error dialog would be the second one for a change
// the user already authorized.
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
await save(cur.profileName, cur.data);
}
const next: LoadedConfig = {
profileName: cur.profileName,
data: { ...cur.data, [k]: v },
};
loadedRef.current = next;
setLoaded(next);
try {
await SettingsSvc.SetGuardedSettings({
profileName: cur.profileName,
username,
[k]: v,
});
} catch (e) {
// The daemon is authoritative either way, so re-read before
// reporting. A declined prompt is not an error and does not come
// through here at all; this is a prompt that could not be raised,
// which carries the command that would have done it.
await reload(cur.profileName);
await errorDialog({
Title: i18next.t("settings.error.saveTitle"),
Message: errorMessage(e),
Command: errorCommand(e),
});
return;
}
// Either the change went through or the user declined it. The daemon
// says which.
await reload(cur.profileName);
},
[username, save, reload],
);
const saveFields = useCallback(
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
if (!loaded) return;
@@ -301,27 +225,15 @@ const useSettingsState = () => {
[loaded, save],
);
return {
config: loaded?.data ?? null,
guiVersion,
setField,
saveField,
saveFields,
saveGuardedField,
saveNow,
};
return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow };
};
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } =
useSettingsState();
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
const value = useMemo<SettingsContextValue | null>(
() =>
config
? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow }
: null,
[config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow],
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
[config, guiVersion, setField, saveField, saveFields, saveNow],
);
if (!value) {

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Settings as SettingsSvc } from "@bindings/services";
import { type Privilege } from "@bindings/services/models.js";
import { Privilege } from "@bindings/services/models.js";
// usePrivilege reports whether this UI process may perform the changes the daemon
// restricts to root/administrator. It is answered in-process from our own token

View File

@@ -1,4 +1,3 @@
import { type TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
@@ -7,91 +6,51 @@ import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { cn } from "@/lib/cn";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { type GuardedField, useSettings } from "@/contexts/SettingsContext.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { usePrivilege } from "@/hooks/usePrivilege.ts";
import type { Privilege } from "@bindings/services/models.js";
import { Privilege } from "@bindings/services/models.js";
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
export function SettingsSSH() {
const { t } = useTranslation();
const { config, setField, saveGuardedField } = useSettings();
const { config, setField } = useSettings();
const privilege = usePrivilege();
// The field whose elevation prompt is currently up, if any. The prompt is
// modal to the operating system, not to us, so the guarded controls are held
// still meanwhile rather than allowed to stack a second one behind it.
const [authorizing, setAuthorizing] = useState<GuardedField | null>(null);
const isSSHServerEnabled = config.serverSshAllowed;
const authorize = async (field: GuardedField, value: boolean) => {
setAuthorizing(field);
try {
await saveGuardedField(field, value);
} finally {
setAuthorizing(null);
}
};
// The daemon restricts only the direction that hands out shells from a process
// running as root: for all three settings that is switching the field on.
//
// An unprivileged user gets that direction routed through the platform's
// elevation prompt where there is one to raise, and otherwise the old
// arrangement, where the control is either unavailable (it is off and only a
// privileged caller could turn it on) or a one-way switch (it is on, they may
// turn it off but not back on) with the command that does it.
// running as root. So for an unprivileged user a guarded control is either
// unavailable (it is off and only they could turn it on) or a one-way switch
// (it is on, they may turn it off, but not back on) — say which, either way.
//
// A null privilege means we could not determine it: leave the control alone
// rather than greying it out with nothing to explain why. The daemon enforces
// this regardless, and a rejected save reports its own guidance.
const guarded = (
field: GuardedField,
guardedDirectionActive: boolean,
command: (p: Privilege) => string,
// inverted marks a control whose guarded direction is switching it off, so
// the one-way warning has to read the other way round.
inverted = false,
) => {
const plain = (value: boolean) => setField(field, value);
if (!privilege || privilege.privileged) {
return { apply: plain, disabled: false, hint: undefined };
return { disabled: false, hint: undefined };
}
const guardedDirectionActive = config[field];
const hint = (pending: boolean, command?: string) => (
<GuardedHint
actor={actorLabel(privilege, t)}
const hint = (
<PrivilegeHint
actor={privilege.actor}
command={command(privilege)}
oneWay={guardedDirectionActive}
inverted={inverted}
pending={pending}
command={command}
/>
);
if (privilege.canElevate) {
return {
// Switching off is ours to do; only switching on is authorized.
apply: (value: boolean) => {
if (!value) {
plain(value);
return;
}
void authorize(field, value);
},
disabled: authorizing !== null,
hint: hint(authorizing === field),
};
}
return {
apply: plain,
disabled: !guardedDirectionActive,
hint: hint(false, command(privilege)),
};
return { disabled: !guardedDirectionActive, hint };
};
const sshServer = guarded("serverSshAllowed", (p) => p.allowSshServer);
const sshRoot = guarded("enableSshRoot", (p) => p.enableSshRoot);
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot);
// Inverted control: the guarded direction is switching authentication off, so
// it is the already-disabled state that is the one-way one.
const sshAuth = guarded("disableSshAuth", (p) => p.disableSshAuth, true);
const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true);
const jwtTtlId = useId();
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
@@ -125,7 +84,7 @@ export function SettingsSSH() {
<SectionGroup title={t("settings.ssh.section.server")}>
<FancyToggleSwitch
value={config.serverSshAllowed}
onChange={sshServer.apply}
onChange={(v) => setField("serverSshAllowed", v)}
disabled={sshServer.disabled}
label={t("settings.ssh.server.label")}
helpText={t("settings.ssh.server.help")}
@@ -139,7 +98,7 @@ export function SettingsSSH() {
>
<FancyToggleSwitch
value={config.enableSshRoot}
onChange={sshRoot.apply}
onChange={(v) => setField("enableSshRoot", v)}
disabled={sshRoot.disabled}
label={t("settings.ssh.root.label")}
helpText={t("settings.ssh.root.help")}
@@ -171,7 +130,7 @@ export function SettingsSSH() {
>
<FancyToggleSwitch
value={!config.disableSshAuth}
onChange={(v) => sshAuth.apply(!v)}
onChange={(v) => setField("disableSshAuth", !v)}
disabled={sshAuth.disabled}
label={t("settings.ssh.jwt.label")}
helpText={t("settings.ssh.jwt.help")}
@@ -204,81 +163,41 @@ export function SettingsSSH() {
);
}
// actorLabel names the principal the daemon requires, in the user's language. The
// Go side reports which one it is rather than wording it, because "administrator
// privileges" is English and a translated sentence cannot borrow it.
function actorLabel(privilege: Privilege, t: TFunction): string {
return privilege.actorKey === "administrator"
? t("settings.ssh.privilege.actorAdministrator")
: t("settings.ssh.privilege.actorRoot");
}
// GuardedHint is what a control the daemon guards says to an unprivileged user.
// There are three things worth saying, and it says at most one:
//
// - A prompt is open. Worth a line because it can take a few seconds to appear,
// long enough that a control which merely went inert would read as a hang.
// - The setting is in its guarded state already (oneWay), so the user may switch
// it back as they please and it is switching it away again that will ask. No
// command either way: the direction they can take is theirs to take.
// - Only a privileged caller can move it at all, and there is no prompt to
// raise: the command that does it belongs here, and nothing else will do.
//
// Which leaves the case of a control whose guarded direction is still ahead of the
// user and a prompt that can be raised for it: nothing to say, because clicking it
// raises the prompt and the prompt explains itself.
function GuardedHint({
// PrivilegeHint explains what an unprivileged user can and cannot do with a
// guarded control, and offers the command that does it with the privileges the
// daemon requires. oneWay covers the control being in the guarded state already:
// switching it back is the part that needs privileges.
function PrivilegeHint({
actor,
command,
oneWay,
inverted,
pending,
command,
}: {
actor: string;
command: string;
oneWay: boolean;
inverted: boolean;
pending: boolean;
command?: string;
}): ReactNode {
const { t } = useTranslation();
if (pending) {
return <HintBox>{t("settings.ssh.privilege.authorizePending")}</HintBox>;
}
if (oneWay) {
return (
<HintBox>
<span>
{inverted
? t("settings.ssh.privilege.oneWayInverted", { actor })
: t("settings.ssh.privilege.oneWay", { actor })}
</span>
</HintBox>
);
}
if (!command) return null;
return (
<HintBox>
<span>{t("settings.ssh.privilege.hint", { actor })}</span>
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
{command}
</code>
</CopyToClipboard>
</HintBox>
);
}
// HintBox is the box a guarded control puts its explanation in, directly under the
// control it belongs to.
function HintBox({ children }: { children: ReactNode }): ReactNode {
return (
<div
className={
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
}
>
{children}
<span>
{!oneWay
? t("settings.ssh.privilege.hint", { actor })
: inverted
? t("settings.ssh.privilege.oneWayInverted", { actor })
: t("settings.ssh.privilege.oneWay", { actor })}
</span>
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
{command}
</code>
</CopyToClipboard>
</div>
);
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Alle sichtbaren Ressourcen umschalten"
},
"settings.nav.label": {
"message": "Einstellungsbereiche"
},
"profile.switch.title": {
"message": "Zu Profil \"{name}\" wechseln?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Debug-Paket fehlgeschlagen"
},
"settings.nav.label": {
"message": "Einstellungsbereiche"
},
"settings.tabs.general": {
"message": "Allgemein"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "Vorgang fehlgeschlagen."
},
"error.elevation_unavailable": {
"message": "NetBird konnte auf diesem System nicht die nötigen Rechte anfordern. Führen Sie stattdessen dies aus:"
},
"error.elevation_failed": {
"message": "Die Änderung konnte mit erhöhten Rechten nicht angewendet werden. Führen Sie stattdessen dies aus:"
},
"settings.ssh.privilege.actorRoot": {
"message": "root-Rechte"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "Administratorrechte"
},
"settings.ssh.privilege.hint": {
"message": "Erfordert {actor}. Führen Sie stattdessen dies aus:"
},
"settings.ssh.privilege.oneWay": {
"message": "Sie können dies deaktivieren, zum erneuten Aktivieren sind {actor} erforderlich."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Sie können dies aktivieren, zum erneuten Deaktivieren sind {actor} erforderlich."
},
"settings.ssh.privilege.authorizePending": {
"message": "Warten auf Autorisierung…"
}
}

View File

@@ -1775,36 +1775,16 @@
"message": "Operation failed.",
"description": "Generic fallback error message used when no specific error applies."
},
"error.elevation_unavailable": {
"message": "NetBird could not ask this system for the privileges the change needs. Run this instead:",
"description": "Error: this computer has no way to prompt for elevated privileges. Followed by a copyable command that applies the setting from a terminal."
},
"error.elevation_failed": {
"message": "The change could not be applied with elevated privileges. Run this instead:",
"description": "Error: the authorization succeeded but applying the setting afterwards failed. Followed by a copyable command that applies the setting from a terminal."
},
"settings.ssh.privilege.actorRoot": {
"message": "root",
"description": "Fills {actor} in the settings.ssh.privilege.* messages on Linux, macOS and BSD, where the daemon requires the root account. 'root' is an account name and stays as it is; add the word for privileges or rights around it if the sentence needs one to read naturally."
},
"settings.ssh.privilege.actorAdministrator": {
"message": "administrator privileges",
"description": "Fills {actor} in the settings.ssh.privilege.* messages on Windows, where the daemon requires an elevated administrator. The Windows term for the rights an account is asked to elevate to."
},
"settings.ssh.privilege.hint": {
"message": "Requires {actor}. Run this instead:",
"description": "Help text under an SSH setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
},
"settings.ssh.privilege.oneWay": {
"message": "You can switch this off, but switching it back on needs {actor}.",
"description": "Help text under an SSH setting that is already on: an unprivileged user may switch it off freely, and switching it on again is what needs the privileges. No command follows, since the direction they can take is theirs to take. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows."
"message": "You can switch this off, but switching it back on needs {actor}:",
"description": "Warning under an SSH setting an unprivileged user may disable but not re-enable. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "You can switch this on, but switching it back off needs {actor}.",
"description": "Same as settings.ssh.privilege.oneWay, for the SSH authentication setting once it has been switched off: switching it off again is what needs the privileges."
},
"settings.ssh.privilege.authorizePending": {
"message": "Waiting for authorization…",
"description": "Replaces the help text under a guarded SSH setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis."
"message": "You can switch this on, but switching it back off needs {actor}:",
"description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Conmutar todos los recursos visibles"
},
"settings.nav.label": {
"message": "Secciones de configuración"
},
"profile.switch.title": {
"message": "¿Cambiar el perfil a «{name}»?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Error en el paquete de diagnóstico"
},
"settings.nav.label": {
"message": "Secciones de configuración"
},
"settings.tabs.general": {
"message": "General"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "La operación falló."
},
"error.elevation_unavailable": {
"message": "NetBird no pudo solicitar a este sistema los privilegios necesarios. Ejecute esto en su lugar:"
},
"error.elevation_failed": {
"message": "No se pudo aplicar el cambio con privilegios elevados. Ejecute esto en su lugar:"
},
"settings.ssh.privilege.actorRoot": {
"message": "privilegios de root"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "privilegios de administrador"
},
"settings.ssh.privilege.hint": {
"message": "Requiere {actor}. Ejecute esto en su lugar:"
},
"settings.ssh.privilege.oneWay": {
"message": "Puede desactivarlo, pero volver a activarlo requiere {actor}."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Puede activarlo, pero volver a desactivarlo requiere {actor}."
},
"settings.ssh.privilege.authorizePending": {
"message": "Esperando la autorización…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Activer/désactiver toutes les ressources visibles"
},
"settings.nav.label": {
"message": "Sections des paramètres"
},
"profile.switch.title": {
"message": "Basculer vers le profil « {name} » ?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Échec du lot de diagnostic"
},
"settings.nav.label": {
"message": "Sections des paramètres"
},
"settings.tabs.general": {
"message": "Général"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "Lopération a échoué."
},
"error.elevation_unavailable": {
"message": "NetBird na pas pu demander à ce système les privilèges nécessaires. Exécutez plutôt ceci :"
},
"error.elevation_failed": {
"message": "La modification na pas pu être appliquée avec des privilèges élevés. Exécutez plutôt ceci :"
},
"settings.ssh.privilege.actorRoot": {
"message": "les privilèges root"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "les privilèges administrateur"
},
"settings.ssh.privilege.hint": {
"message": "Nécessite {actor}. Exécutez plutôt ceci :"
},
"settings.ssh.privilege.oneWay": {
"message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor}."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Vous pouvez lactiver, mais le désactiver de nouveau nécessite {actor}."
},
"settings.ssh.privilege.authorizePending": {
"message": "En attente de lautorisation…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Összes látható erőforrás be/ki"
},
"settings.nav.label": {
"message": "Beállítások szakaszai"
},
"profile.switch.title": {
"message": "Váltás a(z) \"{name}\" profilra?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Hibakeresési csomag sikertelen"
},
"settings.nav.label": {
"message": "Beállítások szakaszai"
},
"settings.tabs.general": {
"message": "Általános"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "A művelet meghiúsult."
},
"error.elevation_unavailable": {
"message": "A NetBird nem tudta bekérni a rendszertől a szükséges jogosultságokat. Futtassa inkább ezt:"
},
"error.elevation_failed": {
"message": "A módosítást emelt szintű jogosultságokkal sem sikerült alkalmazni. Futtassa inkább ezt:"
},
"settings.ssh.privilege.actorRoot": {
"message": "root jogosultság"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "rendszergazdai jogosultság"
},
"settings.ssh.privilege.hint": {
"message": "{actor} szükséges hozzá. Futtassa inkább ezt:"
},
"settings.ssh.privilege.oneWay": {
"message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges."
},
"settings.ssh.privilege.authorizePending": {
"message": "Várakozás az engedélyezésre…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Attiva/disattiva tutte le risorse visibili"
},
"settings.nav.label": {
"message": "Sezioni delle impostazioni"
},
"profile.switch.title": {
"message": "Passare al profilo «{name}»?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Pacchetto di debug non riuscito"
},
"settings.nav.label": {
"message": "Sezioni delle impostazioni"
},
"settings.tabs.general": {
"message": "Generale"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "Operazione non riuscita."
},
"error.elevation_unavailable": {
"message": "NetBird non ha potuto richiedere a questo sistema i privilegi necessari. Esegua invece questo:"
},
"error.elevation_failed": {
"message": "Non è stato possibile applicare la modifica con privilegi elevati. Esegua invece questo:"
},
"settings.ssh.privilege.actorRoot": {
"message": "i privilegi di root"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "i privilegi di amministratore"
},
"settings.ssh.privilege.hint": {
"message": "Richiede {actor}. Esegua invece questo:"
},
"settings.ssh.privilege.oneWay": {
"message": "Può disabilitarlo, ma riabilitarlo richiede {actor}."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}."
},
"settings.ssh.privilege.authorizePending": {
"message": "In attesa dell'autorizzazione…"
}
}

View File

@@ -1304,9 +1304,6 @@
"daemon.outdated.description": {
"message": "このアプリを使用するには NetBird サービスを更新してください。"
},
"daemon.outdated.download": {
"message": "最新版をダウンロード"
},
"error.jwt_clock_skew": {
"message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。"
},
@@ -1330,29 +1327,5 @@
},
"error.unknown": {
"message": "操作に失敗しました。"
},
"error.elevation_unavailable": {
"message": "NetBird はこのシステムに必要な権限を要求できませんでした。代わりに次のコマンドを実行してください:"
},
"error.elevation_failed": {
"message": "昇格した権限でも変更を適用できませんでした。代わりに次のコマンドを実行してください:"
},
"settings.ssh.privilege.actorRoot": {
"message": "root 権限"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "管理者権限"
},
"settings.ssh.privilege.hint": {
"message": "{actor}が必要です。代わりに次のコマンドを実行してください:"
},
"settings.ssh.privilege.oneWay": {
"message": "無効にはできますが、再度有効にするには{actor}が必要です。"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "有効にはできますが、再度無効にするには{actor}が必要です。"
},
"settings.ssh.privilege.authorizePending": {
"message": "承認を待っています…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Alternar todos os recursos visíveis"
},
"settings.nav.label": {
"message": "Seções das configurações"
},
"profile.switch.title": {
"message": "Alternar perfil para \"{name}\"?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Falha no pacote de depuração"
},
"settings.nav.label": {
"message": "Seções das configurações"
},
"settings.tabs.general": {
"message": "Geral"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "A operação falhou."
},
"error.elevation_unavailable": {
"message": "O NetBird não conseguiu solicitar a este sistema os privilégios necessários. Execute isto em vez disso:"
},
"error.elevation_failed": {
"message": "Não foi possível aplicar a alteração com privilégios elevados. Execute isto em vez disso:"
},
"settings.ssh.privilege.actorRoot": {
"message": "privilégios de root"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "privilégios de administrador"
},
"settings.ssh.privilege.hint": {
"message": "Requer {actor}. Execute isto em vez disso:"
},
"settings.ssh.privilege.oneWay": {
"message": "Você pode desativar isto, mas ativar novamente requer {actor}."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Você pode ativar isto, mas desativar novamente requer {actor}."
},
"settings.ssh.privilege.authorizePending": {
"message": "Aguardando a autorização…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Переключить все видимые ресурсы"
},
"settings.nav.label": {
"message": "Разделы настроек"
},
"profile.switch.title": {
"message": "Переключиться на профиль «{name}»?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Не удалось создать отладочный пакет"
},
"settings.nav.label": {
"message": "Разделы настроек"
},
"settings.tabs.general": {
"message": "Общие"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "Не удалось выполнить операцию."
},
"error.elevation_unavailable": {
"message": "NetBird не смог запросить у этой системы нужные права. Выполните вместо этого:"
},
"error.elevation_failed": {
"message": "Не удалось применить изменение с повышенными правами. Выполните вместо этого:"
},
"settings.ssh.privilege.actorRoot": {
"message": "права root"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "права администратора"
},
"settings.ssh.privilege.hint": {
"message": "Требуются {actor}. Выполните вместо этого:"
},
"settings.ssh.privilege.oneWay": {
"message": "Отключить можно, но чтобы включить снова, нужны {actor}."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Включить можно, но чтобы отключить снова, нужны {actor}."
},
"settings.ssh.privilege.authorizePending": {
"message": "Ожидание авторизации…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "切换所有可见资源"
},
"settings.nav.label": {
"message": "设置部分"
},
"profile.switch.title": {
"message": "切换到配置文件“{name}”?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "创建调试包失败"
},
"settings.nav.label": {
"message": "设置部分"
},
"settings.tabs.general": {
"message": "常规"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "操作失败。"
},
"error.elevation_unavailable": {
"message": "NetBird 无法向此系统请求所需的权限。请改为运行:"
},
"error.elevation_failed": {
"message": "即使使用提升的权限也无法应用此更改。请改为运行:"
},
"settings.ssh.privilege.actorRoot": {
"message": "root 权限"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "管理员权限"
},
"settings.ssh.privilege.hint": {
"message": "需要{actor}。请改为运行:"
},
"settings.ssh.privilege.oneWay": {
"message": "您可以关闭此项,但重新开启需要{actor}。"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "您可以开启此项,但再次关闭需要{actor}。"
},
"settings.ssh.privilege.authorizePending": {
"message": "正在等待授权…"
}
}

View File

@@ -8,7 +8,6 @@ import (
"flag"
"io/fs"
"log"
"os"
"runtime"
"strings"
@@ -80,14 +79,6 @@ func init() {
}
func main() {
// The one-shot that applies the settings the daemon restricts to
// root/administrator, which this binary runs itself as under the platform's
// elevation prompt. Handled before anything GUI so no window, tray or
// single-instance lock is involved.
if services.IsPrivilegedSettingsRun(os.Args[1:]) {
os.Exit(runPrivilegedSettings(os.Args[1:]))
}
daemonAddr, userSetLogFile := parseFlagsAndInitLog()
conn := NewConn(daemonAddr)

View File

@@ -1,27 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/services"
)
// The one-shot mode this binary runs itself in, elevated, to apply the settings the
// daemon restricts to root/administrator. It is handled before anything GUI, so no
// window, tray or single-instance lock is involved.
//
// Only the wiring is here: what the mode accepts and does lives beside the code
// that asks for it, in services.RunPrivilegedSettings, so the settings it will
// apply are declared once. There is nothing privileged about the mode itself; it
// sends the same request the frontend would have sent, and the daemon authorizes it
// from the identity the kernel reports on the control channel exactly as it does
// for `sudo netbird up`.
func runPrivilegedSettings(args []string) int {
return services.RunPrivilegedSettings(args, func(addr string) (proto.DaemonServiceClient, error) {
if addr == "" {
addr = DaemonAddr()
}
return NewConn(addr).Client()
})
}

View File

@@ -1,231 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"errors"
"fmt"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/elevate"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// The command line of the one-shot mode this binary runs itself in, elevated, to
// apply a setting the daemon restricts to root/administrator. The setting flags
// spell the same words as `netbird up`, so the command a user is shown and what
// runs behind the prompt read alike. Parsed in oneshot.go.
const (
FlagApplyPrivilegedSettings = "apply-privileged-settings"
FlagDaemonAddr = "daemon-addr"
FlagProfile = "profile"
FlagUser = "user"
FlagLogLevel = "log-level"
FlagManagementURL = "management-url"
FlagAllowServerSSH = "allow-server-ssh"
FlagEnableSSHRoot = "enable-ssh-root"
FlagDisableSSHAuth = "disable-ssh-auth"
)
// Error codes for the ways asking for privileges can fail.
const (
CodeElevationUnavailable = "elevation_unavailable"
CodeElevationFailed = "elevation_failed"
)
// elevationTimeout bounds the wait for a prompt and the change behind it, so a
// dialog nobody answers does not leave its control disabled for the session. Long
// enough to find a password manager, and no shorter than the platforms' own prompt
// timeouts: Windows gives up on its consent dialog after two minutes by itself.
//
// It always ends our waiting, and not always the prompt: Security.framework offers
// no way to withdraw a request, so on macOS the system's own timeout is what closes
// the dialog.
const elevationTimeout = 5 * time.Minute
// elevator raises the platform's privilege prompt and runs the change behind it.
// An interface so tests can answer without a prompt.
type elevator interface {
// Run runs this binary again, elevated, with the given arguments.
Run(ctx context.Context, args ...string) error
// Available reports whether there is a prompt to raise on this host at all.
Available() bool
}
// osElevator is the real thing: see the elevate package.
type osElevator struct{}
func (osElevator) Run(ctx context.Context, args ...string) error {
return elevate.Run(ctx, args...)
}
func (osElevator) Available() bool {
return elevate.Available()
}
// SaveOutcome reports what became of a change that needed authorization.
//
// A declined prompt is a result, not an error: the user was asked and said no, so
// nothing was applied and nothing went wrong. Reporting it as an error would have
// every cancelled prompt logged as one.
type SaveOutcome struct {
// Declined is set when the user dismissed the authorization prompt, or was
// refused by policy. Nothing was changed.
Declined bool `json:"declined"`
}
// GuardedSettings is the subset of the config the daemon restricts to
// root/administrator. Only the fields that are set are changed: a nil pointer, or
// an empty management URL, leaves that setting alone.
//
// The management URL is in here because pointing a host with the SSH server
// running at another management identity hands the decision of who may open a
// shell on it to whoever runs that server, which is the same power as enabling
// the SSH server in the first place.
type GuardedSettings struct {
ProfileName string `json:"profileName"`
Username string `json:"username"`
ManagementURL string `json:"managementUrl,omitempty"`
ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"`
EnableSSHRoot *bool `json:"enableSshRoot,omitempty"`
DisableSSHAuth *bool `json:"disableSshAuth,omitempty"`
}
// guardedSetting is one setting to change, in the two spellings this needs: the
// one-shot's own flag, and the `netbird up` flag that does the same thing from a
// terminal, for when there is no prompt to raise.
type guardedSetting struct {
arg string
flag string
}
// SetGuardedSettings applies settings the daemon refuses from an unprivileged
// caller, by having the operating system run this binary again, elevated, to send
// the same request the frontend would have sent itself.
//
// The user authorizes it at the platform's own prompt: the UAC consent dialog,
// the macOS authentication dialog, or the polkit agent's. Any credentials are the
// operating system's business; NetBird neither sees nor asks for them. Nothing
// about the daemon's rules changes, and the elevated process is authorized like
// any other privileged caller, from the identity the kernel reports for it.
//
// A declined prompt comes back as SaveOutcome.Declined with no error. When there is
// no prompt to raise, or the elevated run failed, the error carries the command
// that does the same thing from a terminal.
func (s *Settings) SetGuardedSettings(ctx context.Context, p GuardedSettings) (SaveOutcome, error) {
settings := guardedSettings(p)
if len(settings) == 0 {
return SaveOutcome{}, &ClientError{
Code: CodeElevationFailed,
Short: "no setting to apply",
Long: "no setting to apply",
}
}
// The elevated run has no window and, on Linux, an environment pkexec has
// cleared, so what it writes to stderr is all there is to go on. It follows
// this process's level so that starting the app with --log-level debug says
// something about the run behind the prompt too.
args := append([]string{
"--" + FlagApplyPrivilegedSettings,
"--" + FlagDaemonAddr, s.daemonAddr,
"--" + FlagProfile, p.ProfileName,
"--" + FlagUser, p.Username,
"--" + FlagLogLevel, log.GetLevel().String(),
}, oneShotArgs(settings)...)
ctx, cancel := context.WithTimeout(ctx, elevationTimeout)
defer cancel()
// These changes hand out shells on this host, so both ends are logged: when the
// prompt went up, and what came of it. It is also the only account of a prompt
// that was slow to appear or never answered.
log.Infof("asking for privileges to apply %s", guardedSummary(p))
if err := s.elevator.Run(ctx, args...); err != nil {
return s.elevationOutcome(err, p)
}
log.Infof("applied %s with the privileges the user authorized", guardedSummary(p))
return SaveOutcome{}, nil
}
// elevationOutcome sorts what came back into the one normal ending and the two
// that need reporting, with the command that does the same thing by hand.
func (s *Settings) elevationOutcome(err error, p GuardedSettings) (SaveOutcome, error) {
switch {
case errors.Is(err, elevate.ErrDeclined):
// With the reason: an account that may not elevate at all lands here too,
// and the log is the only place that says which it was.
log.Infof("the elevation prompt for %s was declined: %v", guardedSummary(p), err)
return SaveOutcome{Declined: true}, nil
case errors.Is(err, elevate.ErrUnavailable):
log.Warnf("cannot ask for privileges to apply %s: %v", guardedSummary(p), err)
return SaveOutcome{}, &ClientError{
Code: CodeElevationUnavailable,
Short: s.classifier.translateShort(CodeElevationUnavailable),
Long: err.Error(),
Command: guardedCommand(p),
}
default:
log.Errorf("applying %s with elevated privileges failed: %v", guardedSummary(p), err)
return SaveOutcome{}, &ClientError{
Code: CodeElevationFailed,
Short: s.classifier.translateShort(CodeElevationFailed),
Long: err.Error(),
Command: guardedCommand(p),
}
}
}
// guardedSettings renders the settings that are actually being changed, from the
// same table the one-shot parses them with: see oneshot.go.
func guardedSettings(p GuardedSettings) []guardedSetting {
var settings []guardedSetting
for _, field := range guardedFields {
value, ok := field.read(p)
if !ok {
continue
}
settings = append(settings, guardedSetting{
arg: "--" + field.flag + "=" + value,
flag: field.up(value),
})
}
return settings
}
func oneShotArgs(settings []guardedSetting) []string {
args := make([]string, 0, len(settings))
for _, setting := range settings {
args = append(args, setting.arg)
}
return args
}
func upFlags(settings []guardedSetting) []string {
flags := make([]string, 0, len(settings))
for _, setting := range settings {
flags = append(flags, setting.flag)
}
return flags
}
// guardedCommand is the elevated command line equivalent to the requested
// change, the same shape the daemon names in its own refusals.
func guardedCommand(p GuardedSettings) string {
settings := guardedSettings(p)
if len(settings) == 0 {
return ""
}
return ipcauth.UpCommand(strings.Join(upFlags(settings), " "))
}
// guardedSummary names the change for the log.
func guardedSummary(p GuardedSettings) string {
return fmt.Sprintf("%v for profile %q", oneShotArgs(guardedSettings(p)), p.ProfileName)
}

View File

@@ -1,355 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"errors"
"testing"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/elevate"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
// A Unix socket, so the daemon address is one that carries a caller's identity and
// elevation is worth offering at all: see Settings.canElevate.
const testDaemonAddr = "unix:///var/run/netbird.sock"
// storedManagementURL is what the stub daemon already holds, so that a request
// naming a different one is a change: see Settings.guardedChanges.
const storedManagementURL = "https://stored.example.com"
// stubElevator stands in for the platform's prompt: it records what would have run
// and answers with a fixed outcome.
type stubElevator struct {
outcome error
available bool
calls [][]string
}
func (e *stubElevator) Run(_ context.Context, args ...string) error {
e.calls = append(e.calls, args)
return e.outcome
}
func (e *stubElevator) Available() bool { return e.available }
// stubDaemon implements only the RPCs under test. The embedded interface is nil, so
// any other call panics rather than passing quietly.
type stubDaemon struct {
proto.DaemonServiceClient
setConfig func(*proto.SetConfigRequest) error
// stored is what GetConfig reports, which is what a refused request's guarded
// settings are compared against.
stored *proto.GetConfigResponse
requests []*proto.SetConfigRequest
}
func (d *stubDaemon) SetConfig(_ context.Context, in *proto.SetConfigRequest, _ ...grpc.CallOption) (*proto.SetConfigResponse, error) {
d.requests = append(d.requests, in)
if err := d.setConfig(in); err != nil {
return nil, err
}
return &proto.SetConfigResponse{}, nil
}
func (d *stubDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
return d.stored, nil
}
type stubConn struct{ client proto.DaemonServiceClient }
func (c stubConn) Client() (proto.DaemonServiceClient, error) { return c.client, nil }
// privilegeRefusal is the error the daemon raises for a change it restricts to
// root, detail and all: see server.privilegeError.
func privilegeRefusal(t *testing.T) error {
t.Helper()
st, err := gstatus.New(codes.PermissionDenied, "Changing the management URL requires root.").
WithDetails(&errdetails.ErrorInfo{
Reason: ipcauth.ErrorReasonPrivilegeRequired,
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{
ipcauth.ErrorMetaSummary: "Changing the management URL requires root.",
ipcauth.ErrorMetaCommand: "sudo netbird down; sudo netbird up -m https://mgmt.example.com",
},
})
require.NoError(t, err, "build the refusal detail")
return st.Err()
}
func settingsWithElevation(t *testing.T, outcome error) (*Settings, *stubElevator) {
t.Helper()
elev := &stubElevator{outcome: outcome, available: true}
return &Settings{daemonAddr: testDaemonAddr, elevator: elev}, elev
}
// settingsRefusingOnce returns a Settings whose daemon refuses the first SetConfig
// for want of privileges and accepts anything after it. Its stored config holds
// another management server and no SSH grants, so a request naming either is a
// change rather than a restatement.
func settingsRefusingOnce(t *testing.T, elev *stubElevator) (*Settings, *stubDaemon) {
t.Helper()
refusal := privilegeRefusal(t)
daemon := &stubDaemon{stored: &proto.GetConfigResponse{ManagementUrl: storedManagementURL}}
daemon.setConfig = func(*proto.SetConfigRequest) error {
if len(daemon.requests) == 1 {
return refusal
}
return nil
}
return &Settings{conn: stubConn{client: daemon}, daemonAddr: testDaemonAddr, elevator: elev}, daemon
}
func TestSetGuardedSettingsPassesOnlyTheChangedSettings(t *testing.T) {
s, elev := settingsWithElevation(t, nil)
root := true
outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
ProfileName: "work",
Username: "vma",
EnableSSHRoot: &root,
})
require.NoError(t, err)
assert.False(t, outcome.Declined, "the prompt was answered")
want := []string{
"--" + FlagApplyPrivilegedSettings,
"--" + FlagDaemonAddr, testDaemonAddr,
"--" + FlagProfile, "work",
"--" + FlagUser, "vma",
"--" + FlagLogLevel, log.GetLevel().String(),
"--" + FlagEnableSSHRoot + "=true",
}
require.Len(t, elev.calls, 1, "one prompt for one change")
assert.Equal(t, want, elev.calls[0], "elevated arguments")
// argv[1] is what the polkit action is pinned to, so the marker has to stay
// first however the rest of the line grows.
assert.Equal(t, "--"+FlagApplyPrivilegedSettings, elev.calls[0][0], "the flag polkit matches on")
}
// Turning a setting off has to be as explicit as turning it on: a bare flag would
// read as "on" to the one-shot's parser.
func TestSetGuardedSettingsSpellsOutFalse(t *testing.T) {
s, elev := settingsWithElevation(t, nil)
off := false
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
ProfileName: "default",
ServerSSHAllowed: &off,
DisableSSHAuth: &off,
})
require.NoError(t, err)
args := elev.calls[0]
assert.Contains(t, args, "--"+FlagAllowServerSSH+"=false", "the setting being switched off")
assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=false", "the setting being switched off")
assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "no flag for a setting nobody touched")
}
func TestSetGuardedSettingsPassesTheManagementURL(t *testing.T) {
s, elev := settingsWithElevation(t, nil)
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
ProfileName: "default",
ManagementURL: "https://mgmt.example.com:33073",
})
require.NoError(t, err)
assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com:33073",
"the management URL to point the profile at")
}
func TestSetGuardedSettingsWithoutASettingDoesNotElevate(t *testing.T) {
s, elev := settingsWithElevation(t, nil)
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ProfileName: "default"})
require.Error(t, err, "nothing to apply is not something to prompt for")
assert.Empty(t, elev.calls, "no prompt at all")
}
// A declined prompt is the one ending that is not an error: reporting it as one
// would have every cancelled prompt logged as a failure.
func TestSetGuardedSettingsReportsADeclinedPromptAsAnOutcome(t *testing.T) {
s, _ := settingsWithElevation(t, elevate.ErrDeclined)
root := true
outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
ProfileName: "default",
EnableSSHRoot: &root,
})
require.NoError(t, err, "the user was asked and answered; nothing went wrong")
assert.True(t, outcome.Declined, "nothing was applied")
}
func TestSetGuardedSettingsMapsFailures(t *testing.T) {
tests := []struct {
name string
outcome error
wantCode string
}{
{
// Nothing to raise a prompt with: the user needs the command.
name: "no mechanism falls back to the command",
outcome: elevate.ErrUnavailable,
wantCode: CodeElevationUnavailable,
},
{
name: "a failed run falls back to the command",
outcome: errors.New("elevated netbird exited with 1"),
wantCode: CodeElevationFailed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, _ := settingsWithElevation(t, tt.outcome)
root := true
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
ProfileName: "default",
EnableSSHRoot: &root,
})
var clientErr *ClientError
require.ErrorAs(t, err, &clientErr, "the frontend needs a code to act on")
assert.Equal(t, tt.wantCode, clientErr.Code, "error code")
assert.Contains(t, clientErr.Command, "--"+FlagEnableSSHRoot+"=true",
"the setting in the fallback command")
assert.Contains(t, clientErr.Command, "netbird up", "the fallback command")
})
}
}
// Changing the management URL is only privileged while the host runs the SSH
// server, which no control can know up front, so the refusal is what triggers the
// prompt. The original request goes again afterwards, so the fields the one-shot
// does not understand are applied too.
func TestSetConfigElevatesAfterARefusalAndRetries(t *testing.T) {
elev := &stubElevator{available: true}
s, daemon := settingsRefusingOnce(t, elev)
mtu := int64(1280)
outcome, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: "https://mgmt.example.com",
MTU: &mtu,
})
require.NoError(t, err)
assert.False(t, outcome.Declined, "the prompt was answered")
require.Len(t, elev.calls, 1, "one prompt")
assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com",
"the guarded part of the request")
require.Len(t, daemon.requests, 2, "the refused request and the retry")
assert.Equal(t, mtu, daemon.requests[1].GetMtu(),
"the retry carries the rest of the request, which the one-shot does not understand")
}
func TestSetConfigDoesNotRetryWhenTheUserDeclines(t *testing.T) {
elev := &stubElevator{outcome: elevate.ErrDeclined, available: true}
s, daemon := settingsRefusingOnce(t, elev)
outcome, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: "https://mgmt.example.com",
})
require.NoError(t, err, "a declined prompt is not an error")
assert.True(t, outcome.Declined, "nothing was applied")
assert.Len(t, daemon.requests, 1, "only the refused request")
}
// With no prompt to raise, the refusal is reported as the daemon wrote it, which is
// the guidance that was there before elevation existed.
func TestSetConfigReportsTheRefusalWhenItCannotElevate(t *testing.T) {
elev := &stubElevator{available: false}
s, _ := settingsRefusingOnce(t, elev)
_, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: "https://mgmt.example.com",
})
var clientErr *ClientError
require.ErrorAs(t, err, &clientErr)
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
assert.Contains(t, clientErr.Command, "netbird up -m https://mgmt.example.com",
"the daemon's own command")
assert.Empty(t, elev.calls, "no prompt where there is none to raise")
}
// One authorization must buy only the change the user made. A settings form
// submits every field it holds, so most of a refused request restates what the
// daemon already has, and elevating those too would apply a guarded setting the
// user never touched — a value gone stale since the form loaded above all.
func TestSetConfigElevatesOnlyTheGuardedSettingsThatChange(t *testing.T) {
elev := &stubElevator{available: true}
s, _ := settingsRefusingOnce(t, elev)
on, off := true, false
_, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: storedManagementURL,
ServerSSHAllowed: &off,
EnableSSHRoot: &off,
DisableSSHAuth: &on,
})
require.NoError(t, err)
require.Len(t, elev.calls, 1, "one prompt")
args := elev.calls[0]
assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=true", "the setting that changes")
assert.NotContains(t, args, "--"+FlagManagementURL+"="+storedManagementURL,
"a management URL the daemon already holds")
assert.NotContains(t, args, "--"+FlagAllowServerSSH+"=false", "a setting already off")
assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "a setting already off")
}
// A request that changes no guarded setting has nothing an elevated run could
// apply, so the refusal must have come from somewhere a prompt cannot reach.
func TestSetConfigDoesNotElevateWhenNoGuardedSettingChanges(t *testing.T) {
elev := &stubElevator{available: true}
s, _ := settingsRefusingOnce(t, elev)
off := false
_, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: storedManagementURL,
ServerSSHAllowed: &off,
})
var clientErr *ClientError
require.ErrorAs(t, err, &clientErr)
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
assert.Empty(t, elev.calls, "no prompt for a change nobody made")
}
// A refusal with nothing in the request the one-shot could apply: the daemon
// cannot see who is calling, and being root would not help either.
func TestSetConfigReportsARefusalWithNothingToElevate(t *testing.T) {
elev := &stubElevator{available: true}
s, _ := settingsRefusingOnce(t, elev)
_, err := s.SetConfig(context.Background(), SetConfigParams{ProfileName: "default"})
var clientErr *ClientError
require.ErrorAs(t, err, &clientErr)
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
assert.Empty(t, elev.calls, "no prompt")
}

View File

@@ -1,239 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"errors"
"flag"
"fmt"
"os"
"strconv"
"time"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/elevate"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
// The other end of SetGuardedSettings: the mode this binary runs itself in,
// elevated, to apply the settings the daemon restricts to root/administrator.
//
// Both ends are here on purpose. What may be changed this way is an allowlist, and
// an allowlist declared twice is one that will eventually disagree with itself, so
// the arguments are rendered and parsed from a single table: guardedFields. Adding
// a setting is one row; nothing generic passes through, and no field outside the
// table can be reached with an elevated request no matter what lands on the command
// line.
// oneShotTimeout bounds the whole one-shot: connect, one RPC, exit. Generous
// because the user has just waited for an authentication dialog, and a failure here
// costs them the entire round trip.
const oneShotTimeout = 30 * time.Second
// Exit codes the parent reads where the platform gives it one.
const (
exitOK = 0
exitFailure = 1
exitUsage = 2
)
// guardedField is one setting the one-shot understands, in the two spellings it
// needs and with the two halves of its plumbing.
type guardedField struct {
// flag names it on the one-shot's command line.
flag string
usage string
// read returns the value to send and whether the caller asked for this setting
// at all.
read func(GuardedSettings) (string, bool)
// write parses a value from the command line onto the request. It is the only
// thing that validates the value, so it fails on anything it does not
// recognise rather than guessing.
write func(*proto.SetConfigRequest, string) error
// up renders the equivalent `netbird up` flag, for the fallback command shown
// when there is no prompt to raise.
up func(value string) string
}
var guardedFields = []guardedField{
{
flag: FlagManagementURL,
usage: "Management server the profile registers with.",
read: func(p GuardedSettings) (string, bool) { return p.ManagementURL, p.ManagementURL != "" },
write: func(req *proto.SetConfigRequest, value string) error {
// Parsed with the config layer's own parser, so what the elevated run
// accepts cannot drift from what the daemon would store.
if _, err := profilemanager.ParseServiceURL("Management URL", value); err != nil {
return err
}
req.ManagementUrl = value
return nil
},
// The daemon names this one as `-m <url>` in its own refusals.
up: func(value string) string { return "-m " + value },
},
boolField(FlagAllowServerSSH, "Run the NetBird SSH server.",
func(p GuardedSettings) *bool { return p.ServerSSHAllowed },
func(req *proto.SetConfigRequest, v *bool) { req.ServerSSHAllowed = v }),
boolField(FlagEnableSSHRoot, "Allow SSH sessions to privileged accounts.",
func(p GuardedSettings) *bool { return p.EnableSSHRoot },
func(req *proto.SetConfigRequest, v *bool) { req.EnableSSHRoot = v }),
boolField(FlagDisableSSHAuth, "Accept SSH sessions without authentication.",
func(p GuardedSettings) *bool { return p.DisableSSHAuth },
func(req *proto.SetConfigRequest, v *bool) { req.DisableSSHAuth = v }),
}
// fieldValue is a flag that remembers whether it was given, and requires a value:
// the renderer always writes one, so a bare flag is a caller that got it wrong.
type fieldValue struct {
set bool
value string
}
func (v *fieldValue) String() string {
if v == nil {
return ""
}
return v.value
}
func (v *fieldValue) Set(value string) error {
v.set, v.value = true, value
return nil
}
// boolField describes a setting that is on or off. The value is always spelled out,
// so that turning a setting off is as unambiguous as turning it on and a flag with
// no value is a mistake rather than an "on".
func boolField(
name, usage string,
read func(GuardedSettings) *bool,
write func(*proto.SetConfigRequest, *bool),
) guardedField {
return guardedField{
flag: name,
usage: usage,
read: func(p GuardedSettings) (string, bool) {
value := read(p)
if value == nil {
return "", false
}
return strconv.FormatBool(*value), true
},
write: func(req *proto.SetConfigRequest, value string) error {
parsed, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("parse %q as a boolean: %w", value, err)
}
write(req, &parsed)
return nil
},
up: func(value string) string { return "--" + name + "=" + value },
}
}
// IsPrivilegedSettingsRun reports whether this process was started as the one-shot.
// The flag is a marker rather than a value, so only the bare forms count: reading a
// value would mean "--flag=false" started it too.
func IsPrivilegedSettingsRun(args []string) bool {
for _, arg := range args {
if arg == "--"+FlagApplyPrivilegedSettings || arg == "-"+FlagApplyPrivilegedSettings {
return true
}
}
return false
}
// RunPrivilegedSettings applies the requested settings and returns the process exit
// code. connect dials the daemon, which is the caller's business because only it
// knows how this build talks to it.
//
// Everything it reports goes to stderr, which is what the parent captures where the
// platform lets it. On success it says so on standard output, because macOS gives
// the parent no exit status to read: see elevate.AppliedMarker.
func RunPrivilegedSettings(args []string, connect func(addr string) (proto.DaemonServiceClient, error)) int {
fs := flag.NewFlagSet("netbird-ui --"+FlagApplyPrivilegedSettings, flag.ContinueOnError)
fs.Bool(FlagApplyPrivilegedSettings, false, "Apply the settings the daemon restricts to root/administrator and exit.")
daemonAddr := fs.String(FlagDaemonAddr, "", "Daemon gRPC address: unix:///path, npipe://name or tcp://host:port")
logLevel := fs.String(FlagLogLevel, "info", "Log level: trace|debug|info|warn|error.")
profile := fs.String(FlagProfile, "", "Profile to change.")
username := fs.String(FlagUser, "", "Owner of the profile.")
values := make([]fieldValue, len(guardedFields))
for i, field := range guardedFields {
fs.Var(&values[i], field.flag, field.usage)
}
if err := fs.Parse(args); err != nil {
return exitUsage
}
if err := util.InitLog(*logLevel, "console"); err != nil {
fmt.Fprintf(os.Stderr, "init log: %v\n", err)
return exitFailure
}
req, err := privilegedRequest(*profile, *username, values)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
return exitUsage
}
ctx, cancel := context.WithTimeout(context.Background(), oneShotTimeout)
defer cancel()
if err := applyPrivilegedSettings(ctx, *daemonAddr, req, connect); err != nil {
fmt.Fprintf(os.Stderr, "apply settings: %v\n", err)
return exitFailure
}
fmt.Fprintln(os.Stdout, elevate.AppliedMarker)
return exitOK
}
// privilegedRequest builds the request from the flags that were given, and refuses
// one that asks for nothing.
func privilegedRequest(profile, username string, values []fieldValue) (*proto.SetConfigRequest, error) {
req := &proto.SetConfigRequest{ProfileName: profile, Username: username}
given := 0
for i, field := range guardedFields {
if !values[i].set {
continue
}
if err := field.write(req, values[i].value); err != nil {
return nil, fmt.Errorf("--%s: %w", field.flag, err)
}
given++
}
if given == 0 {
return nil, errors.New("no setting to apply")
}
return req, nil
}
func applyPrivilegedSettings(
ctx context.Context,
daemonAddr string,
req *proto.SetConfigRequest,
connect func(addr string) (proto.DaemonServiceClient, error),
) error {
client, err := connect(daemonAddr)
if err != nil {
return err
}
if _, err := client.SetConfig(ctx, req); err != nil {
// Unwrapped: the daemon's message is written for a person, and a refusal
// elevation cannot fix has to say so where the parent can read it off
// stderr.
return errors.New(gstatus.Convert(err).Message())
}
return nil
}
// interface guard: the one-shot's flags are flag.Value.
var _ flag.Value = (*fieldValue)(nil)

View File

@@ -1,151 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"flag"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
)
func TestIsPrivilegedSettingsRun(t *testing.T) {
tests := []struct {
name string
args []string
want bool
}{
{name: "no arguments"},
{name: "double dash", args: []string{"--" + FlagApplyPrivilegedSettings}, want: true},
{name: "single dash", args: []string{"-" + FlagApplyPrivilegedSettings}, want: true},
{
name: "among other flags",
args: []string{"--daemon-addr", "unix:///tmp/x.sock", "--" + FlagApplyPrivilegedSettings},
want: true,
},
// A marker, not a value: the caller never passes one, and reading a value
// would mean "--flag=false" started the one-shot too.
{name: "with a value", args: []string{"--" + FlagApplyPrivilegedSettings + "=true"}},
{name: "unrelated flags", args: []string{"--log-level", "debug"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, IsPrivilegedSettingsRun(tt.args), "args %v", tt.args)
})
}
}
// What SetGuardedSettings renders has to be what the one-shot reads back, for every
// setting in the table. This is the property that keeps the two ends of an allowlist
// from drifting, so it is checked field by field rather than by example.
func TestGuardedFieldsRoundTrip(t *testing.T) {
on, off := true, false
tests := []struct {
name string
settings GuardedSettings
want func(*testing.T, *proto.SetConfigRequest)
}{
{
name: "management url",
settings: GuardedSettings{ManagementURL: "https://mgmt.example.com:33073"},
want: func(t *testing.T, req *proto.SetConfigRequest) {
assert.Equal(t, "https://mgmt.example.com:33073", req.GetManagementUrl())
},
},
{
name: "ssh server on",
settings: GuardedSettings{ServerSSHAllowed: &on},
want: func(t *testing.T, req *proto.SetConfigRequest) {
require.NotNil(t, req.ServerSSHAllowed)
assert.True(t, *req.ServerSSHAllowed)
},
},
{
name: "ssh root off",
settings: GuardedSettings{EnableSSHRoot: &off},
want: func(t *testing.T, req *proto.SetConfigRequest) {
require.NotNil(t, req.EnableSSHRoot, "an explicit false must survive, not read as absent")
assert.False(t, *req.EnableSSHRoot)
},
},
{
name: "ssh auth off",
settings: GuardedSettings{DisableSSHAuth: &on},
want: func(t *testing.T, req *proto.SetConfigRequest) {
require.NotNil(t, req.DisableSSHAuth)
assert.True(t, *req.DisableSSHAuth)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := parseRendered(t, tt.settings)
tt.want(t, req)
})
}
}
// A setting nobody asked about must not arrive at the daemon at all: sending its
// zero value would change it.
func TestGuardedFieldsCarryOnlyWhatWasAsked(t *testing.T) {
on := true
req := parseRendered(t, GuardedSettings{ProfileName: "work", EnableSSHRoot: &on})
assert.Equal(t, "work", req.GetProfileName(), "profile")
require.NotNil(t, req.EnableSSHRoot)
assert.Nil(t, req.ServerSSHAllowed, "untouched setting")
assert.Nil(t, req.DisableSSHAuth, "untouched setting")
assert.Empty(t, req.GetManagementUrl(), "untouched setting")
}
func TestPrivilegedRequestRejectsAnEmptyChange(t *testing.T) {
_, err := privilegedRequest("default", "vma", make([]fieldValue, len(guardedFields)))
require.Error(t, err, "nothing to apply is not a request worth sending as root")
}
// A value the table cannot parse is refused rather than guessed at.
func TestPrivilegedRequestRejectsAnUnparseableValue(t *testing.T) {
values := make([]fieldValue, len(guardedFields))
for i, field := range guardedFields {
if field.flag != FlagEnableSSHRoot {
continue
}
require.NoError(t, values[i].Set("perhaps"))
}
_, err := privilegedRequest("default", "vma", values)
require.Error(t, err)
assert.Contains(t, err.Error(), FlagEnableSSHRoot, "which flag was wrong")
}
// parseRendered puts the settings through both ends: rendered as the arguments the
// elevated process is given, then parsed by a flag set registered from the same
// table, which is what the one-shot itself parses them with. Anything hand-rolled
// here would pin down a parser nothing uses.
func parseRendered(t *testing.T, p GuardedSettings) *proto.SetConfigRequest {
t.Helper()
rendered := guardedSettings(p)
require.NotEmpty(t, rendered, "nothing rendered for %+v", p)
args := make([]string, 0, len(rendered))
for _, setting := range rendered {
args = append(args, setting.arg)
}
fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError)
values := make([]fieldValue, len(guardedFields))
for i, field := range guardedFields {
fs.Var(&values[i], field.flag, field.usage)
}
require.NoError(t, fs.Parse(args), "the one-shot's own flag set must accept %v", args)
req, err := privilegedRequest(p.ProfileName, p.Username, values)
require.NoError(t, err)
return req
}

View File

@@ -44,19 +44,12 @@ type Restrictions struct {
}
// Privilege tells the frontend whether this process may perform the changes the
// daemon restricts to root/administrator, whether it can ask the operating
// system for the privileges instead, and the command for each so a control that
// can do neither can still show the way.
// daemon restricts to root/administrator, and carries the command for each so a
// disabled control can show the way to do it.
type Privilege struct {
Privileged bool `json:"privileged"`
// ActorKey identifies the principal the operation requires without wording it,
// so the frontend can name it in the user's language: see
// ipcauth.PrivilegedActorKey. The words are not sent, because English ones
// cannot be dropped into a translated sentence.
ActorKey string `json:"actorKey"`
// CanElevate reports whether a guarded control can offer to authorize the
// change through the platform's own prompt: see SetGuardedSettings.
CanElevate bool `json:"canElevate"`
// Actor names what the operation requires ("root", "administrator privileges").
Actor string `json:"actor"`
// Commands equivalent to the settings the daemon guards, ready to copy.
AllowSSHServer string `json:"allowSshServer"`
EnableSSHRoot string `json:"enableSshRoot"`
@@ -135,9 +128,6 @@ type Settings struct {
// daemonAddr is where the daemon listens, used to tell whether it runs as
// this user and would therefore authorize us: see Privilege.
daemonAddr string
// elevator raises the platform's privilege prompt when a change needs more
// rights than this process has.
elevator elevator
}
func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings {
@@ -145,7 +135,6 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
conn: conn,
classifier: errorClassifier{translator: translator, prefs: prefs},
daemonAddr: daemonAddr,
elevator: osElevator{},
}
}
@@ -191,10 +180,10 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
}, nil
}
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) {
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
cli, err := s.conn.Client()
if err != nil {
return SaveOutcome{}, err
return err
}
req := &proto.SetConfigRequest{
ProfileName: p.ProfileName,
@@ -226,92 +215,19 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcom
SshJWTCacheTTL: p.SSHJWTCacheTTL,
}
if _, err := cli.SetConfig(ctx, req); err != nil {
if _, refused := privilegeErrorInfo(err); refused {
return s.setConfigElevated(ctx, p, req, err)
}
// Classified so the frontend gets the daemon's guidance instead of the
// gRPC envelope.
return SaveOutcome{}, s.classifier.classify(err)
// gRPC envelope, which is what a refused privileged change looks like.
return s.classifier.classify(err)
}
return SaveOutcome{}, nil
}
// setConfigElevated answers a request the daemon refused for want of privileges by
// asking the user to authorize it, and sending it again if they do. It is the same
// offer the SSH settings make up front, for the changes a control cannot know are
// guarded until it is told: repointing a profile at another management server is
// only privileged while that host runs the SSH server.
//
// Two steps, because the elevated one-shot deliberately understands only the
// settings the daemon guards: it applies those, and the original request then goes
// through as this user, its privileged parts now asking for nothing that is not
// already stored. Nothing was applied by the refused attempt — the daemon decides
// before it writes — so there is no half-applied state to undo either way.
func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req *proto.SetConfigRequest, refusal error) (SaveOutcome, error) {
if !s.canElevate() {
return SaveOutcome{}, s.classifier.classify(refusal)
}
guarded, err := s.guardedChanges(ctx, p)
if err != nil {
log.Warnf("cannot tell which guarded settings this request changes: %v", err)
return SaveOutcome{}, s.classifier.classify(refusal)
}
if len(guardedSettings(guarded)) == 0 {
// Refused over something no prompt can settle, such as a control channel
// that carries no caller identity. Report the daemon's own guidance.
return SaveOutcome{}, s.classifier.classify(refusal)
}
outcome, err := s.SetGuardedSettings(ctx, guarded)
if err != nil || outcome.Declined {
return outcome, err
}
cli, err := s.conn.Client()
if err != nil {
return SaveOutcome{}, err
}
if _, err := cli.SetConfig(ctx, req); err != nil {
return SaveOutcome{}, s.classifier.classify(err)
}
return SaveOutcome{}, nil
}
// guardedChanges is the guarded part of a request, reduced to what it actually
// changes.
//
// A settings form submits every field it holds, so a request restates values the
// daemon already has. Carrying those into the elevated run would spend one
// authorization on more than the user asked for, and a value that has gone stale
// since the form was loaded would spend it on something they never asked about.
func (s *Settings) guardedChanges(ctx context.Context, p SetConfigParams) (GuardedSettings, error) {
stored, err := s.GetConfig(ctx, ConfigParams{ProfileName: p.ProfileName, Username: p.Username})
if err != nil {
return GuardedSettings{}, fmt.Errorf("read the stored config: %w", err)
}
guarded := GuardedSettings{
ProfileName: p.ProfileName,
Username: p.Username,
ServerSSHAllowed: changedFlag(p.ServerSSHAllowed, stored.ServerSSHAllowed),
EnableSSHRoot: changedFlag(p.EnableSSHRoot, stored.EnableSSHRoot),
DisableSSHAuth: changedFlag(p.DisableSSHAuth, stored.DisableSSHAuth),
}
// An empty URL leaves the setting alone, which is the daemon's rule too.
if p.ManagementURL != "" && p.ManagementURL != stored.ManagementURL {
guarded.ManagementURL = p.ManagementURL
}
return guarded, nil
return nil
}
// Privilege reports whether this UI process could carry out the changes the
// daemon restricts to root/administrator, whether it can instead ask the
// operating system for the privileges when the user wants one of them, and the
// command that performs the ones users hit in the SSH settings. It applies the
// daemon's own rule to what it can see locally, so the frontend can decide up
// front how to present those controls instead of letting a save fail. No daemon
// round-trip, so it also works while the daemon is down.
// daemon restricts to root/administrator, and the command that performs the one
// users hit in the SSH settings. It applies the daemon's own rule to what it can
// see locally, so the frontend can present those controls as unavailable up front
// instead of letting a save fail. No daemon round-trip, so it also works while the
// daemon is down.
//
// Being root or an elevated administrator is one way. The other is running as the
// daemon's own user while the daemon is unprivileged, which the daemon accepts
@@ -321,40 +237,26 @@ func (s *Settings) guardedChanges(ctx context.Context, p SetConfigParams) (Guard
func (s *Settings) Privilege() Privilege {
id, err := ipcauth.CurrentProcessIdentity()
if err != nil {
// Fail closed: report unprivileged, which only ever asks for more.
// Fail closed: report unprivileged, which only ever disables controls.
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
return s.newPrivilege(false)
return newPrivilege(false)
}
if id.IsPrivileged() {
return s.newPrivilege(true)
return newPrivilege(true)
}
return s.newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
}
func (s *Settings) newPrivilege(privileged bool) Privilege {
func newPrivilege(privileged bool) Privilege {
return Privilege{
Privileged: privileged,
ActorKey: ipcauth.PrivilegedActorKey(),
CanElevate: s.canElevate(),
Actor: ipcauth.PrivilegedActor(),
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
}
}
// canElevate reports whether offering the platform's elevation prompt would get
// the user anywhere. It needs a mechanism to raise the prompt with and a control
// channel that tells the daemon who is calling: on loopback TCP the daemon
// refuses these changes to everybody, root included, so a prompt there would
// only waste the user's password.
func (s *Settings) canElevate() bool {
if !daemonaddr.CarriesIdentity(s.daemonAddr) {
log.Debugf("not offering elevation: the daemon address %s carries no caller identity", s.daemonAddr)
return false
}
return s.elevator.Available()
}
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
cli, err := s.conn.Client()
if err != nil {
@@ -387,15 +289,6 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
return r, nil
}
// changedFlag returns requested only when it differs from what is stored, so a
// setting the request merely restates is left out of the elevated run.
func changedFlag(requested *bool, stored bool) *bool {
if requested == nil || *requested == stored {
return nil
}
return requested
}
func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
managed := cfgResp.GetMDMManagedFields()
if len(managed) == 0 {

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-20260416123949-2355d972be42
github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45
github.com/oapi-codegen/runtime v1.1.2
github.com/okta/okta-sdk-golang/v2 v2.18.0

4
go.sum
View File

@@ -484,8 +484,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-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8=
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg=
github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 h1:iJeUvSMC0BTpkw7u4JyWcY4/3dl7fEL9DR/TpKf2+1w=
github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87/go.mod h1:pmsCPx1S0nuZRxCextGpc9AV4hLgGSuTsc4NMuwGeCo=
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8=
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=

View File

@@ -0,0 +1,57 @@
package networkmap_pgsql
import (
"context"
"testing"
"time"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"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 := networkmap_pgsql.GetAccountSettingsViaPgxConnection(ctx, conn(t, 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 = networkmap_pgsql.GetAccountSettingsViaPgxConnection(ctx, conn(t, 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 = networkmap_pgsql.GetAccountSettingsViaPgxConnection(ctx, conn(t, ctx), "account-3")
assert.NoError(t, err)
assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{})
}

View File

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

@@ -0,0 +1,23 @@
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 := pgstore.GetDnsSettings(ctx, "account-1")
assert.NoError(t, err)
assert.Equal(t, settings, nmdata.DNSSettings{
DisabledManagementGroups: []string{"disabled-group-1", "disabled-group-2"},
})
settings, err = pgstore.GetDnsSettings(ctx, "account-2")
assert.NoError(t, err)
assert.Equal(t, settings, nmdata.DNSSettings{})
}

View File

@@ -0,0 +1,61 @@
package networkmap_pgsql
import (
"context"
"testing"
"github.com/miekg/dns"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"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 := networkmap_pgsql.GetAppliedZoneCandidatesViaPgxConnection(ctx, conn(t, 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

@@ -0,0 +1,37 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"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 := pgstore.GetDomains(ctx, "account-1")
assert.NoError(t, err)
assert.Len(t, domains, 2)
assert.Contains(t, domains, networkmap_pgsql.Domain{
Domain: sql.NullString{String: "test-1.com", Valid: true},
TargetCluster: sql.NullString{String: "target-1.cluster.local", Valid: true},
})
assert.Contains(t, domains, networkmap_pgsql.Domain{
Domain: sql.NullString{String: "test-2.com", Valid: true},
TargetCluster: sql.NullString{String: "target-2.cluster.local", Valid: true},
})
}

View File

@@ -0,0 +1,59 @@
package networkmap_pgsql
import (
"context"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetGroups(t *testing.T) {
ctx := context.TODO()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
assert.NoError(t, err)
groups, resourceToGroupIdx, err := s.GetGroups(ctx, "account-1")
assert.NoError(t, err)
assert.Contains(t,
groups,
nmdata.Group{ID: "group-one-resource-id", Name: "group-1-name", PublicID: "group-one-resource-id-public", Resources: []nmdata.Resource{{ID: "host-id-1", Type: "host"}}, Peers: []string{"peer-id-1"}},
)
assert.NotNil(t, resourceToGroupIdx["host-id-1"]["group-one-resource-id"])
assert.Contains(t,
groups,
nmdata.Group{ID: "group-two-resources-id", Name: "group-2-name", PublicID: "group-two-resources-id-public",
Resources: []nmdata.Resource{{ID: "subnet-id-1", Type: "subnet"}, {ID: "host-id-2", Type: "host"}},
Peers: []string{"peer-id-2", "peer-id-3"}},
)
assert.NotNil(t, resourceToGroupIdx["host-id-2"]["group-two-resources-id"])
assert.NotNil(t, resourceToGroupIdx["subnet-id-1"]["group-two-resources-id"])
assert.Contains(t,
groups,
nmdata.Group{ID: "group-no-resources-id", Name: "group-3-name", PublicID: "group-no-resources-id-public"})
}
// Verify handling of empty fields in groups table
// Verify that group's PublicID gets populated on retrieval
// TODO (dmitri) PublicID should not be populated with delta updates,
// which require stable PublicIDs
func TestGetGroupsWithoutExpectedFields(t *testing.T) {
ctx := context.TODO()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
assert.NoError(t, err)
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')")
assert.NoError(t, err)
groups, _, err := s.GetGroups(ctx, "random-id")
assert.NoError(t, err)
assert.Len(t, groups, 1)
assert.NotEmpty(t, groups[0].PublicID)
}

View File

@@ -0,0 +1,155 @@
package networkmap_pgsql
import (
"context"
_ "embed"
"fmt"
"os"
"regexp"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/assert"
log "github.com/sirupsen/logrus"
"gorm.io/driver/postgres"
"gorm.io/gorm"
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"
)
//go:embed base_data.sql
var baseData string
var (
dsn string
pgstore *networkmap_pgsql.PgStore
)
func TestMain(m *testing.M) {
_, 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()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
if err != nil {
log.Fatal("error creating postgres store %w", err)
}
for _, query := range strings.Split(baseData, ";") {
if _, err := s.Pool.Exec(ctx, query); err != nil {
log.Fatalf("error initializing db: %s", err.Error())
}
}
pgstore, err = networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
if err != nil {
log.Fatalf("error creating pg store %v", err.Error())
}
code := m.Run()
cleanup()
os.Exit(code)
}
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}`)
}
func conn(t *testing.T, ctx context.Context) *pgx.Conn {
t.Helper()
c, err := pgstore.Pool.Acquire(ctx)
assert.NoError(t, err)
return c.Conn()
}
func execQuery(t *testing.T, ctx context.Context, q string) {
t.Helper()
_, err := pgstore.Pool.Exec(ctx, q)
assert.NoError(t, err)
}

View File

@@ -0,0 +1,60 @@
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"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 := networkmap_pgsql.GetNameServerGroupsViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Contains(t, nsgroups, nmdata.NameServerGroup{
ID: "nsgroup-1",
PublicID: "nsgroup-1-public",
Name: "nsgroup-1",
Description: "nsgroup-1",
NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.31.2"), NSType: 1, Port: 53}},
Groups: []string{"group-one-resource-id"},
Domains: []string{"test-1.com"},
Primary: true,
SearchDomainsEnabled: false,
Enabled: true,
})
assert.Contains(t, nsgroups, nmdata.NameServerGroup{
ID: "nsgroup-2",
PublicID: "nsgroup-2-public",
Name: "nsgroup-2",
Description: "nsgroup-2",
NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.32.3"), NSType: 1, Port: 53}},
Groups: []string{"group-one-resource-id", "group-no-resources-id"},
Domains: []string{"test-1.com", "test-2.com"},
Primary: true,
SearchDomainsEnabled: false,
Enabled: true,
})
assert.Contains(t, nsgroups, nmdata.NameServerGroup{
ID: "nsgroup-3",
PublicID: "nsgroup-3-public",
Primary: false,
SearchDomainsEnabled: false,
Enabled: true,
})
}

View File

@@ -0,0 +1,67 @@
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetNetworkResources(t *testing.T) {
ctx := context.TODO()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
assert.NoError(t, err)
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 := s.GetNetworkResources(ctx, "account-1")
assert.NoError(t, err)
assert.Contains(t, resources, nmdata.NetworkResource{
ID: "net-resource-1",
AccountID: "account-1",
NetworkID: "network-1",
PublicID: "net-resource-public-1",
Name: "network-resource-1",
Description: "network-resource-1",
Type: "subnet",
Domain: "",
Prefix: netip.MustParsePrefix("10.0.0.0/16"),
Enabled: true,
})
assert.Contains(t, resources, nmdata.NetworkResource{
ID: "net-resource-2",
AccountID: "account-1",
NetworkID: "network-2",
PublicID: "net-resource-public-2",
Name: "network-resource-2",
Description: "network-resource-2",
Type: "domain",
Domain: "test.com",
Enabled: true,
})
assert.Contains(t, resources, nmdata.NetworkResource{
ID: "net-resource-3",
AccountID: "account-1",
NetworkID: "network-3",
PublicID: "net-resource-public-3",
Name: "network-resource-3",
Description: "network-resource-3",
Type: "host",
Domain: "",
Prefix: netip.MustParsePrefix("10.0.0.1/32"),
Enabled: true,
})
}

View File

@@ -0,0 +1,35 @@
package networkmap_pgsql
import (
"context"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetNetworkRouters(t *testing.T) {
ctx := context.TODO()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
assert.NoError(t, err)
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 := s.GetNetworkRouters(ctx, "account-1")
assert.NoError(t, err)
assert.NotEmpty(t, routers)
assert.Equal(t, routers["network-id-1"],
map[string]*nmdata.NetworkRouter{"peer-id-1": {PublicID: "public-id-1", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: []string{"group-one-resource-id"}}})
assert.Equal(t, routers["network-id-2"],
map[string]*nmdata.NetworkRouter{
"peer-id-2": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}},
"peer-id-3": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}})
}

View File

@@ -0,0 +1,58 @@
package networkmap_pgsql
import (
"context"
"encoding/json"
"net"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetNetwork(t *testing.T) {
ctx := context.TODO()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
assert.NoError(t, err)
network, err := s.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 = s.GetNetwork(ctx, "account-2")
assert.NoError(t, err)
assert.Equal(t, network, nmdata.Network{
Identifier: "network-2",
Net: mustParseCIDR("110.0.0.0/16"),
NetV6: mustParseCIDR("fddf:e995:fd38:a465::/64"),
Serial: 2,
})
}
func mustParseCIDR(s string) net.IPNet {
var toret net.IPNet
_, net, err := net.ParseCIDR(s)
if err != nil {
panic(err)
}
jn, err := json.Marshal(net)
if err != nil {
panic(err)
}
err = json.Unmarshal(jn, &toret)
if err != nil {
panic(err)
}
return toret
}

View File

@@ -0,0 +1,25 @@
package networkmap_pgsql
import (
"context"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"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 := networkmap_pgsql.GetNetworkXIDToPublicIdMapViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Equal(t, networksIdx, map[string]string{
"network-1": "network-1-public",
"network-2": "network-2-public",
})
}

View File

@@ -0,0 +1,60 @@
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"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 := networkmap_pgsql.GetPostureChecksViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Equal(t, idToPublicIDIdx, map[string]string{
"posturecheck-1": "posturecheck-1-public",
"posturecheck-2": "posturecheck-2-public",
"posturecheck-3": "posturecheck-3-public",
})
assert.Contains(t, postureChecks, nmdata.PostureChecks{
ID: "posturecheck-1",
Checks: nmdata.ChecksDefinition{
NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
OSVersionCheck: &nmdata.OSVersionCheck{Darwin: &nmdata.MinVersionCheck{MinVersion: "12.0"}},
GeoLocationCheck: &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "FI"}}, Action: "allow"},
PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "deny", Ranges: []netip.Prefix{netip.MustParsePrefix("192.168.0.1/24")}},
}})
assert.Contains(t, postureChecks, nmdata.PostureChecks{
ID: "posturecheck-2",
Checks: nmdata.ChecksDefinition{
NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
OSVersionCheck: &nmdata.OSVersionCheck{Android: &nmdata.MinVersionCheck{MinVersion: "0"}},
GeoLocationCheck: &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "US", CityName: "Harker Heights"}}, Action: "allow"},
PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "allow", Ranges: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}},
}})
assert.Contains(t, postureChecks, nmdata.PostureChecks{
ID: "posturecheck-3"})
}

View File

@@ -0,0 +1,86 @@
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"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 := networkmap_pgsql.GetRoutesViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Contains(t, routes, nmdata.Route{
ID: "route-1",
AccountID: "account-1",
PublicID: "route-1-public",
Network: netip.MustParsePrefix("172.0.0.0/16"),
Domains: domain.List{"test-1.com"},
KeepRoute: true,
NetID: "route-1-net-id",
Description: "route-1",
Peer: "peer-id-1",
PeerID: "peer-id-1",
PeerGroups: []string{"group-one-resource-id"},
NetworkType: 1,
Masquerade: true,
Metric: 9999,
Enabled: true,
Groups: []string{"group-one-resource-id"},
AccessControlGroups: []string{"group-one-resource-id"},
SkipAutoApply: false,
})
assert.Contains(t, routes, nmdata.Route{
ID: "route-2",
AccountID: "account-1",
PublicID: "route-2-public",
Network: netip.MustParsePrefix("172.10.0.0/16"),
Domains: domain.List{"test-1.com", "test-2.com"},
KeepRoute: true,
NetID: "route-2-net-id",
Description: "route-2",
Peer: "peer-id-2",
PeerID: "peer-id-2",
PeerGroups: []string{"group-two-resources-id"},
NetworkType: 1,
Masquerade: true,
Metric: 9999,
Enabled: true,
Groups: []string{"group-two-resources-id"},
AccessControlGroups: []string{"group-two-resources-id"},
SkipAutoApply: false,
})
assert.Contains(t, routes, nmdata.Route{
ID: "route-3",
AccountID: "account-1",
PublicID: "route-3-public",
Description: "route-3",
})
}

View File

@@ -0,0 +1,110 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
)
func TestGetPrivateServicesViaPgxConnection(t *testing.T) {
ctx := context.TODO()
_, err := pgstore.Pool.Exec(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')`)
assert.NoError(t, err)
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')`)
assert.NoError(t, err)
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)`)
assert.NoError(t, err)
services, err := networkmap_pgsql.GetPrivateServicesViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Contains(t, services, networkmap_pgsql.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, networkmap_pgsql.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, networkmap_pgsql.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 TestGetProxyTargetedDomainResourceIDsViaPgxConnection(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 := networkmap_pgsql.GetProxyTargetedDomainResourceIDsViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Equal(t, servtargetedDomains, map[string]struct{}{
"target-1": {},
"target-6": {},
"target-7": {},
})
}

View File

@@ -0,0 +1,56 @@
package networkmap_pgsql
import (
"context"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"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 := networkmap_pgsql.GetAllowedUsersViaPgxConnection(ctx, conn(t, 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,6 +18,7 @@ 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"
@@ -30,6 +31,8 @@ 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"
@@ -61,6 +64,8 @@ type Controller struct {
serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion
perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion
nmdataStore *networkmapdb.NetworkMapDBStoreImpl
}
type bufferUpdate struct {
@@ -78,7 +83,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) *Controller {
func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller {
nMetrics, err := newMetrics(metrics.UpdateChannelMetrics())
if err != nil {
log.Fatal(fmt.Errorf("error creating metrics: %w", err))
@@ -99,6 +104,7 @@ 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,
}
}
@@ -147,6 +153,11 @@ func (c *Controller) CountStreams() int {
func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
log.WithContext(ctx).Tracef("updating peers for account %s from %s", accountID, util.GetCallerName())
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.sendUpdateAccountPeersFromData(ctx, accountID, reason, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
return fmt.Errorf("failed to get account: %v", err)
@@ -167,7 +178,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
return nil
}
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validate peers: %v", err)
}
@@ -254,7 +265,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
// the client merges it into Calculate()'s output the same
// way the legacy server did via NetworkMap.Merge.
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -275,7 +286,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
}
start = time.Now()
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -293,6 +304,247 @@ 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 {
affected := make(map[string]struct{}, len(peerIDs))
for _, id := range peerIDs {
affected[id] = struct{}{}
}
peersToUpdate := c.connectedPeersFromData(nmData, affected)
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)
}
func (c *Controller) connectedPeersFromData(nmData *networkmap.NetworkMapData, affected map[string]struct{}) []*nmdata.Peer {
var result []*nmdata.Peer
for _, peer := range nmData.Peers {
if affected != nil {
if _, ok := affected[peer.ID]; !ok {
continue
}
}
if c.peersUpdateManager.HasChannel(peer.ID) {
result = append(result, peer)
}
}
return result
}
func (c *Controller) sendUpdatesFromData(ctx context.Context, accountID string, nmData *networkmap.NetworkMapData, peersToUpdate []*nmdata.Peer, reason *types.UpdateReason) error {
globalStart := time.Now()
extraSettings, err := c.settingsManager.GetExtraSettings(ctx, accountID)
if err != nil {
return fmt.Errorf("failed to get flow enabled status: %v", err)
}
dnsCache := &cache.DNSConfigCache{}
dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, ipv6AllowedPeersFromData(nmData))
dnsFwdPort := computeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
var wg sync.WaitGroup
semaphore := make(chan struct{}, 10)
for _, peer := range peersToUpdate {
if reason != nil && c.accountManagerMetrics != nil {
c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation))
}
wg.Add(1)
semaphore <- struct{}{}
go func(p *nmdata.Peer) {
defer wg.Done()
defer func() { <-semaphore }()
start := time.Now()
postureChecks := peerPostureChecksFromData(nmData, p.ID)
c.metrics.CountCalcPostureChecksDuration(time.Since(start))
start = time.Now()
peerGroups := maps.Keys(nmData.GetPeerGroups(p.ID))
var update *proto.SyncResponse
commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion))
log.WithContext(ctx).
WithFields(log.Fields{
"sync_message_version": commonSyncMessageVersion,
"server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
"peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion),
}).Debug("common highest sync message version")
if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap {
components := nmData.GetPeerNetworkMapComponents(p.ID, peersCustomZone)
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
start = time.Now()
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, nil, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
Update: update,
MessageType: network_map.MessageTypeNetworkMap,
})
return
}
nmap := networkMapFromData(ctx, nmData, p.ID, peersCustomZone)
c.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
@@ -325,6 +577,10 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
return nil
}
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.sendUpdateForAffectedPeersFromData(ctx, accountID, peerIDs, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
return fmt.Errorf("failed to get account: %v", err)
@@ -340,7 +596,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: sending network map to %d connected peers", len(peersToUpdate))
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validate peers: %v", err)
}
@@ -426,7 +682,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
// the client merges it into Calculate()'s output the same
// way the legacy server did via NetworkMap.Merge.
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -447,7 +703,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
}
start = time.Now()
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -504,7 +760,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
return fmt.Errorf("peer %s doesn't exists in account %s", peerId, accountId)
}
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validated peers: %v", err)
}
@@ -564,7 +820,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
// the client merges it into Calculate()'s output the same
// way the legacy server did via NetworkMap.Merge.
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
Update: update,
@@ -581,7 +837,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
nmap.Merge(proxyNetworkMap)
}
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
Update: update,
@@ -641,7 +897,11 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
if err != nil {
return nil, nil, nil, nil, 0, err
}
return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil
return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil
}
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.getValidatedPeerWithComponentsFromData(ctx, accountID, peer, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
@@ -651,7 +911,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
c.injectAllProxyPolicies(ctx, account)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return nil, nil, nil, nil, 0, err
}
@@ -688,6 +948,21 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil
}
// getValidatedPeerWithComponentsFromData is the account-free variant of
// GetValidatedPeerWithComponents. The proxy network map fragment is omitted
// like on the other nmdata paths.
func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) {
postureChecks := peerPostureChecksFromData(nmData, peer.ID)
dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, ipv6AllowedPeersFromData(nmData))
components := nmData.GetPeerNetworkMapComponents(peer.ID, peersCustomZone)
dnsFwdPort := computeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
return peer, components, nil, postureChecks, dnsFwdPort, nil
}
// BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval.
func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
if len(peerIDs) == 0 {
@@ -794,11 +1069,15 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
}
emptyMap := &types.NetworkMap{
Network: network.Copy(),
Network: types.TwinNetwork(network),
}
return emptyMap, nil, 0, nil
}
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.getValidatedPeerWithMapFromData(ctx, accountID, peerID, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
return nil, nil, 0, err
@@ -806,7 +1085,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
c.injectAllProxyPolicies(ctx, account)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return nil, nil, 0, err
}
@@ -846,6 +1125,21 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
return networkMap, postureChecks, dnsFwdPort, nil
}
// getValidatedPeerWithMapFromData is the account-free variant of
// GetValidatedPeerWithMap. The proxy network map fragment is omitted like on
// the other nmdata paths.
func (c *Controller) getValidatedPeerWithMapFromData(ctx context.Context, accountID string, peerID string, nmData *networkmap.NetworkMapData) (*types.NetworkMap, []*posture.Checks, int64, error) {
postureChecks := peerPostureChecksFromData(nmData, peerID)
dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, ipv6AllowedPeersFromData(nmData))
networkMap := networkMapFromData(ctx, nmData, peerID, peersCustomZone)
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 {
@@ -908,20 +1202,36 @@ func (c *Controller) StartWarmup(ctx context.Context) {
// computeForwarderPort checks if all peers in the account have updated to a specific version or newer.
// If all peers have the required version, it returns the new well-known port (22054), otherwise returns 0.
func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
if len(peers) == 0 {
versions := make([]string, 0, len(peers))
for _, peer := range peers {
versions = append(versions, peer.Meta.WtVersion)
}
return computeForwarderPortFromVersions(versions, requiredVersion)
}
func computeForwarderPortFromData(peers map[string]*nmdata.Peer, requiredVersion string) int64 {
versions := make([]string, 0, len(peers))
for _, peer := range peers {
versions = append(versions, peer.Meta.WtVersion)
}
return computeForwarderPortFromVersions(versions, requiredVersion)
}
func computeForwarderPortFromVersions(wtVersions []string, requiredVersion string) int64 {
if len(wtVersions) == 0 {
return int64(network_map.OldForwarderPort)
}
reqVer := semver.Canonical(requiredVersion)
// Check if all peers have the required version or newer
for _, peer := range peers {
for _, wtVersion := range wtVersions {
// Development version is always supported
if version.IsDevelopmentVersion(peer.Meta.WtVersion) {
if version.IsDevelopmentVersion(wtVersion) {
continue
}
peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
peerVersion := semver.Canonical("v" + wtVersion)
if peerVersion == "" {
// If any peer doesn't have version info, return 0
return int64(network_map.OldForwarderPort)
@@ -1055,7 +1365,7 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
groups[groupID] = group.Peers
}
validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,171 @@
package networkmapdb
import (
"context"
"database/sql"
"encoding/json"
"errors"
"reflect"
"strings"
"github.com/rs/xid"
"golang.org/x/exp/maps"
"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"
)
const (
NMAP_STRUCT_TAG = "nmap"
NMAP_SKIP = "skip"
NMAP_MAP_TO = "map_to"
)
type NetworkMapDBStore interface { //nolint:revive // established name across the codebase
GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, 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)
GetNetworkMapData(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error)
GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error)
GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, 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,
}
}
func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error) {
nmdata, err := s.store.GetNetworkMapData(ctx, accountId)
if err != nil {
return nil, err
}
extraSettings, err := s.extraSettingsManager.GetExtraSettings(ctx, accountId)
if err != nil {
return nil, err
}
nmdata.ValidatedPeers, err = s.integratedPeerValidator.GetValidatedPeers(ctx, accountId, maps.Values(nmdata.Groups), maps.Values(nmdata.Peers), extraSettings)
if err != nil {
return nil, err
}
return nmdata, nil
}
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 "[]string":
if srcField.IsNil() {
continue
}
dstv := reflect.MakeSlice(dstField.Type(), srcField.Len(), srcField.Cap())
reflect.Copy(dstv, srcField)
dstField.Set(dstv)
}
}
return nil
}
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])}
}

View File

@@ -0,0 +1,84 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/jackc/pgx/v5"
"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 (pg *PgStore) GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nmdata.AccountSettingsInfo{}, err
}
return GetAccountSettingsViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetAccountSettingsViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) (nmdata.AccountSettingsInfo, error) {
rows, err := con.Query(ctx, GetAccountSettingsQuery, accountId)
if err != nil {
return nmdata.AccountSettingsInfo{}, err
}
settings, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[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
}
type account struct {
PeerLoginExpirationEnabled sql.NullBool
PeerLoginExpiration sql.NullInt64
PeerInactivityExpirationEnabled sql.NullBool
PeerInactivityExpiration sql.NullInt64
DNSDomain sql.NullString
IPv6EnabledGroups json.RawMessage
RoutingPeerDNSResolutionEnabled sql.NullBool
LazyConnectionEnabled sql.NullBool
AutoUpdateVersion sql.NullString
AutoUpdateAlways sql.NullBool
MetricsPushEnabled sql.NullBool
}

View File

@@ -0,0 +1,124 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"reflect"
"github.com/jackc/pgx/v5"
"github.com/miekg/dns"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
var ErrDnsUnsupportedRecordType = errors.New("unsupported record type")
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 (pg *PgStore) GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, err
}
return GetAppliedZoneCandidatesViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetAppliedZoneCandidatesViaPgxConnection(ctx context.Context, conn *pgx.Conn, accountId string) ([]networkmap.AppliedZoneCandidate, error) {
rows, err := conn.Query(ctx, GetAccountZonesQuery, accountId)
if err != nil {
return nil, err
}
zones, err := pgx.CollectRows(rows, pgx.RowToStructByName[zone])
if err != nil {
return nil, err
}
toret := make([]networkmap.AppliedZoneCandidate, 0, len(zones))
currentZoneId := ""
for _, z := range zones {
if !z.RecordType.Valid {
continue
}
zone := nmdata.CustomZone{}
err := networkmapdb.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
}
type zone struct {
Id string `nmap:"skip"`
DistributionGroups json.RawMessage `nmap:"skip"`
Domain sql.NullString
SearchDomainDisabled sql.NullBool
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"`
}
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 appliedZoneCandidateFromZone(z nmdata.CustomZone, distributionGroups []string) networkmap.AppliedZoneCandidate {
return networkmap.AppliedZoneCandidate{
DistributionGroups: distributionGroups,
Zone: z,
}
}

View File

@@ -0,0 +1,53 @@
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 (pg *PgStore) GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nmdata.DNSSettings{}, err
}
return GetDnsSettingsViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetDnsSettingsViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) (nmdata.DNSSettings, error) {
rows, err := con.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

@@ -0,0 +1,38 @@
package networkmap_pgsql
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

@@ -0,0 +1,38 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"github.com/jackc/pgx/v5"
)
const (
GetDomainsQuery = `
select domain, target_cluster
from domains
where account_id=$1 and domain<>'' and target_cluster<>''
`
)
func (pg *PgStore) GetDomains(ctx context.Context, accountId string) ([]Domain, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, err
}
return GetDomainsViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetDomainsViaPgxConnection(ctx context.Context, conn *pgx.Conn, accountId string) ([]Domain, error) {
rows, err := conn.Query(ctx, GetDomainsQuery, accountId)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, pgx.RowToStructByName[Domain])
}
type Domain struct {
Domain sql.NullString
TargetCluster sql.NullString
}

View File

@@ -0,0 +1,72 @@
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 (pg *PgStore) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, nil, err
}
return GetGroupsViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetGroupsViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.Group, map[string]map[string]any, error) {
rows, err := con.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

@@ -0,0 +1,65 @@
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 (
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 (pg *PgStore) GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, err
}
return GetNameServerGroupsViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetNameServerGroupsViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.NameServerGroup, error) {
rows, err := con.Query(ctx, GetNameserversQuery, accountId)
if err != nil {
return nil, err
}
nsgroups, err := pgx.CollectRows(rows, pgx.RowToStructByName[nameserverGroup])
if err != nil {
return nil, err
}
toret := make([]nmdata.NameServerGroup, 0, len(nsgroups))
for _, nsg := range nsgroups {
group := nmdata.NameServerGroup{}
err := networkmapdb.FromSqlTypesToSharedTypes(
reflect.ValueOf(&nsg), reflect.ValueOf(&group))
if err != nil {
return nil, err
}
toret = append(toret, group)
}
return toret, nil
}
type nameserverGroup struct {
ID string
PublicID sql.NullString
Name sql.NullString
Description sql.NullString
NameServers json.RawMessage
Groups json.RawMessage
Primary sql.NullBool
Domains json.RawMessage
Enabled sql.NullBool
SearchDomainsEnabled sql.NullBool
}

View File

@@ -0,0 +1,57 @@
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 (
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 (pg *PgStore) GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nmdata.Network{}, err
}
return GetNetworkViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetNetworkViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) (nmdata.Network, error) {
rows, err := con.Query(ctx, GetNetworkQuery, accountId)
if err != nil {
return nmdata.Network{}, err
}
n, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[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
}
type accountnetwork struct {
Identifier sql.NullString
Net json.RawMessage
NetV6 json.RawMessage
Dns sql.NullString
Serial sql.NullInt64
}

View File

@@ -0,0 +1,242 @@
package networkmap_pgsql
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
func (pg *PgStore) GetNetworkMapData(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error) {
tx, err := pg.Pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
return nil, err
}
acctSettings, err := GetAccountSettingsViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get account settings: %w", err))
}
dnsZones, err := GetAppliedZoneCandidatesViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get applied zone candidates: %w", err))
}
groups, resourceToGroupIdx, err := GetGroupsViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get groups: %w", err))
}
nsGroups, err := GetNameServerGroupsViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get nameserver groups: %w", err))
}
networkResources, err := GetNetworkResourcesViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network resources: %w", err))
}
routers, err := GetNetworkRoutersViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network routers: %w", err))
}
network, err := GetNetworkViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network: %w", err))
}
peers, proxyPeers, err := GetPeersViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get peers: %w", err))
}
policies, policyToDestinationResourceIdx, policyToDestinationGroupIdx, err := GetPoliciesViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get policies: %w", err))
}
postureChecks, postureCheckXIDToPublicID, err := GetPostureChecksViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get posture checks: %w", err))
}
routes, err := GetRoutesViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get routes: %w", err))
}
networkXIDToPublicID, err := GetNetworkXIDToPublicIdMapViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network xid to public id map: %w", err))
}
allowedUserIds, groupsToUserIds, err := GetAllowedUsersViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get allowed users: %w", err))
}
dnsSettings, err := GetDnsSettingsViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get dns settings: %w", err))
}
domains, err := GetDomainsViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, err)
}
services, err := GetPrivateServicesViaPgxConnection(ctx, tx.Conn(), accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, err)
}
proxyTargetedDomainResourceIDs, err := GetProxyTargetedDomainResourceIDsViaPgxConnection(ctx, tx.Conn(), 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.Commit(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,
}
return &toret, nil
}
func rollbackAndReturnError(ctx context.Context, tx pgx.Tx, err error) (*networkmap.NetworkMapData, error) {
if errr := tx.Rollback(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

@@ -0,0 +1,65 @@
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 (
GetNetworkResourcesQuery = `
select id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled
from network_resources
where account_id=$1
`
)
func (pg *PgStore) GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, err
}
return GetNetworkResourcesViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetNetworkResourcesViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.NetworkResource, error) {
rows, err := con.Query(ctx, GetNetworkResourcesQuery, accountId)
if err != nil {
return nil, err
}
netresorces, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkresource])
if err != nil {
return nil, err
}
toret := make([]nmdata.NetworkResource, 0, len(netresorces))
for _, nres := range netresorces {
resource := nmdata.NetworkResource{}
err := networkmapdb.FromSqlTypesToSharedTypes(
reflect.ValueOf(&nres), reflect.ValueOf(&resource))
if err != nil {
return nil, err
}
toret = append(toret, resource)
}
return toret, nil
}
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 json.RawMessage
Enabled sql.NullBool
}

View File

@@ -0,0 +1,88 @@
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 (pg *PgStore) GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, err
}
return GetNetworkRoutersViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetNetworkRoutersViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error) {
rows, err := con.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

@@ -0,0 +1,52 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"github.com/jackc/pgx/v5"
)
const (
GetNetworksQuery = `
select id, public_id
from networks where account_id=$1
`
)
func (pg *PgStore) GetNetworks(ctx context.Context, accountId string) ([]network, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, err
}
return GetNetworksViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetNetworksViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]network, error) {
rows, err := con.Query(ctx, GetNetworksQuery, accountId)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, pgx.RowToStructByName[network])
}
func GetNetworkXIDToPublicIdMapViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) (map[string]string, error) {
networks, err := GetNetworksViaPgxConnection(ctx, con, accountId)
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
}
type network struct {
ID string
PublicID sql.NullString
}

View File

@@ -0,0 +1,148 @@
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 (
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 (pg *PgStore) GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, nil, err
}
return GetPeersViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetPeersViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) {
rows, err := con.Query(ctx, GetPeersQuery, accountId)
if err != nil {
return nil, nil, err
}
peers, err := pgx.CollectRows(rows, pgx.RowToStructByName[peer])
if err != nil {
return nil, nil, err
}
toret := make([]nmdata.Peer, 0, len(peers))
clusterToPeerIdx := make(map[string][]*nmdata.Peer)
for _, p := range peers {
dp := nmdata.Peer{}
err := networkmapdb.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
}
// 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 json.RawMessage
UserID sql.NullString
LastLogin sql.NullTime
SSHEnabled sql.NullBool
LoginExpirationEnabled sql.NullBool
PeerStatusConnected sql.NullBool `nmap:"skip"`
PeerStatusRequiresApproval sql.NullBool `nmap:"map_to:RequiresApproval"`
ProxyMetaEmbedded sql.NullBool `nmap:"skip"`
ProxyMetaCluster sql.NullString `nmap:"skip"`
IP json.RawMessage
IPv6 json.RawMessage
LocationConnectionIp json.RawMessage `nmap:"skip"`
MetaFiles json.RawMessage `nmap:"skip"`
MetaCapabilities json.RawMessage `nmap:"skip"`
MetaFlags json.RawMessage `nmap:"skip"`
MetaNetworkAddresses json.RawMessage `nmap:"skip"`
MetaWtVersion sql.NullString `nmap:"skip"`
MetaGoOS sql.NullString `nmap:"skip"`
MetaOSVersion sql.NullString `nmap:"skip"`
MetaKernelVersion sql.NullString `nmap:"skip"`
MetaSyncMessageVersion sql.NullInt64 `nmap:"skip"`
LocationCountryCode sql.NullString `nmap:"skip"`
LocationCityName sql.NullString `nmap:"skip"`
}

View File

@@ -0,0 +1,56 @@
package networkmap_pgsql
import (
"context"
"fmt"
"time"
"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
}
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 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

@@ -0,0 +1,168 @@
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 (
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 (pg *PgStore) GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, nil, nil, err
}
return GetPoliciesViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetPoliciesViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) {
rows, err := con.Query(ctx, GetPoliciesQuery, accountId)
if err != nil {
return nil, nil, nil, err
}
policies, err := pgx.CollectRows(rows, pgx.RowToStructByName[policy])
if err != nil {
return nil, nil, nil, err
}
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 := networkmapdb.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, err
}
type policy struct {
ID string
PublicID sql.NullString
SourcePostureChecks json.RawMessage
Enabled sql.NullBool
RuleEnabled sql.NullBool `nmap:"skip"`
Bidirectional sql.NullBool `nmap:"skip"`
Action sql.NullString `nmap:"skip"`
Protocol sql.NullString `nmap:"skip"`
Sources json.RawMessage `nmap:"skip"`
Destinations json.RawMessage `nmap:"skip"`
SourceResource json.RawMessage `nmap:"skip"`
DestinationResource json.RawMessage `nmap:"skip"`
Ports json.RawMessage `nmap:"skip"`
PortRanges json.RawMessage `nmap:"skip"`
AuthorizedGroups json.RawMessage `nmap:"skip"`
AuthorizedUser sql.NullString `nmap:"skip"`
}

View File

@@ -0,0 +1,60 @@
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 (
GetPostureChecksQuery = `
select id, public_id, checks
from posture_checks
where account_id=$1
`
)
func (pg *PgStore) GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, nil, err
}
return GetPostureChecksViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetPostureChecksViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.PostureChecks, map[string]string, error) {
rows, err := con.Query(ctx, GetPostureChecksQuery, accountId)
if err != nil {
return nil, nil, err
}
checks, err := pgx.CollectRows(rows, pgx.RowToStructByName[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
}
type posturechecks struct {
ID string
PublicID sql.NullString `nmap:"skip"`
Checks json.RawMessage
}

View File

@@ -0,0 +1,75 @@
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 (
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 (pg *PgStore) GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, err
}
return GetRoutesViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetRoutesViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.Route, error) {
rows, err := con.Query(ctx, GetRoutesQuery, accountId)
if err != nil {
return nil, err
}
routes, err := pgx.CollectRows(rows, pgx.RowToStructByName[route])
if err != nil {
return nil, err
}
toret := make([]nmdata.Route, 0, len(routes))
for _, r := range routes {
route := nmdata.Route{}
err := networkmapdb.FromSqlTypesToSharedTypes(
reflect.ValueOf(&r), reflect.ValueOf(&route))
if err != nil {
return nil, err
}
toret = append(toret, route)
}
return toret, nil
}
type route struct {
ID string
AccountID sql.NullString
PublicID sql.NullString
Network json.RawMessage
Domains json.RawMessage
KeepRoute sql.NullBool
NetID sql.NullString
Description sql.NullString
Peer sql.NullString
PeerID sql.NullString
PeerGroups json.RawMessage
NetworkType sql.NullInt64
Masquerade sql.NullBool
Metric sql.NullInt64
Enabled sql.NullBool
Groups json.RawMessage
AccessControlGroups json.RawMessage
SkipAutoApply sql.NullBool
}

View File

@@ -0,0 +1,67 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"github.com/jackc/pgx/v5"
)
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 (pg *PgStore) GetPrivateServices(ctx context.Context, accountId string) ([]Service, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, err
}
return GetPrivateServicesViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetPrivateServicesViaPgxConnection(ctx context.Context, conn *pgx.Conn, accountId string) ([]Service, error) {
rows, err := conn.Query(ctx, GetServicesQuery, accountId)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, pgx.RowToStructByName[Service])
}
func GetProxyTargetedDomainResourceIDsViaPgxConnection(ctx context.Context, conn *pgx.Conn, accountId string) (map[string]struct{}, error) {
rows, err := 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
}
type Service struct {
Enabled sql.NullBool
Private sql.NullBool
AccessGroups []string
ProxyCluster sql.NullString
Domain sql.NullString
}

View File

@@ -0,0 +1,219 @@
package networkmap_pgsql
import (
"database/sql"
"encoding/json"
"reflect"
"testing"
"time"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/stretchr/testify/assert"
)
func TestNullStringSupport(t *testing.T) {
src := withNullString{Name: sql.NullString{String: "string", Valid: true}}
dst := withString{}
assert.NoError(t, networkmapdb.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, networkmapdb.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, networkmapdb.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, networkmapdb.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, networkmapdb.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, networkmapdb.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, networkmapdb.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, networkmapdb.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, networkmapdb.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, networkmapdb.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, networkmapdb.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, networkmapdb.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, networkmapdb.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, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
assert.NotEmpty(t, dst.PublicID)
assert.NotEmpty(t, dst.PublicId)
}
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
}

View File

@@ -0,0 +1,68 @@
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 (pg *PgStore) GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error) {
c, err := pg.Pool.Acquire(ctx)
if err != nil {
return nil, nil, err
}
return GetAllowedUsersViaPgxConnection(ctx, c.Conn(), accountId)
}
func GetAllowedUsersViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) (map[string]struct{}, map[string][]string, error) {
rows, err := con.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 = con.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

@@ -7,6 +7,7 @@ import (
"crypto/tls"
"net/http"
"net/netip"
"os"
"slices"
"time"
@@ -28,6 +29,8 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/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"
@@ -99,6 +102,22 @@ 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

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)
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())
})
}

View File

@@ -4,10 +4,9 @@ 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"
)
@@ -84,6 +83,7 @@ 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,7 +105,6 @@ 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,
@@ -130,7 +129,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 *types.Network) uint64 {
func networkSerial(n *nmdata.Network) uint64 {
if n == nil {
return 0
}
@@ -143,16 +142,15 @@ type componentEncoder struct {
peerOrder map[string]uint32
peers []*proto.PeerCompact
agentVersionOrder map[string]uint32
agentVersions []string
networkIdToPublicId map[string]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)),
agentVersionOrder: make(map[string]uint32),
components: c,
peerOrder: make(map[string]uint32, len(c.Peers)),
peers: make([]*proto.PeerCompact, 0, len(c.Peers)),
networkIdToPublicId: make(map[string]string),
}
}
@@ -165,7 +163,7 @@ func (e *componentEncoder) indexAllPeers() {
}
}
func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 {
func (e *componentEncoder) appendPeer(p *nmdata.Peer) uint32 {
if idx, ok := e.peerOrder[p.ID]; ok {
return idx
}
@@ -179,7 +177,7 @@ func (e *componentEncoder) appendPeer(p *types.ComponentPeer) 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]*types.ComponentPeer) []uint32 {
func (e *componentEncoder) indexRouterPeers(routers map[string]*nmdata.Peer) []uint32 {
if len(routers) == 0 {
return nil
}
@@ -193,6 +191,15 @@ func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentP
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
@@ -206,10 +213,22 @@ 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
@@ -219,7 +238,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 []*types.Policy) []*proto.PolicyCompact {
func (e *componentEncoder) encodePolicies(policies []*nmdata.Policy) []*proto.PolicyCompact {
if len(policies) == 0 {
return nil
}
@@ -241,7 +260,7 @@ func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.Pol
}
// encodePolicyRule maps a single PolicyRule under pol to a PolicyCompact entry.
func (e *componentEncoder) encodePolicyRule(pol *types.Policy, r *types.PolicyRule) *proto.PolicyCompact {
func (e *componentEncoder) encodePolicyRule(pol *nmdata.Policy, r *nmdata.PolicyRule) *proto.PolicyCompact {
return &proto.PolicyCompact{
Id: pol.PublicID,
Action: networkmap.GetProtoAction(string(r.Action)),
@@ -280,14 +299,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 []*types.Policy, resourcePolicies map[string][]*types.Policy) []*types.Policy {
func unionPolicies(policies []*nmdata.Policy, resourcePolicies map[string][]*nmdata.Policy) []*nmdata.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([]*types.Policy, 0, len(policies))
out := make([]*nmdata.Policy, 0, len(policies))
for _, p := range policies {
if p == nil {
continue
@@ -345,18 +364,31 @@ 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 types.Resource) *proto.ResourceCompact {
if r.ID == "" && r.Type == "" {
func (e *componentEncoder) resourceToProto(r nmdata.Resource) *proto.ResourceCompact {
t, ok := proto.ResourceCompactType_value[string(r.Type)]
if !ok || t == 0 || r.ID == "" {
return nil
}
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
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},
}
}
return out
publicID, ok := e.networkIdToPublicId[r.ID]
if !ok {
return nil
}
return &proto.ResourceCompact{
Type: proto.ResourceCompactType(t),
ResourceId: &proto.ResourceCompact_Id{Id: publicID},
}
}
// postureCheckSeqs translates a slice of posture-check xids to their
@@ -389,7 +421,7 @@ func (e *componentEncoder) networkPublicId(xid string) (string, bool) {
return id, true
}
func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSettingsCompact {
func (e *componentEncoder) encodeDNSSettings(s *nmdata.DNSSettings) *proto.DNSSettingsCompact {
if s == nil || len(s.DisabledManagementGroups) == 0 {
return nil
}
@@ -404,7 +436,7 @@ func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSet
return out
}
func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteRaw {
func (e *componentEncoder) encodeRoutes(routes []*nmdata.Route) []*proto.RouteRaw {
if len(routes) == 0 {
return nil
}
@@ -442,7 +474,7 @@ func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteR
return out
}
func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup) []*proto.NameServerGroupRaw {
func (e *componentEncoder) encodeNameServerGroups(nsgs []*nmdata.NameServerGroup) []*proto.NameServerGroupRaw {
if len(nsgs) == 0 {
return nil
}
@@ -465,7 +497,7 @@ func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup)
return out
}
func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer {
func encodeNameServers(servers []nmdata.NameServer) []*proto.NameServer {
if len(servers) == 0 {
return nil
}
@@ -480,7 +512,7 @@ func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer {
return out
}
func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord {
func encodeSimpleRecords(records []nmdata.SimpleRecord) []*proto.SimpleRecord {
if len(records) == 0 {
return nil
}
@@ -497,7 +529,7 @@ func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord {
return out
}
func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone {
func encodeCustomZones(zones []nmdata.CustomZone) []*proto.CustomZone {
if len(zones) == 0 {
return nil
}
@@ -513,7 +545,7 @@ func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone {
return out
}
func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw {
func (e *componentEncoder) encodeNetworkResources(resources []*nmdata.NetworkResource) []*proto.NetworkResourceRaw {
if len(resources) == 0 {
return nil
}
@@ -542,7 +574,7 @@ func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentRe
return out
}
func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList {
func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*nmdata.NetworkRouter) map[string]*proto.NetworkRouterList {
if len(routersMap) == 0 {
return nil
}
@@ -578,7 +610,7 @@ func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*ty
return out
}
func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Policy) map[string]*proto.PolicyIds {
func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*nmdata.Policy) map[string]*proto.PolicyIds {
if len(rpm) == 0 {
return nil
}
@@ -665,7 +697,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 *types.AccountSettingsInfo) *proto.AccountSettingsCompact {
func toAccountSettingsCompact(s *nmdata.AccountSettingsInfo) *proto.AccountSettingsCompact {
if s == nil {
return &proto.AccountSettingsCompact{}
}
@@ -675,7 +707,7 @@ func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettin
}
}
func toAccountNetwork(n *types.Network) *proto.AccountNetwork {
func toAccountNetwork(n *nmdata.Network) *proto.AccountNetwork {
if n == nil {
return nil
}
@@ -691,20 +723,20 @@ func toAccountNetwork(n *types.Network) *proto.AccountNetwork {
return out
}
func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact {
func toPeerCompact(p *nmdata.Peer) *proto.PeerCompact {
pc := &proto.PeerCompact{
WgPubKey: decodeWgKey(p.Key),
SshPubKey: []byte(p.SSHKey),
DnsLabel: p.DNSLabel,
AgentVersion: p.AgentVersion,
AddedWithSsoLogin: p.AddedWithSSOLogin,
AgentVersion: p.Meta.WtVersion,
AddedWithSsoLogin: p.UserID != "",
LoginExpirationEnabled: p.LoginExpirationEnabled,
SshEnabled: p.SSHEnabled,
SupportsIpv6: p.SupportsIPv6,
SupportsSourcePrefixes: p.SupportsSourcePrefixes,
ServerSshAllowed: p.ServerSSHAllowed,
SupportsIpv6: p.SupportsIPv6(),
SupportsSourcePrefixes: p.SupportsSourcePrefixes(),
ServerSshAllowed: p.Meta.Flags.ServerSSHAllowed,
}
if !p.LastLogin.IsZero() {
if p.LastLogin != nil {
pc.LastLoginUnixNano = p.LastLogin.UnixNano()
}
switch {
@@ -753,7 +785,7 @@ func portsToUint32(ports []string) []uint32 {
return out
}
func portRangesToProto(ranges []types.RulePortRange) []*proto.PortInfo_Range {
func portRangesToProto(ranges []nmdata.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"
nbroute "github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -152,66 +152,66 @@ func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool {
}
func newTestComponents() *types.NetworkMapComponents {
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",
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"},
}
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",
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"},
}
peerC := &types.ComponentPeer{
ID: "peer-c",
Key: testWgKeyC,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
DNSLabel: "peerc",
AgentVersion: "0.40.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"},
}
return &types.NetworkMapComponents{
PeerID: "peer-a",
Network: &types.Network{
Network: &nmdata.Network{
Identifier: "net-test",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 7,
},
AccountSettings: &types.AccountSettingsInfo{
AccountSettings: &nmdata.AccountSettingsInfo{
PeerLoginExpirationEnabled: true,
PeerLoginExpiration: 2 * time.Hour,
},
Peers: map[string]*types.ComponentPeer{
Peers: map[string]*nmdata.Peer{
"peer-a": peerA,
"peer-b": peerB,
"peer-c": peerC,
},
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"}},
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"}},
},
Policies: []*types.Policy{
Policies: []*nmdata.Policy{
{
ID: "pol-1",
PublicID: "10",
Enabled: true,
Rules: []*types.PolicyRule{{
ID: "rule-1", Enabled: true, Action: types.PolicyTrafficActionAccept,
Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true,
Rules: []*nmdata.PolicyRule{{
ID: "rule-1", Enabled: true, Action: string(types.PolicyTrafficActionAccept),
Protocol: string(types.PolicyRuleProtocolTCP), Bidirectional: true,
Ports: []string{"22", "80"},
PortRanges: []types.RulePortRange{{Start: 8000, End: 8100}},
PortRanges: []nmdata.RulePortRange{{Start: 8000, End: 8100}},
Sources: []string{"group-src"},
Destinations: []string{"group-dst"},
}},
},
},
RouterPeers: map[string]*types.ComponentPeer{"peer-c": peerC},
RouterPeers: map[string]*nmdata.Peer{"peer-c": peerC},
}
}
@@ -304,6 +304,31 @@ 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()
@@ -377,12 +402,12 @@ func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) {
func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) {
c := newTestComponents()
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",
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"},
}
c.Peers["peer-v6"] = v6Only
@@ -401,11 +426,11 @@ func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) {
func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) {
c := newTestComponents()
c.Peers["peer-noip"] = &types.ComponentPeer{
ID: "peer-noip",
Key: testWgKeyA,
DNSLabel: "peernoip",
AgentVersion: "0.40.0",
c.Peers["peer-noip"] = &nmdata.Peer{
ID: "peer-noip",
Key: testWgKeyA,
DNSLabel: "peernoip",
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
}
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
@@ -423,7 +448,7 @@ func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) {
func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) {
c := &types.NetworkMapComponents{
Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
}
env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c})
@@ -440,9 +465,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"].AddedWithSSOLogin = true
c.Peers["peer-a"].UserID = "user-1"
c.Peers["peer-a"].LoginExpirationEnabled = true
c.Peers["peer-a"].LastLogin = now
c.Peers["peer-a"].LastLogin = &now
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
@@ -472,7 +497,7 @@ func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) {
func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) {
c := newTestComponents()
c.Routes = []*nbroute.Route{
c.Routes = []*nmdata.Route{
{
ID: "route-peer",
PublicID: "100",
@@ -519,7 +544,7 @@ func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) {
func TestEncodeNetworkMapEnvelope_RouteWithMissingPeerLeavesIndexUnset(t *testing.T) {
c := newTestComponents()
c.Routes = []*nbroute.Route{{
c.Routes = []*nmdata.Route{{
ID: "route-x",
PublicID: "100",
Peer: "peer-not-in-components",
@@ -539,21 +564,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 := &types.Policy{
resourceOnlyPolicy := &nmdata.Policy{
ID: "pol-resource", PublicID: "99", Enabled: true,
Rules: []*types.PolicyRule{{
ID: "rule-r", Enabled: true, Action: types.PolicyTrafficActionAccept,
Protocol: types.PolicyRuleProtocolTCP,
Rules: []*nmdata.PolicyRule{{
ID: "rule-r", Enabled: true, Action: string(types.PolicyTrafficActionAccept),
Protocol: string(types.PolicyRuleProtocolTCP),
Sources: []string{"group-src"},
Destinations: []string{"group-dst"},
}},
}
c.ResourcePoliciesMap = map[string][]*types.Policy{
c.ResourcePoliciesMap = map[string][]*nmdata.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 = []*types.ComponentResource{
c.NetworkResources = []*nmdata.NetworkResource{
{ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true},
}
@@ -579,10 +604,10 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing
func TestEncodeNetworkMapEnvelope_NameServerGroups(t *testing.T) {
c := newTestComponents()
c.NameServerGroups = []*nbdns.NameServerGroup{{
c.NameServerGroups = []*nmdata.NameServerGroup{{
ID: "nsg-1", PublicID: "50", Name: "Main", Description: "primary",
NameServers: []nbdns.NameServer{{
IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53,
NameServers: []nmdata.NameServer{{
IP: netip.MustParseAddr("8.8.8.8"), NSType: int(nbdns.UDPNameServerType), Port: 53,
}},
Groups: []string{"group-src", "group-not-persisted"},
Primary: true, Enabled: true,
@@ -621,11 +646,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]*types.ComponentRouter{
c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{
"net-1": {
"peer-c": {
PublicID: "200",
Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true,
PublicID: "200",
Masquerade: true, Metric: 10, Enabled: true,
},
},
}
@@ -651,14 +676,14 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) {
// peer_index reference must still resolve.
c := newTestComponents()
delete(c.Peers, "peer-c")
routerPeer := &types.ComponentPeer{
routerPeer := &nmdata.Peer{
ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
DNSLabel: "peerc", AgentVersion: "0.40.0",
DNSLabel: "peerc", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
}
c.RouterPeers = map[string]*types.ComponentPeer{"peer-c": routerPeer}
c.RouterPeers = map[string]*nmdata.Peer{"peer-c": routerPeer}
c.NetworkXIDToPublicID = map[string]string{"net-1": "5"}
c.RoutersMap = map[string]map[string]*types.ComponentRouter{
"net-1": {"peer-c": {PublicID: "1", Peer: "peer-c", Enabled: true}},
c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{
"net-1": {"peer-c": {PublicID: "1", Enabled: true}},
}
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
@@ -691,9 +716,9 @@ func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) {
func TestToProxyPatch_PopulatesAllFields(t *testing.T) {
nm := &types.NetworkMap{
Peers: []*types.ComponentPeer{{
Peers: []*nmdata.Peer{{
ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}),
DNSLabel: "extpeer", AgentVersion: "0.40.0",
DNSLabel: "extpeer", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
}},
FirewallRules: []*types.FirewallRule{{
PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp",
@@ -765,7 +790,7 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
c := &types.NetworkMapComponents{
Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
// AccountSettings deliberately nil
}
@@ -779,8 +804,8 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
func emptyNetworkMapComponents() *types.NetworkMapComponents {
return types.EmptyNetworkMapComponents(
&types.NetworkMapComponents{
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}},
Network: &types.Network{
PeerID: "peer-id", Peers: map[string]*nmdata.Peer{"peer-id": {}},
Network: &nmdata.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 *nbpeer.Peer,
peer *nmdata.Peer,
turnCredentials *Token,
relayCredentials *Token,
components *types.NetworkMapComponents,
proxyPatch *types.NetworkMap,
dnsName string,
checks []*posture.Checks,
settings *types.Settings,
settings *nmdata.AccountSettingsInfo,
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 *nbpeer.Peer) bool {
func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nmdata.Peer) bool {
if c == nil || peer == nil {
return false
}
@@ -170,25 +170,25 @@ func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.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 *types.PolicyRule, peer *nbpeer.Peer) bool {
func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peer *nmdata.Peer) bool {
if rule == nil || !rule.Enabled {
return false
}
if !peerInDestinations(c, rule, peer.ID) {
return false
}
if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH {
if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) {
return true
}
return peer.SSHEnabled && types.PolicyRuleImpliesLegacySSH(rule)
return peer.SSHEnabled && nmdata.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 *types.PolicyRule, peerID string) bool {
if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" {
func peerInDestinations(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peerID string) bool {
if rule.DestinationResource.Type == string(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,16 +17,15 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
const targetPeerID = "target"
const targetGroupID = "g_dst"
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}}
mkComponents := func(rule *nmdata.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nmdata.Peer) {
peer := &nmdata.Peer{ID: targetPeerID, SSHEnabled: sshEnabled}
return &types.NetworkMapComponents{
Peers: map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()},
Groups: map[string]*types.ComponentGroup{targetGroupID: group},
Policies: []*types.Policy{{
Peers: map[string]*nmdata.Peer{targetPeerID: peer},
Groups: map[string]*nmdata.Group{targetGroupID: {Name: "dst", Peers: []string{targetPeerID}}},
Policies: []*nmdata.Policy{{
ID: "p",
Enabled: true,
Rules: []*types.PolicyRule{rule},
Rules: []*nmdata.PolicyRule{rule},
}},
}, peer
}
@@ -34,14 +33,14 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
cases := []struct {
name string
peerSSH bool
rule types.PolicyRule
rule nmdata.PolicyRule
wantEnabled bool
}{
{
name: "explicit-netbird-ssh-activates-regardless-of-peer-ssh",
peerSSH: false,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -49,8 +48,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-tcp-22-with-peer-ssh",
peerSSH: true,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -58,8 +57,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-tcp-22-without-peer-ssh-disabled",
peerSSH: false,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
Destinations: []string{targetGroupID},
},
wantEnabled: false,
@@ -67,8 +66,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-tcp-22022-with-peer-ssh",
peerSSH: true,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22022"},
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22022"},
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -76,8 +75,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-all-protocol-with-peer-ssh",
peerSSH: true,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolALL,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolALL),
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -85,10 +84,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-port-range-covers-22",
peerSSH: true,
rule: types.PolicyRule{
rule: nmdata.PolicyRule{
Enabled: true,
Protocol: types.PolicyRuleProtocolTCP,
PortRanges: []types.RulePortRange{{Start: 20, End: 30}},
Protocol: string(types.PolicyRuleProtocolTCP),
PortRanges: []nmdata.RulePortRange{{Start: 20, End: 30}},
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -96,8 +95,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "tcp-80-no-ssh",
peerSSH: true,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"80"},
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"80"},
Destinations: []string{targetGroupID},
},
wantEnabled: false,
@@ -105,8 +104,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "disabled-rule-skipped",
peerSSH: true,
rule: types.PolicyRule{
Enabled: false, Protocol: types.PolicyRuleProtocolNetbirdSSH,
rule: nmdata.PolicyRule{
Enabled: false, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
Destinations: []string{targetGroupID},
},
wantEnabled: false,
@@ -114,8 +113,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "peer-not-in-destinations",
peerSSH: true,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
Destinations: []string{"g_other"}, // target not in this group
},
wantEnabled: false,
@@ -123,21 +122,21 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "peer-typed-destination-resource-matches",
peerSSH: false,
rule: types.PolicyRule{
rule: nmdata.PolicyRule{
Enabled: true,
Protocol: types.PolicyRuleProtocolNetbirdSSH,
DestinationResource: types.Resource{ID: targetPeerID, Type: types.ResourceTypePeer},
Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
DestinationResource: nmdata.Resource{ID: targetPeerID, Type: string(types.ResourceTypePeer)},
},
wantEnabled: true,
},
{
name: "non-peer-destination-resource-falls-through-to-groups",
peerSSH: false,
rule: types.PolicyRule{
rule: nmdata.PolicyRule{
Enabled: true,
Protocol: types.PolicyRuleProtocolNetbirdSSH,
DestinationResource: types.Resource{ID: targetPeerID, Type: "host"}, // wrong type
Destinations: []string{targetGroupID}, // saved by group fallback
Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
DestinationResource: nmdata.Resource{ID: targetPeerID, Type: "host"}, // wrong type
Destinations: []string{targetGroupID}, // saved by group fallback
},
wantEnabled: true,
},
@@ -156,16 +155,16 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
// belt-and-suspenders presence guard mirroring Calculate's
// getAllPeersFromGroups invariant.
func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) {
peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true}
peer := &nmdata.Peer{ID: "missing", SSHEnabled: true}
c := &types.NetworkMapComponents{
Peers: map[string]*types.ComponentPeer{}, // target peer NOT present
Groups: map[string]*types.ComponentGroup{
"g": {ID: "g", Peers: []string{"missing"}},
Peers: map[string]*nmdata.Peer{}, // target peer NOT present
Groups: map[string]*nmdata.Group{
"g": {Peers: []string{"missing"}},
},
Policies: []*types.Policy{{
Policies: []*nmdata.Policy{{
ID: "p", Enabled: true,
Rules: []*types.PolicyRule{{
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
Rules: []*nmdata.PolicyRule{{
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
Destinations: []string{"g"},
}},
}},
@@ -179,6 +178,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, &nbpeer.Peer{ID: "x"}))
assert.False(t, computeSSHEnabledForPeer(nil, &nmdata.Peer{ID: "x"}))
assert.False(t, computeSSHEnabledForPeer(&types.NetworkMapComponents{}, nil))
}

View File

@@ -18,10 +18,10 @@ import (
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
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"
"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/netiputil"
)
@@ -47,7 +47,7 @@ func init() {
// nil when no server config is set (the fan-out network-map path) because clients treat any
// non-nil config as authoritative: a config without a relay section is interpreted as relay
// disabled and wipes the clients' relay URLs.
func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *types.Settings) *proto.NetbirdConfig {
func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *nmdata.AccountSettingsInfo) *proto.NetbirdConfig {
if config == nil {
return nil
}
@@ -119,7 +119,7 @@ func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken
return nbConfig
}
func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
func toPeerConfig(peer *nmdata.Peer, network *nmdata.Network, dnsName string, settings *nmdata.AccountSettingsInfo, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
netmask, _ := network.Net.Mask.Size()
fqdn := peer.FQDN(dnsName)
@@ -154,7 +154,7 @@ func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, set
return peerConfig
}
func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nbpeer.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *types.Settings, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse {
func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nmdata.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *nmdata.AccountSettingsInfo, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse {
// IPv6 data in AllowedIPs and SourcePrefixes wildcard expansion depends on
// whether the target peer supports IPv6. Routes and firewall rules are already
// filtered at the source (network map builder).

View File

@@ -278,7 +278,7 @@ func TestToNetbirdConfig_RelayInvariant(t *testing.T) {
settings := &types.Settings{MetricsPushEnabled: true}
t.Run("nil server config returns nil config", func(t *testing.T) {
nbCfg := toNetbirdConfig(nil, nil, nil, nil, settings)
nbCfg := toNetbirdConfig(nil, nil, nil, nil, types.TwinAccountSettings(settings))
assert.Nil(t, nbCfg, "fan-out updates must not carry a partial NetbirdConfig even when settings are present")
})
@@ -293,7 +293,7 @@ func TestToNetbirdConfig_RelayInvariant(t *testing.T) {
}
relayToken := &Token{Payload: "token-payload", Signature: "token-signature"}
nbCfg := toNetbirdConfig(cfg, nil, relayToken, nil, settings)
nbCfg := toNetbirdConfig(cfg, nil, relayToken, nil, types.TwinAccountSettings(settings))
require.NotNil(t, nbCfg)
require.NotNil(t, nbCfg.Relay, "non-nil NetbirdConfig must include the relay section")
assert.Equal(t, cfg.Relay.Addresses, nbCfg.Relay.Urls, "relay URLs should match the server config")
@@ -329,7 +329,7 @@ func TestToPeerConfig_RoutingPeerDNSResolution(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings := &types.Settings{RoutingPeerDNSResolutionEnabled: tt.globalFlag}
cfg := toPeerConfig(newPeer(tt.embedded), network, "netbird.selfhosted", settings, nil, nil, false, tt.forceParam)
cfg := toPeerConfig(types.TwinPeer(newPeer(tt.embedded)), types.TwinNetwork(network), "netbird.selfhosted", types.TwinAccountSettings(settings), nil, nil, false, tt.forceParam)
assert.Equal(t, tt.wantEnabled, cfg.RoutingPeerDnsResolutionEnabled,
"RoutingPeerDnsResolutionEnabled should reflect global || embedded || forced")
})

View File

@@ -29,6 +29,7 @@ import (
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
@@ -36,7 +37,6 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/users"
proxyauth "github.com/netbirdio/netbird/proxy/auth"

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