mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-09 17:21:29 +02:00
Compare commits
26 Commits
notificati
...
ssh-settin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e403567b88 | ||
|
|
1ea9405557 | ||
|
|
c4d01f0c8e | ||
|
|
6495ad8687 | ||
|
|
6526fc2bec | ||
|
|
2afa69b622 | ||
|
|
2a61eac047 | ||
|
|
f2d13b884a | ||
|
|
564595d283 | ||
|
|
78c1c2fc32 | ||
|
|
bc7a15ab71 | ||
|
|
530021aec6 | ||
|
|
1bedb4e59d | ||
|
|
7546e7751c | ||
|
|
075b319fb3 | ||
|
|
b82a42c855 | ||
|
|
e90be36cd5 | ||
|
|
d29bc23bb7 | ||
|
|
ee1389d736 | ||
|
|
6f42636514 | ||
|
|
28197e6504 | ||
|
|
2f721ec0d5 | ||
|
|
f9b412228e | ||
|
|
2bfd9fcffe | ||
|
|
7639655883 | ||
|
|
6044663788 |
2
.github/workflows/pr-title-check.yml
vendored
2
.github/workflows/pr-title-check.yml
vendored
@@ -16,6 +16,8 @@ jobs:
|
|||||||
const allowedTags = [
|
const allowedTags = [
|
||||||
'management',
|
'management',
|
||||||
'client',
|
'client',
|
||||||
|
'android',
|
||||||
|
'ios',
|
||||||
'signal',
|
'signal',
|
||||||
'proxy',
|
'proxy',
|
||||||
'relay',
|
'relay',
|
||||||
|
|||||||
@@ -92,8 +92,15 @@ nfpms:
|
|||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.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:
|
dependencies:
|
||||||
- netbird
|
- netbird (>= 0.75.0)
|
||||||
|
- libgtk-4-1 (>= 4.14)
|
||||||
|
- libwebkitgtk-6.0-4
|
||||||
|
|
||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
@@ -113,8 +120,15 @@ nfpms:
|
|||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.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:
|
dependencies:
|
||||||
- netbird
|
- netbird >= 0.75.0
|
||||||
|
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||||
|
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||||
|
|
||||||
rpm:
|
rpm:
|
||||||
signature:
|
signature:
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ type DnsReadyListener interface {
|
|||||||
dns.ReadyListener
|
dns.ReadyListener
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
|
||||||
|
type TunSettings struct {
|
||||||
|
Routes string
|
||||||
|
SearchDomains string
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
formatter.SetLogcatFormatter(log.StandardLogger())
|
formatter.SetLogcatFormatter(log.StandardLogger())
|
||||||
}
|
}
|
||||||
@@ -76,6 +82,8 @@ type Client struct {
|
|||||||
connectClient *internal.ConnectClient
|
connectClient *internal.ConnectClient
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
cacheDir string
|
cacheDir string
|
||||||
|
// Identifies the running profile for the SSO login hint; see profile_state.go.
|
||||||
|
cfgPath string
|
||||||
|
|
||||||
stateChangeMu sync.Mutex
|
stateChangeMu sync.Mutex
|
||||||
stateChangeSubID string
|
stateChangeSubID string
|
||||||
@@ -96,11 +104,12 @@ type Client struct {
|
|||||||
extendCancel context.CancelFunc
|
extendCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
|
||||||
c.stateMu.Lock()
|
c.stateMu.Lock()
|
||||||
defer c.stateMu.Unlock()
|
defer c.stateMu.Unlock()
|
||||||
c.config = cfg
|
c.config = cfg
|
||||||
c.cacheDir = cacheDir
|
c.cacheDir = cacheDir
|
||||||
|
c.cfgPath = cfgPath
|
||||||
c.connectClient = cc
|
c.connectClient = cc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +119,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn
|
|||||||
return c.config, c.cacheDir, c.connectClient
|
return c.config, c.cacheDir, c.connectClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authSnapshot returns the config together with the path it was loaded from, in
|
||||||
|
// one lock: the path identifies the profile whose account email backs the login
|
||||||
|
// hint, so reading it separately could pair one profile's config with another's
|
||||||
|
// hint when a profile switch lands in between.
|
||||||
|
func (c *Client) authSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) {
|
||||||
|
c.stateMu.RLock()
|
||||||
|
defer c.stateMu.RUnlock()
|
||||||
|
return c.config, c.cfgPath, c.connectClient
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) getConnectClient() *internal.ConnectClient {
|
func (c *Client) getConnectClient() *internal.ConnectClient {
|
||||||
c.stateMu.RLock()
|
c.stateMu.RLock()
|
||||||
defer c.stateMu.RUnlock()
|
defer c.stateMu.RUnlock()
|
||||||
@@ -162,7 +181,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
|||||||
defer c.ctxCancel()
|
defer c.ctxCancel()
|
||||||
c.ctxCancelLock.Unlock()
|
c.ctxCancelLock.Unlock()
|
||||||
|
|
||||||
auth := NewAuthWithConfig(ctx, cfg)
|
auth := NewAuthWithConfig(ctx, cfg, cfgFile)
|
||||||
err = auth.login(urlOpener, isAndroidTV)
|
err = auth.login(urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -170,7 +189,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
|||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
c.setState(cfg, cacheDir, connectClient)
|
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||||
// is authenticated again — release the latch Status() reports from. Clear
|
// is authenticated again — release the latch Status() reports from. Clear
|
||||||
// only once the fresh connect client is installed: until then Status()
|
// only once the fresh connect client is installed: until then Status()
|
||||||
@@ -211,7 +230,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
|||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
c.setState(cfg, cacheDir, connectClient)
|
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +259,24 @@ func (c *Client) RenewTun(fd int) error {
|
|||||||
return e.RenewTun(fd)
|
return e.RenewTun(fd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||||
|
cc := c.getConnectClient()
|
||||||
|
if cc == nil {
|
||||||
|
return nil, fmt.Errorf("engine not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
e := cc.Engine()
|
||||||
|
if e == nil {
|
||||||
|
return nil, fmt.Errorf("engine not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
routes, searchDomains := e.TunSettings()
|
||||||
|
return &TunSettings{
|
||||||
|
Routes: strings.Join(routes, ";"),
|
||||||
|
SearchDomains: strings.Join(searchDomains, ";"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||||
// It works both with and without a running engine.
|
// It works both with and without a running engine.
|
||||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
@@ -36,12 +38,20 @@ type Auth struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAuth instantiate Auth struct and validate the management URL
|
// NewAuth instantiate Auth struct and validate the management URL
|
||||||
|
//
|
||||||
|
// The configuration at cfgPath is reused when one is already there, and only created when it is
|
||||||
|
// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on
|
||||||
|
// every call: the peer registers under that key, the key is written out, and any peer registered by
|
||||||
|
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
|
||||||
|
// the persisted config, because the identity it registered is not the one it runs with — the
|
||||||
|
// management stream rejects it with "no peer auth method provided".
|
||||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||||
inputCfg := profilemanager.ConfigInput{
|
inputCfg := profilemanager.ConfigInput{
|
||||||
|
ConfigPath: cfgPath,
|
||||||
ManagementURL: mgmURL,
|
ManagementURL: mgmURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
|
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -53,11 +63,14 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthWithConfig instantiate Auth based on existing config
|
// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the
|
||||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
|
// file the config was loaded from; it identifies the profile whose account email
|
||||||
|
// backs the login_hint.
|
||||||
|
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth {
|
||||||
return &Auth{
|
return &Auth{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
config: config,
|
config: config,
|
||||||
|
cfgPath: cfgPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,12 +163,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jwtToken := ""
|
jwtToken := ""
|
||||||
|
email := ""
|
||||||
if needsLogin {
|
if needsLogin {
|
||||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||||
}
|
}
|
||||||
jwtToken = tokenInfo.GetTokenToUse()
|
jwtToken = tokenInfo.GetTokenToUse()
|
||||||
|
email = tokenInfo.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
||||||
@@ -163,17 +178,42 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
|||||||
return fmt.Errorf("login failed: %v", err)
|
return fmt.Errorf("login failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stored after Login, not before: a rejected token must not leave a hint
|
||||||
|
// pointing at an account that cannot be used.
|
||||||
|
if email != "" && a.cfgPath != "" {
|
||||||
|
if err := writeProfileEmail(a.cfgPath, email); err != nil {
|
||||||
|
log.Warnf("failed to store profile account email: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
go urlOpener.OnLoginSuccess()
|
go urlOpener.OnLoginSuccess()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loginHintSetter is implemented by both concrete flows (PKCE and device code)
|
||||||
|
// but absent from the OAuthFlow interface, hence the assertion below — the same
|
||||||
|
// way internal/auth wires it in authenticateWithPKCEFlow.
|
||||||
|
type loginHintSetter interface {
|
||||||
|
SetLoginHint(hint string)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty hint is deliberate, not a fallback: a fresh or logged-out profile
|
||||||
|
// leaves the choice to the IdP, which is how accounts get switched.
|
||||||
|
if a.cfgPath != "" {
|
||||||
|
if hint := readProfileEmail(a.cfgPath); hint != "" {
|
||||||
|
if setter, ok := oAuthFlow.(loginHintSetter); ok {
|
||||||
|
setter.SetLoginHint(hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||||
|
|||||||
51
client/android/login_test.go
Normal file
51
client/android/login_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one.
|
||||||
|
//
|
||||||
|
// Creating a new in-memory config on every call gives the client a new WireGuard private key each
|
||||||
|
// time. The peer registers under that key and the key is written out, so a peer registered by an
|
||||||
|
// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns
|
||||||
|
// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so
|
||||||
|
// the identity that registered is not the identity that runs, and the management stream rejects it
|
||||||
|
// with "no peer auth method provided, please use a setup key or interactive SSO login".
|
||||||
|
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
||||||
|
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
|
first, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first NewAuth: %v", err)
|
||||||
|
}
|
||||||
|
if first.config.PrivateKey == "" {
|
||||||
|
t.Fatal("first NewAuth produced no private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second NewAuth: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if second.config.PrivateKey != first.config.PrivateKey {
|
||||||
|
t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing configuration is still created, so a first enrolment works unchanged.
|
||||||
|
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
|
||||||
|
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
|
auth, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAuth: %v", err)
|
||||||
|
}
|
||||||
|
if auth.config == nil || auth.config.PrivateKey == "" {
|
||||||
|
t.Fatal("NewAuth did not create a usable configuration")
|
||||||
|
}
|
||||||
|
if auth.cfgPath != cfgPath {
|
||||||
|
t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,18 +13,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Android-specific config filename (different from desktop default.json)
|
|
||||||
defaultConfigFilename = "netbird.cfg"
|
|
||||||
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
|
||||||
profilesSubdir = "profiles"
|
|
||||||
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
||||||
androidUsername = "android"
|
androidUsername = "android"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Profile represents a profile for gomobile
|
// Profile represents a profile for gomobile
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
|
// Email is the account this profile last logged in with, "" if it never
|
||||||
|
// completed an SSO login or was logged out. See profile_state.go.
|
||||||
|
Email string
|
||||||
IsActive bool
|
IsActive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +100,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
|||||||
profiles = append(profiles, &Profile{
|
profiles = append(profiles, &Profile{
|
||||||
ID: p.ID.String(),
|
ID: p.ID.String(),
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
|
Email: pm.profileEmail(p.ID.String()),
|
||||||
IsActive: p.IsActive,
|
IsActive: p.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,22 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
||||||
}
|
}
|
||||||
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
|
return &Profile{
|
||||||
|
ID: prof.ID.String(),
|
||||||
|
Name: prof.Name,
|
||||||
|
Email: pm.profileEmail(prof.ID.String()),
|
||||||
|
IsActive: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileEmail returns the account email recorded for a profile. Display-only, so
|
||||||
|
// an unresolvable path degrades to "" rather than an error.
|
||||||
|
func (pm *ProfileManager) profileEmail(id string) string {
|
||||||
|
configPath, err := pm.getProfileConfigPath(id)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return readProfileEmail(configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchProfile switches to a different profile
|
// SwitchProfile switches to a different profile
|
||||||
@@ -185,6 +200,11 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
|||||||
return fmt.Errorf("failed to save config: %w", err)
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not fatal: a stale hint costs an account switch, not the logout itself.
|
||||||
|
if err := removeProfileEmail(configPath); err != nil {
|
||||||
|
log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Infof("logged out from profile: %s", id)
|
log.Infof("logged out from profile: %s", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
108
client/android/profile_state.go
Normal file
108
client/android/profile_state.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Android-specific config filename (different from desktop default.json)
|
||||||
|
defaultConfigFilename = "netbird.cfg"
|
||||||
|
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||||
|
profilesSubdir = "profiles"
|
||||||
|
// profileAccountSuffix names the file holding the profile's account email.
|
||||||
|
// Deliberately not ".state.json", which desktop uses for the same data:
|
||||||
|
// there the email and the engine's state manager live in different
|
||||||
|
// directories, but on Android both resolve under files/, so sharing the name
|
||||||
|
// would have the two overwrite each other — the state manager rewrites the
|
||||||
|
// whole file from its own keys (see statemanager.Manager.PersistState), and
|
||||||
|
// this package's writer does the same in reverse.
|
||||||
|
profileAccountSuffix = ".account.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// profileAccountPathFor derives the account file path from a profile's config
|
||||||
|
// path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json.
|
||||||
|
//
|
||||||
|
// Deriving from the config path rather than resolving the active profile keeps
|
||||||
|
// the write on the profile the login actually ran for: Auth.login runs in a
|
||||||
|
// goroutine, so the active profile can change under a flow already in flight.
|
||||||
|
func profileAccountPathFor(configPath string) (string, error) {
|
||||||
|
if configPath == "" {
|
||||||
|
return "", fmt.Errorf("empty config path")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Base(configPath)
|
||||||
|
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||||||
|
if stem == "" || stem == "." {
|
||||||
|
return "", fmt.Errorf("config path %q has no filename stem", configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readProfileEmail returns the account email stored for the profile whose config
|
||||||
|
// lives at configPath. A missing or unreadable file yields "", which leaves the
|
||||||
|
// account choice to the IdP.
|
||||||
|
func readProfileEmail(configPath string) string {
|
||||||
|
accountPath, err := profileAccountPathFor(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("no profile account path for login hint: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var state profilemanager.ProfileState
|
||||||
|
if _, err := util.ReadJson(accountPath, &state); err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
log.Debugf("failed to read profile account for login hint: %v", err)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeProfileEmail records the account email for the profile whose config lives
|
||||||
|
// at configPath, so later logins can pass it as an OIDC login_hint. An empty
|
||||||
|
// email is ignored rather than blanking what is already stored.
|
||||||
|
func writeProfileEmail(configPath string, email string) error {
|
||||||
|
if email == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accountPath, err := profileAccountPathFor(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve profile account path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := profilemanager.ProfileState{Email: email}
|
||||||
|
if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
|
||||||
|
return fmt.Errorf("write profile account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeProfileEmail drops the stored account email. Called on logout: while the
|
||||||
|
// email is on disk it goes out as a login_hint, which would steer the next login
|
||||||
|
// straight back into the account just logged out of. Mirrors the desktop UI's
|
||||||
|
// RemoveProfileState call.
|
||||||
|
func removeProfileEmail(configPath string) error {
|
||||||
|
accountPath, err := profileAccountPathFor(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve profile account path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove profile account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
161
client/android/profile_state_test.go
Normal file
161
client/android/profile_state_test.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProfileAccountPathFor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configPath string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default profile",
|
||||||
|
configPath: "/data/data/io.netbird.client/files/netbird.cfg",
|
||||||
|
want: filepath.FromSlash("/data/data/io.netbird.client/files/netbird.account.json"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "id profile",
|
||||||
|
configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
|
||||||
|
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy name-keyed profile is handled the same way",
|
||||||
|
configPath: "/data/data/io.netbird.client/files/profiles/work.json",
|
||||||
|
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/work.account.json"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty path is rejected",
|
||||||
|
configPath: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := profileAccountPathFor(tt.configPath)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected an error, got path %q", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
|
||||||
|
root := "/data/data/io.netbird.client/files"
|
||||||
|
|
||||||
|
defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("default profile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("id profile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if defaultAccount == idAccount {
|
||||||
|
t.Fatalf("default and id profile share an account file: %q", defaultAccount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The account file must never land on the engine state file: on Android both
|
||||||
|
// resolve under files/, and the state manager rewrites the whole file from its
|
||||||
|
// own keys, so sharing a path would have the two overwrite each other. The
|
||||||
|
// expected names here mirror ProfileManager.GetStateFilePath.
|
||||||
|
func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
|
||||||
|
root := "/data/data/io.netbird.client/files"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
configPath string
|
||||||
|
engineState string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
configPath: filepath.Join(root, defaultConfigFilename),
|
||||||
|
engineState: filepath.Join(root, "state.json"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
configPath: filepath.Join(root, profilesSubdir, "abc123.json"),
|
||||||
|
engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
account, err := profileAccountPathFor(c.configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", c.configPath, err)
|
||||||
|
}
|
||||||
|
if account == c.engineState {
|
||||||
|
t.Errorf("account file collides with the engine state file: %q", account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteThenReadProfileEmail(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||||
|
if err := ensureDirFor(t, configPath); err != nil {
|
||||||
|
t.Fatalf("prepare dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := readProfileEmail(configPath); got != "" {
|
||||||
|
t.Errorf("expected no email before a login, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = "user@example.com"
|
||||||
|
if err := writeProfileEmail(configPath, email); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := readProfileEmail(configPath); got != email {
|
||||||
|
t.Errorf("got %q, want %q", got, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := removeProfileEmail(configPath); err != nil {
|
||||||
|
t.Fatalf("remove: %v", err)
|
||||||
|
}
|
||||||
|
if got := readProfileEmail(configPath); got != "" {
|
||||||
|
t.Errorf("expected no email after logout, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout may run on a never-logged-in profile, so a second remove must pass.
|
||||||
|
if err := removeProfileEmail(configPath); err != nil {
|
||||||
|
t.Fatalf("second remove should be a no-op: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||||
|
if err := ensureDirFor(t, configPath); err != nil {
|
||||||
|
t.Fatalf("prepare dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = "user@example.com"
|
||||||
|
if err := writeProfileEmail(configPath, email); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if err := writeProfileEmail(configPath, ""); err != nil {
|
||||||
|
t.Fatalf("write empty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := readProfileEmail(configPath); got != email {
|
||||||
|
t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDirFor(t *testing.T, path string) error {
|
||||||
|
t.Helper()
|
||||||
|
return os.MkdirAll(filepath.Dir(path), 0o700)
|
||||||
|
}
|
||||||
@@ -278,7 +278,7 @@ func (c *Client) endExtend() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
||||||
cfg, _, cc := c.stateSnapshot()
|
cfg, cfgPath, cc := c.authSnapshot()
|
||||||
if cfg == nil || cc == nil {
|
if cfg == nil || cc == nil {
|
||||||
return fmt.Errorf("engine is not running")
|
return fmt.Errorf("engine is not running")
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,10 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
|
|||||||
}
|
}
|
||||||
defer authClient.Close()
|
defer authClient.Close()
|
||||||
|
|
||||||
a := &Auth{ctx: ctx, config: cfg}
|
// Passing the config path makes the flow pick up the login_hint: an extend
|
||||||
|
// renews the session of the account already signed in, so it must not stop to
|
||||||
|
// offer a choice.
|
||||||
|
a := NewAuthWithConfig(ctx, cfg, cfgPath)
|
||||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||||
|
|||||||
@@ -113,11 +113,14 @@ func (c *ConnectClient) RunOnAndroid(
|
|||||||
stateFilePath string,
|
stateFilePath string,
|
||||||
cacheDir string,
|
cacheDir string,
|
||||||
) error {
|
) error {
|
||||||
|
notifier := tunnelnotifier.New(networkChangeListener, nil)
|
||||||
|
defer notifier.Close()
|
||||||
|
|
||||||
// in case of non Android os these variables will be nil
|
// in case of non Android os these variables will be nil
|
||||||
mobileDependency := MobileDependency{
|
mobileDependency := MobileDependency{
|
||||||
TunAdapter: tunAdapter,
|
TunAdapter: tunAdapter,
|
||||||
IFaceDiscover: iFaceDiscover,
|
IFaceDiscover: iFaceDiscover,
|
||||||
NetworkChangeListener: networkChangeListener,
|
NetworkChangeListener: notifier,
|
||||||
HostDNSAddresses: dnsAddresses,
|
HostDNSAddresses: dnsAddresses,
|
||||||
DnsReadyListener: dnsReadyListener,
|
DnsReadyListener: dnsReadyListener,
|
||||||
StateFilePath: stateFilePath,
|
StateFilePath: stateFilePath,
|
||||||
|
|||||||
17
client/internal/daemonaddr/identity.go
Normal file
17
client/internal/daemonaddr/identity.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
29
client/internal/daemonaddr/identity_test.go
Normal file
29
client/internal/daemonaddr/identity_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,5 @@ func (n *notifier) notify() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go func(l listener.NetworkChangeListener) {
|
n.listener.OnNetworkChanged("")
|
||||||
l.OnNetworkChanged("")
|
|
||||||
}(n.listener)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
|
|||||||
ds.hostsDNSHolder.set(hostsDnsList)
|
ds.hostsDNSHolder.set(hostsDnsList)
|
||||||
ds.permanent = true
|
ds.permanent = true
|
||||||
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
|
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
|
||||||
ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
|
ds.searchDomainNotifier = newNotifier(ds.searchDomains())
|
||||||
ds.searchDomainNotifier.setListener(listener)
|
ds.searchDomainNotifier.setListener(listener)
|
||||||
setServerDns(ds)
|
setServerDns(ds)
|
||||||
return ds
|
return ds
|
||||||
@@ -602,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *DefaultServer) SearchDomains() []string {
|
func (s *DefaultServer) SearchDomains() []string {
|
||||||
|
s.mux.Lock()
|
||||||
|
defer s.mux.Unlock()
|
||||||
|
return s.searchDomains()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DefaultServer) searchDomains() []string {
|
||||||
var searchDomains []string
|
var searchDomains []string
|
||||||
|
|
||||||
for _, dConf := range s.currentConfig.Domains {
|
for _, dConf := range s.currentConfig.Domains {
|
||||||
@@ -686,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if s.searchDomainNotifier != nil {
|
if s.searchDomainNotifier != nil {
|
||||||
s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
|
s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
|
||||||
}
|
}
|
||||||
|
|
||||||
s.updateNSGroupStates(update.NameServerGroups)
|
s.updateNSGroupStates(update.NameServerGroups)
|
||||||
|
|||||||
74
client/internal/elevate/elevate.go
Normal file
74
client/internal/elevate/elevate.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// 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()
|
||||||
|
}
|
||||||
18
client/internal/elevate/output.go
Normal file
18
client/internal/elevate/output.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
21
client/internal/elevate/output_test.go
Normal file
21
client/internal/elevate/output_test.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
359
client/internal/elevate/run_darwin.go
Normal file
359
client/internal/elevate/run_darwin.go
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
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]
|
||||||
|
}
|
||||||
111
client/internal/elevate/run_darwin_test.go
Normal file
111
client/internal/elevate/run_darwin_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
117
client/internal/elevate/run_unix.go
Normal file
117
client/internal/elevate/run_unix.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
110
client/internal/elevate/run_unix_test.go
Normal file
110
client/internal/elevate/run_unix_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
19
client/internal/elevate/run_unsupported.go
Normal file
19
client/internal/elevate/run_unsupported.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
193
client/internal/elevate/run_windows.go
Normal file
193
client/internal/elevate/run_windows.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
40
client/internal/elevate/trusted.go
Normal file
40
client/internal/elevate/trusted.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
10
client/internal/elevate/trusted_group_darwin.go
Normal file
10
client/internal/elevate/trusted_group_darwin.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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}
|
||||||
9
client/internal/elevate/trusted_group_unix.go
Normal file
9
client/internal/elevate/trusted_group_unix.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//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}
|
||||||
146
client/internal/elevate/trusted_unix.go
Normal file
146
client/internal/elevate/trusted_unix.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
177
client/internal/elevate/trusted_unix_test.go
Normal file
177
client/internal/elevate/trusted_unix_test.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
|
}
|
||||||
215
client/internal/elevate/trusted_windows.go
Normal file
215
client/internal/elevate/trusted_windows.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
126
client/internal/elevate/trusted_windows_test.go
Normal file
126
client/internal/elevate/trusted_windows_test.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -572,12 +572,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
}
|
}
|
||||||
e.stateManager.Start()
|
e.stateManager.Start()
|
||||||
|
|
||||||
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
|
dnsServer, err := e.newDnsServer()
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read initial settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsServer, err := e.newDnsServer(dnsConfig)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create dns server: %w", err)
|
return fmt.Errorf("create dns server: %w", err)
|
||||||
}
|
}
|
||||||
@@ -595,10 +590,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
WGInterface: e.wgInterface,
|
WGInterface: e.wgInterface,
|
||||||
StatusRecorder: e.statusRecorder,
|
StatusRecorder: e.statusRecorder,
|
||||||
RelayManager: e.relayManager,
|
RelayManager: e.relayManager,
|
||||||
InitialRoutes: initialRoutes,
|
|
||||||
StateManager: e.stateManager,
|
StateManager: e.stateManager,
|
||||||
DNSServer: dnsServer,
|
DNSServer: dnsServer,
|
||||||
DNSFeatureFlag: dnsFeatureFlag,
|
|
||||||
PeerStore: e.peerStore,
|
PeerStore: e.peerStore,
|
||||||
DisableClientRoutes: e.config.DisableClientRoutes,
|
DisableClientRoutes: e.config.DisableClientRoutes,
|
||||||
DisableServerRoutes: e.config.DisableServerRoutes,
|
DisableServerRoutes: e.config.DisableServerRoutes,
|
||||||
@@ -2102,42 +2095,6 @@ func (e *Engine) close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
|
|
||||||
if runtime.GOOS != "android" {
|
|
||||||
// nolint:nilnil
|
|
||||||
return nil, nil, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
info := system.GetInfo(e.ctx)
|
|
||||||
info.SetFlags(
|
|
||||||
e.config.RosenpassEnabled,
|
|
||||||
e.config.RosenpassPermissive,
|
|
||||||
&e.config.ServerSSHAllowed,
|
|
||||||
e.config.DisableClientRoutes,
|
|
||||||
e.config.DisableServerRoutes,
|
|
||||||
e.config.DisableDNS,
|
|
||||||
e.config.DisableFirewall,
|
|
||||||
e.config.BlockLANAccess,
|
|
||||||
e.config.BlockInbound,
|
|
||||||
e.config.DisableIPv6,
|
|
||||||
e.config.SyncMessageVersion,
|
|
||||||
e.config.EnableSSHRoot,
|
|
||||||
e.config.EnableSSHSFTP,
|
|
||||||
e.config.EnableSSHLocalPortForwarding,
|
|
||||||
e.config.EnableSSHRemotePortForwarding,
|
|
||||||
e.config.DisableSSHAuth,
|
|
||||||
)
|
|
||||||
|
|
||||||
netMap, err := e.mgmClient.GetNetworkMap(info)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, false, err
|
|
||||||
}
|
|
||||||
routes := toRoutes(netMap.GetRoutes())
|
|
||||||
dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address())
|
|
||||||
dnsFeatureFlag := toDNSFeatureFlag(netMap)
|
|
||||||
return routes, &dnsCfg, dnsFeatureFlag, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||||
transportNet, err := e.newStdNet()
|
transportNet, err := e.newStdNet()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2172,7 +2129,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
|||||||
func (e *Engine) wgInterfaceCreate() (err error) {
|
func (e *Engine) wgInterfaceCreate() (err error) {
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "android":
|
case "android":
|
||||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||||
case "ios":
|
case "ios":
|
||||||
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
|
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
|
||||||
if e.config.WgAddr.HasIPv6() {
|
if e.config.WgAddr.HasIPv6() {
|
||||||
@@ -2185,7 +2142,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
func (e *Engine) newDnsServer() (dns.Server, error) {
|
||||||
// due to tests where we are using a mocked version of the DNS server
|
// due to tests where we are using a mocked version of the DNS server
|
||||||
if e.dnsServer != nil {
|
if e.dnsServer != nil {
|
||||||
return e.dnsServer, nil
|
return e.dnsServer, nil
|
||||||
@@ -2197,7 +2154,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
|||||||
e.ctx,
|
e.ctx,
|
||||||
e.wgInterface,
|
e.wgInterface,
|
||||||
e.mobileDep.HostDNSAddresses,
|
e.mobileDep.HostDNSAddresses,
|
||||||
*dnsConfig,
|
nbdns.Config{},
|
||||||
e.mobileDep.NetworkChangeListener,
|
e.mobileDep.NetworkChangeListener,
|
||||||
e.statusRecorder,
|
e.statusRecorder,
|
||||||
e.config.DisableDNS,
|
e.config.DisableDNS,
|
||||||
|
|||||||
20
client/internal/engine_tunsettings.go
Normal file
20
client/internal/engine_tunsettings.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
func (e *Engine) TunSettings() ([]string, []string) {
|
||||||
|
e.syncMsgMux.Lock()
|
||||||
|
routeManager := e.routeManager
|
||||||
|
dnsServer := e.dnsServer
|
||||||
|
e.syncMsgMux.Unlock()
|
||||||
|
|
||||||
|
var routes []string
|
||||||
|
if routeManager != nil {
|
||||||
|
routes = routeManager.CurrentRouteRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchDomains []string
|
||||||
|
if dnsServer != nil {
|
||||||
|
searchDomains = dnsServer.SearchDomains()
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes, searchDomains
|
||||||
|
}
|
||||||
@@ -91,6 +91,12 @@ func SelfDelegatesTo() (Identity, bool) {
|
|||||||
return selfIdentity, true
|
return selfIdentity, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The values PrivilegedActorKey returns.
|
||||||
|
const (
|
||||||
|
ActorKeyAdministrator = "administrator"
|
||||||
|
ActorKeyRoot = "root"
|
||||||
|
)
|
||||||
|
|
||||||
// PrivilegedActor names the principal a privileged operation requires, for use
|
// PrivilegedActor names the principal a privileged operation requires, for use
|
||||||
// in messages shown to the user.
|
// in messages shown to the user.
|
||||||
func PrivilegedActor() string {
|
func PrivilegedActor() string {
|
||||||
@@ -100,6 +106,16 @@ func PrivilegedActor() string {
|
|||||||
return "root"
|
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
|
// 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
|
// 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
|
// returned unchanged and the user is expected to run it from an elevated
|
||||||
|
|||||||
@@ -45,12 +45,35 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
|
|||||||
return &state, nil
|
return &state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
// SetProfileState writes the state file of the profile identified by id. Prefer
|
||||||
|
// it over SetActiveProfileState whenever the caller knows which profile the data
|
||||||
|
// belongs to: an SSO login spans seconds of user interaction, and the active
|
||||||
|
// profile can change during it, which would file the account email under
|
||||||
|
// whichever profile happened to be active when the flow returned.
|
||||||
|
func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error {
|
||||||
configDir, err := getConfigDir()
|
configDir, err := getConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get config directory: %w", err)
|
return fmt.Errorf("get config directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
return fmt.Errorf("empty profile ID")
|
||||||
|
}
|
||||||
|
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||||
|
return fmt.Errorf("invalid profile ID: %q", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||||
|
if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil {
|
||||||
|
return fmt.Errorf("write profile state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveProfileState writes the state file of whichever profile is active at
|
||||||
|
// call time. Use SetProfileState when the target profile is known.
|
||||||
|
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||||
activeProf, err := pm.GetActiveProfile()
|
activeProf, err := pm.GetActiveProfile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNoActiveProfile) {
|
if errors.Is(err, ErrNoActiveProfile) {
|
||||||
@@ -59,18 +82,7 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
|||||||
return fmt.Errorf("get active profile: %w", err)
|
return fmt.Errorf("get active profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
id := activeProf.ID
|
return pm.SetProfileState(activeProf.ID, state)
|
||||||
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
|
||||||
return fmt.Errorf("invalid active profile ID: %q", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
|
||||||
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("write profile state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveProfileState deletes the per-profile state file (which holds the
|
// RemoveProfileState deletes the per-profile state file (which holds the
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger
|
|||||||
|
|
||||||
// internalDnatFw checks if the firewall supports internal DNAT
|
// internalDnatFw checks if the firewall supports internal DNAT
|
||||||
func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
|
func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
|
||||||
if d.firewall == nil || runtime.GOOS != "android" {
|
if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
fw, ok := d.firewall.(internalDNATer)
|
fw, ok := d.firewall.(internalDNATer)
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/hashicorp/go-multierror"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/exp/maps"
|
"golang.org/x/exp/maps"
|
||||||
@@ -62,7 +61,7 @@ type Manager interface {
|
|||||||
GetActiveClientRoutes() route.HAMap
|
GetActiveClientRoutes() route.HAMap
|
||||||
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
||||||
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
||||||
InitialRouteRange() []string
|
CurrentRouteRange() []string
|
||||||
SetFirewall(firewall.Manager) error
|
SetFirewall(firewall.Manager) error
|
||||||
SetDNSForwarderPort(port uint16)
|
SetDNSForwarderPort(port uint16)
|
||||||
ReconcilePeerAllowedIPs(peerKey string) error
|
ReconcilePeerAllowedIPs(peerKey string) error
|
||||||
@@ -76,10 +75,8 @@ type ManagerConfig struct {
|
|||||||
WGInterface iface.WGIface
|
WGInterface iface.WGIface
|
||||||
StatusRecorder *peer.Status
|
StatusRecorder *peer.Status
|
||||||
RelayManager *relayClient.Manager
|
RelayManager *relayClient.Manager
|
||||||
InitialRoutes []*route.Route
|
|
||||||
StateManager *statemanager.Manager
|
StateManager *statemanager.Manager
|
||||||
DNSServer dns.Server
|
DNSServer dns.Server
|
||||||
DNSFeatureFlag bool
|
|
||||||
PeerStore *peerstore.Store
|
PeerStore *peerstore.Store
|
||||||
DisableClientRoutes bool
|
DisableClientRoutes bool
|
||||||
DisableServerRoutes bool
|
DisableServerRoutes bool
|
||||||
@@ -149,45 +146,12 @@ func NewManager(config ManagerConfig) *DefaultManager {
|
|||||||
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
|
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
|
||||||
dm.setupRefCounters(useNoop)
|
dm.setupRefCounters(useNoop)
|
||||||
|
|
||||||
// don't proceed with client routes if it is disabled
|
|
||||||
if config.DisableClientRoutes {
|
|
||||||
return dm
|
|
||||||
}
|
|
||||||
|
|
||||||
if runtime.GOOS == "android" {
|
|
||||||
dm.setupAndroidRoutes(config)
|
|
||||||
}
|
|
||||||
return dm
|
return dm
|
||||||
}
|
}
|
||||||
func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
|
|
||||||
cr := m.initialClientRoutes(config.InitialRoutes)
|
|
||||||
|
|
||||||
routesForComparison := slices.Clone(cr)
|
func (m *DefaultManager) enableFakeIPRoutes() {
|
||||||
|
m.fakeIPManager = fakeip.NewManager()
|
||||||
if config.DNSFeatureFlag {
|
m.notifier.NotifyRouteChange()
|
||||||
m.fakeIPManager = fakeip.NewManager()
|
|
||||||
|
|
||||||
v4ID := uuid.NewString()
|
|
||||||
fakeIPRoute := &route.Route{
|
|
||||||
ID: route.ID(v4ID),
|
|
||||||
Network: m.fakeIPManager.GetFakeIPBlock(),
|
|
||||||
NetID: route.NetID(v4ID),
|
|
||||||
Peer: m.pubKey,
|
|
||||||
NetworkType: route.IPv4Network,
|
|
||||||
}
|
|
||||||
v6ID := uuid.NewString()
|
|
||||||
fakeIPv6Route := &route.Route{
|
|
||||||
ID: route.ID(v6ID),
|
|
||||||
Network: m.fakeIPManager.GetFakeIPv6Block(),
|
|
||||||
NetID: route.NetID(v6ID),
|
|
||||||
Peer: m.pubKey,
|
|
||||||
NetworkType: route.IPv6Network,
|
|
||||||
}
|
|
||||||
cr = append(cr, fakeIPRoute, fakeIPv6Route)
|
|
||||||
m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route})
|
|
||||||
}
|
|
||||||
|
|
||||||
m.notifier.SetInitialClientRoutes(cr, routesForComparison)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
||||||
@@ -464,6 +428,9 @@ func (m *DefaultManager) UpdateRoutes(
|
|||||||
|
|
||||||
var merr *multierror.Error
|
var merr *multierror.Error
|
||||||
if !m.disableClientRoutes {
|
if !m.disableClientRoutes {
|
||||||
|
if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil {
|
||||||
|
m.enableFakeIPRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
// Update route selector based on management server's isSelected status
|
// Update route selector based on management server's isSelected status
|
||||||
m.updateRouteSelectorFromManagement(clientRoutes)
|
m.updateRouteSelectorFromManagement(clientRoutes)
|
||||||
@@ -500,9 +467,32 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL
|
|||||||
m.notifier.SetListener(listener)
|
m.notifier.SetListener(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitialRouteRange return the list of initial routes. It used by mobile systems
|
// CurrentRouteRange returns the current TUN route list. It is used by mobile systems
|
||||||
func (m *DefaultManager) InitialRouteRange() []string {
|
func (m *DefaultManager) CurrentRouteRange() []string {
|
||||||
return m.notifier.GetInitialRouteRanges()
|
m.mux.Lock()
|
||||||
|
defer m.mux.Unlock()
|
||||||
|
|
||||||
|
if m.disableClientRoutes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
|
||||||
|
var nets []string
|
||||||
|
for _, routes := range filtered {
|
||||||
|
for _, r := range routes {
|
||||||
|
if r.IsDynamic() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nets = append(nets, r.NetString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.fakeIPManager != nil {
|
||||||
|
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(nets)
|
||||||
|
return nets
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRouteSelector returns the route selector
|
// GetRouteSelector returns the route selector
|
||||||
@@ -700,16 +690,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]
|
|||||||
return newServerRoutesMap, newClientRoutesIDMap
|
return newServerRoutesMap, newClientRoutesIDMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route {
|
|
||||||
_, crMap := m.ClassifyRoutes(initialRoutes)
|
|
||||||
rs := make([]*route.Route, 0, len(crMap))
|
|
||||||
for _, routes := range crMap {
|
|
||||||
rs = append(rs, routes...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rs
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRouteSupported(route *route.Route) bool {
|
func isRouteSupported(route *route.Route) bool {
|
||||||
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
|
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ func (m *MockManager) Init() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitialRouteRange mock implementation of InitialRouteRange from Manager interface
|
// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
|
||||||
func (m *MockManager) InitialRouteRange() []string {
|
func (m *MockManager) CurrentRouteRange() []string {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/listener"
|
"github.com/netbirdio/netbird/client/internal/listener"
|
||||||
@@ -14,12 +13,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Notifier struct {
|
type Notifier struct {
|
||||||
initialRoutes []*route.Route
|
mu sync.Mutex
|
||||||
currentRoutes []*route.Route
|
|
||||||
fakeIPRoutes []*route.Route
|
|
||||||
|
|
||||||
listener listener.NetworkChangeListener
|
// currentRoutes is the last announced route set. It exists only to
|
||||||
listenerMux sync.Mutex
|
// suppress noise: without it every network map sync would trigger the
|
||||||
|
// Java side, even when the routes did not change. The actual TUN route
|
||||||
|
// state is owned by the route manager and pulled from there.
|
||||||
|
currentRoutes []*route.Route
|
||||||
|
|
||||||
|
listener listener.NetworkChangeListener
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotifier() *Notifier {
|
func NewNotifier() *Notifier {
|
||||||
@@ -27,20 +29,15 @@ func NewNotifier() *Notifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||||
n.listenerMux.Lock()
|
n.mu.Lock()
|
||||||
defer n.listenerMux.Unlock()
|
defer n.mu.Unlock()
|
||||||
n.listener = listener
|
n.listener = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetInitialClientRoutes stores the initial route sets for TUN configuration.
|
func (n *Notifier) NotifyRouteChange() {
|
||||||
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
|
n.mu.Lock()
|
||||||
n.initialRoutes = filterStatic(initialRoutes)
|
defer n.mu.Unlock()
|
||||||
n.currentRoutes = filterStatic(routesForComparison)
|
n.notifyLocked()
|
||||||
}
|
|
||||||
|
|
||||||
// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild.
|
|
||||||
func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) {
|
|
||||||
n.fakeIPRoutes = routes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||||
@@ -54,46 +51,32 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !n.hasRouteDiff(n.currentRoutes, newRoutes) {
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
if !hasRouteDiff(n.currentRoutes, newRoutes) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
n.currentRoutes = newRoutes
|
n.currentRoutes = newRoutes
|
||||||
n.notify()
|
n.notifyLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
|
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
|
||||||
// Not used on Android
|
// Not used on Android
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) notify() {
|
func (n *Notifier) notifyLocked() {
|
||||||
n.listenerMux.Lock()
|
|
||||||
defer n.listenerMux.Unlock()
|
|
||||||
if n.listener == nil {
|
if n.listener == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
n.listener.OnNetworkChanged("")
|
||||||
allRoutes := slices.Clone(n.currentRoutes)
|
|
||||||
allRoutes = append(allRoutes, n.fakeIPRoutes...)
|
|
||||||
|
|
||||||
routeStrings := n.routesToStrings(allRoutes)
|
|
||||||
sort.Strings(routeStrings)
|
|
||||||
go func(l listener.NetworkChangeListener) {
|
|
||||||
l.OnNetworkChanged(strings.Join(routeStrings, ","))
|
|
||||||
}(n.listener)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterStatic(routes []*route.Route) []*route.Route {
|
func (n *Notifier) Close() {
|
||||||
out := make([]*route.Route, 0, len(routes))
|
// unused
|
||||||
for _, r := range routes {
|
|
||||||
if !r.IsDynamic() {
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
func routesToStrings(routes []*route.Route) []string {
|
||||||
nets := make([]string, 0, len(routes))
|
nets := make([]string, 0, len(routes))
|
||||||
for _, r := range routes {
|
for _, r := range routes {
|
||||||
nets = append(nets, r.NetString())
|
nets = append(nets, r.NetString())
|
||||||
@@ -101,25 +84,10 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
|||||||
return nets
|
return nets
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||||
slices.SortFunc(a, func(x, y *route.Route) int {
|
as := routesToStrings(a)
|
||||||
return strings.Compare(x.NetString(), y.NetString())
|
bs := routesToStrings(b)
|
||||||
})
|
sort.Strings(as)
|
||||||
slices.SortFunc(b, func(x, y *route.Route) int {
|
sort.Strings(bs)
|
||||||
return strings.Compare(x.NetString(), y.NetString())
|
return !slices.Equal(as, bs)
|
||||||
})
|
|
||||||
|
|
||||||
return !slices.EqualFunc(a, b, func(x, y *route.Route) bool {
|
|
||||||
return x.NetString() == y.NetString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
|
||||||
initialStrings := n.routesToStrings(n.initialRoutes)
|
|
||||||
sort.Strings(initialStrings)
|
|
||||||
return initialStrings
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *Notifier) Close() {
|
|
||||||
// unused
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
|||||||
n.listener = listener
|
n.listener = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
func (n *Notifier) NotifyRouteChange() {
|
||||||
// iOS doesn't care about initial routes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
|
||||||
// Not used on iOS
|
// Not used on iOS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
|||||||
// Not used on non-mobile platforms
|
// Not used on non-mobile platforms
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
func (n *Notifier) NotifyRouteChange() {
|
||||||
// Not used on non-mobile platforms
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
|
||||||
// Not used on non-mobile platforms
|
// Not used on non-mobile platforms
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,10 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
|||||||
// Not used on non-mobile platforms
|
// Not used on non-mobile platforms
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *Notifier) Close() {
|
func (n *Notifier) Close() {
|
||||||
// unused
|
// unused
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,47 +98,44 @@ func (u *Installer) startDaemon(daemonFolder string) error {
|
|||||||
func (u *Installer) startUIAsUser() error {
|
func (u *Installer) startUIAsUser() error {
|
||||||
log.Infof("starting netbird-ui: %s", uiBinary)
|
log.Infof("starting netbird-ui: %s", uiBinary)
|
||||||
|
|
||||||
// Get the current console user
|
username, err := consoleUser()
|
||||||
cmd := exec.Command("stat", "-f", "%Su", "/dev/console")
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get console user: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
username := strings.TrimSpace(string(output))
|
|
||||||
if username == "" || username == "root" {
|
|
||||||
return fmt.Errorf("no active user session found")
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("starting UI for user: %s", username)
|
|
||||||
|
|
||||||
// Get user's UID
|
|
||||||
userInfo, err := user.Lookup(username)
|
userInfo, err := user.Lookup(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to lookup user %s: %w", username, err)
|
return fmt.Errorf("lookup user %s: %w", username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the UI process as the console user using launchctl
|
log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid)
|
||||||
// This ensures the app runs in the user's context with proper GUI access
|
|
||||||
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary)
|
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary)
|
||||||
log.Infof("launchCmd: %s", launchCmd.String())
|
log.Infof("launchCmd: %s", launchCmd.String())
|
||||||
// Set the user's home directory for proper macOS app behavior
|
|
||||||
launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir)
|
|
||||||
log.Infof("set HOME environment variable: %s", userInfo.HomeDir)
|
|
||||||
|
|
||||||
if err := launchCmd.Start(); err != nil {
|
if err := launchCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to start UI process: %w", err)
|
return fmt.Errorf("run UI launch: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
// Release the process so it can run independently
|
|
||||||
if err := launchCmd.Process.Release(); err != nil {
|
|
||||||
log.Warnf("failed to release UI process: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("netbird-ui started successfully for user %s", username)
|
log.Infof("netbird-ui started successfully for user %s", username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func consoleUser() (string, error) {
|
||||||
|
output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get console user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(string(output))
|
||||||
|
switch username {
|
||||||
|
case "", "root", "loginwindow", "_mbsetupuser":
|
||||||
|
return "", fmt.Errorf("no active GUI user session, console user: %q", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
return username, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Installer) installPkgFile(ctx context.Context, path string) error {
|
func (u *Installer) installPkgFile(ctx context.Context, path string) error {
|
||||||
log.Infof("installing pkg file: %s", path)
|
log.Infof("installing pkg file: %s", path)
|
||||||
|
|
||||||
|
|||||||
110
client/server/login_outcome_test.go
Normal file
110
client/server/login_outcome_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A login that never reached Management is not a decision about the peer's
|
||||||
|
// credentials, so it must come back as a retryable error rather than an SSO
|
||||||
|
// prompt: the user cannot finish a browser login while Management is down, and
|
||||||
|
// the CLI's own backoff resolves the outage on its own once the daemon reports
|
||||||
|
// the failure. Reproduces `netbird down; netbird up` printing a device-code URL
|
||||||
|
// because Management happened to be restarting when the daemon dialed it.
|
||||||
|
func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) {
|
||||||
|
s, _, _, username, _ := setupServerWithProfile(t)
|
||||||
|
s.rootCtx = internal.CtxInitState(context.Background())
|
||||||
|
|
||||||
|
unreachable := errors.New("create connection: dial context: context deadline exceeded")
|
||||||
|
attempts := 0
|
||||||
|
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||||
|
attempts++
|
||||||
|
return false, unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else")
|
||||||
|
require.Nil(t, resp, "a failed login must not answer with a login response")
|
||||||
|
require.Equal(t, 1, attempts)
|
||||||
|
require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided")
|
||||||
|
|
||||||
|
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, internal.StatusLoginFailed, status,
|
||||||
|
"a peer that could not reach Management is not waiting on a login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The counterpart: Management refusing the peer's credentials is a decision, and
|
||||||
|
// the SSO flow still has to start for it. The profile carries an unusable
|
||||||
|
// private key so the flow setup fails immediately instead of dialing, which is
|
||||||
|
// enough to show the branch was entered — the refusal itself is never what comes
|
||||||
|
// back out.
|
||||||
|
func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
|
||||||
|
s, _, _, username, cfgPath := setupServerWithProfile(t)
|
||||||
|
s.rootCtx = internal.CtxInitState(context.Background())
|
||||||
|
breakProfilePrivateKey(t, cfgPath)
|
||||||
|
|
||||||
|
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
|
||||||
|
require.NoError(t, stateErr)
|
||||||
|
require.Equal(t, internal.StatusLoginFailed, status,
|
||||||
|
"the SSO flow setup was never reached with the broken key")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) {
|
||||||
|
s, _, _, username, _ := setupServerWithProfile(t)
|
||||||
|
s.rootCtx = internal.CtxInitState(context.Background())
|
||||||
|
|
||||||
|
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var keysTried []string
|
||||||
|
s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) {
|
||||||
|
keysTried = append(keysTried, setupKey)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F"
|
||||||
|
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey})
|
||||||
|
require.NoError(t, err, "the probe's outcome leaked out as the login result")
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt")
|
||||||
|
require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow")
|
||||||
|
|
||||||
|
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, internal.StatusIdle, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// breakProfilePrivateKey replaces the profile's private key with an unparseable
|
||||||
|
// one, which makes any attempt to build a Management client fail on the spot.
|
||||||
|
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(cfgPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var cfg map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &cfg))
|
||||||
|
cfg["PrivateKey"] = "not-a-key"
|
||||||
|
|
||||||
|
patched, err := json.Marshal(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.WriteFile(cfgPath, patched, 0o600))
|
||||||
|
}
|
||||||
@@ -135,6 +135,13 @@ type Server struct {
|
|||||||
updateManager *updater.Manager
|
updateManager *updater.Manager
|
||||||
|
|
||||||
jwtCache *jwtCache
|
jwtCache *jwtCache
|
||||||
|
|
||||||
|
// loginAttemptFn stands in for the Management login round trip. Tests set
|
||||||
|
// it to drive the login outcomes that need a server on the other end;
|
||||||
|
// production leaves it nil, and every login goes through loginAttempt.
|
||||||
|
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
|
||||||
|
|
||||||
|
isLoginRequiredFn func(ctx context.Context) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthAuthFlow struct {
|
type oauthAuthFlow struct {
|
||||||
@@ -370,7 +377,34 @@ func (s *Server) connectionGoroutineRunning() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
|
// attemptLogin runs a login round trip against Management, or the stand-in a
|
||||||
|
// test installed in place of it.
|
||||||
|
func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
||||||
|
if s.loginAttemptFn != nil {
|
||||||
|
return s.loginAttemptFn(ctx, setupKey, jwtToken)
|
||||||
|
}
|
||||||
|
return s.loginAttempt(ctx, setupKey, jwtToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) isLoginRequired(ctx context.Context) (bool, error) {
|
||||||
|
if s.isLoginRequiredFn != nil {
|
||||||
|
return s.isLoginRequiredFn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to create auth client: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer authClient.Close()
|
||||||
|
|
||||||
|
return authClient.IsLoginRequired(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginAttempt attempts to login using the provided information. It returns
|
||||||
|
// StatusNeedsLogin when Management refused the peer's credentials and
|
||||||
|
// StatusLoginFailed for every other failure, so callers can tell an
|
||||||
|
// authentication decision apart from a login that never got made.
|
||||||
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
||||||
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -623,7 +657,19 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
s.config = config
|
s.config = config
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
if _, err := s.loginAttempt(ctx, "", ""); err == nil {
|
// A probe that errors leaves the login undecided: Management unreachable, a
|
||||||
|
// restart mid-request, an internal error. Those are returned for the caller
|
||||||
|
// to retry, because turning them into an SSO prompt asks the user to solve
|
||||||
|
// something that is not theirs to solve, and a browser login cannot succeed
|
||||||
|
// while Management is unreachable anyway. Only Management refusing the
|
||||||
|
// peer's key is a decision, and IsLoginRequired reports that as
|
||||||
|
// needsLogin=true rather than an error.
|
||||||
|
needsLogin, err := s.isLoginRequired(ctx)
|
||||||
|
if err != nil {
|
||||||
|
state.Set(internal.StatusLoginFailed)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !needsLogin {
|
||||||
state.Set(internal.StatusIdle)
|
state.Set(internal.StatusIdle)
|
||||||
return &proto.LoginResponse{}, nil
|
return &proto.LoginResponse{}, nil
|
||||||
}
|
}
|
||||||
@@ -684,7 +730,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
// which returns NeedsLogin and parks on the browser leg.
|
// which returns NeedsLogin and parks on the browser leg.
|
||||||
state.Set(internal.StatusConnecting)
|
state.Set(internal.StatusConnecting)
|
||||||
|
|
||||||
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
|
if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil {
|
||||||
state.Set(loginStatus)
|
state.Set(loginStatus)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -839,7 +885,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
|||||||
s.oauthAuthFlow.expiresAt = time.Now()
|
s.oauthAuthFlow.expiresAt = time.Now()
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
||||||
state.Set(loginStatus)
|
state.Set(loginStatus)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1769,6 +1815,9 @@ func (s *Server) RequestExtendAuthSession(
|
|||||||
if connectClient == nil {
|
if connectClient == nil {
|
||||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
||||||
}
|
}
|
||||||
|
if connectClient.Engine() == nil {
|
||||||
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect")
|
||||||
|
}
|
||||||
|
|
||||||
hint := ""
|
hint := ""
|
||||||
if msg.Hint != nil {
|
if msg.Hint != nil {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=netbird-ui
|
Name=NetBird
|
||||||
|
Comment=NetBird desktop client
|
||||||
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui
|
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui
|
||||||
Icon=netbird-ui
|
Icon=netbird-ui
|
||||||
Categories=Development;
|
Categories=Utility;Network;
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Keywords=wails
|
Keywords=netbird;vpn;wireguard;
|
||||||
Version=1.0
|
Version=1.0
|
||||||
StartupNotify=false
|
StartupNotify=false
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Name=Netbird
|
Name=NetBird
|
||||||
|
Comment=NetBird desktop client
|
||||||
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
|
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
|
||||||
Icon=netbird
|
Icon=netbird
|
||||||
Type=Application
|
Type=Application
|
||||||
|
|||||||
@@ -21,22 +21,31 @@ contents:
|
|||||||
dst: "/usr/local/bin/netbird-ui"
|
dst: "/usr/local/bin/netbird-ui"
|
||||||
- src: "./build/appicon.png"
|
- src: "./build/appicon.png"
|
||||||
dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.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"
|
- src: "./build/linux/netbird-ui.desktop"
|
||||||
dst: "/usr/share/applications/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+)
|
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
|
||||||
depends:
|
depends:
|
||||||
- libgtk-4-1
|
- libgtk-4-1 (>= 4.14)
|
||||||
- libwebkitgtk-6.0-4
|
- libwebkitgtk-6.0-4
|
||||||
- xdg-utils
|
- xdg-utils
|
||||||
|
|
||||||
# Distribution-specific overrides for different package formats
|
# Distribution-specific overrides for different package formats
|
||||||
overrides:
|
overrides:
|
||||||
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
|
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE
|
||||||
rpm:
|
rpm:
|
||||||
depends:
|
depends:
|
||||||
- gtk4
|
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||||
- webkitgtk6.0
|
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||||
- xdg-utils
|
- xdg-utils
|
||||||
|
|
||||||
# Arch Linux packages
|
# Arch Linux packages
|
||||||
|
|||||||
47
client/ui/build/linux/polkit/io.netbird.settings.policy
Normal file
47
client/ui/build/linux/polkit/io.netbird.settings.policy
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?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,12 +22,18 @@ const logSaveError = (err: unknown) => console.error("[SettingsContext] save fai
|
|||||||
|
|
||||||
export type AutostartState = { supported: boolean; enabled: boolean };
|
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 = {
|
type SettingsContextValue = {
|
||||||
config: Config;
|
config: Config;
|
||||||
guiVersion: string;
|
guiVersion: string;
|
||||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||||
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
||||||
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
|
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
|
||||||
|
saveGuardedField: (k: GuardedField, v: boolean) => Promise<void>;
|
||||||
saveNow: () => Promise<void>;
|
saveNow: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,6 +69,12 @@ const useSettingsState = () => {
|
|||||||
const [guiVersion, setGuiVersion] = useState<string>("—");
|
const [guiVersion, setGuiVersion] = useState<string>("—");
|
||||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const loadedRef = useRef<LoadedConfig | 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(() => {
|
useEffect(() => {
|
||||||
loadedRef.current = loaded;
|
loadedRef.current = loaded;
|
||||||
@@ -73,6 +85,7 @@ const useSettingsState = () => {
|
|||||||
// update the daemon then rejected.
|
// update the daemon then rejected.
|
||||||
const reload = useCallback(
|
const reload = useCallback(
|
||||||
async (profileName: string) => {
|
async (profileName: string) => {
|
||||||
|
reloadOwed.current = false;
|
||||||
try {
|
try {
|
||||||
const data = await SettingsSvc.GetConfig({ profileName, username });
|
const data = await SettingsSvc.GetConfig({ profileName, username });
|
||||||
setLoaded({ profileName, data });
|
setLoaded({ profileName, data });
|
||||||
@@ -94,7 +107,12 @@ const useSettingsState = () => {
|
|||||||
username,
|
username,
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (saveTimer.current) 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;
|
||||||
|
}
|
||||||
setLoaded({ profileName: activeProfileId, data });
|
setLoaded({ profileName: activeProfileId, data });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled || !showError) return;
|
if (cancelled || !showError) return;
|
||||||
@@ -141,12 +159,17 @@ const useSettingsState = () => {
|
|||||||
async (profileName: string, next: Config, preSharedKey?: string) => {
|
async (profileName: string, next: Config, preSharedKey?: string) => {
|
||||||
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
|
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
|
||||||
try {
|
try {
|
||||||
await SettingsSvc.SetConfig({
|
const { declined } = await SettingsSvc.SetConfig({
|
||||||
...next,
|
...next,
|
||||||
...preSharedKeyWrite,
|
...preSharedKeyWrite,
|
||||||
profileName,
|
profileName,
|
||||||
username,
|
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) {
|
} catch (e) {
|
||||||
// The optimistic update is wrong now: the daemon refused it
|
// The optimistic update is wrong now: the daemon refused it
|
||||||
// (a change that needs elevated privileges, an MDM-managed
|
// (a change that needs elevated privileges, an MDM-managed
|
||||||
@@ -206,6 +229,59 @@ const useSettingsState = () => {
|
|||||||
[loaded, save],
|
[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(
|
const saveFields = useCallback(
|
||||||
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
|
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
|
||||||
if (!loaded) return;
|
if (!loaded) return;
|
||||||
@@ -225,15 +301,27 @@ const useSettingsState = () => {
|
|||||||
[loaded, save],
|
[loaded, save],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow };
|
return {
|
||||||
|
config: loaded?.data ?? null,
|
||||||
|
guiVersion,
|
||||||
|
setField,
|
||||||
|
saveField,
|
||||||
|
saveFields,
|
||||||
|
saveGuardedField,
|
||||||
|
saveNow,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
|
const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } =
|
||||||
|
useSettingsState();
|
||||||
|
|
||||||
const value = useMemo<SettingsContextValue | null>(
|
const value = useMemo<SettingsContextValue | null>(
|
||||||
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
|
() =>
|
||||||
[config, guiVersion, setField, saveField, saveFields, saveNow],
|
config
|
||||||
|
? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow }
|
||||||
|
: null,
|
||||||
|
[config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Settings as SettingsSvc } from "@bindings/services";
|
import { Settings as SettingsSvc } from "@bindings/services";
|
||||||
import { Privilege } from "@bindings/services/models.js";
|
import { type Privilege } from "@bindings/services/models.js";
|
||||||
|
|
||||||
// usePrivilege reports whether this UI process may perform the changes the daemon
|
// 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
|
// restricts to root/administrator. It is answered in-process from our own token
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runSsoLogin(
|
async function runSsoLogin(
|
||||||
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
|
result: {
|
||||||
|
verificationUri: string;
|
||||||
|
verificationUriComplete: string;
|
||||||
|
userCode: string;
|
||||||
|
profileId: string;
|
||||||
|
},
|
||||||
state: SsoState,
|
state: SsoState,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -56,7 +61,7 @@ async function runSsoLogin(
|
|||||||
// suspended, so a frontend-driven Up (a promise continuation) would not
|
// suspended, so a frontend-driven Up (a promise continuation) would not
|
||||||
// fire until the user woke the window (e.g. hovering the tray icon).
|
// fire until the user woke the window (e.g. hovering the tray icon).
|
||||||
const waitPromise = Connection.WaitSSOLoginAndUp(
|
const waitPromise = Connection.WaitSSOLoginAndUp(
|
||||||
{ userCode: result.userCode, hostname: "" },
|
{ userCode: result.userCode, hostname: "", profileId: result.profileId },
|
||||||
{ profileName: "", username: "" },
|
{ profileName: "", username: "" },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
|
|||||||
import { SquareIcon } from "@/components/SquareIcon";
|
import { SquareIcon } from "@/components/SquareIcon";
|
||||||
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
||||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||||
import { EVENT_BROWSER_LOGIN_CANCEL } from "@/lib/connection";
|
import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection";
|
||||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||||
import { formatRemaining } from "@/lib/formatters";
|
import { formatRemaining } from "@/lib/formatters";
|
||||||
|
|
||||||
@@ -131,6 +131,21 @@ export default function SessionExpirationDialog() {
|
|||||||
}
|
}
|
||||||
}, [busy, t]);
|
}, [busy, t]);
|
||||||
|
|
||||||
|
const authenticate = useCallback(async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await Events.Emit(EVENT_TRIGGER_LOGIN);
|
||||||
|
await WindowManager.CloseSessionExpiration();
|
||||||
|
} catch (e) {
|
||||||
|
setBusy(false);
|
||||||
|
await errorDialog({
|
||||||
|
Title: t("connect.error.loginTitle"),
|
||||||
|
Message: formatErrorMessage(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [busy, t]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -185,7 +200,7 @@ export default function SessionExpirationDialog() {
|
|||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
size={"md"}
|
size={"md"}
|
||||||
className={"w-full"}
|
className={"w-full"}
|
||||||
onClick={stay}
|
onClick={expired ? authenticate : stay}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
|
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { type TFunction } from "i18next";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||||
@@ -6,51 +7,91 @@ import { Input } from "@/components/inputs/Input";
|
|||||||
import { Label } from "@/components/typography/Label";
|
import { Label } from "@/components/typography/Label";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
import { type GuardedField, useSettings } from "@/contexts/SettingsContext.tsx";
|
||||||
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
||||||
import { Privilege } from "@bindings/services/models.js";
|
import type { Privilege } from "@bindings/services/models.js";
|
||||||
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
|
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
export function SettingsSSH() {
|
export function SettingsSSH() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config, setField } = useSettings();
|
const { config, setField, saveGuardedField } = useSettings();
|
||||||
const privilege = usePrivilege();
|
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 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
|
// The daemon restricts only the direction that hands out shells from a process
|
||||||
// running as root. So for an unprivileged user a guarded control is either
|
// running as root: for all three settings that is switching the field on.
|
||||||
// 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.
|
// 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.
|
||||||
//
|
//
|
||||||
// A null privilege means we could not determine it: leave the control alone
|
// 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
|
// rather than greying it out with nothing to explain why. The daemon enforces
|
||||||
// this regardless, and a rejected save reports its own guidance.
|
// this regardless, and a rejected save reports its own guidance.
|
||||||
const guarded = (
|
const guarded = (
|
||||||
guardedDirectionActive: boolean,
|
field: GuardedField,
|
||||||
command: (p: Privilege) => string,
|
command: (p: Privilege) => string,
|
||||||
// inverted marks a control whose guarded direction is switching it off, so
|
// inverted marks a control whose guarded direction is switching it off, so
|
||||||
// the one-way warning has to read the other way round.
|
// the one-way warning has to read the other way round.
|
||||||
inverted = false,
|
inverted = false,
|
||||||
) => {
|
) => {
|
||||||
|
const plain = (value: boolean) => setField(field, value);
|
||||||
if (!privilege || privilege.privileged) {
|
if (!privilege || privilege.privileged) {
|
||||||
return { disabled: false, hint: undefined };
|
return { apply: plain, disabled: false, hint: undefined };
|
||||||
}
|
}
|
||||||
const hint = (
|
|
||||||
<PrivilegeHint
|
const guardedDirectionActive = config[field];
|
||||||
actor={privilege.actor}
|
const hint = (pending: boolean, command?: string) => (
|
||||||
command={command(privilege)}
|
<GuardedHint
|
||||||
|
actor={actorLabel(privilege, t)}
|
||||||
oneWay={guardedDirectionActive}
|
oneWay={guardedDirectionActive}
|
||||||
inverted={inverted}
|
inverted={inverted}
|
||||||
|
pending={pending}
|
||||||
|
command={command}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return { disabled: !guardedDirectionActive, hint };
|
|
||||||
|
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)),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
|
const sshServer = guarded("serverSshAllowed", (p) => p.allowSshServer);
|
||||||
const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot);
|
const sshRoot = guarded("enableSshRoot", (p) => p.enableSshRoot);
|
||||||
// Inverted control: the guarded direction is switching authentication off, so
|
// Inverted control: the guarded direction is switching authentication off, so
|
||||||
// it is the already-disabled state that is the one-way one.
|
// it is the already-disabled state that is the one-way one.
|
||||||
const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true);
|
const sshAuth = guarded("disableSshAuth", (p) => p.disableSshAuth, true);
|
||||||
const jwtTtlId = useId();
|
const jwtTtlId = useId();
|
||||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||||
|
|
||||||
@@ -84,7 +125,7 @@ export function SettingsSSH() {
|
|||||||
<SectionGroup title={t("settings.ssh.section.server")}>
|
<SectionGroup title={t("settings.ssh.section.server")}>
|
||||||
<FancyToggleSwitch
|
<FancyToggleSwitch
|
||||||
value={config.serverSshAllowed}
|
value={config.serverSshAllowed}
|
||||||
onChange={(v) => setField("serverSshAllowed", v)}
|
onChange={sshServer.apply}
|
||||||
disabled={sshServer.disabled}
|
disabled={sshServer.disabled}
|
||||||
label={t("settings.ssh.server.label")}
|
label={t("settings.ssh.server.label")}
|
||||||
helpText={t("settings.ssh.server.help")}
|
helpText={t("settings.ssh.server.help")}
|
||||||
@@ -98,7 +139,7 @@ export function SettingsSSH() {
|
|||||||
>
|
>
|
||||||
<FancyToggleSwitch
|
<FancyToggleSwitch
|
||||||
value={config.enableSshRoot}
|
value={config.enableSshRoot}
|
||||||
onChange={(v) => setField("enableSshRoot", v)}
|
onChange={sshRoot.apply}
|
||||||
disabled={sshRoot.disabled}
|
disabled={sshRoot.disabled}
|
||||||
label={t("settings.ssh.root.label")}
|
label={t("settings.ssh.root.label")}
|
||||||
helpText={t("settings.ssh.root.help")}
|
helpText={t("settings.ssh.root.help")}
|
||||||
@@ -130,7 +171,7 @@ export function SettingsSSH() {
|
|||||||
>
|
>
|
||||||
<FancyToggleSwitch
|
<FancyToggleSwitch
|
||||||
value={!config.disableSshAuth}
|
value={!config.disableSshAuth}
|
||||||
onChange={(v) => setField("disableSshAuth", !v)}
|
onChange={(v) => sshAuth.apply(!v)}
|
||||||
disabled={sshAuth.disabled}
|
disabled={sshAuth.disabled}
|
||||||
label={t("settings.ssh.jwt.label")}
|
label={t("settings.ssh.jwt.label")}
|
||||||
helpText={t("settings.ssh.jwt.help")}
|
helpText={t("settings.ssh.jwt.help")}
|
||||||
@@ -163,41 +204,81 @@ export function SettingsSSH() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrivilegeHint explains what an unprivileged user can and cannot do with a
|
// actorLabel names the principal the daemon requires, in the user's language. The
|
||||||
// guarded control, and offers the command that does it with the privileges the
|
// Go side reports which one it is rather than wording it, because "administrator
|
||||||
// daemon requires. oneWay covers the control being in the guarded state already:
|
// privileges" is English and a translated sentence cannot borrow it.
|
||||||
// switching it back is the part that needs privileges.
|
function actorLabel(privilege: Privilege, t: TFunction): string {
|
||||||
function PrivilegeHint({
|
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({
|
||||||
actor,
|
actor,
|
||||||
command,
|
|
||||||
oneWay,
|
oneWay,
|
||||||
inverted,
|
inverted,
|
||||||
|
pending,
|
||||||
|
command,
|
||||||
}: {
|
}: {
|
||||||
actor: string;
|
actor: string;
|
||||||
command: string;
|
|
||||||
oneWay: boolean;
|
oneWay: boolean;
|
||||||
inverted: boolean;
|
inverted: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
command?: string;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const { t } = useTranslation();
|
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;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
|
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>
|
{children}
|
||||||
{!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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Alle sichtbaren Ressourcen umschalten"
|
"message": "Alle sichtbaren Ressourcen umschalten"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Einstellungsbereiche"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Zu Profil \"{name}\" wechseln?"
|
"message": "Zu Profil \"{name}\" wechseln?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Debug-Paket fehlgeschlagen"
|
"message": "Debug-Paket fehlgeschlagen"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Einstellungsbereiche"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Allgemein"
|
"message": "Allgemein"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "Vorgang fehlgeschlagen."
|
"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,16 +1775,36 @@
|
|||||||
"message": "Operation failed.",
|
"message": "Operation failed.",
|
||||||
"description": "Generic fallback error message used when no specific error applies."
|
"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": {
|
"settings.ssh.privilege.hint": {
|
||||||
"message": "Requires {actor}. Run this instead:",
|
"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."
|
"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": {
|
"settings.ssh.privilege.oneWay": {
|
||||||
"message": "You can switch this off, but switching it back on needs {actor}:",
|
"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."
|
"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."
|
||||||
},
|
},
|
||||||
"settings.ssh.privilege.oneWayInverted": {
|
"settings.ssh.privilege.oneWayInverted": {
|
||||||
"message": "You can switch this on, but switching it back off needs {actor}:",
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Conmutar todos los recursos visibles"
|
"message": "Conmutar todos los recursos visibles"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Secciones de configuración"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "¿Cambiar el perfil a «{name}»?"
|
"message": "¿Cambiar el perfil a «{name}»?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Error en el paquete de diagnóstico"
|
"message": "Error en el paquete de diagnóstico"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Secciones de configuración"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "General"
|
"message": "General"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "La operación falló."
|
"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,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Activer/désactiver toutes les ressources visibles"
|
"message": "Activer/désactiver toutes les ressources visibles"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Sections des paramètres"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Basculer vers le profil « {name} » ?"
|
"message": "Basculer vers le profil « {name} » ?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Échec du lot de diagnostic"
|
"message": "Échec du lot de diagnostic"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Sections des paramètres"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Général"
|
"message": "Général"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "L’opération a échoué."
|
"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,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Összes látható erőforrás be/ki"
|
"message": "Összes látható erőforrás be/ki"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Beállítások szakaszai"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Váltás a(z) \"{name}\" profilra?"
|
"message": "Váltás a(z) \"{name}\" profilra?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Hibakeresési csomag sikertelen"
|
"message": "Hibakeresési csomag sikertelen"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Beállítások szakaszai"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Általános"
|
"message": "Általános"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "A művelet meghiúsult."
|
"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,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Attiva/disattiva tutte le risorse visibili"
|
"message": "Attiva/disattiva tutte le risorse visibili"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Sezioni delle impostazioni"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Passare al profilo «{name}»?"
|
"message": "Passare al profilo «{name}»?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Pacchetto di debug non riuscito"
|
"message": "Pacchetto di debug non riuscito"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Sezioni delle impostazioni"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Generale"
|
"message": "Generale"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "Operazione non riuscita."
|
"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,6 +1304,9 @@
|
|||||||
"daemon.outdated.description": {
|
"daemon.outdated.description": {
|
||||||
"message": "このアプリを使用するには NetBird サービスを更新してください。"
|
"message": "このアプリを使用するには NetBird サービスを更新してください。"
|
||||||
},
|
},
|
||||||
|
"daemon.outdated.download": {
|
||||||
|
"message": "最新版をダウンロード"
|
||||||
|
},
|
||||||
"error.jwt_clock_skew": {
|
"error.jwt_clock_skew": {
|
||||||
"message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。"
|
"message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。"
|
||||||
},
|
},
|
||||||
@@ -1327,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "操作に失敗しました。"
|
"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,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Alternar todos os recursos visíveis"
|
"message": "Alternar todos os recursos visíveis"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Seções das configurações"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Alternar perfil para \"{name}\"?"
|
"message": "Alternar perfil para \"{name}\"?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Falha no pacote de depuração"
|
"message": "Falha no pacote de depuração"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Seções das configurações"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Geral"
|
"message": "Geral"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "A operação falhou."
|
"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,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "Переключить все видимые ресурсы"
|
"message": "Переключить все видимые ресурсы"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "Разделы настроек"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "Переключиться на профиль «{name}»?"
|
"message": "Переключиться на профиль «{name}»?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "Не удалось создать отладочный пакет"
|
"message": "Не удалось создать отладочный пакет"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "Разделы настроек"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "Общие"
|
"message": "Общие"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "Не удалось выполнить операцию."
|
"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,9 +401,6 @@
|
|||||||
"networks.bulk.label": {
|
"networks.bulk.label": {
|
||||||
"message": "切换所有可见资源"
|
"message": "切换所有可见资源"
|
||||||
},
|
},
|
||||||
"settings.nav.label": {
|
|
||||||
"message": "设置部分"
|
|
||||||
},
|
|
||||||
"profile.switch.title": {
|
"profile.switch.title": {
|
||||||
"message": "切换到配置文件“{name}”?"
|
"message": "切换到配置文件“{name}”?"
|
||||||
},
|
},
|
||||||
@@ -497,6 +494,9 @@
|
|||||||
"settings.error.debugBundleTitle": {
|
"settings.error.debugBundleTitle": {
|
||||||
"message": "创建调试包失败"
|
"message": "创建调试包失败"
|
||||||
},
|
},
|
||||||
|
"settings.nav.label": {
|
||||||
|
"message": "设置部分"
|
||||||
|
},
|
||||||
"settings.tabs.general": {
|
"settings.tabs.general": {
|
||||||
"message": "常规"
|
"message": "常规"
|
||||||
},
|
},
|
||||||
@@ -1330,5 +1330,29 @@
|
|||||||
},
|
},
|
||||||
"error.unknown": {
|
"error.unknown": {
|
||||||
"message": "操作失败。"
|
"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,13 +8,13 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
"github.com/wailsapp/wails/v3/pkg/events"
|
"github.com/wailsapp/wails/v3/pkg/events"
|
||||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||||
@@ -63,7 +63,7 @@ type registeredServices struct {
|
|||||||
profiles *services.Profiles
|
profiles *services.Profiles
|
||||||
update *services.Update
|
update *services.Update
|
||||||
daemonFeed *services.DaemonFeed
|
daemonFeed *services.DaemonFeed
|
||||||
notifier *notifications.NotificationService
|
notifier *Notifier
|
||||||
compat *services.Compat
|
compat *services.Compat
|
||||||
profileSwitcher *services.ProfileSwitcher
|
profileSwitcher *services.ProfileSwitcher
|
||||||
bundle *i18n.Bundle
|
bundle *i18n.Bundle
|
||||||
@@ -80,6 +80,14 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
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()
|
daemonAddr, userSetLogFile := parseFlagsAndInitLog()
|
||||||
conn := NewConn(daemonAddr)
|
conn := NewConn(daemonAddr)
|
||||||
|
|
||||||
@@ -102,7 +110,7 @@ func main() {
|
|||||||
updaterHolder := updater.NewHolder(app.Event)
|
updaterHolder := updater.NewHolder(app.Event)
|
||||||
update := services.NewUpdate(conn, updaterHolder)
|
update := services.NewUpdate(conn, updaterHolder)
|
||||||
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
|
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
|
||||||
notifier := notifications.New()
|
notifier := newNotifier()
|
||||||
compat := services.NewCompat(conn)
|
compat := services.NewCompat(conn)
|
||||||
// macOS shows no toast until permission is requested. Run it after
|
// macOS shows no toast until permission is requested. Run it after
|
||||||
// ApplicationStarted so the notifier's Startup has initialised the
|
// ApplicationStarted so the notifier's Startup has initialised the
|
||||||
@@ -210,7 +218,7 @@ func main() {
|
|||||||
// requestNotificationAuthorization prompts for macOS notification permission.
|
// requestNotificationAuthorization prompts for macOS notification permission.
|
||||||
// The request blocks until the user responds (up to 3 minutes), so callers run
|
// The request blocks until the user responds (up to 3 minutes), so callers run
|
||||||
// it in a goroutine. No-op on Linux/Windows.
|
// it in a goroutine. No-op on Linux/Windows.
|
||||||
func requestNotificationAuthorization(notifier *notifications.NotificationService) {
|
func requestNotificationAuthorization(notifier *Notifier) {
|
||||||
authorized, err := notifier.CheckNotificationAuthorization()
|
authorized, err := notifier.CheckNotificationAuthorization()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Debugf("check notification authorization: %v", err)
|
logrus.Debugf("check notification authorization: %v", err)
|
||||||
|
|||||||
101
client/ui/notifier.go
Normal file
101
client/ui/notifier.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//go:build !android && !ios && !freebsd && !js
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNotificationsUnavailable = errors.New("notifications unavailable")
|
||||||
|
|
||||||
|
// Notifier wraps the Wails notification service so an unavailable backend
|
||||||
|
// disables notifications instead of aborting the app. Startup fails for
|
||||||
|
// environment reasons (a bare unbundled binary on macOS has no bundle
|
||||||
|
// identifier, a headless Linux session has no D-Bus session bus), and Wails
|
||||||
|
// treats a service startup error as fatal. After a failed startup every call
|
||||||
|
// is a no-op: on macOS, touching UNUserNotificationCenter without a bundle
|
||||||
|
// identifier raises an Objective-C exception that recover() cannot catch.
|
||||||
|
type Notifier struct {
|
||||||
|
inner *notifications.NotificationService
|
||||||
|
available atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNotifier() *Notifier {
|
||||||
|
return &Notifier{inner: notifications.New()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceName implements the Wails service-name hook for startup logs.
|
||||||
|
func (n *Notifier) ServiceName() string {
|
||||||
|
return n.inner.ServiceName()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceStartup starts the platform notifier, downgrading failure to a
|
||||||
|
// warning so the app keeps running without notifications.
|
||||||
|
func (n *Notifier) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||||
|
if err := n.inner.ServiceStartup(ctx, options); err != nil {
|
||||||
|
log.Warnf("notifications disabled: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n.available.Store(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) ServiceShutdown() error {
|
||||||
|
if !n.available.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n.inner.ServiceShutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) CheckNotificationAuthorization() (bool, error) {
|
||||||
|
if !n.available.Load() {
|
||||||
|
return false, errNotificationsUnavailable
|
||||||
|
}
|
||||||
|
return n.inner.CheckNotificationAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) RequestNotificationAuthorization() (bool, error) {
|
||||||
|
if !n.available.Load() {
|
||||||
|
return false, errNotificationsUnavailable
|
||||||
|
}
|
||||||
|
return n.inner.RequestNotificationAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendNotification delivers a notification, silently dropping it when the
|
||||||
|
// backend never started (notifications are best-effort everywhere).
|
||||||
|
func (n *Notifier) SendNotification(options notifications.NotificationOptions) error {
|
||||||
|
if !n.available.Load() {
|
||||||
|
log.Debugf("notifications disabled, dropping %q", options.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n.inner.SendNotification(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) SendNotificationWithActions(options notifications.NotificationOptions) error {
|
||||||
|
if !n.available.Load() {
|
||||||
|
log.Debugf("notifications disabled, dropping %q", options.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n.inner.SendNotificationWithActions(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error {
|
||||||
|
if !n.available.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n.inner.RegisterNotificationCategory(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnNotificationResponse registers the response callback. Pure Go state, so
|
||||||
|
// it is safe (and simply inert) when the backend never started.
|
||||||
|
//
|
||||||
|
//wails:ignore
|
||||||
|
func (n *Notifier) OnNotificationResponse(callback func(result notifications.NotificationResult)) {
|
||||||
|
n.inner.OnNotificationResponse(callback)
|
||||||
|
}
|
||||||
@@ -246,6 +246,7 @@ func (s *Store) ExistedAtLoad() bool {
|
|||||||
func (s *Store) load() error {
|
func (s *Store) load() error {
|
||||||
if _, err := os.Stat(s.path); err != nil {
|
if _, err := os.Stat(s.path); err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
log.Infof("no ui preferences file at %s; using defaults", s.path)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("stat preferences: %w", err)
|
return fmt.Errorf("stat preferences: %w", err)
|
||||||
|
|||||||
27
client/ui/privileged_settings.go
Normal file
27
client/ui/privileged_settings.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
//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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -33,12 +33,21 @@ type LoginResult struct {
|
|||||||
UserCode string `json:"userCode"`
|
UserCode string `json:"userCode"`
|
||||||
VerificationURI string `json:"verificationUri"`
|
VerificationURI string `json:"verificationUri"`
|
||||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||||
|
// ProfileID is the ID of the profile this login ran against, or "" when the
|
||||||
|
// caller named the profile itself and no ID was resolved. Pass it back in
|
||||||
|
// WaitSSOParams so the account email lands on this profile even if the
|
||||||
|
// active one changes during SSO.
|
||||||
|
ProfileID string `json:"profileId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitSSOParams are the inputs to waitSSOLogin.
|
// WaitSSOParams are the inputs to waitSSOLogin.
|
||||||
type WaitSSOParams struct {
|
type WaitSSOParams struct {
|
||||||
UserCode string `json:"userCode"`
|
UserCode string `json:"userCode"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
|
// ProfileID is the profile the login was started for, used to file the
|
||||||
|
// account email against it rather than against whichever profile is active
|
||||||
|
// when the flow returns. Optional: empty falls back to the active profile.
|
||||||
|
ProfileID string `json:"profileId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpParams selects the profile to bring up.
|
// UpParams selects the profile to bring up.
|
||||||
@@ -77,11 +86,16 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
|||||||
// Fall back to the daemon's active profile and the current OS user.
|
// Fall back to the daemon's active profile and the current OS user.
|
||||||
profileName := p.ProfileName
|
profileName := p.ProfileName
|
||||||
username := p.Username
|
username := p.Username
|
||||||
|
// Only set when the daemon told us the ID. A caller-supplied ProfileName is
|
||||||
|
// a handle — a display name or an ID prefix resolve too — and the state file
|
||||||
|
// is named after the ID, so passing a handle on would name the wrong file.
|
||||||
|
profileID := ""
|
||||||
if profileName == "" {
|
if profileName == "" {
|
||||||
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
|
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
|
||||||
// Address the active profile by ID (the daemon resolves it as a
|
// Address the active profile by ID (the daemon resolves it as a
|
||||||
// handle); names can collide, the ID cannot.
|
// handle); names can collide, the ID cannot.
|
||||||
profileName = active.GetId()
|
profileName = active.GetId()
|
||||||
|
profileID = profileName
|
||||||
if username == "" {
|
if username == "" {
|
||||||
username = active.GetUsername()
|
username = active.GetUsername()
|
||||||
}
|
}
|
||||||
@@ -122,6 +136,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
|||||||
UserCode: resp.GetUserCode(),
|
UserCode: resp.GetUserCode(),
|
||||||
VerificationURI: resp.GetVerificationURI(),
|
VerificationURI: resp.GetVerificationURI(),
|
||||||
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
||||||
|
ProfileID: profileID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +257,31 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
|||||||
return "", s.classifyDaemonError(err)
|
return "", s.classifyDaemonError(err)
|
||||||
}
|
}
|
||||||
log.Infof("SSO login completed, daemon reported success")
|
log.Infof("SSO login completed, daemon reported success")
|
||||||
|
|
||||||
|
// Persist the account email the same way the CLI does after its own
|
||||||
|
// WaitSSOLogin: the daemon returns it but cannot store it, since it runs as
|
||||||
|
// root and the per-profile state file is user-owned (see Logout below).
|
||||||
|
// Without this the profile has no email, so Profiles.List shows no account
|
||||||
|
// and later logins and session extends go out without a login_hint —
|
||||||
|
// leaving the IdP to guess which account was meant.
|
||||||
|
if email := resp.GetEmail(); email != "" {
|
||||||
|
state := &profilemanager.ProfileState{Email: email}
|
||||||
|
pm := profilemanager.NewProfileManager()
|
||||||
|
|
||||||
|
// Against the profile the login was started for: SSO spans seconds of
|
||||||
|
// user interaction, and a profile switch in that window would otherwise
|
||||||
|
// file the email under the wrong profile.
|
||||||
|
if p.ProfileID != "" {
|
||||||
|
err = pm.SetProfileState(profilemanager.ID(p.ProfileID), state)
|
||||||
|
} else {
|
||||||
|
err = pm.SetActiveProfileState(state)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Non-fatal: the login itself succeeded.
|
||||||
|
log.Warnf("failed to store account email: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return resp.GetEmail(), nil
|
return resp.GetEmail(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
231
client/ui/services/guarded.go
Normal file
231
client/ui/services/guarded.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
355
client/ui/services/guarded_test.go
Normal file
355
client/ui/services/guarded_test.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
239
client/ui/services/oneshot.go
Normal file
239
client/ui/services/oneshot.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
//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)
|
||||||
151
client/ui/services/oneshot_test.go
Normal file
151
client/ui/services/oneshot_test.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os/user"
|
"os/user"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
)
|
)
|
||||||
@@ -151,11 +153,31 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||||
ProfileName: p.ProfileName,
|
ProfileName: p.ProfileName,
|
||||||
Username: p.Username,
|
Username: p.Username,
|
||||||
})
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The daemon deletes what it owns but runs as root, so it leaves the
|
||||||
|
// user-owned state file holding the account email behind (same split as
|
||||||
|
// Connection.Logout). Legacy profiles are keyed by name rather than by a
|
||||||
|
// generated ID, so a recreated profile of the same name would inherit the
|
||||||
|
// deleted one's email and offer it as the login_hint.
|
||||||
|
//
|
||||||
|
// Keyed on the ID the daemon resolved, not on the request handle: that may
|
||||||
|
// have been a display name or an ID prefix, which would name a different
|
||||||
|
// file (or none).
|
||||||
|
if id := resp.GetId(); id != "" {
|
||||||
|
if err := profilemanager.NewProfileManager().RemoveProfileState(id); err != nil {
|
||||||
|
// Non-fatal: the profile itself is gone.
|
||||||
|
log.Warnf("failed to remove profile state for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename changes a profile's display name. The on-disk ID is unaffected, so
|
// Rename changes a profile's display name. The on-disk ID is unaffected, so
|
||||||
|
|||||||
@@ -44,12 +44,19 @@ type Restrictions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Privilege tells the frontend whether this process may perform the changes the
|
// Privilege tells the frontend whether this process may perform the changes the
|
||||||
// daemon restricts to root/administrator, and carries the command for each so a
|
// daemon restricts to root/administrator, whether it can ask the operating
|
||||||
// disabled control can show the way to do it.
|
// system for the privileges instead, and the command for each so a control that
|
||||||
|
// can do neither can still show the way.
|
||||||
type Privilege struct {
|
type Privilege struct {
|
||||||
Privileged bool `json:"privileged"`
|
Privileged bool `json:"privileged"`
|
||||||
// Actor names what the operation requires ("root", "administrator privileges").
|
// ActorKey identifies the principal the operation requires without wording it,
|
||||||
Actor string `json:"actor"`
|
// 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"`
|
||||||
// Commands equivalent to the settings the daemon guards, ready to copy.
|
// Commands equivalent to the settings the daemon guards, ready to copy.
|
||||||
AllowSSHServer string `json:"allowSshServer"`
|
AllowSSHServer string `json:"allowSshServer"`
|
||||||
EnableSSHRoot string `json:"enableSshRoot"`
|
EnableSSHRoot string `json:"enableSshRoot"`
|
||||||
@@ -128,6 +135,9 @@ type Settings struct {
|
|||||||
// daemonAddr is where the daemon listens, used to tell whether it runs as
|
// daemonAddr is where the daemon listens, used to tell whether it runs as
|
||||||
// this user and would therefore authorize us: see Privilege.
|
// this user and would therefore authorize us: see Privilege.
|
||||||
daemonAddr string
|
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 {
|
func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings {
|
||||||
@@ -135,6 +145,7 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
|
|||||||
conn: conn,
|
conn: conn,
|
||||||
classifier: errorClassifier{translator: translator, prefs: prefs},
|
classifier: errorClassifier{translator: translator, prefs: prefs},
|
||||||
daemonAddr: daemonAddr,
|
daemonAddr: daemonAddr,
|
||||||
|
elevator: osElevator{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,10 +191,10 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) {
|
||||||
cli, err := s.conn.Client()
|
cli, err := s.conn.Client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return SaveOutcome{}, err
|
||||||
}
|
}
|
||||||
req := &proto.SetConfigRequest{
|
req := &proto.SetConfigRequest{
|
||||||
ProfileName: p.ProfileName,
|
ProfileName: p.ProfileName,
|
||||||
@@ -215,19 +226,92 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
|||||||
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
||||||
}
|
}
|
||||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
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
|
// Classified so the frontend gets the daemon's guidance instead of the
|
||||||
// gRPC envelope, which is what a refused privileged change looks like.
|
// gRPC envelope.
|
||||||
return s.classifier.classify(err)
|
return SaveOutcome{}, s.classifier.classify(err)
|
||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Privilege reports whether this UI process could carry out the changes the
|
// Privilege reports whether this UI process could carry out the changes the
|
||||||
// daemon restricts to root/administrator, and the command that performs the one
|
// daemon restricts to root/administrator, whether it can instead ask the
|
||||||
// users hit in the SSH settings. It applies the daemon's own rule to what it can
|
// operating system for the privileges when the user wants one of them, and the
|
||||||
// see locally, so the frontend can present those controls as unavailable up front
|
// command that performs the ones users hit in the SSH settings. It applies the
|
||||||
// instead of letting a save fail. No daemon round-trip, so it also works while the
|
// daemon's own rule to what it can see locally, so the frontend can decide up
|
||||||
// daemon is down.
|
// 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.
|
||||||
//
|
//
|
||||||
// Being root or an elevated administrator is one way. The other is running as the
|
// 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
|
// daemon's own user while the daemon is unprivileged, which the daemon accepts
|
||||||
@@ -237,26 +321,40 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
|||||||
func (s *Settings) Privilege() Privilege {
|
func (s *Settings) Privilege() Privilege {
|
||||||
id, err := ipcauth.CurrentProcessIdentity()
|
id, err := ipcauth.CurrentProcessIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fail closed: report unprivileged, which only ever disables controls.
|
// Fail closed: report unprivileged, which only ever asks for more.
|
||||||
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
|
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
|
||||||
return newPrivilege(false)
|
return s.newPrivilege(false)
|
||||||
}
|
}
|
||||||
if id.IsPrivileged() {
|
if id.IsPrivileged() {
|
||||||
return newPrivilege(true)
|
return s.newPrivilege(true)
|
||||||
}
|
}
|
||||||
return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
|
return s.newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPrivilege(privileged bool) Privilege {
|
func (s *Settings) newPrivilege(privileged bool) Privilege {
|
||||||
return Privilege{
|
return Privilege{
|
||||||
Privileged: privileged,
|
Privileged: privileged,
|
||||||
Actor: ipcauth.PrivilegedActor(),
|
ActorKey: ipcauth.PrivilegedActorKey(),
|
||||||
|
CanElevate: s.canElevate(),
|
||||||
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
|
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
|
||||||
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
|
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
|
||||||
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
|
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) {
|
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
||||||
cli, err := s.conn.Client()
|
cli, err := s.conn.Client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -289,6 +387,15 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
|||||||
return r, nil
|
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) {
|
func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
|
||||||
managed := cfgResp.GetMDMManagedFields()
|
managed := cfgResp.GetMDMManagedFields()
|
||||||
if len(managed) == 0 {
|
if len(managed) == 0 {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type TrayServices struct {
|
|||||||
Profiles *services.Profiles
|
Profiles *services.Profiles
|
||||||
Networks *services.Networks
|
Networks *services.Networks
|
||||||
DaemonFeed *services.DaemonFeed
|
DaemonFeed *services.DaemonFeed
|
||||||
Notifier *notifications.NotificationService
|
Notifier *Notifier
|
||||||
Update *services.Update
|
Update *services.Update
|
||||||
ProfileSwitcher *services.ProfileSwitcher
|
ProfileSwitcher *services.ProfileSwitcher
|
||||||
WindowManager *services.WindowManager
|
WindowManager *services.WindowManager
|
||||||
|
|||||||
@@ -4,17 +4,26 @@ package main
|
|||||||
|
|
||||||
// bindTrayClick wires the tray icon's left-click handler on Linux.
|
// bindTrayClick wires the tray icon's left-click handler on Linux.
|
||||||
//
|
//
|
||||||
// Both Linux click paths converge on Wails' linuxSystemTray.Activate, which
|
// Expected behaviour per tray host:
|
||||||
// fires the registered clickHandler:
|
//
|
||||||
// - Real SNI hosts (KDE Plasma, Waybar, GNOME Shell + AppIndicator) invoke
|
// Host Left click Right click
|
||||||
// org.kde.StatusNotifierItem.Activate over D-Bus on left-click.
|
// KDE Plasma, Waybar main window (Activate) menu (host-rendered)
|
||||||
// - The in-process StatusNotifierWatcher + XEmbed host used on minimal WMs
|
// GNOME Shell + AppIndicator menu only menu only
|
||||||
// (Fluxbox, i3, dwm, OpenBox) maps a Button1 press to that same Activate
|
// Minimal WMs via XEmbed host main window (Activate) XEmbed GTK popup
|
||||||
// call itself (xembed_host_linux.go), so it routes through the same hook.
|
//
|
||||||
// Registering OnClick here therefore covers both paths with one handler — no
|
// OnClick fires only on org.kde.StatusNotifierItem.Activate — a real left
|
||||||
// changes to the watcher or XEmbed C code are needed. Left-click now opens the
|
// click. KDE/Waybar send it over D-Bus; the in-process XEmbed host
|
||||||
// main window; right-click still opens the menu via Wails' default
|
// (xembed_host_linux.go) maps a Button1 press to the same Activate call.
|
||||||
// SecondaryActivate→OpenMenu handler (and the XEmbed GTK popup on minimal WMs).
|
//
|
||||||
|
// GNOME Shell + AppIndicator never sends Activate: it renders the dbusmenu
|
||||||
|
// on ANY click and only reports the menu opening via dbusmenu
|
||||||
|
// Event("opened"). Upstream Wails treated that event as a click, so on GNOME
|
||||||
|
// both buttons raised the main window on top of the menu, and on KDE/Waybar
|
||||||
|
// a right click raised it over the freshly opened menu. The netbirdio/wails
|
||||||
|
// fork (go.mod replace) drops that heuristic: a menu open never fires
|
||||||
|
// OnClick. On GNOME the main window is reached via the "Open NetBird" menu
|
||||||
|
// entry; left-click-opens-window is not achievable there anyway, since the
|
||||||
|
// host always opens the menu itself.
|
||||||
//
|
//
|
||||||
// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it
|
// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it
|
||||||
// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's
|
// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat
|
|||||||
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
|
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
|
||||||
// is reachable but too old for this UI. A probe error means the daemon isn't
|
// is reachable but too old for this UI. A probe error means the daemon isn't
|
||||||
// reachable (not outdated), so it is left to the normal connection flow.
|
// reachable (not outdated), so it is left to the normal connection flow.
|
||||||
func notifyIfDaemonOutdated(compat *services.Compat, notifier *notifications.NotificationService, loc *Localizer) {
|
func notifyIfDaemonOutdated(compat *services.Compat, notifier *Notifier, loc *Localizer) {
|
||||||
ready, err := compat.DaemonReady(context.Background())
|
ready, err := compat.DaemonReady(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("daemon compatibility probe: %v", err)
|
log.Debugf("daemon compatibility probe: %v", err)
|
||||||
|
|||||||
@@ -27,11 +27,10 @@ const (
|
|||||||
finalWarningCountdownSeconds = 120
|
finalWarningCountdownSeconds = 120
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal.
|
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
|
||||||
func (t *Tray) handleSessionExpired() {
|
func (t *Tray) handleSessionExpired() {
|
||||||
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
|
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
|
||||||
if t.window != nil {
|
if t.window != nil {
|
||||||
t.window.SetURL("/#/login")
|
|
||||||
t.window.Show()
|
t.window.Show()
|
||||||
t.window.Focus()
|
t.window.Focus()
|
||||||
}
|
}
|
||||||
@@ -308,11 +307,7 @@ func (t *Tray) openSessionExtendFlow() {
|
|||||||
}
|
}
|
||||||
seconds := int(time.Until(deadline).Seconds())
|
seconds := int(time.Until(deadline).Seconds())
|
||||||
if seconds <= 0 {
|
if seconds <= 0 {
|
||||||
if t.window != nil {
|
t.app.Event.Emit(services.EventTriggerLogin)
|
||||||
t.window.SetURL("/#/login")
|
|
||||||
t.window.Show()
|
|
||||||
t.window.Focus()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if t.svc.WindowManager == nil {
|
if t.svc.WindowManager == nil {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type trayUpdater struct {
|
|||||||
app *application.App
|
app *application.App
|
||||||
window *application.WebviewWindow
|
window *application.WebviewWindow
|
||||||
update *services.Update
|
update *services.Update
|
||||||
notifier *notifications.NotificationService
|
notifier *Notifier
|
||||||
loc *Localizer
|
loc *Localizer
|
||||||
onIconChange func()
|
onIconChange func()
|
||||||
// onMenuChange drives a full tray relayout: the update row lives in the
|
// onMenuChange drives a full tray relayout: the update row lives in the
|
||||||
@@ -36,7 +36,7 @@ type trayUpdater struct {
|
|||||||
progressWindowOpen bool
|
progressWindowOpen bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
|
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
|
||||||
u := &trayUpdater{
|
u := &trayUpdater{
|
||||||
app: app,
|
app: app,
|
||||||
window: window,
|
window: window,
|
||||||
|
|||||||
@@ -160,8 +160,19 @@ func TestSettingsRoundTrip(t *testing.T) {
|
|||||||
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
|
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
|
||||||
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
|
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
|
||||||
|
|
||||||
|
// A cluster different from the pinned one must be rejected; echoing the
|
||||||
|
// pinned one back is valid.
|
||||||
|
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
Cluster: ptr("attacker.cluster.invalid"),
|
||||||
|
EnableLogCollection: before.EnableLogCollection,
|
||||||
|
EnablePromptCollection: before.EnablePromptCollection,
|
||||||
|
RedactPii: before.RedactPii,
|
||||||
|
})
|
||||||
|
requireClientError(t, err)
|
||||||
|
|
||||||
// Restore the original toggles.
|
// Restore the original toggles.
|
||||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
Cluster: ptr(before.Cluster),
|
||||||
EnableLogCollection: before.EnableLogCollection,
|
EnableLogCollection: before.EnableLogCollection,
|
||||||
EnablePromptCollection: before.EnablePromptCollection,
|
EnablePromptCollection: before.EnablePromptCollection,
|
||||||
RedactPii: before.RedactPii,
|
RedactPii: before.RedactPii,
|
||||||
|
|||||||
114
e2e/agentnetwork/settings_bootstrap_test.go
Normal file
114
e2e/agentnetwork/settings_bootstrap_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package agentnetwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/e2e/harness"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// harnessStartFresh boots a dedicated combined server with its own fresh
|
||||||
|
// account and registers its teardown on t.
|
||||||
|
func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) {
|
||||||
|
t.Helper()
|
||||||
|
fresh, err := harness.StartCombined(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = fresh.Terminate(context.Background()) })
|
||||||
|
if _, err := fresh.Bootstrap(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fresh, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an
|
||||||
|
// account that has never been bootstrapped: the GET reads as the defaults
|
||||||
|
// with an empty cluster/subdomain/endpoint, a PUT without a cluster has
|
||||||
|
// nothing to pin and fails, and a PUT carrying a cluster creates the row and
|
||||||
|
// pins it immutably. The shared srv cannot provide that starting state (any
|
||||||
|
// provider-creating test bootstraps it, and test order is deliberately not
|
||||||
|
// relied on), so this boots a dedicated combined server — the image is
|
||||||
|
// already built and cached by TestMain's StartCombined, so the extra cost is
|
||||||
|
// one container start.
|
||||||
|
func TestSettingsBootstrapViaPut(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fresh, err := harnessStartFresh(ctx, t)
|
||||||
|
require.NoError(t, err, "start dedicated combined server")
|
||||||
|
|
||||||
|
// Before agent-network bootstrap the settings read as the defaults, not
|
||||||
|
// as an error and not as a null body.
|
||||||
|
before, err := fresh.GetSettings(ctx)
|
||||||
|
require.NoError(t, err, "get settings on a fresh account must succeed")
|
||||||
|
assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap")
|
||||||
|
assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap")
|
||||||
|
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot")
|
||||||
|
assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
|
||||||
|
assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off")
|
||||||
|
|
||||||
|
// A PUT without a cluster has nothing to pin the account to.
|
||||||
|
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
EnableLogCollection: true,
|
||||||
|
})
|
||||||
|
requireClientError(t, err)
|
||||||
|
|
||||||
|
// A PUT carrying a cluster bootstraps the account and applies the
|
||||||
|
// mutable fields from the same request. Every toggle is set away from
|
||||||
|
// its bootstrap default so each assertion can actually fail.
|
||||||
|
const cluster = "e2e.bootstrap.netbird.selfhosted"
|
||||||
|
bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
Cluster: ptr(cluster),
|
||||||
|
EnableLogCollection: false,
|
||||||
|
EnablePromptCollection: true,
|
||||||
|
RedactPii: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "bootstrap settings via PUT must succeed")
|
||||||
|
assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request")
|
||||||
|
require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap")
|
||||||
|
assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster")
|
||||||
|
assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default")
|
||||||
|
assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply")
|
||||||
|
assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply")
|
||||||
|
|
||||||
|
// The row is persisted: an independent read agrees on every field.
|
||||||
|
after, err := fresh.GetSettings(ctx)
|
||||||
|
require.NoError(t, err, "get settings after bootstrap must succeed")
|
||||||
|
assert.Equal(t, bootstrapped.Endpoint, after.Endpoint, "bootstrap must persist across reads")
|
||||||
|
assert.Equal(t, bootstrapped.EnableLogCollection, after.EnableLogCollection, "log collection must persist")
|
||||||
|
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
|
||||||
|
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
|
||||||
|
|
||||||
|
// Once bootstrapped, later updates may omit the cluster entirely.
|
||||||
|
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
EnableLogCollection: true,
|
||||||
|
EnablePromptCollection: false,
|
||||||
|
RedactPii: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "post-bootstrap update without cluster must succeed")
|
||||||
|
assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value")
|
||||||
|
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
|
||||||
|
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
|
||||||
|
|
||||||
|
// The cluster is immutable: a different value is rejected rather than
|
||||||
|
// silently ignored, and the rejected update must not disturb anything.
|
||||||
|
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
Cluster: ptr("other.cluster.invalid"),
|
||||||
|
EnableLogCollection: false,
|
||||||
|
})
|
||||||
|
requireClientError(t, err)
|
||||||
|
|
||||||
|
final, err := fresh.GetSettings(ctx)
|
||||||
|
require.NoError(t, err, "get settings after the rejected cluster change must succeed")
|
||||||
|
assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster")
|
||||||
|
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint")
|
||||||
|
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles")
|
||||||
|
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles")
|
||||||
|
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles")
|
||||||
|
}
|
||||||
4
go.mod
4
go.mod
@@ -114,7 +114,7 @@ require (
|
|||||||
github.com/ti-mo/conntrack v0.5.1
|
github.com/ti-mo/conntrack v0.5.1
|
||||||
github.com/ti-mo/netfilter v0.5.2
|
github.com/ti-mo/netfilter v0.5.2
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
|
github.com/wailsapp/wails/v3 v3.0.0-beta.3
|
||||||
github.com/yusufpapurcu/wmi v1.2.4
|
github.com/yusufpapurcu/wmi v1.2.4
|
||||||
github.com/zcalusic/sysinfo v1.1.3
|
github.com/zcalusic/sysinfo v1.1.3
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||||
@@ -339,3 +339,5 @@ replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205
|
|||||||
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1
|
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1
|
||||||
|
|
||||||
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
|
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
|
||||||
|
|
||||||
|
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -490,6 +490,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
|
|||||||
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
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=
|
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
||||||
|
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4 h1:UKztc3QjWvzU5DZk+uYaOWN0x62NSe/pkxuPvzqZIy4=
|
||||||
|
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
|
||||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
||||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
@@ -660,8 +662,6 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
|||||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
|
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
|
|
||||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||||
|
"github.com/netbirdio/netbird/management/server/account"
|
||||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||||
"github.com/netbirdio/netbird/management/server/permissions"
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
"github.com/netbirdio/netbird/management/server/store"
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
@@ -61,10 +62,23 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
|
|||||||
Return(true, context.Background(), nil).
|
Return(true, context.Background(), nil).
|
||||||
AnyTimes()
|
AnyTimes()
|
||||||
|
|
||||||
manager := agentnetwork.NewManager(st, perms, nil, nil)
|
// Swallow activity events so the mutation paths (create/update/delete)
|
||||||
|
// are exercisable through the HTTP layer.
|
||||||
|
accounts := account.NewMockManager(ctrl)
|
||||||
|
accounts.EXPECT().
|
||||||
|
StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
|
AnyTimes()
|
||||||
|
accounts.EXPECT().
|
||||||
|
UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
|
AnyTimes()
|
||||||
|
|
||||||
|
manager := agentnetwork.NewManager(st, perms, accounts, nil)
|
||||||
h := &handler{manager: manager}
|
h := &handler{manager: manager}
|
||||||
|
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
|
router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST")
|
||||||
|
router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET")
|
||||||
|
router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT")
|
||||||
h.addPolicyEndpoints(router)
|
h.addPolicyEndpoints(router)
|
||||||
h.addConsumptionEndpoints(router)
|
h.addConsumptionEndpoints(router)
|
||||||
h.addBudgetRuleEndpoints(router)
|
h.addBudgetRuleEndpoints(router)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
|
nethttp "net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -51,3 +53,50 @@ func TestValidate_ModelRates(t *testing.T) {
|
|||||||
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
|
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProviderHandler_UpdateReplacesFullState pins the update contract shared
|
||||||
|
// with the other PUT endpoints: the request replaces the provider's mutable
|
||||||
|
// state, so optional fields absent from the JSON land as their zero values.
|
||||||
|
// The two exceptions are server-side: the api_key (a secret — omitted means
|
||||||
|
// "not rotated") and the session keypair, both preserved by the manager. The
|
||||||
|
// identity headers stay on the wire as explicit empty strings so a cleared
|
||||||
|
// value round-trips.
|
||||||
|
func TestProviderHandler_UpdateReplacesFullState(t *testing.T) {
|
||||||
|
f := newAgentNetworkHandlerFixture(t)
|
||||||
|
|
||||||
|
create := `{
|
||||||
|
"provider_id": "openai_api",
|
||||||
|
"name": "openai",
|
||||||
|
"upstream_url": "https://api.openai.com",
|
||||||
|
"api_key": "sk-test",
|
||||||
|
"enabled": true,
|
||||||
|
"metadata_disabled": true,
|
||||||
|
"skip_tls_verification": true,
|
||||||
|
"extra_values": {"x-portkey-config": "pc-prod-3f2a"},
|
||||||
|
"identity_header_user_id": "x-bf-dim-netbird_user_id",
|
||||||
|
"models": [{"id": "gpt-4o", "input_per_1k": 0.0025, "output_per_1k": 0.01}]
|
||||||
|
}`
|
||||||
|
rec := f.do(t, nethttp.MethodPost, "/agent-network/providers", create)
|
||||||
|
require.Equal(t, nethttp.StatusOK, rec.Code, "create must succeed: %s", rec.Body.String())
|
||||||
|
|
||||||
|
var created api.AgentNetworkProvider
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
|
||||||
|
|
||||||
|
// Minimal update: only the required fields, no api_key. Everything
|
||||||
|
// optional must land as its zero value.
|
||||||
|
update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://api.openai.com", "enabled": true}`
|
||||||
|
rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update)
|
||||||
|
require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String())
|
||||||
|
|
||||||
|
var updated api.AgentNetworkProvider
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated))
|
||||||
|
assert.Equal(t, "openai-renamed", updated.Name, "sent field must apply")
|
||||||
|
assert.True(t, updated.Enabled, "sent field must apply")
|
||||||
|
assert.False(t, updated.MetadataDisabled, "omitted metadata_disabled must land as false — PUT replaces the full state")
|
||||||
|
assert.False(t, updated.SkipTlsVerification, "omitted skip_tls_verification must land as false")
|
||||||
|
assert.Nil(t, updated.ExtraValues, "omitted extra_values must be cleared")
|
||||||
|
assert.Equal(t, "", updated.IdentityHeaderUserId, "omitted identity header must be cleared yet stay on the wire")
|
||||||
|
assert.Empty(t, updated.Models, "omitted models must be cleared")
|
||||||
|
assert.Contains(t, rec.Body.String(), `"identity_header_user_id":""`,
|
||||||
|
"cleared identity header must round-trip as an explicit empty string")
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@@ -11,19 +10,20 @@ import (
|
|||||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||||
"github.com/netbirdio/netbird/shared/management/status"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// addSettingsEndpoints registers the Agent Network settings routes. The
|
// addSettingsEndpoints registers the Agent Network settings routes. The
|
||||||
// settings row is bootstrapped server-side on first provider create; GET reads
|
// settings row is bootstrapped server-side on first provider create or on the
|
||||||
// it and PUT updates the mutable collection toggles (cluster/subdomain stay
|
// first PUT carrying a cluster; GET reads it and PUT applies a partial update
|
||||||
// immutable).
|
// of the mutable collection toggles (cluster/subdomain stay immutable).
|
||||||
func (h *handler) addSettingsEndpoints(router *mux.Router) {
|
func (h *handler) addSettingsEndpoints(router *mux.Router) {
|
||||||
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
|
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
|
||||||
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
|
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSettings applies the collection toggles to the account's settings row.
|
// updateSettings replaces the mutable settings fields on the account's row.
|
||||||
|
// A request carrying a cluster bootstraps the row when the account doesn't
|
||||||
|
// have one yet.
|
||||||
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,11 +48,9 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSettings returns the account's agent-network settings. The settings
|
// getSettings returns the account's agent-network settings. Accounts that
|
||||||
// row is bootstrapped on first provider create, so freshly-onboarded
|
// haven't been bootstrapped yet read as the defaults with an empty cluster,
|
||||||
// accounts have nothing to read. Rather than 404-ing in that case (which
|
// subdomain and endpoint; the manager synthesises that view.
|
||||||
// the dashboard would have to special-case), return a JSON null with 200
|
|
||||||
// so consumers can branch on the body alone.
|
|
||||||
func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,11 +60,6 @@ func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
|
settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var sErr *status.Error
|
|
||||||
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
|
|
||||||
util.WriteJSONObject(r.Context(), w, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
util.WriteError(r.Context(), err, w)
|
util.WriteError(r.Context(), err, w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read
|
||||||
|
// convention shared with the account and DNS settings endpoints: settings
|
||||||
|
// always read as a JSON object. Before bootstrap that object carries the
|
||||||
|
// defaults with an empty cluster/subdomain/endpoint (the "not bootstrapped"
|
||||||
|
// signal) and no timestamps — never a 404 and never the legacy null body.
|
||||||
|
func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
|
||||||
|
f := newAgentNetworkHandlerFixture(t)
|
||||||
|
|
||||||
|
rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code,
|
||||||
|
"unbootstrapped account must read as 200 with defaults: got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
require.NotEqual(t, "null", trimSpace(rec.Body.String()),
|
||||||
|
"the legacy 200+null shape must not come back")
|
||||||
|
|
||||||
|
var got api.AgentNetworkSettings
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||||
|
assert.Empty(t, got.Cluster, "cluster must be empty until bootstrapped")
|
||||||
|
assert.Empty(t, got.Subdomain, "subdomain must be empty until bootstrapped")
|
||||||
|
assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped, not a bare dot")
|
||||||
|
assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
|
||||||
|
assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off")
|
||||||
|
assert.False(t, got.RedactPii, "defaults must show redaction off")
|
||||||
|
require.NotNil(t, got.AccessLogRetentionDays)
|
||||||
|
assert.Equal(t, 30, *got.AccessLogRetentionDays, "defaults must show the bootstrap retention")
|
||||||
|
assert.Nil(t, got.CreatedAt, "no timestamps before a row exists")
|
||||||
|
assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsHandler_PutBootstrapsWithCluster covers the settings-first
|
||||||
|
// bootstrap path: a PUT carrying a cluster on an unbootstrapped account
|
||||||
|
// creates the row (cluster pinned, subdomain assigned) and applies the
|
||||||
|
// mutable fields from the same request.
|
||||||
|
func TestSettingsHandler_PutBootstrapsWithCluster(t *testing.T) {
|
||||||
|
f := newAgentNetworkHandlerFixture(t)
|
||||||
|
|
||||||
|
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||||
|
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
|
||||||
|
|
||||||
|
var got api.AgentNetworkSettings
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||||
|
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be pinned from the request")
|
||||||
|
assert.NotEmpty(t, got.Subdomain, "subdomain must be assigned at bootstrap")
|
||||||
|
assert.Equal(t, got.Subdomain+".eu.proxy.netbird.io", got.Endpoint, "endpoint must combine subdomain and cluster")
|
||||||
|
assert.True(t, got.EnableLogCollection, "toggle from the bootstrap request must apply")
|
||||||
|
assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply")
|
||||||
|
require.NotNil(t, got.AccessLogRetentionDays)
|
||||||
|
assert.Equal(t, 30, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
|
||||||
|
|
||||||
|
// The row is now readable via GET.
|
||||||
|
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsHandler_PutWithoutClusterOnUnbootstrapped pins that a PUT
|
||||||
|
// without a cluster cannot conjure a settings row out of nothing — there is
|
||||||
|
// no cluster to pin — and surfaces as 404 like the GET.
|
||||||
|
func TestSettingsHandler_PutWithoutClusterOnUnbootstrapped(t *testing.T) {
|
||||||
|
f := newAgentNetworkHandlerFixture(t)
|
||||||
|
|
||||||
|
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||||
|
`{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||||
|
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||||
|
"cluster-less PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
assert.Contains(t, rec.Body.String(), "cluster",
|
||||||
|
"the error must point the caller at the bootstrap paths: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared
|
||||||
|
// with the other PUT endpoints: the request replaces every mutable field, so a
|
||||||
|
// toggle absent from the JSON lands as its zero value rather than being
|
||||||
|
// preserved. Cluster and subdomain survive untouched.
|
||||||
|
func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
|
||||||
|
f := newAgentNetworkHandlerFixture(t)
|
||||||
|
|
||||||
|
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||||
|
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
|
||||||
|
|
||||||
|
var before api.AgentNetworkSettings
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
|
||||||
|
|
||||||
|
rec = f.do(t, http.MethodPut, "/agent-network/settings",
|
||||||
|
`{"enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
|
||||||
|
|
||||||
|
var got api.AgentNetworkSettings
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||||
|
assert.True(t, got.EnableLogCollection, "sent toggle must apply")
|
||||||
|
assert.False(t, got.EnablePromptCollection, "sent toggle must apply")
|
||||||
|
assert.False(t, got.RedactPii, "sent toggle must apply")
|
||||||
|
require.NotNil(t, got.AccessLogRetentionDays)
|
||||||
|
assert.Equal(t, 0, *got.AccessLogRetentionDays,
|
||||||
|
"retention absent from the request must land as the zero value — PUT replaces all mutable fields")
|
||||||
|
assert.Equal(t, before.Cluster, got.Cluster, "cluster must survive updates untouched")
|
||||||
|
assert.Equal(t, before.Subdomain, got.Subdomain, "subdomain must survive updates untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsHandler_PutRejectsClusterChange pins cluster immutability: once
|
||||||
|
// assigned, a differing cluster is rejected as a validation error instead of
|
||||||
|
// being silently ignored, so callers never observe a value other than the one
|
||||||
|
// they sent. Echoing the assigned cluster back stays valid, which lets
|
||||||
|
// declarative clients send their full desired state idempotently.
|
||||||
|
func TestSettingsHandler_PutRejectsClusterChange(t *testing.T) {
|
||||||
|
f := newAgentNetworkHandlerFixture(t)
|
||||||
|
|
||||||
|
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||||
|
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
|
||||||
|
|
||||||
|
rec = f.do(t, http.MethodPut, "/agent-network/settings",
|
||||||
|
`{"cluster": "us.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||||
|
"cluster change must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
|
||||||
|
rec = f.do(t, http.MethodPut, "/agent-network/settings",
|
||||||
|
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": true}`)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "echoing the assigned cluster must stay valid: %s", rec.Body.String())
|
||||||
|
|
||||||
|
var got api.AgentNetworkSettings
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||||
|
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be unchanged")
|
||||||
|
assert.True(t, got.RedactPii, "toggle sent alongside the echoed cluster must apply")
|
||||||
|
}
|
||||||
@@ -157,14 +157,14 @@ func NewManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
|
func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) {
|
func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
|
return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
|
||||||
@@ -175,9 +175,14 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid
|
|||||||
// been created yet; otherwise it is ignored (the cluster is pinned on
|
// been created yet; otherwise it is ignored (the cluster is pinned on
|
||||||
// Settings and every provider in the account routes through it).
|
// Settings and every provider in the account routes through it).
|
||||||
func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error) {
|
func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error) {
|
||||||
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Create); err != nil {
|
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(bootstrapCluster) != "" {
|
||||||
|
if err := m.requireSettingsBootstrapPermission(ctx, provider.AccountID, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// An empty api_key would silently produce a synthesised service
|
// An empty api_key would silently produce a synthesised service
|
||||||
// that 401s on every upstream request. Surface the misconfiguration
|
// that 401s on every upstream request. Surface the misconfiguration
|
||||||
@@ -202,7 +207,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(bootstrapCluster) != "" {
|
if strings.TrimSpace(bootstrapCluster) != "" {
|
||||||
if _, err := m.bootstrapSettingsIfNeeded(ctx, provider.AccountID, bootstrapCluster); err != nil {
|
if _, err := m.bootstrapSettingsIfNeeded(ctx, m.store, provider.AccountID, bootstrapCluster); err != nil {
|
||||||
// The provider create has already succeeded; logging the
|
// The provider create has already succeeded; logging the
|
||||||
// bootstrap miss matches the plan's PoC behaviour. The synth
|
// bootstrap miss matches the plan's PoC behaviour. The synth
|
||||||
// path treats a missing settings row as a no-op, and the next
|
// path treats a missing settings row as a no-op, and the next
|
||||||
@@ -218,7 +223,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) {
|
func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) {
|
||||||
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Update); err != nil {
|
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Update); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +262,7 @@ func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provide
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error {
|
func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Delete); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,21 +311,21 @@ func pluralize(n int, singular, plural string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) {
|
func (m *managerImpl) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
return m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) {
|
func (m *managerImpl) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthNone, accountID, policyID)
|
return m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthNone, accountID, policyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
|
func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
|
||||||
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Create); err != nil {
|
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Create); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +351,7 @@ func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
|
func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
|
||||||
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Update); err != nil {
|
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Update); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +378,7 @@ func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, policyID string) error {
|
func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, policyID string) error {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Delete); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,21 +398,21 @@ func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, polic
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) {
|
func (m *managerImpl) GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID)
|
return m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) {
|
func (m *managerImpl) GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthNone, accountID, guardrailID)
|
return m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthNone, accountID, guardrailID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
|
func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
|
||||||
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Create); err != nil {
|
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Create); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +434,7 @@ func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
|
func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
|
||||||
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Update); err != nil {
|
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Update); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +457,7 @@ func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error {
|
func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Delete); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +478,7 @@ func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, gu
|
|||||||
|
|
||||||
// GetAllBudgetRules returns every account-level budget rule for the account.
|
// GetAllBudgetRules returns every account-level budget rule for the account.
|
||||||
func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) {
|
func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID)
|
return m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID)
|
||||||
@@ -481,7 +486,7 @@ func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID s
|
|||||||
|
|
||||||
// GetBudgetRule returns a single account-level budget rule.
|
// GetBudgetRule returns a single account-level budget rule.
|
||||||
func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) {
|
func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthNone, accountID, ruleID)
|
return m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthNone, accountID, ruleID)
|
||||||
@@ -491,7 +496,7 @@ func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, rule
|
|||||||
// enforced at request time (CheckLLMPolicyLimits), not baked into the synth
|
// enforced at request time (CheckLLMPolicyLimits), not baked into the synth
|
||||||
// proxy config, so no reconcile is needed.
|
// proxy config, so no reconcile is needed.
|
||||||
func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
|
func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
|
||||||
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Create); err != nil {
|
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Create); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +518,7 @@ func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule
|
|||||||
|
|
||||||
// UpdateBudgetRule updates an existing account-level budget rule.
|
// UpdateBudgetRule updates an existing account-level budget rule.
|
||||||
func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
|
func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
|
||||||
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Update); err != nil {
|
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Update); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,7 +541,7 @@ func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule
|
|||||||
|
|
||||||
// DeleteBudgetRule removes an account-level budget rule.
|
// DeleteBudgetRule removes an account-level budget rule.
|
||||||
func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error {
|
func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Delete); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,40 +559,83 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettings applies the mutable account-level settings — the collection
|
// UpdateSettings replaces the mutable account-level settings — the collection
|
||||||
// toggles — onto the existing row. Cluster and Subdomain are immutable and are
|
// toggles and retention — on the account's row. When the account has no
|
||||||
// preserved from the persisted row regardless of the input. Because the
|
// settings row yet, a non-empty settings.Cluster bootstraps one (same path as
|
||||||
// collection toggles change the synthesised service config (prompt-capture
|
// first provider create); without it the update fails with NotFound. On an
|
||||||
// gating, access-log emission), a reconcile is triggered so the proxy and peer
|
// existing row the cluster and subdomain are immutable: a differing
|
||||||
// network maps converge on the new state.
|
// settings.Cluster is rejected rather than silently ignored so callers never
|
||||||
|
// observe a value other than what they sent. Because the collection toggles
|
||||||
|
// change the synthesised service config (prompt-capture gating, access-log
|
||||||
|
// emission), a reconcile is triggered so the proxy and peer network maps
|
||||||
|
// converge on the new state.
|
||||||
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) {
|
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) {
|
||||||
if err := m.requirePermission(ctx, settings.AccountID, userID, operations.Update); err != nil {
|
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
|
requestedCluster := strings.TrimSpace(settings.Cluster)
|
||||||
|
|
||||||
|
// The row lock from LockingStrengthUpdate only holds for the duration of
|
||||||
|
// the surrounding transaction, so the read, the cluster-immutability
|
||||||
|
// check, and the save must share one — otherwise concurrent PUTs could
|
||||||
|
// interleave between them.
|
||||||
|
var updated *types.Settings
|
||||||
|
err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error {
|
||||||
|
existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if requestedCluster != "" && requestedCluster != existing.Cluster {
|
||||||
|
return status.Errorf(status.InvalidArgument, "cluster is immutable once assigned (current: %s)", existing.Cluster)
|
||||||
|
}
|
||||||
|
case isNotFound(err):
|
||||||
|
if requestedCluster == "" {
|
||||||
|
return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; pass cluster to bootstrap them, or create a provider with bootstrap_cluster set")
|
||||||
|
}
|
||||||
|
// Bootstrapping pins the cluster and subdomain — a settings
|
||||||
|
// create on top of the update the caller already passed, matching
|
||||||
|
// the gate on the provider-create bootstrap path.
|
||||||
|
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing, err = m.bootstrapSettingsIfNeeded(ctx, tx, settings.AccountID, requestedCluster)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("get agent network settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.EnableLogCollection = settings.EnableLogCollection
|
||||||
|
existing.EnablePromptCollection = settings.EnablePromptCollection
|
||||||
|
existing.RedactPii = settings.RedactPii
|
||||||
|
existing.AccessLogRetentionDays = settings.AccessLogRetentionDays
|
||||||
|
existing.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
if err := tx.SaveAgentNetworkSettings(ctx, existing); err != nil {
|
||||||
|
return fmt.Errorf("save agent network settings: %w", err)
|
||||||
|
}
|
||||||
|
updated = existing
|
||||||
|
return nil
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
existing.EnableLogCollection = settings.EnableLogCollection
|
|
||||||
existing.EnablePromptCollection = settings.EnablePromptCollection
|
|
||||||
existing.RedactPii = settings.RedactPii
|
|
||||||
existing.AccessLogRetentionDays = settings.AccessLogRetentionDays
|
|
||||||
existing.UpdatedAt = time.Now().UTC()
|
|
||||||
|
|
||||||
if err := m.store.SaveAgentNetworkSettings(ctx, existing); err != nil {
|
|
||||||
return nil, fmt.Errorf("save agent network settings: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{
|
m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{
|
||||||
"log_collection": existing.EnableLogCollection,
|
"log_collection": updated.EnableLogCollection,
|
||||||
"prompt_collection": existing.EnablePromptCollection,
|
"prompt_collection": updated.EnablePromptCollection,
|
||||||
"redact_pii": existing.RedactPii,
|
"redact_pii": updated.RedactPii,
|
||||||
})
|
})
|
||||||
m.reconcile(ctx, settings.AccountID)
|
m.reconcile(ctx, settings.AccountID)
|
||||||
|
|
||||||
return existing, nil
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNotFound reports whether err is a status.NotFound error.
|
||||||
|
func isNotFound(err error) bool {
|
||||||
|
var sErr *status.Error
|
||||||
|
return errors.As(err, &sErr) && sErr.Type() == status.NotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateProviderRefs ensures every destination provider id refers to a
|
// validateProviderRefs ensures every destination provider id refers to a
|
||||||
@@ -611,14 +659,38 @@ func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSettings returns the agent-network settings row for the account.
|
// GetSettings returns the agent-network settings row for the account. When no
|
||||||
// Returns the underlying status.NotFound when no row has been
|
// row has been bootstrapped yet, the defaults are returned (without
|
||||||
// bootstrapped yet (i.e. the account has no providers).
|
// persisting) with cluster and subdomain empty — settings always read as an
|
||||||
|
// object, like the account and DNS settings endpoints.
|
||||||
func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
|
func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return settings, nil
|
||||||
|
case isNotFound(err):
|
||||||
|
return types.DefaultSettings(accountID), nil
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireSettingsBootstrapPermission gates the one-time settings bootstrap a
|
||||||
|
// first provider create performs. Pinning the account's cluster and subdomain
|
||||||
|
// is a settings write, so it needs the settings permission on top of the
|
||||||
|
// provider one. No-op once the settings row exists.
|
||||||
|
func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, accountID, userID string) error {
|
||||||
|
_, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isNotFound(err) {
|
||||||
|
return fmt.Errorf("get agent network settings: %w", err)
|
||||||
|
}
|
||||||
|
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrapSettingsIfNeeded creates the per-account agent-network
|
// bootstrapSettingsIfNeeded creates the per-account agent-network
|
||||||
@@ -626,8 +698,9 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string)
|
|||||||
// hint the dashboard sends (auto-picked from the active cluster list);
|
// hint the dashboard sends (auto-picked from the active cluster list);
|
||||||
// the subdomain is picked from the curated wordlist avoiding
|
// the subdomain is picked from the curated wordlist avoiding
|
||||||
// collisions on the same cluster. Idempotent: if a row already exists
|
// collisions on the same cluster. Idempotent: if a row already exists
|
||||||
// it is returned untouched and the hint is ignored.
|
// it is returned untouched and the hint is ignored. st is the store to
|
||||||
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) {
|
// operate on — pass the transaction store when calling from within one.
|
||||||
|
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.Store, accountID, providerCluster string) (*types.Settings, error) {
|
||||||
if accountID == "" {
|
if accountID == "" {
|
||||||
return nil, fmt.Errorf("bootstrap settings: account id is required")
|
return nil, fmt.Errorf("bootstrap settings: account id is required")
|
||||||
}
|
}
|
||||||
@@ -635,16 +708,15 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
|
|||||||
return nil, fmt.Errorf("bootstrap settings: provider cluster is required")
|
return nil, fmt.Errorf("bootstrap settings: provider cluster is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
existing, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
var sErr *status.Error
|
if !isNotFound(err) {
|
||||||
if !errors.As(err, &sErr) || sErr.Type() != status.NotFound {
|
|
||||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
return nil, fmt.Errorf("get agent network settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
|
siblings, err := st.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -663,18 +735,12 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
|
|||||||
m.labelRngMu.Unlock()
|
m.labelRngMu.Unlock()
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
settings := &types.Settings{
|
settings := types.DefaultSettings(accountID)
|
||||||
AccountID: accountID,
|
settings.Cluster = providerCluster
|
||||||
Cluster: providerCluster,
|
settings.Subdomain = subdomain
|
||||||
Subdomain: subdomain,
|
settings.CreatedAt = now
|
||||||
// Logs on by default; usage is collected regardless. Retention bounds
|
settings.UpdatedAt = now
|
||||||
// how long full log rows are kept.
|
if err := st.SaveAgentNetworkSettings(ctx, settings); err != nil {
|
||||||
EnableLogCollection: true,
|
|
||||||
AccessLogRetentionDays: types.DefaultAccessLogRetentionDays,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil {
|
|
||||||
return nil, fmt.Errorf("save agent network settings: %w", err)
|
return nil, fmt.Errorf("save agent network settings: %w", err)
|
||||||
}
|
}
|
||||||
return settings, nil
|
return settings, nil
|
||||||
@@ -685,7 +751,7 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
|
|||||||
// counter view; permission gate is the same Read role that gates
|
// counter view; permission gate is the same Read role that gates
|
||||||
// every other agent-network surface.
|
// every other agent-network surface.
|
||||||
func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) {
|
func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID)
|
return m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID)
|
||||||
@@ -694,7 +760,7 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str
|
|||||||
// ListAccessLogs returns a paginated, server-side-filtered page of
|
// ListAccessLogs returns a paginated, server-side-filtered page of
|
||||||
// agent-network access logs plus the total count matching the filter.
|
// agent-network access logs plus the total count matching the filter.
|
||||||
func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) {
|
func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
|
return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
|
||||||
@@ -704,7 +770,7 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri
|
|||||||
// agent-network access logs grouped by session, plus the total number of
|
// agent-network access logs grouped by session, plus the total number of
|
||||||
// sessions matching the filter.
|
// sessions matching the filter.
|
||||||
func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) {
|
func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
|
return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
|
||||||
@@ -713,7 +779,7 @@ func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, user
|
|||||||
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
|
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
|
||||||
// at the requested granularity, oldest-first.
|
// at the requested granularity, oldest-first.
|
||||||
func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) {
|
func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) {
|
||||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
|
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
|
||||||
@@ -787,8 +853,8 @@ func (m *managerImpl) RecordConsumption(ctx context.Context, accountID string, k
|
|||||||
return m.store.IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD)
|
return m.store.IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, op operations.Operation) error {
|
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, module modules.Module, op operations.Operation) error {
|
||||||
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetwork, op)
|
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, module, op)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status.NewPermissionValidationError(err)
|
return status.NewPermissionValidationError(err)
|
||||||
}
|
}
|
||||||
@@ -877,8 +943,8 @@ func (*mockManager) UpdateBudgetRule(_ context.Context, _ string, r *types.Accou
|
|||||||
|
|
||||||
func (*mockManager) DeleteBudgetRule(_ context.Context, _, _, _ string) error { return nil }
|
func (*mockManager) DeleteBudgetRule(_ context.Context, _, _, _ string) error { return nil }
|
||||||
|
|
||||||
func (*mockManager) GetSettings(_ context.Context, _, _ string) (*types.Settings, error) {
|
func (*mockManager) GetSettings(_ context.Context, accountID, _ string) (*types.Settings, error) {
|
||||||
return nil, status.Errorf(status.NotFound, "agent network settings not found")
|
return types.DefaultSettings(accountID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) {
|
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package agentnetwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/golang/mock/gomock"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||||
|
"github.com/netbirdio/netbird/management/server/account"
|
||||||
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
|
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||||
|
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||||
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
|
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
|
||||||
|
// so tests can grant the provider permission while denying (or never
|
||||||
|
// expecting) the settings one.
|
||||||
|
type bootstrapFixture struct {
|
||||||
|
manager Manager
|
||||||
|
store store.Store
|
||||||
|
perms *permissions.MockManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
|
||||||
|
t.Helper()
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("sqlite store not properly supported on Windows yet")
|
||||||
|
}
|
||||||
|
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
|
||||||
|
|
||||||
|
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||||
|
require.NoError(t, err, "test store setup must succeed")
|
||||||
|
t.Cleanup(cleanUp)
|
||||||
|
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
perms := permissions.NewMockManager(ctrl)
|
||||||
|
|
||||||
|
accounts := account.NewMockManager(ctrl)
|
||||||
|
accounts.EXPECT().StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
|
||||||
|
return &bootstrapFixture{
|
||||||
|
manager: NewManager(st, perms, accounts, nil),
|
||||||
|
store: st,
|
||||||
|
perms: perms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
|
||||||
|
f.perms.EXPECT().
|
||||||
|
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
|
||||||
|
Return(allowed, context.Background(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBootstrapProvider(accountID string) *types.Provider {
|
||||||
|
p := types.NewProvider(accountID)
|
||||||
|
p.Name = "openai"
|
||||||
|
p.UpstreamURL = "https://api.openai.com"
|
||||||
|
p.APIKey = "sk-test"
|
||||||
|
p.Enabled = true
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProviderBootstrapRequiresSettingsPermission pins the gate on the
|
||||||
|
// one-time settings bootstrap: creating the first provider with a
|
||||||
|
// bootstrap_cluster pins the account's cluster and subdomain, which is a
|
||||||
|
// settings write and must not ride on the providers permission alone.
|
||||||
|
func TestCreateProviderBootstrapRequiresSettingsPermission(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("denied without settings permission", func(t *testing.T) {
|
||||||
|
f := newBootstrapFixture(t)
|
||||||
|
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||||
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
|
||||||
|
|
||||||
|
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
|
||||||
|
require.Error(t, err, "bootstrap without settings permission must fail")
|
||||||
|
var sErr *status.Error
|
||||||
|
require.ErrorAs(t, err, &sErr)
|
||||||
|
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
|
||||||
|
|
||||||
|
providers, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, providers, "provider must not be persisted when bootstrap is denied")
|
||||||
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||||
|
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allowed with settings permission", func(t *testing.T) {
|
||||||
|
f := newBootstrapFixture(t)
|
||||||
|
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||||
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||||
|
|
||||||
|
created, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
|
||||||
|
require.NoError(t, err, "bootstrap with both permissions must succeed")
|
||||||
|
require.NotNil(t, created)
|
||||||
|
|
||||||
|
settings, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||||
|
require.NoError(t, err, "bootstrap must create the settings row")
|
||||||
|
assert.Equal(t, "cluster1.example.com", settings.Cluster, "settings should pin the bootstrap cluster")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("existing settings need no settings permission", func(t *testing.T) {
|
||||||
|
f := newBootstrapFixture(t)
|
||||||
|
require.NoError(t, f.store.SaveAgentNetworkSettings(ctx, &types.Settings{
|
||||||
|
AccountID: "account1",
|
||||||
|
Cluster: "cluster1.example.com",
|
||||||
|
Subdomain: "existing",
|
||||||
|
}), "pre-existing settings row setup must succeed")
|
||||||
|
|
||||||
|
// Only the providers permission may be consulted: gomock fails the
|
||||||
|
// test on any unexpected settings-permission call.
|
||||||
|
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||||
|
|
||||||
|
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
|
||||||
|
require.NoError(t, err, "create with existing settings must not require the settings permission")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no bootstrap cluster needs no settings permission", func(t *testing.T) {
|
||||||
|
f := newBootstrapFixture(t)
|
||||||
|
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||||
|
|
||||||
|
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "")
|
||||||
|
require.NoError(t, err, "create without bootstrap must not require the settings permission")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -164,9 +164,7 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
|
|||||||
p.MetadataDisabled = *req.MetadataDisabled
|
p.MetadataDisabled = *req.MetadataDisabled
|
||||||
}
|
}
|
||||||
// Identity-header overrides for catalogs flagged Customizable.
|
// Identity-header overrides for catalogs flagged Customizable.
|
||||||
// nil pointer = "field omitted on the wire" → leave the stored
|
// Empty or omitted disables stamping for this dimension.
|
||||||
// value untouched (per the openapi description). Empty string is
|
|
||||||
// an explicit clear that disables stamping for this dimension.
|
|
||||||
if req.IdentityHeaderUserId != nil {
|
if req.IdentityHeaderUserId != nil {
|
||||||
p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId)
|
p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId)
|
||||||
}
|
}
|
||||||
@@ -192,16 +190,20 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
|
|||||||
created := p.CreatedAt
|
created := p.CreatedAt
|
||||||
updated := p.UpdatedAt
|
updated := p.UpdatedAt
|
||||||
resp := &api.AgentNetworkProvider{
|
resp := &api.AgentNetworkProvider{
|
||||||
Id: p.ID,
|
Id: p.ID,
|
||||||
ProviderId: p.ProviderID,
|
ProviderId: p.ProviderID,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
UpstreamUrl: p.UpstreamURL,
|
UpstreamUrl: p.UpstreamURL,
|
||||||
Models: models,
|
Models: models,
|
||||||
Enabled: p.Enabled,
|
// Always present on the wire so an explicitly cleared header
|
||||||
SkipTlsVerification: p.SkipTLSVerification,
|
// round-trips as "" instead of vanishing from the response.
|
||||||
MetadataDisabled: p.MetadataDisabled,
|
IdentityHeaderUserId: p.IdentityHeaderUserID,
|
||||||
CreatedAt: &created,
|
IdentityHeaderGroups: p.IdentityHeaderGroups,
|
||||||
UpdatedAt: &updated,
|
Enabled: p.Enabled,
|
||||||
|
SkipTlsVerification: p.SkipTLSVerification,
|
||||||
|
MetadataDisabled: p.MetadataDisabled,
|
||||||
|
CreatedAt: &created,
|
||||||
|
UpdatedAt: &updated,
|
||||||
}
|
}
|
||||||
if len(p.ExtraValues) > 0 {
|
if len(p.ExtraValues) > 0 {
|
||||||
out := make(map[string]string, len(p.ExtraValues))
|
out := make(map[string]string, len(p.ExtraValues))
|
||||||
@@ -210,14 +212,6 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
|
|||||||
}
|
}
|
||||||
resp.ExtraValues = &out
|
resp.ExtraValues = &out
|
||||||
}
|
}
|
||||||
if p.IdentityHeaderUserID != "" {
|
|
||||||
v := p.IdentityHeaderUserID
|
|
||||||
resp.IdentityHeaderUserId = &v
|
|
||||||
}
|
|
||||||
if p.IdentityHeaderGroups != "" {
|
|
||||||
v := p.IdentityHeaderGroups
|
|
||||||
resp.IdentityHeaderGroups = &v
|
|
||||||
}
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,41 @@ func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) {
|
|||||||
assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled")
|
assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled")
|
||||||
assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value")
|
assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProvider_IdentityHeaders_AlwaysOnWire pins that the identity header
|
||||||
|
// fields are always present in the API response — an explicitly cleared
|
||||||
|
// ("") header must round-trip as "" rather than vanish, so API consumers
|
||||||
|
// (e.g. the Terraform provider) never observe a value other than the one
|
||||||
|
// they wrote.
|
||||||
|
func TestProvider_IdentityHeaders_AlwaysOnWire(t *testing.T) {
|
||||||
|
set := "x-bf-dim-netbird_user_id"
|
||||||
|
empty := ""
|
||||||
|
|
||||||
|
base := func() *api.AgentNetworkProviderRequest {
|
||||||
|
return &api.AgentNetworkProviderRequest{
|
||||||
|
ProviderId: "custom",
|
||||||
|
Name: "bifrost",
|
||||||
|
UpstreamUrl: "https://bifrost.internal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := NewProvider("acc-1")
|
||||||
|
resp := p.ToAPIResponse()
|
||||||
|
assert.Equal(t, "", resp.IdentityHeaderUserId, "unset header must surface as empty string, not be omitted")
|
||||||
|
assert.Equal(t, "", resp.IdentityHeaderGroups, "unset header must surface as empty string, not be omitted")
|
||||||
|
|
||||||
|
req := base()
|
||||||
|
req.IdentityHeaderUserId = &set
|
||||||
|
p.FromAPIRequest(req)
|
||||||
|
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "configured header must round-trip")
|
||||||
|
|
||||||
|
// Omitting the field preserves it.
|
||||||
|
p.FromAPIRequest(base())
|
||||||
|
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "omitted header must preserve the stored value")
|
||||||
|
|
||||||
|
// An explicit "" clears it AND stays visible on the wire.
|
||||||
|
req = base()
|
||||||
|
req.IdentityHeaderUserId = &empty
|
||||||
|
p.FromAPIRequest(req)
|
||||||
|
assert.Equal(t, "", p.ToAPIResponse().IdentityHeaderUserId, "cleared header must round-trip as empty string")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
@@ -42,18 +43,34 @@ type Settings struct {
|
|||||||
// schema cohesive.
|
// schema cohesive.
|
||||||
func (Settings) TableName() string { return "agent_network_settings" }
|
func (Settings) TableName() string { return "agent_network_settings" }
|
||||||
|
|
||||||
|
// DefaultSettings returns the settings an account observes before its row is
|
||||||
|
// bootstrapped: log collection on with the default retention, everything else
|
||||||
|
// off, and no cluster/subdomain assigned yet. Bootstrap persists exactly these
|
||||||
|
// values plus the assigned cluster and subdomain, so the pre-bootstrap read
|
||||||
|
// and the freshly bootstrapped row agree.
|
||||||
|
func DefaultSettings(accountID string) *Settings {
|
||||||
|
return &Settings{
|
||||||
|
AccountID: accountID,
|
||||||
|
EnableLogCollection: true,
|
||||||
|
AccessLogRetentionDays: DefaultAccessLogRetentionDays,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Endpoint returns the bare hostname agents reach this account at:
|
// Endpoint returns the bare hostname agents reach this account at:
|
||||||
// `<subdomain>.<cluster>`.
|
// `<subdomain>.<cluster>`. Empty until both halves are assigned at bootstrap.
|
||||||
func (s *Settings) Endpoint() string {
|
func (s *Settings) Endpoint() string {
|
||||||
|
if s.Cluster == "" || s.Subdomain == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return s.Subdomain + "." + s.Cluster
|
return s.Subdomain + "." + s.Cluster
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToAPIResponse renders the settings as the API representation.
|
// ToAPIResponse renders the settings as the API representation. The
|
||||||
|
// timestamps are omitted while zero — a default (not yet bootstrapped) view
|
||||||
|
// has no persisted row to date.
|
||||||
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
||||||
created := s.CreatedAt
|
|
||||||
updated := s.UpdatedAt
|
|
||||||
retention := s.AccessLogRetentionDays
|
retention := s.AccessLogRetentionDays
|
||||||
return &api.AgentNetworkSettings{
|
resp := &api.AgentNetworkSettings{
|
||||||
Cluster: s.Cluster,
|
Cluster: s.Cluster,
|
||||||
Subdomain: s.Subdomain,
|
Subdomain: s.Subdomain,
|
||||||
Endpoint: s.Endpoint(),
|
Endpoint: s.Endpoint(),
|
||||||
@@ -61,14 +78,27 @@ func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
|||||||
EnablePromptCollection: s.EnablePromptCollection,
|
EnablePromptCollection: s.EnablePromptCollection,
|
||||||
RedactPii: s.RedactPii,
|
RedactPii: s.RedactPii,
|
||||||
AccessLogRetentionDays: &retention,
|
AccessLogRetentionDays: &retention,
|
||||||
CreatedAt: &created,
|
|
||||||
UpdatedAt: &updated,
|
|
||||||
}
|
}
|
||||||
|
if !s.CreatedAt.IsZero() {
|
||||||
|
created := s.CreatedAt
|
||||||
|
resp.CreatedAt = &created
|
||||||
|
}
|
||||||
|
if !s.UpdatedAt.IsZero() {
|
||||||
|
updated := s.UpdatedAt
|
||||||
|
resp.UpdatedAt = &updated
|
||||||
|
}
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromAPIRequest applies the mutable settings fields from the request. Cluster
|
// FromAPIRequest applies the request onto the receiver. The mutable
|
||||||
// and Subdomain are immutable and intentionally not touched here.
|
// collection fields are always replaced with the request values. Cluster
|
||||||
|
// participates only in bootstrap and the immutability check (see
|
||||||
|
// Manager.UpdateSettings); Subdomain is server-assigned and never taken
|
||||||
|
// from a request.
|
||||||
func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) {
|
func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) {
|
||||||
|
if req.Cluster != nil {
|
||||||
|
s.Cluster = strings.TrimSpace(*req.Cluster)
|
||||||
|
}
|
||||||
s.EnableLogCollection = req.EnableLogCollection
|
s.EnableLogCollection = req.EnableLogCollection
|
||||||
s.EnablePromptCollection = req.EnablePromptCollection
|
s.EnablePromptCollection = req.EnablePromptCollection
|
||||||
s.RedactPii = req.RedactPii
|
s.RedactPii = req.RedactPii
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/encryption"
|
"github.com/netbirdio/netbird/encryption"
|
||||||
"github.com/netbirdio/netbird/formatter/hook"
|
"github.com/netbirdio/netbird/formatter/hook"
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||||
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
|
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
|
||||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||||
"github.com/netbirdio/netbird/management/server/activity"
|
"github.com/netbirdio/netbird/management/server/activity"
|
||||||
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
|
||||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||||
nbContext "github.com/netbirdio/netbird/management/server/context"
|
nbContext "github.com/netbirdio/netbird/management/server/context"
|
||||||
nbhttp "github.com/netbirdio/netbird/management/server/http"
|
nbhttp "github.com/netbirdio/netbird/management/server/http"
|
||||||
@@ -184,6 +184,10 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
|
|||||||
grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor, proxyStream),
|
grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor, proxyStream),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append interceptors contributed by registered gRPC extensions. These
|
||||||
|
// run after the built-in chain (ChainUnaryInterceptor is additive).
|
||||||
|
gRPCOpts = appendExtensionInterceptors(gRPCOpts, s.grpcExtensions)
|
||||||
|
|
||||||
if s.Config.HttpConfig.LetsEncryptDomain != "" {
|
if s.Config.HttpConfig.LetsEncryptDomain != "" {
|
||||||
certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
|
certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,6 +219,9 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
|
|||||||
mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer())
|
mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer())
|
||||||
log.Info("ProxyService registered on gRPC server")
|
log.Info("ProxyService registered on gRPC server")
|
||||||
|
|
||||||
|
// Register services contributed by external modules via the extension seam.
|
||||||
|
registerExtensions(gRPCAPIHandler, s.grpcExtensions)
|
||||||
|
|
||||||
return gRPCAPIHandler
|
return gRPCAPIHandler
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
74
management/internals/server/grpc_extension.go
Normal file
74
management/internals/server/grpc_extension.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GRPCExtension bundles an external module's contribution to the management
|
||||||
|
// gRPC server: the registration of one or more services onto the shared
|
||||||
|
// grpc.Server, any server-wide interceptors those services require, and an
|
||||||
|
// optional shutdown hook. It is a generic extension point with no knowledge of
|
||||||
|
// any specific service.
|
||||||
|
type GRPCExtension struct {
|
||||||
|
// Register is invoked with the shared grpc.Server (as a ServiceRegistrar)
|
||||||
|
// after the built-in services are registered. It may register any number of
|
||||||
|
// services. May be nil.
|
||||||
|
Register func(grpc.ServiceRegistrar)
|
||||||
|
// UnaryInterceptors are appended to the server's unary interceptor chain,
|
||||||
|
// running after the built-in interceptors. May be empty.
|
||||||
|
UnaryInterceptors []grpc.UnaryServerInterceptor
|
||||||
|
// StreamInterceptors are appended to the server's stream interceptor chain,
|
||||||
|
// running after the built-in interceptors. May be empty.
|
||||||
|
StreamInterceptors []grpc.StreamServerInterceptor
|
||||||
|
// Shutdown, if non-nil, is called once during Stop() with the context
|
||||||
|
// governing server shutdown, which carries a deadline. The hook MUST
|
||||||
|
// return promptly and MUST abandon its work once that context is
|
||||||
|
// cancelled or expires: it runs before the rest of Stop()'s cleanup
|
||||||
|
// (store, event store, embedded IdP) and before Stop() itself checks the
|
||||||
|
// context's deadline, so a hook that ignores the context will delay all
|
||||||
|
// of that cleanup and prevent Stop() from returning on time. May be nil.
|
||||||
|
Shutdown func(ctx context.Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterGRPCExtension registers a gRPC extension. Call before the gRPC server
|
||||||
|
// is first built (i.e. before Start); registrations after that have no effect.
|
||||||
|
func (s *BaseServer) RegisterGRPCExtension(ext GRPCExtension) {
|
||||||
|
s.grpcExtensions = append(s.grpcExtensions, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendExtensionInterceptors appends each extension's interceptors to the gRPC
|
||||||
|
// server options as additional chained interceptors. grpc.ChainUnaryInterceptor
|
||||||
|
// and grpc.ChainStreamInterceptor are additive, so the returned options run the
|
||||||
|
// extension interceptors after any interceptors already present in opts.
|
||||||
|
func appendExtensionInterceptors(opts []grpc.ServerOption, exts []GRPCExtension) []grpc.ServerOption {
|
||||||
|
for _, ext := range exts {
|
||||||
|
if len(ext.UnaryInterceptors) > 0 {
|
||||||
|
opts = append(opts, grpc.ChainUnaryInterceptor(ext.UnaryInterceptors...))
|
||||||
|
}
|
||||||
|
if len(ext.StreamInterceptors) > 0 {
|
||||||
|
opts = append(opts, grpc.ChainStreamInterceptor(ext.StreamInterceptors...))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerExtensions registers each extension's services onto reg.
|
||||||
|
func registerExtensions(reg grpc.ServiceRegistrar, exts []GRPCExtension) {
|
||||||
|
for _, ext := range exts {
|
||||||
|
if ext.Register != nil {
|
||||||
|
ext.Register(reg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runExtensionShutdownHooks calls each extension's shutdown hook, if set,
|
||||||
|
// passing ctx through so hooks can honor its deadline/cancellation.
|
||||||
|
func runExtensionShutdownHooks(ctx context.Context, exts []GRPCExtension) {
|
||||||
|
for _, ext := range exts {
|
||||||
|
if ext.Shutdown != nil {
|
||||||
|
ext.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
160
management/internals/server/grpc_extension_test.go
Normal file
160
management/internals/server/grpc_extension_test.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/health"
|
||||||
|
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
||||||
|
"google.golang.org/grpc/test/bufconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test that an extension's interceptors and service registration are actually
|
||||||
|
// wired onto a real in-process gRPC server via the helpers, and that shutdown
|
||||||
|
// hooks run. This validates the load-bearing assumption that
|
||||||
|
// grpc.ChainUnaryInterceptor is additive (extension interceptors run in
|
||||||
|
// addition to any base chain).
|
||||||
|
func TestGRPCExtensionAppliedToServer(t *testing.T) {
|
||||||
|
var unaryCalls atomic.Int32
|
||||||
|
var streamShutdownCalled atomic.Bool
|
||||||
|
|
||||||
|
ext := GRPCExtension{
|
||||||
|
Register: func(reg grpc.ServiceRegistrar) {
|
||||||
|
healthgrpc.RegisterHealthServer(reg, health.NewServer())
|
||||||
|
},
|
||||||
|
UnaryInterceptors: []grpc.UnaryServerInterceptor{
|
||||||
|
func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||||
|
unaryCalls.Add(1)
|
||||||
|
return handler(ctx, req)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Shutdown: func(ctx context.Context) { streamShutdownCalled.Store(true) },
|
||||||
|
}
|
||||||
|
exts := []GRPCExtension{ext}
|
||||||
|
|
||||||
|
// Base options mimic GRPCServer(): a pre-existing chain the extension appends to.
|
||||||
|
var baseUnaryCalls atomic.Int32
|
||||||
|
opts := []grpc.ServerOption{
|
||||||
|
grpc.ChainUnaryInterceptor(func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||||
|
baseUnaryCalls.Add(1)
|
||||||
|
return handler(ctx, req)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
opts = appendExtensionInterceptors(opts, exts)
|
||||||
|
|
||||||
|
srv := grpc.NewServer(opts...)
|
||||||
|
registerExtensions(srv, exts)
|
||||||
|
|
||||||
|
lis := bufconn.Listen(1024 * 1024)
|
||||||
|
go func() { _ = srv.Serve(lis) }()
|
||||||
|
t.Cleanup(srv.Stop)
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||||
|
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
_, err = healthgrpc.NewHealthClient(conn).Check(context.Background(), &healthgrpc.HealthCheckRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("health check via extension-registered service failed: %v", err)
|
||||||
|
}
|
||||||
|
if baseUnaryCalls.Load() != 1 {
|
||||||
|
t.Errorf("base interceptor calls = %d, want 1 (base chain must be preserved)", baseUnaryCalls.Load())
|
||||||
|
}
|
||||||
|
if unaryCalls.Load() != 1 {
|
||||||
|
t.Errorf("extension interceptor calls = %d, want 1", unaryCalls.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
runExtensionShutdownHooks(context.Background(), exts)
|
||||||
|
if !streamShutdownCalled.Load() {
|
||||||
|
t.Error("extension shutdown hook was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGRPCExtensionShutdownHookReceivesCallerContext asserts that each hook receives
|
||||||
|
// a non-nil context and that it is the very same context the caller passed
|
||||||
|
// in, so hooks can rely on values/deadlines placed on it by Stop().
|
||||||
|
func TestGRPCExtensionShutdownHookReceivesCallerContext(t *testing.T) {
|
||||||
|
type sentinelKey struct{}
|
||||||
|
want := "shutdown-ctx-sentinel"
|
||||||
|
ctx := context.WithValue(context.Background(), sentinelKey{}, want)
|
||||||
|
|
||||||
|
var called bool
|
||||||
|
ext := GRPCExtension{
|
||||||
|
Shutdown: func(hookCtx context.Context) {
|
||||||
|
called = true
|
||||||
|
if hookCtx == nil {
|
||||||
|
t.Fatal("hook received a nil context")
|
||||||
|
}
|
||||||
|
got, _ := hookCtx.Value(sentinelKey{}).(string)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("hook context sentinel = %q, want %q (not the caller's context)", got, want)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
|
||||||
|
if !called {
|
||||||
|
t.Fatal("shutdown hook was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGRPCExtensionShutdownHookObservesCancellation documents, by test, that
|
||||||
|
// hooks can honor cancellation/deadlines: a hook given an already-cancelled
|
||||||
|
// context must see ctx.Err() != nil and a closed Done() channel.
|
||||||
|
func TestGRPCExtensionShutdownHookObservesCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
var called bool
|
||||||
|
ext := GRPCExtension{
|
||||||
|
Shutdown: func(hookCtx context.Context) {
|
||||||
|
called = true
|
||||||
|
if hookCtx.Err() == nil {
|
||||||
|
t.Error("hook context Err() = nil, want non-nil for a cancelled context")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-hookCtx.Done():
|
||||||
|
default:
|
||||||
|
t.Error("hook context Done() channel is not closed for a cancelled context")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
|
||||||
|
if !called {
|
||||||
|
t.Fatal("shutdown hook was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGRPCExtensionShutdownHookNilSkipped asserts that an extension
|
||||||
|
// with a nil Shutdown hook is skipped without panicking, and that hooks for
|
||||||
|
// other extensions still run.
|
||||||
|
func TestGRPCExtensionShutdownHookNilSkipped(t *testing.T) {
|
||||||
|
var called atomic.Bool
|
||||||
|
exts := []GRPCExtension{
|
||||||
|
{Shutdown: nil},
|
||||||
|
{Shutdown: func(context.Context) { called.Store(true) }},
|
||||||
|
}
|
||||||
|
|
||||||
|
runExtensionShutdownHooks(context.Background(), exts)
|
||||||
|
if !called.Load() {
|
||||||
|
t.Error("shutdown hook for non-nil extension was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterGRPCExtensionAccumulates(t *testing.T) {
|
||||||
|
s := &BaseServer{}
|
||||||
|
s.RegisterGRPCExtension(GRPCExtension{})
|
||||||
|
s.RegisterGRPCExtension(GRPCExtension{})
|
||||||
|
if len(s.grpcExtensions) != 2 {
|
||||||
|
t.Fatalf("grpcExtensions len = %d, want 2", len(s.grpcExtensions))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,11 @@ type BaseServer struct {
|
|||||||
|
|
||||||
proxyAuthClose func()
|
proxyAuthClose func()
|
||||||
|
|
||||||
|
// grpcExtensions holds additional gRPC services, interceptors, and shutdown
|
||||||
|
// hooks registered by external modules via RegisterGRPCExtension. Populated
|
||||||
|
// during boot (single-threaded), consumed by GRPCServer() and Stop().
|
||||||
|
grpcExtensions []GRPCExtension
|
||||||
|
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
certManager *autocert.Manager
|
certManager *autocert.Manager
|
||||||
update *version.Update
|
update *version.Update
|
||||||
@@ -257,6 +262,7 @@ func (s *BaseServer) Stop() error {
|
|||||||
s.proxyAuthClose()
|
s.proxyAuthClose()
|
||||||
s.proxyAuthClose = nil
|
s.proxyAuthClose = nil
|
||||||
}
|
}
|
||||||
|
runExtensionShutdownHooks(ctx, s.grpcExtensions)
|
||||||
_ = s.Store().Close(ctx)
|
_ = s.Store().Close(ctx)
|
||||||
_ = s.EventStore().Close(ctx)
|
_ = s.EventStore().Close(ctx)
|
||||||
if s.update != nil {
|
if s.update != nil {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
|
|||||||
return &proto.NetworkMapEnvelope{
|
return &proto.NetworkMapEnvelope{
|
||||||
Payload: &proto.NetworkMapEnvelope_Full{
|
Payload: &proto.NetworkMapEnvelope_Full{
|
||||||
Full: &proto.NetworkMapComponentsFull{
|
Full: &proto.NetworkMapComponentsFull{
|
||||||
|
Serial: networkSerial(c.Network),
|
||||||
|
Network: toAccountNetwork(c.Network),
|
||||||
PeerConfig: in.PeerConfig,
|
PeerConfig: in.PeerConfig,
|
||||||
// components.Peers always contains the target peer
|
// components.Peers always contains the target peer
|
||||||
Peers: []*proto.PeerCompact{toPeerCompact(c.Peers[c.PeerID])},
|
Peers: []*proto.PeerCompact{toPeerCompact(c.Peers[c.PeerID])},
|
||||||
|
|||||||
@@ -758,6 +758,9 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
|
|||||||
assert.Equal(t, "netbird.cloud", full.DnsDomain)
|
assert.Equal(t, "netbird.cloud", full.DnsDomain)
|
||||||
assert.Len(t, full.Peers, 1)
|
assert.Len(t, full.Peers, 1)
|
||||||
assert.Empty(t, full.Policies)
|
assert.Empty(t, full.Policies)
|
||||||
|
require.NotNil(t, full.Network, "client runs Calculate() over the envelope and dereferences Network unconditionally; a nil here would crash the receiver")
|
||||||
|
assert.Equal(t, "net-empty", full.Network.Identifier)
|
||||||
|
assert.Equal(t, uint64(9), full.Serial)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
|
func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
|
||||||
@@ -776,6 +779,12 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
|
|||||||
func emptyNetworkMapComponents() *types.NetworkMapComponents {
|
func emptyNetworkMapComponents() *types.NetworkMapComponents {
|
||||||
return types.EmptyNetworkMapComponents(
|
return types.EmptyNetworkMapComponents(
|
||||||
&types.NetworkMapComponents{
|
&types.NetworkMapComponents{
|
||||||
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}},
|
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}},
|
||||||
|
Network: &types.Network{
|
||||||
|
Identifier: "net-empty",
|
||||||
|
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
|
||||||
|
Serial: 9,
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,11 +102,20 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
|||||||
require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap")
|
require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap")
|
||||||
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
|
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
|
||||||
|
|
||||||
// Attempt to flip toggles AND smuggle a different cluster/subdomain — the
|
// A cluster different from the one pinned at bootstrap must be rejected
|
||||||
// immutable fields must be ignored.
|
// outright — never silently swapped or ignored.
|
||||||
|
_, err = mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||||
|
AccountID: accountID,
|
||||||
|
Cluster: "attacker.cluster",
|
||||||
|
EnableLogCollection: true,
|
||||||
|
})
|
||||||
|
require.Error(t, err, "UpdateSettings with a mismatched cluster must fail")
|
||||||
|
|
||||||
|
// Flipping the toggles works with the pinned cluster echoed back (and
|
||||||
|
// with it omitted); the subdomain is never taken from the request.
|
||||||
updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Cluster: "attacker.cluster",
|
Cluster: clusterAddr,
|
||||||
Subdomain: "evil",
|
Subdomain: "evil",
|
||||||
EnableLogCollection: true,
|
EnableLogCollection: true,
|
||||||
EnablePromptCollection: true,
|
EnablePromptCollection: true,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
@@ -744,6 +746,14 @@ func validateDeleteGroup(ctx context.Context, transaction store.Store, group *ty
|
|||||||
return &GroupLinkError{"network router", linkedRouter.ID}
|
return &GroupLinkError{"network router", linkedRouter.ID}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isLinked, linkedService := isGroupLinkedToReverseProxyService(ctx, transaction, group.AccountID, group.ID); isLinked {
|
||||||
|
return &GroupLinkError{"reverse proxy service", linkedService.Domain}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLinked, linkedPolicy := isGroupLinkedToAgentNetworkPolicy(ctx, transaction, group.AccountID, group.ID); isLinked {
|
||||||
|
return &GroupLinkError{"agent network policy", linkedPolicy.Name}
|
||||||
|
}
|
||||||
|
|
||||||
return checkGroupLinkedToSettings(ctx, transaction, group)
|
return checkGroupLinkedToSettings(ctx, transaction, group)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,6 +885,46 @@ func isGroupLinkedToNetworkRouter(ctx context.Context, transaction store.Store,
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isGroupLinkedToReverseProxyService checks if a group is used as an access group
|
||||||
|
// of a private reverse proxy service or as a bearer-auth distribution group.
|
||||||
|
func isGroupLinkedToReverseProxyService(ctx context.Context, transaction store.Store, accountID string, groupID string) (bool, *service.Service) {
|
||||||
|
services, err := transaction.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(ctx).Errorf("error retrieving reverse proxy services while checking group linkage: %v", err)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, svc := range services {
|
||||||
|
if svc.Private && slices.Contains(svc.AccessGroups, groupID) {
|
||||||
|
return true, svc
|
||||||
|
}
|
||||||
|
if svc.Auth.BearerAuth != nil && svc.Auth.BearerAuth.Enabled && slices.Contains(svc.Auth.BearerAuth.DistributionGroups, groupID) {
|
||||||
|
return true, svc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isGroupLinkedToAgentNetworkPolicy checks if a group is used as a source group by any
|
||||||
|
// agent network policy in the account.
|
||||||
|
func isGroupLinkedToAgentNetworkPolicy(ctx context.Context, transaction store.Store, accountID string, groupID string) (bool, *agentNetworkTypes.Policy) {
|
||||||
|
policies, err := transaction.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(ctx).Errorf("error retrieving agent network policies while checking group linkage: %v", err)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, policy := range policies {
|
||||||
|
if policy == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(policy.SourceGroups, groupID) {
|
||||||
|
return true, policy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
// areGroupChangesAffectPeers checks if any changes to the specified groups will affect peers.
|
// areGroupChangesAffectPeers checks if any changes to the specified groups will affect peers.
|
||||||
// It fetches each collection once and checks all groupIDs against them in memory.
|
// It fetches each collection once and checks all groupIDs against them in memory.
|
||||||
func areGroupChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) {
|
func areGroupChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"golang.org/x/exp/maps"
|
"golang.org/x/exp/maps"
|
||||||
|
|
||||||
nbdns "github.com/netbirdio/netbird/dns"
|
nbdns "github.com/netbirdio/netbird/dns"
|
||||||
|
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||||
|
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||||
"github.com/netbirdio/netbird/management/server/groups"
|
"github.com/netbirdio/netbird/management/server/groups"
|
||||||
"github.com/netbirdio/netbird/management/server/networks"
|
"github.com/netbirdio/netbird/management/server/networks"
|
||||||
"github.com/netbirdio/netbird/management/server/networks/resources"
|
"github.com/netbirdio/netbird/management/server/networks/resources"
|
||||||
@@ -125,6 +127,21 @@ func TestDefaultAccountManager_DeleteGroup(t *testing.T) {
|
|||||||
"grp-for-integration",
|
"grp-for-integration",
|
||||||
"only service users with admin power can delete integration group",
|
"only service users with admin power can delete integration group",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"agent network policy",
|
||||||
|
"grp-for-agent-network-policy",
|
||||||
|
"agent network policy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reverse proxy private service access group",
|
||||||
|
"grp-for-rp-private",
|
||||||
|
"reverse proxy service",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reverse proxy bearer distribution group",
|
||||||
|
"grp-for-rp-bearer",
|
||||||
|
"reverse proxy service",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
for _, testCase := range testCases {
|
||||||
@@ -218,6 +235,17 @@ func TestDefaultAccountManager_DeleteGroups(t *testing.T) {
|
|||||||
groupIDs: []string{"grp-for-integration"},
|
groupIDs: []string{"grp-for-integration"},
|
||||||
expectedReasons: []string{"only service users with admin power can delete integration group"},
|
expectedReasons: []string{"only service users with admin power can delete integration group"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "agent network policy",
|
||||||
|
groupIDs: []string{"grp-for-agent-network-policy"},
|
||||||
|
expectedReasons: []string{"agent network policy"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reverse proxy services",
|
||||||
|
groupIDs: []string{"grp-for-rp-private", "grp-for-rp-bearer"},
|
||||||
|
expectedReasons: []string{"reverse proxy service", "reverse proxy service"},
|
||||||
|
expectedNotDeleted: []string{"grp-for-rp-private", "grp-for-rp-bearer"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "successfully delete multiple groups",
|
name: "successfully delete multiple groups",
|
||||||
groupIDs: []string{"group-1", "group-2"},
|
groupIDs: []string{"group-1", "group-2"},
|
||||||
@@ -285,6 +313,65 @@ func TestDefaultAccountManager_DeleteGroups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultAccountManager_DeleteGroupUnlinkedFromReverseProxyService(t *testing.T) {
|
||||||
|
am, _, err := createManager(t)
|
||||||
|
require.NoError(t, err, "Failed to create account manager")
|
||||||
|
|
||||||
|
_, account, err := initTestGroupAccount(am)
|
||||||
|
require.NoError(t, err, "Failed to init testing account")
|
||||||
|
|
||||||
|
deletableGroups := []*types.Group{
|
||||||
|
{
|
||||||
|
ID: "grp-rp-bearer-disabled",
|
||||||
|
AccountID: account.Id,
|
||||||
|
Name: "Group only in a disabled bearer auth",
|
||||||
|
Issued: types.GroupIssuedAPI,
|
||||||
|
Peers: make([]string, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "grp-rp-nonprivate-access",
|
||||||
|
AccountID: account.Id,
|
||||||
|
Name: "Group only in a non-private service's access groups",
|
||||||
|
Issued: types.GroupIssuedAPI,
|
||||||
|
Peers: make([]string, 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, group := range deletableGroups {
|
||||||
|
require.NoError(t, am.CreateGroup(context.Background(), account.Id, groupAdminUserID, group))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled bearer auth and stale access groups on a non-private service
|
||||||
|
// are inert configuration and must not block group deletion.
|
||||||
|
services := []*rpservice.Service{
|
||||||
|
{
|
||||||
|
ID: "rp-svc-bearer-disabled",
|
||||||
|
AccountID: account.Id,
|
||||||
|
Domain: "bearer-disabled.services.example.com",
|
||||||
|
Auth: rpservice.AuthConfig{
|
||||||
|
BearerAuth: &rpservice.BearerAuthConfig{
|
||||||
|
Enabled: false,
|
||||||
|
DistributionGroups: []string{"grp-rp-bearer-disabled"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rp-svc-nonprivate-access",
|
||||||
|
AccountID: account.Id,
|
||||||
|
Domain: "nonprivate.services.example.com",
|
||||||
|
Private: false,
|
||||||
|
AccessGroups: []string{"grp-rp-nonprivate-access"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, svc := range services {
|
||||||
|
require.NoError(t, am.Store.CreateService(context.Background(), svc))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, group := range deletableGroups {
|
||||||
|
err = am.DeleteGroup(context.Background(), account.Id, groupAdminUserID, group.ID)
|
||||||
|
assert.NoError(t, err, "group %s is not referenced by an active reverse proxy gate and should be deletable", group.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultAccountManager_DeleteGroupLinkedToFlowGroup(t *testing.T) {
|
func TestDefaultAccountManager_DeleteGroupLinkedToFlowGroup(t *testing.T) {
|
||||||
am, _, err := createManager(t)
|
am, _, err := createManager(t)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -406,6 +493,30 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
|
|||||||
Peers: make([]string, 0),
|
Peers: make([]string, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
groupForAgentNetworkPolicy := &types.Group{
|
||||||
|
ID: "grp-for-agent-network-policy",
|
||||||
|
AccountID: "account-id",
|
||||||
|
Name: "Group for agent network policies",
|
||||||
|
Issued: types.GroupIssuedAPI,
|
||||||
|
Peers: make([]string, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
groupForRPPrivate := &types.Group{
|
||||||
|
ID: "grp-for-rp-private",
|
||||||
|
AccountID: "account-id",
|
||||||
|
Name: "Group for private reverse proxy service",
|
||||||
|
Issued: types.GroupIssuedAPI,
|
||||||
|
Peers: make([]string, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
groupForRPBearer := &types.Group{
|
||||||
|
ID: "grp-for-rp-bearer",
|
||||||
|
AccountID: "account-id",
|
||||||
|
Name: "Group for bearer reverse proxy service",
|
||||||
|
Issued: types.GroupIssuedAPI,
|
||||||
|
Peers: make([]string, 0),
|
||||||
|
}
|
||||||
|
|
||||||
routeResource := &route.Route{
|
routeResource := &route.Route{
|
||||||
ID: "example route",
|
ID: "example route",
|
||||||
Groups: []string{groupForRoute.ID},
|
Groups: []string{groupForRoute.ID},
|
||||||
@@ -461,6 +572,66 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
|
|||||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForSetupKeys)
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForSetupKeys)
|
||||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForUsers)
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForUsers)
|
||||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForIntegration)
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForIntegration)
|
||||||
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForAgentNetworkPolicy)
|
||||||
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForRPPrivate)
|
||||||
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForRPBearer)
|
||||||
|
|
||||||
|
agentNetworkPolicy := &agentNetworkTypes.Policy{
|
||||||
|
ID: "example agent network policy",
|
||||||
|
AccountID: accountID,
|
||||||
|
Name: "Example agent network policy",
|
||||||
|
Enabled: true,
|
||||||
|
SourceGroups: []string{groupForAgentNetworkPolicy.ID},
|
||||||
|
}
|
||||||
|
if err := am.Store.SaveAgentNetworkPolicy(context.Background(), agentNetworkPolicy); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decoy services are created first so the linkage check has to scan
|
||||||
|
// past services that do not reference the groups under test.
|
||||||
|
rpServices := []*rpservice.Service{
|
||||||
|
{
|
||||||
|
ID: "rp-svc-private-decoy",
|
||||||
|
AccountID: accountID,
|
||||||
|
Domain: "private-decoy.services.example.com",
|
||||||
|
Private: true,
|
||||||
|
AccessGroups: []string{"unrelated-group"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rp-svc-bearer-decoy",
|
||||||
|
AccountID: accountID,
|
||||||
|
Domain: "bearer-decoy.services.example.com",
|
||||||
|
Auth: rpservice.AuthConfig{
|
||||||
|
BearerAuth: &rpservice.BearerAuthConfig{
|
||||||
|
Enabled: true,
|
||||||
|
DistributionGroups: []string{"unrelated-group"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rp-svc-private",
|
||||||
|
AccountID: accountID,
|
||||||
|
Domain: "private.services.example.com",
|
||||||
|
Private: true,
|
||||||
|
AccessGroups: []string{groupForRPPrivate.ID},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rp-svc-bearer",
|
||||||
|
AccountID: accountID,
|
||||||
|
Domain: "bearer.services.example.com",
|
||||||
|
Auth: rpservice.AuthConfig{
|
||||||
|
BearerAuth: &rpservice.BearerAuthConfig{
|
||||||
|
Enabled: true,
|
||||||
|
DistributionGroups: []string{groupForRPBearer.ID},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, svc := range rpServices {
|
||||||
|
if err := am.Store.CreateService(context.Background(), svc); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
acc, err := am.Store.GetAccount(context.Background(), account.Id)
|
acc, err := am.Store.GetAccount(context.Background(), account.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user