mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-06 07:41:27 +02:00
Compare commits
96 Commits
ssh-settin
...
revert/com
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b8393c6b0 | ||
|
|
316e82337f | ||
|
|
bacacb8f77 | ||
|
|
994e84a686 | ||
|
|
07809fe923 | ||
|
|
ec3e37456e | ||
|
|
c81103dfa6 | ||
|
|
ba574dc739 | ||
|
|
067982f772 | ||
|
|
ce4f0a7821 | ||
|
|
3218e2f744 | ||
|
|
7e24c27ef1 | ||
|
|
06817271bf | ||
|
|
1530739b54 | ||
|
|
e745aedc92 | ||
|
|
4cf3903c83 | ||
|
|
1e14b554a6 | ||
|
|
11a3627899 | ||
|
|
2442bd6198 | ||
|
|
006cee000f | ||
|
|
c93aa03c0e | ||
|
|
efe2eaeb09 | ||
|
|
1926e983fb | ||
|
|
e9b8175915 | ||
|
|
88f1930450 | ||
|
|
1774a3d9dd | ||
|
|
795e06ce83 | ||
|
|
748f6b3fbb | ||
|
|
d5adfc799f | ||
|
|
60fe56e442 | ||
|
|
327fa4c0d6 | ||
|
|
ed015a7972 | ||
|
|
b43708e31b | ||
|
|
ce55b8406b | ||
|
|
0456b64ea1 | ||
|
|
942ee81ec0 | ||
|
|
33a0e1bc2b | ||
|
|
dbfdd04c7b | ||
|
|
4d010f60ce | ||
|
|
3272058e56 | ||
|
|
70c3feb05b | ||
|
|
a0fe80cd99 | ||
|
|
6fadf8f24a | ||
|
|
ef0032685b | ||
|
|
5ed38569f7 | ||
|
|
d4e1c8978e | ||
|
|
d5ac70d806 | ||
|
|
23579e4dd4 | ||
|
|
a00c5164a8 | ||
|
|
02138dffdd | ||
|
|
02ec1f5dcb | ||
|
|
07d0440e34 | ||
|
|
2c85d94c6c | ||
|
|
1f190a50cf | ||
|
|
52fdfd5bdc | ||
|
|
e35a0f3318 | ||
|
|
56c411babd | ||
|
|
164b2baa4d | ||
|
|
0b29c6ed1a | ||
|
|
724b61440b | ||
|
|
22590ad66e | ||
|
|
b8e004ea89 | ||
|
|
e2797360f4 | ||
|
|
2f399f1e6e | ||
|
|
82c1e18264 | ||
|
|
ce9023bd27 | ||
|
|
7d33356776 | ||
|
|
993291149c | ||
|
|
98f7ea40a1 | ||
|
|
76db9ab94f | ||
|
|
9653b15d78 | ||
|
|
206bb1676b | ||
|
|
600b0c752b | ||
|
|
b02736adc3 | ||
|
|
5d6117d2c0 | ||
|
|
c2f8360b00 | ||
|
|
ea1b4d56e8 | ||
|
|
acbe22b831 | ||
|
|
3b5c8e2298 | ||
|
|
2baeb4bc0d | ||
|
|
4bf75fdc97 | ||
|
|
799f3a3c62 | ||
|
|
129736ad61 | ||
|
|
f7be9c4347 | ||
|
|
e05cb5264d | ||
|
|
5a10561ca1 | ||
|
|
42ce83a8f3 | ||
|
|
e620c86cd4 | ||
|
|
9dee2d60b9 | ||
|
|
4525014632 | ||
|
|
23a5c0de4b | ||
|
|
25e882004f | ||
|
|
15003258d2 | ||
|
|
2af3a5fba5 | ||
|
|
a6603a2e0a | ||
|
|
7ed3737cda |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -232,4 +232,3 @@ func toNetIDs(routes []string) []route.NetID {
|
||||
}
|
||||
return netIDs
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "L’opération a échoué."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird n’a pas pu demander à ce système les privilèges nécessaires. Exécutez plutôt ceci :"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "La modification n’a 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 l’activer, mais le désactiver de nouveau nécessite {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "En attente de l’autorisation…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "承認を待っています…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Ожидание авторизации…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "正在等待授权…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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."},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
155
integration_tests/management/network_map_db/pgsql/main_test.go
Normal file
155
integration_tests/management/network_map_db/pgsql/main_test.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"}}})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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": {},
|
||||
})
|
||||
}
|
||||
@@ -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"},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
171
management/internals/network_map_db/db_store.go
Normal file
171
management/internals/network_map_db/db_store.go
Normal 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])}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
124
management/internals/network_map_db/pgsql/dns.go
Normal file
124
management/internals/network_map_db/pgsql/dns.go
Normal 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,
|
||||
}
|
||||
}
|
||||
53
management/internals/network_map_db/pgsql/dns_settings.go
Normal file
53
management/internals/network_map_db/pgsql/dns_settings.go
Normal 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
|
||||
}
|
||||
38
management/internals/network_map_db/pgsql/dns_test.go
Normal file
38
management/internals/network_map_db/pgsql/dns_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
38
management/internals/network_map_db/pgsql/domain.go
Normal file
38
management/internals/network_map_db/pgsql/domain.go
Normal 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
|
||||
}
|
||||
72
management/internals/network_map_db/pgsql/group.go
Normal file
72
management/internals/network_map_db/pgsql/group.go
Normal 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
|
||||
}
|
||||
65
management/internals/network_map_db/pgsql/nameserver.go
Normal file
65
management/internals/network_map_db/pgsql/nameserver.go
Normal 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
|
||||
}
|
||||
57
management/internals/network_map_db/pgsql/network.go
Normal file
57
management/internals/network_map_db/pgsql/network.go
Normal 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
|
||||
}
|
||||
242
management/internals/network_map_db/pgsql/network_map_data.go
Normal file
242
management/internals/network_map_db/pgsql/network_map_data.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
88
management/internals/network_map_db/pgsql/network_router.go
Normal file
88
management/internals/network_map_db/pgsql/network_router.go
Normal 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
|
||||
}
|
||||
52
management/internals/network_map_db/pgsql/networks.go
Normal file
52
management/internals/network_map_db/pgsql/networks.go
Normal 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
|
||||
}
|
||||
148
management/internals/network_map_db/pgsql/peer.go
Normal file
148
management/internals/network_map_db/pgsql/peer.go
Normal 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"`
|
||||
}
|
||||
56
management/internals/network_map_db/pgsql/pg_store.go
Normal file
56
management/internals/network_map_db/pgsql/pg_store.go
Normal 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
|
||||
}
|
||||
168
management/internals/network_map_db/pgsql/policy.go
Normal file
168
management/internals/network_map_db/pgsql/policy.go
Normal 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"`
|
||||
}
|
||||
60
management/internals/network_map_db/pgsql/posture.go
Normal file
60
management/internals/network_map_db/pgsql/posture.go
Normal 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
|
||||
}
|
||||
75
management/internals/network_map_db/pgsql/route.go
Normal file
75
management/internals/network_map_db/pgsql/route.go
Normal 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
|
||||
}
|
||||
67
management/internals/network_map_db/pgsql/service.go
Normal file
67
management/internals/network_map_db/pgsql/service.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
68
management/internals/network_map_db/pgsql/user.go
Normal file
68
management/internals/network_map_db/pgsql/user.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user