mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-08 00:31:28 +02:00
Compare commits
15 Commits
feature/io
...
debug-ui-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baac630f43 | ||
|
|
16c119309d | ||
|
|
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 = [
|
||||
'management',
|
||||
'client',
|
||||
'android',
|
||||
'ios',
|
||||
'signal',
|
||||
'proxy',
|
||||
'relay',
|
||||
|
||||
@@ -93,7 +93,9 @@ nfpms:
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
- libwebkitgtk-6.0-4
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
@@ -114,7 +116,9 @@ nfpms:
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- netbird >= 0.75.0
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||
|
||||
rpm:
|
||||
signature:
|
||||
|
||||
@@ -57,6 +57,12 @@ type DnsReadyListener interface {
|
||||
dns.ReadyListener
|
||||
}
|
||||
|
||||
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
|
||||
type TunSettings struct {
|
||||
Routes string
|
||||
SearchDomains string
|
||||
}
|
||||
|
||||
func init() {
|
||||
formatter.SetLogcatFormatter(log.StandardLogger())
|
||||
}
|
||||
@@ -240,6 +246,24 @@ func (c *Client) RenewTun(fd int) error {
|
||||
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.
|
||||
// It works both with and without a running engine.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
||||
|
||||
@@ -36,12 +36,20 @@ type Auth struct {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
|
||||
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
|
||||
if err != nil {
|
||||
return nil, 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)
|
||||
}
|
||||
}
|
||||
@@ -113,11 +113,14 @@ func (c *ConnectClient) RunOnAndroid(
|
||||
stateFilePath string,
|
||||
cacheDir string,
|
||||
) error {
|
||||
notifier := tunnelnotifier.New(networkChangeListener, nil)
|
||||
defer notifier.Close()
|
||||
|
||||
// in case of non Android os these variables will be nil
|
||||
mobileDependency := MobileDependency{
|
||||
TunAdapter: tunAdapter,
|
||||
IFaceDiscover: iFaceDiscover,
|
||||
NetworkChangeListener: networkChangeListener,
|
||||
NetworkChangeListener: notifier,
|
||||
HostDNSAddresses: dnsAddresses,
|
||||
DnsReadyListener: dnsReadyListener,
|
||||
StateFilePath: stateFilePath,
|
||||
|
||||
@@ -51,7 +51,5 @@ func (n *notifier) notify() {
|
||||
return
|
||||
}
|
||||
|
||||
go func(l listener.NetworkChangeListener) {
|
||||
l.OnNetworkChanged("")
|
||||
}(n.listener)
|
||||
n.listener.OnNetworkChanged("")
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
|
||||
ds.hostsDNSHolder.set(hostsDnsList)
|
||||
ds.permanent = true
|
||||
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
|
||||
ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
|
||||
ds.searchDomainNotifier = newNotifier(ds.searchDomains())
|
||||
ds.searchDomainNotifier.setListener(listener)
|
||||
setServerDns(ds)
|
||||
return ds
|
||||
@@ -602,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
|
||||
}
|
||||
|
||||
func (s *DefaultServer) SearchDomains() []string {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
return s.searchDomains()
|
||||
}
|
||||
|
||||
func (s *DefaultServer) searchDomains() []string {
|
||||
var searchDomains []string
|
||||
|
||||
for _, dConf := range s.currentConfig.Domains {
|
||||
@@ -686,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
|
||||
}()
|
||||
|
||||
if s.searchDomainNotifier != nil {
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
|
||||
}
|
||||
|
||||
s.updateNSGroupStates(update.NameServerGroups)
|
||||
|
||||
@@ -572,12 +572,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
}
|
||||
e.stateManager.Start()
|
||||
|
||||
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read initial settings: %w", err)
|
||||
}
|
||||
|
||||
dnsServer, err := e.newDnsServer(dnsConfig)
|
||||
dnsServer, err := e.newDnsServer()
|
||||
if err != nil {
|
||||
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,
|
||||
StatusRecorder: e.statusRecorder,
|
||||
RelayManager: e.relayManager,
|
||||
InitialRoutes: initialRoutes,
|
||||
StateManager: e.stateManager,
|
||||
DNSServer: dnsServer,
|
||||
DNSFeatureFlag: dnsFeatureFlag,
|
||||
PeerStore: e.peerStore,
|
||||
DisableClientRoutes: e.config.DisableClientRoutes,
|
||||
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) {
|
||||
transportNet, err := e.newStdNet()
|
||||
if err != nil {
|
||||
@@ -2172,7 +2129,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
switch runtime.GOOS {
|
||||
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":
|
||||
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
|
||||
if e.config.WgAddr.HasIPv6() {
|
||||
@@ -2185,7 +2142,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
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
|
||||
if e.dnsServer != nil {
|
||||
return e.dnsServer, nil
|
||||
@@ -2197,7 +2154,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
||||
e.ctx,
|
||||
e.wgInterface,
|
||||
e.mobileDep.HostDNSAddresses,
|
||||
*dnsConfig,
|
||||
nbdns.Config{},
|
||||
e.mobileDep.NetworkChangeListener,
|
||||
e.statusRecorder,
|
||||
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
|
||||
}
|
||||
@@ -45,12 +45,35 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
|
||||
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()
|
||||
if err != nil {
|
||||
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()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoActiveProfile) {
|
||||
@@ -59,18 +82,7 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
return fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
|
||||
id := activeProf.ID
|
||||
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
|
||||
return pm.SetProfileState(activeProf.ID, state)
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
fw, ok := d.firewall.(internalDNATer)
|
||||
|
||||
@@ -8,14 +8,13 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
@@ -62,7 +61,7 @@ type Manager interface {
|
||||
GetActiveClientRoutes() route.HAMap
|
||||
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
||||
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
||||
InitialRouteRange() []string
|
||||
CurrentRouteRange() []string
|
||||
SetFirewall(firewall.Manager) error
|
||||
SetDNSForwarderPort(port uint16)
|
||||
ReconcilePeerAllowedIPs(peerKey string) error
|
||||
@@ -76,10 +75,8 @@ type ManagerConfig struct {
|
||||
WGInterface iface.WGIface
|
||||
StatusRecorder *peer.Status
|
||||
RelayManager *relayClient.Manager
|
||||
InitialRoutes []*route.Route
|
||||
StateManager *statemanager.Manager
|
||||
DNSServer dns.Server
|
||||
DNSFeatureFlag bool
|
||||
PeerStore *peerstore.Store
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -149,45 +146,12 @@ func NewManager(config ManagerConfig) *DefaultManager {
|
||||
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
|
||||
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
|
||||
}
|
||||
func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
|
||||
cr := m.initialClientRoutes(config.InitialRoutes)
|
||||
|
||||
routesForComparison := slices.Clone(cr)
|
||||
|
||||
if config.DNSFeatureFlag {
|
||||
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) enableFakeIPRoutes() {
|
||||
m.fakeIPManager = fakeip.NewManager()
|
||||
m.notifier.NotifyRouteChange()
|
||||
}
|
||||
|
||||
func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
||||
@@ -464,6 +428,9 @@ func (m *DefaultManager) UpdateRoutes(
|
||||
|
||||
var merr *multierror.Error
|
||||
if !m.disableClientRoutes {
|
||||
if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil {
|
||||
m.enableFakeIPRoutes()
|
||||
}
|
||||
|
||||
// Update route selector based on management server's isSelected status
|
||||
m.updateRouteSelectorFromManagement(clientRoutes)
|
||||
@@ -500,9 +467,32 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL
|
||||
m.notifier.SetListener(listener)
|
||||
}
|
||||
|
||||
// InitialRouteRange return the list of initial routes. It used by mobile systems
|
||||
func (m *DefaultManager) InitialRouteRange() []string {
|
||||
return m.notifier.GetInitialRouteRanges()
|
||||
// CurrentRouteRange returns the current TUN route list. It is used by mobile systems
|
||||
func (m *DefaultManager) CurrentRouteRange() []string {
|
||||
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
|
||||
@@ -700,16 +690,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]
|
||||
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 {
|
||||
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
|
||||
return true
|
||||
|
||||
@@ -30,8 +30,8 @@ func (m *MockManager) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitialRouteRange mock implementation of InitialRouteRange from Manager interface
|
||||
func (m *MockManager) InitialRouteRange() []string {
|
||||
// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
|
||||
func (m *MockManager) CurrentRouteRange() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
@@ -14,12 +13,15 @@ import (
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
initialRoutes []*route.Route
|
||||
currentRoutes []*route.Route
|
||||
fakeIPRoutes []*route.Route
|
||||
mu sync.Mutex
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
listenerMux sync.Mutex
|
||||
// currentRoutes is the last announced route set. It exists only to
|
||||
// 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 {
|
||||
@@ -27,20 +29,15 @@ func NewNotifier() *Notifier {
|
||||
}
|
||||
|
||||
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
// SetInitialClientRoutes stores the initial route sets for TUN configuration.
|
||||
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
|
||||
n.initialRoutes = filterStatic(initialRoutes)
|
||||
n.currentRoutes = filterStatic(routesForComparison)
|
||||
}
|
||||
|
||||
// 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) NotifyRouteChange() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.notifyLocked()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
n.currentRoutes = newRoutes
|
||||
n.notify()
|
||||
n.notifyLocked()
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
|
||||
// Not used on Android
|
||||
}
|
||||
|
||||
func (n *Notifier) notify() {
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
func (n *Notifier) notifyLocked() {
|
||||
if n.listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
n.listener.OnNetworkChanged("")
|
||||
}
|
||||
|
||||
func filterStatic(routes []*route.Route) []*route.Route {
|
||||
out := make([]*route.Route, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
if !r.IsDynamic() {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
||||
func routesToStrings(routes []*route.Route) []string {
|
||||
nets := make([]string, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
nets = append(nets, r.NetString())
|
||||
@@ -101,25 +84,10 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
||||
return nets
|
||||
}
|
||||
|
||||
func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
slices.SortFunc(a, func(x, y *route.Route) int {
|
||||
return strings.Compare(x.NetString(), y.NetString())
|
||||
})
|
||||
slices.SortFunc(b, func(x, y *route.Route) int {
|
||||
return strings.Compare(x.NetString(), y.NetString())
|
||||
})
|
||||
|
||||
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
|
||||
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
as := routesToStrings(a)
|
||||
bs := routesToStrings(b)
|
||||
sort.Strings(as)
|
||||
sort.Strings(bs)
|
||||
return !slices.Equal(as, bs)
|
||||
}
|
||||
|
||||
@@ -29,11 +29,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// iOS doesn't care about initial routes
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
// Not used on iOS
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
@@ -35,10 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
@@ -98,47 +98,44 @@ func (u *Installer) startDaemon(daemonFolder string) error {
|
||||
func (u *Installer) startUIAsUser() error {
|
||||
log.Infof("starting netbird-ui: %s", uiBinary)
|
||||
|
||||
// Get the current console user
|
||||
cmd := exec.Command("stat", "-f", "%Su", "/dev/console")
|
||||
output, err := cmd.Output()
|
||||
username, err := consoleUser()
|
||||
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)
|
||||
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
|
||||
// This ensures the app runs in the user's context with proper GUI access
|
||||
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary)
|
||||
log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid)
|
||||
|
||||
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary)
|
||||
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 {
|
||||
return fmt.Errorf("failed to start UI process: %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)
|
||||
if err := launchCmd.Run(); err != nil {
|
||||
return fmt.Errorf("run UI launch: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("netbird-ui started successfully for user %s", username)
|
||||
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 {
|
||||
log.Infof("installing pkg file: %s", path)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -85,14 +84,7 @@ type Client struct {
|
||||
|
||||
stateMu sync.RWMutex
|
||||
connectClient *internal.ConnectClient
|
||||
// config holds the active configuration once Run has loaded it. Consumed by
|
||||
// the in-app SSH client for the NetBird SSH key and the OAuth flow.
|
||||
config *profilemanager.Config
|
||||
|
||||
// sshJWTCache keeps the SSH JWT token between reconnects so the user is not
|
||||
// forced through the browser OAuth flow on every session. Lives on Client
|
||||
// (not SSHClient) because the app creates a new SSHClient per session.
|
||||
sshJWTCache *jwtcache.Cache
|
||||
config *profilemanager.Config
|
||||
}
|
||||
|
||||
// NewClient instantiate a new Client
|
||||
@@ -109,7 +101,6 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
dnsManager: dnsManager,
|
||||
sshJWTCache: jwtcache.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +175,6 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
c.onHostDnsFn = func([]string) {}
|
||||
cfg.WgIface = interfaceName
|
||||
c.config = cfg
|
||||
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, connectClient)
|
||||
@@ -707,13 +697,6 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, *internal.ConnectClien
|
||||
return c.config, c.connectClient
|
||||
}
|
||||
|
||||
// sshState returns the active config and the running connect client for the
|
||||
// in-app SSH client. Both are nil until Run has loaded the config and started
|
||||
// the tunnel.
|
||||
func (c *Client) sshState() (*profilemanager.Config, *internal.ConnectClient) {
|
||||
return c.stateSnapshot()
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
ds := d.String()
|
||||
dotIndex := strings.Index(ds, ".")
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
//go:build ios
|
||||
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||
)
|
||||
|
||||
const (
|
||||
sshDialTimeout = 30 * time.Second
|
||||
sshDetectionTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// SSHTerminalListener receives SSH session events. It is implemented in Swift.
|
||||
//
|
||||
// All callbacks are invoked from goroutines and may run concurrently with each
|
||||
// other; the implementation must be safe to call from any thread.
|
||||
type SSHTerminalListener interface {
|
||||
OnConnected()
|
||||
OnData(data []byte)
|
||||
OnClose(reason string)
|
||||
OnError(message string)
|
||||
}
|
||||
|
||||
// SSHClient is a NetBird-aware SSH client exposed to Swift via gomobile.
|
||||
//
|
||||
// It dials through the running NetBird tunnel and runs a standard SSH session
|
||||
// on top with PTY enabled. Host-key verification uses the NetBird-provided
|
||||
// peer SSH host keys, identical to the desktop client.
|
||||
type SSHClient struct {
|
||||
nb *Client
|
||||
mu sync.Mutex
|
||||
listener SSHTerminalListener
|
||||
urlOpener URLOpener
|
||||
|
||||
sshClient *gossh.Client
|
||||
session *gossh.Session
|
||||
stdin io.WriteCloser
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewSSHClient creates a new SSH client bound to the running NetBird Client.
|
||||
func NewSSHClient(c *Client) *SSHClient {
|
||||
return &SSHClient{nb: c}
|
||||
}
|
||||
|
||||
// SetListener registers the Swift listener. Must be called before Connect to
|
||||
// receive any events.
|
||||
func (s *SSHClient) SetListener(l SSHTerminalListener) {
|
||||
s.mu.Lock()
|
||||
s.listener = l
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetURLOpener registers the Swift URL opener used to display the device-code
|
||||
// authorization page in an in-app browser when the target peer requires JWT
|
||||
// authentication. Must be set before Connect to be effective.
|
||||
func (s *SSHClient) SetURLOpener(opener URLOpener) {
|
||||
s.mu.Lock()
|
||||
s.urlOpener = opener
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Connect dials the SSH server through the NetBird tunnel and performs the
|
||||
// SSH handshake. It auto-detects the server type via SSH banner inspection
|
||||
// and selects the appropriate authentication path:
|
||||
//
|
||||
// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
|
||||
// flow, opens the verification URL through the registered URLOpener, and
|
||||
// uses the resulting token as the SSH password. Host-key verification
|
||||
// uses the NetBird peer registry.
|
||||
// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
|
||||
// private key. Host-key verification uses the NetBird peer registry.
|
||||
// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
|
||||
// first (so a user-installed NetBird public key works), then falls back
|
||||
// to the supplied password if non-empty. Host-key verification is
|
||||
// disabled (TOFU pending).
|
||||
//
|
||||
// The password parameter is only consulted for regular SSH servers.
|
||||
func (s *SSHClient) Connect(host string, port int, user, password string) error {
|
||||
cfg, cc := s.nb.sshState()
|
||||
if cc == nil {
|
||||
return errors.New("netbird client not running")
|
||||
}
|
||||
if cfg == nil {
|
||||
return errors.New("netbird config not loaded")
|
||||
}
|
||||
engine := cc.Engine()
|
||||
if engine == nil {
|
||||
return errors.New("netbird engine not available")
|
||||
}
|
||||
|
||||
serverType := detectServerType(host, port)
|
||||
log.Infof("SSH server type for %s:%d: %s", host, port, serverType)
|
||||
|
||||
authMethods, hostKeyCallback, err := s.buildAuth(cfg, engine, serverType, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clientConfig := &gossh.ClientConfig{
|
||||
User: user,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
return s.dialAndHandshake(host, port, clientConfig)
|
||||
}
|
||||
|
||||
// StartSession requests a PTY and starts an interactive shell. Output from
|
||||
// the session is forwarded to the listener via OnData.
|
||||
func (s *SSHClient) StartSession(cols, rows int) error {
|
||||
log.Debugf("SSH: starting session %dx%d", cols, rows)
|
||||
s.mu.Lock()
|
||||
sshClient := s.sshClient
|
||||
s.mu.Unlock()
|
||||
|
||||
if sshClient == nil {
|
||||
return errors.New("ssh client not connected")
|
||||
}
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("new session: %w", err)
|
||||
}
|
||||
|
||||
modes := gossh.TerminalModes{
|
||||
gossh.ECHO: 1,
|
||||
gossh.TTY_OP_ISPEED: 14400,
|
||||
gossh.TTY_OP_OSPEED: 14400,
|
||||
gossh.VINTR: 3,
|
||||
gossh.VQUIT: 28,
|
||||
gossh.VERASE: 127,
|
||||
}
|
||||
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
||||
closeQuiet(session, "session after pty error")
|
||||
return fmt.Errorf("request pty: %w", err)
|
||||
}
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
closeQuiet(session, "session after stdin error")
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
closeQuiet(session, "session after stdout error")
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
closeQuiet(session, "session after stderr error")
|
||||
return fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
closeQuiet(session, "session after shell error")
|
||||
return fmt.Errorf("start shell: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.session = session
|
||||
s.stdin = stdin
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.readLoop(stdout, "stdout")
|
||||
go s.readLoop(stderr, "stderr")
|
||||
log.Debug("SSH: session started, shell running")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write sends data to the SSH session stdin.
|
||||
func (s *SSHClient) Write(data []byte) error {
|
||||
s.mu.Lock()
|
||||
stdin := s.stdin
|
||||
s.mu.Unlock()
|
||||
if stdin == nil {
|
||||
return errors.New("ssh session not started")
|
||||
}
|
||||
if _, err := stdin.Write(data); err != nil {
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resize updates the PTY window size.
|
||||
func (s *SSHClient) Resize(cols, rows int) error {
|
||||
s.mu.Lock()
|
||||
session := s.session
|
||||
s.mu.Unlock()
|
||||
if session == nil {
|
||||
return errors.New("ssh session not started")
|
||||
}
|
||||
return session.WindowChange(rows, cols)
|
||||
}
|
||||
|
||||
// Close terminates the SSH session and underlying connection. Safe to call
|
||||
// multiple times.
|
||||
func (s *SSHClient) Close() error {
|
||||
s.mu.Lock()
|
||||
sshClient := s.sshClient
|
||||
session := s.session
|
||||
stdin := s.stdin
|
||||
s.sshClient = nil
|
||||
s.session = nil
|
||||
s.stdin = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if stdin != nil {
|
||||
if err := stdin.Close(); err != nil {
|
||||
log.Debugf("ssh: stdin close: %v", err)
|
||||
}
|
||||
}
|
||||
if session != nil {
|
||||
if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||
log.Debugf("ssh: session close: %v", err)
|
||||
}
|
||||
}
|
||||
var firstErr error
|
||||
if sshClient != nil {
|
||||
if err := sshClient.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
s.notifyClose("closed by client")
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, engine *internal.Engine,
|
||||
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
|
||||
|
||||
switch serverType {
|
||||
case detection.ServerTypeNetBirdJWT:
|
||||
token, err := s.requestJWTToken(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("jwt: %w", err)
|
||||
}
|
||||
auths := []gossh.AuthMethod{gossh.Password(token)}
|
||||
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
|
||||
|
||||
case detection.ServerTypeNetBirdNoJWT:
|
||||
if cfg.SSHKey == "" {
|
||||
return nil, nil, errors.New("no NetBird SSH key available")
|
||||
}
|
||||
signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
|
||||
}
|
||||
auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
|
||||
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
|
||||
|
||||
default: // regular SSH
|
||||
var auths []gossh.AuthMethod
|
||||
if cfg.SSHKey != "" {
|
||||
if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
|
||||
auths = append(auths, gossh.PublicKeys(signer))
|
||||
} else {
|
||||
log.Debugf("ssh: parse netbird key for regular auth: %v", err)
|
||||
}
|
||||
}
|
||||
if password != "" {
|
||||
pw := password
|
||||
auths = append(auths, gossh.Password(pw))
|
||||
auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
|
||||
answers := make([]string, len(questions))
|
||||
for i := range questions {
|
||||
answers[i] = pw
|
||||
}
|
||||
return answers, nil
|
||||
}))
|
||||
}
|
||||
if len(auths) == 0 {
|
||||
return nil, nil, errors.New("no auth method available: provide a password or configure NetBird SSH key")
|
||||
}
|
||||
return auths, gossh.InsecureIgnoreHostKey(), nil // nolint:gosec // TOFU not yet implemented
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) {
|
||||
// Reuse a cached token so the user is not forced through the browser OAuth
|
||||
// flow on every reconnect. TTL comes from cfg.SSHJWTCacheTTL, same as the
|
||||
// daemon's cache; unset/0 disables caching.
|
||||
if token, ok := s.nb.sshJWTCache.Get(); ok {
|
||||
log.Debug("SSH: reusing cached JWT token")
|
||||
return token, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
urlOpener := s.urlOpener
|
||||
s.mu.Unlock()
|
||||
if urlOpener == nil {
|
||||
return "", errors.New("URL opener not configured for JWT auth")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profilemanager.GetLoginHint())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create oauth flow: %w", err)
|
||||
}
|
||||
|
||||
flowInfo, err := flow.RequestAuthInfo(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request auth info: %w", err)
|
||||
}
|
||||
|
||||
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
||||
|
||||
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wait for token: %w", err)
|
||||
}
|
||||
|
||||
token := tokenInfo.GetTokenToUse()
|
||||
if token == "" {
|
||||
return "", errors.New("empty token returned by IdP")
|
||||
}
|
||||
|
||||
if ttl := jwtcache.ResolveTTL(cfg.SSHJWTCacheTTL); ttl > 0 {
|
||||
s.nb.sshJWTCache.Store(token, ttl)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *SSHClient) dialAndHandshake(host string, port int, clientConfig *gossh.ClientConfig) error {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
log.Infof("SSH: connecting to %s as %s", addr, clientConfig.User)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
|
||||
defer cancel()
|
||||
|
||||
var dialer net.Dialer
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
sshConn, chans, reqs, err := gossh.NewClientConn(conn, addr, clientConfig)
|
||||
if err != nil {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("ssh: close after handshake error: %v", cerr)
|
||||
}
|
||||
return fmt.Errorf("ssh handshake: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sshClient = gossh.NewClient(sshConn, chans, reqs)
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Infof("SSH: connected to %s", addr)
|
||||
if listener != nil {
|
||||
listener.OnConnected()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SSHClient) readLoop(r io.Reader, name string) {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
s.mu.Lock()
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
listener.OnData(chunk)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
log.Debugf("ssh %s read: %v", name, err)
|
||||
}
|
||||
s.notifyClose(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSHClient) notifyClose(reason string) {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
listener.OnClose(reason)
|
||||
}
|
||||
}
|
||||
|
||||
// engineHostKeyVerifier adapts *internal.Engine to nbssh.HostKeyVerifier.
|
||||
type engineHostKeyVerifier struct {
|
||||
engine *internal.Engine
|
||||
}
|
||||
|
||||
func (v *engineHostKeyVerifier) VerifySSHHostKey(peerAddress string, presented []byte) error {
|
||||
storedKey, found := v.engine.GetPeerSSHKey(peerAddress)
|
||||
if !found {
|
||||
return nbssh.ErrPeerNotFound
|
||||
}
|
||||
return nbssh.VerifyHostKey(storedKey, presented, peerAddress)
|
||||
}
|
||||
|
||||
func detectServerType(host string, port int) detection.ServerType {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialer := &net.Dialer{}
|
||||
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
|
||||
if err != nil {
|
||||
log.Debugf("ssh: server detection for %s:%d failed: %v (assuming regular SSH)", host, port, err)
|
||||
return detection.ServerTypeRegular
|
||||
}
|
||||
return serverType
|
||||
}
|
||||
|
||||
func closeQuiet(c io.Closer, label string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||
log.Debugf("ssh: close %s: %v", label, err)
|
||||
}
|
||||
}
|
||||
79
client/server/jwt_cache.go
Normal file
79
client/server/jwt_cache.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type jwtCache struct {
|
||||
mu sync.RWMutex
|
||||
enclave *memguard.Enclave
|
||||
expiresAt time.Time
|
||||
timer *time.Timer
|
||||
maxTokenSize int
|
||||
}
|
||||
|
||||
func newJWTCache() *jwtCache {
|
||||
return &jwtCache{
|
||||
maxTokenSize: 8192,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *jwtCache) store(token string, maxAge time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.cleanup()
|
||||
|
||||
if c.timer != nil {
|
||||
c.timer.Stop()
|
||||
}
|
||||
|
||||
tokenBytes := []byte(token)
|
||||
c.enclave = memguard.NewEnclave(tokenBytes)
|
||||
|
||||
c.expiresAt = time.Now().Add(maxAge)
|
||||
|
||||
var timer *time.Timer
|
||||
timer = time.AfterFunc(maxAge, func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.timer != timer {
|
||||
return
|
||||
}
|
||||
c.cleanup()
|
||||
c.timer = nil
|
||||
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
|
||||
})
|
||||
c.timer = timer
|
||||
}
|
||||
|
||||
func (c *jwtCache) get() (string, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.enclave == nil || time.Now().After(c.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
buffer, err := c.enclave.Open()
|
||||
if err != nil {
|
||||
log.Debugf("Failed to open JWT token enclave: %v", err)
|
||||
return "", false
|
||||
}
|
||||
defer buffer.Destroy()
|
||||
|
||||
token := string(buffer.Bytes())
|
||||
return token, true
|
||||
}
|
||||
|
||||
// cleanup destroys the secure enclave, must be called with lock held
|
||||
func (c *jwtCache) cleanup() {
|
||||
if c.enclave != nil {
|
||||
c.enclave = nil
|
||||
}
|
||||
c.expiresAt = time.Time{}
|
||||
}
|
||||
89
client/server/login_outcome_test.go
Normal file
89
client/server/login_outcome_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"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.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
|
||||
attempts++
|
||||
return internal.StatusLoginFailed, 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)
|
||||
|
||||
refused := gstatus.Error(codes.PermissionDenied, "peer is not registered")
|
||||
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
|
||||
return internal.StatusNeedsLogin, refused
|
||||
}
|
||||
|
||||
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, refused,
|
||||
"the refusal was handed back to the caller instead of starting the SSO flow")
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -51,6 +50,9 @@ const (
|
||||
defaultMaxRetryTime = 14 * 24 * time.Hour
|
||||
defaultRetryMultiplier = 1.7
|
||||
|
||||
// JWT token cache TTL for the client daemon (disabled by default)
|
||||
defaultJWTCacheTTL = 0
|
||||
|
||||
errRestoreResidualState = "failed to restore residual state: %v"
|
||||
errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled"
|
||||
errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled"
|
||||
@@ -132,7 +134,12 @@ type Server struct {
|
||||
|
||||
updateManager *updater.Manager
|
||||
|
||||
jwtCache *jwtcache.Cache
|
||||
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)
|
||||
}
|
||||
|
||||
type oauthAuthFlow struct {
|
||||
@@ -154,7 +161,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
||||
updateSettingsDisabled: updateSettingsDisabled,
|
||||
captureEnabled: captureEnabled,
|
||||
networksDisabled: networksDisabled,
|
||||
jwtCache: jwtcache.New(),
|
||||
jwtCache: newJWTCache(),
|
||||
extendAuthSessionFlow: auth.NewPendingFlow(),
|
||||
probeThrottle: newProbeThrottle(probeThreshold),
|
||||
}
|
||||
@@ -368,7 +375,19 @@ 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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
||||
if err != nil {
|
||||
@@ -621,11 +640,23 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
s.config = config
|
||||
s.mutex.Unlock()
|
||||
|
||||
if _, err := s.loginAttempt(ctx, "", ""); err == nil {
|
||||
loginStatus, err := s.attemptLogin(ctx, "", "")
|
||||
if err == nil {
|
||||
state.Set(internal.StatusIdle)
|
||||
return &proto.LoginResponse{}, nil
|
||||
}
|
||||
|
||||
// Only an authentication refusal means the peer has to (re-)authenticate.
|
||||
// Any other failure 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.
|
||||
if loginStatus != internal.StatusNeedsLogin {
|
||||
state.Set(loginStatus)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if msg.SetupKey == "" {
|
||||
hint := ""
|
||||
if msg.Hint != nil {
|
||||
@@ -682,7 +713,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
// which returns NeedsLogin and parks on the browser leg.
|
||||
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)
|
||||
return nil, err
|
||||
}
|
||||
@@ -837,7 +868,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
||||
s.oauthAuthFlow.expiresAt = time.Now()
|
||||
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)
|
||||
return nil, err
|
||||
}
|
||||
@@ -1622,11 +1653,19 @@ func (s *Server) getJWTCacheTTL() time.Duration {
|
||||
config := s.config
|
||||
s.mutex.Unlock()
|
||||
|
||||
if config == nil {
|
||||
return jwtcache.DefaultTTL
|
||||
if config == nil || config.SSHJWTCacheTTL == nil {
|
||||
return defaultJWTCacheTTL
|
||||
}
|
||||
|
||||
return jwtcache.ResolveTTL(config.SSHJWTCacheTTL)
|
||||
seconds := *config.SSHJWTCacheTTL
|
||||
if seconds == 0 {
|
||||
log.Debug("SSH JWT cache disabled (configured to 0)")
|
||||
return 0
|
||||
}
|
||||
|
||||
ttl := time.Duration(seconds) * time.Second
|
||||
log.Debugf("SSH JWT cache TTL set to %v from config", ttl)
|
||||
return ttl
|
||||
}
|
||||
|
||||
// RequestJWTAuth initiates JWT authentication flow for SSH
|
||||
@@ -1648,7 +1687,7 @@ func (s *Server) RequestJWTAuth(
|
||||
|
||||
jwtCacheTTL := s.getJWTCacheTTL()
|
||||
if jwtCacheTTL > 0 {
|
||||
if cachedToken, found := s.jwtCache.Get(); found {
|
||||
if cachedToken, found := s.jwtCache.get(); found {
|
||||
log.Debugf("JWT token found in cache, returning cached token for SSH authentication")
|
||||
|
||||
return &proto.RequestJWTAuthResponse{
|
||||
@@ -1721,7 +1760,7 @@ func (s *Server) WaitJWTToken(
|
||||
|
||||
jwtCacheTTL := s.getJWTCacheTTL()
|
||||
if jwtCacheTTL > 0 {
|
||||
s.jwtCache.Store(token, jwtCacheTTL)
|
||||
s.jwtCache.store(token, jwtCacheTTL)
|
||||
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
|
||||
} else {
|
||||
log.Debug("JWT caching disabled, not storing token")
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
// Package jwtcache provides an in-memory, TTL-bound cache for SSH JWT tokens.
|
||||
// The token is kept in a secure memguard enclave and wiped from memory when it
|
||||
// expires. It is shared by the daemon gRPC server and the mobile SDKs, which
|
||||
// have no daemon process to delegate caching to.
|
||||
package jwtcache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DefaultTTL is used when no TTL is configured: caching disabled.
|
||||
const DefaultTTL = 0
|
||||
|
||||
// Cache stores a single JWT token in a secure enclave until it expires.
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
enclave *memguard.Enclave
|
||||
expiresAt time.Time
|
||||
timer *time.Timer
|
||||
maxTokenSize int
|
||||
}
|
||||
|
||||
// New creates an empty Cache.
|
||||
func New() *Cache {
|
||||
return &Cache{
|
||||
maxTokenSize: 8192,
|
||||
}
|
||||
}
|
||||
|
||||
// Store caches the token for maxAge. A previously stored token is wiped.
|
||||
func (c *Cache) Store(token string, maxAge time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.cleanup()
|
||||
|
||||
if c.timer != nil {
|
||||
c.timer.Stop()
|
||||
}
|
||||
|
||||
tokenBytes := []byte(token)
|
||||
c.enclave = memguard.NewEnclave(tokenBytes)
|
||||
|
||||
c.expiresAt = time.Now().Add(maxAge)
|
||||
|
||||
var timer *time.Timer
|
||||
timer = time.AfterFunc(maxAge, func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.timer != timer {
|
||||
return
|
||||
}
|
||||
c.cleanup()
|
||||
c.timer = nil
|
||||
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
|
||||
})
|
||||
c.timer = timer
|
||||
}
|
||||
|
||||
// Get returns the cached token, or false if none is stored or it has expired.
|
||||
func (c *Cache) Get() (string, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.enclave == nil || time.Now().After(c.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
buffer, err := c.enclave.Open()
|
||||
if err != nil {
|
||||
log.Debugf("Failed to open JWT token enclave: %v", err)
|
||||
return "", false
|
||||
}
|
||||
defer buffer.Destroy()
|
||||
|
||||
token := string(buffer.Bytes())
|
||||
return token, true
|
||||
}
|
||||
|
||||
// cleanup destroys the secure enclave, must be called with lock held
|
||||
func (c *Cache) cleanup() {
|
||||
if c.enclave != nil {
|
||||
c.enclave = nil
|
||||
}
|
||||
c.expiresAt = time.Time{}
|
||||
}
|
||||
|
||||
// ResolveTTL converts the configured TTL (seconds, from
|
||||
// profilemanager.Config.SSHJWTCacheTTL) into a duration. Returns DefaultTTL
|
||||
// when unset; 0 means caching is disabled.
|
||||
func ResolveTTL(configuredSeconds *int) time.Duration {
|
||||
if configuredSeconds == nil {
|
||||
return DefaultTTL
|
||||
}
|
||||
|
||||
seconds := *configuredSeconds
|
||||
if seconds == 0 {
|
||||
log.Debug("SSH JWT cache disabled (configured to 0)")
|
||||
return 0
|
||||
}
|
||||
|
||||
ttl := time.Duration(seconds) * time.Second
|
||||
log.Debugf("SSH JWT cache TTL set to %v from config", ttl)
|
||||
return ttl
|
||||
}
|
||||
@@ -26,17 +26,17 @@ contents:
|
||||
|
||||
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
|
||||
depends:
|
||||
- libgtk-4-1
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
- libwebkitgtk-6.0-4
|
||||
- xdg-utils
|
||||
|
||||
# Distribution-specific overrides for different package formats
|
||||
overrides:
|
||||
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
|
||||
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE
|
||||
rpm:
|
||||
depends:
|
||||
- gtk4
|
||||
- webkitgtk6.0
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||
- xdg-utils
|
||||
|
||||
# Arch Linux packages
|
||||
|
||||
@@ -43,7 +43,12 @@ function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<v
|
||||
}
|
||||
|
||||
async function runSsoLogin(
|
||||
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
|
||||
result: {
|
||||
verificationUri: string;
|
||||
verificationUriComplete: string;
|
||||
userCode: string;
|
||||
profileId: string;
|
||||
},
|
||||
state: SsoState,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
@@ -56,7 +61,7 @@ async function runSsoLogin(
|
||||
// suspended, so a frontend-driven Up (a promise continuation) would not
|
||||
// fire until the user woke the window (e.g. hovering the tray icon).
|
||||
const waitPromise = Connection.WaitSSOLoginAndUp(
|
||||
{ userCode: result.userCode, hostname: "" },
|
||||
{ userCode: result.userCode, hostname: "", profileId: result.profileId },
|
||||
{ profileName: "", username: "" },
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"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/i18n"
|
||||
@@ -63,7 +62,7 @@ type registeredServices struct {
|
||||
profiles *services.Profiles
|
||||
update *services.Update
|
||||
daemonFeed *services.DaemonFeed
|
||||
notifier *notifications.NotificationService
|
||||
notifier *Notifier
|
||||
compat *services.Compat
|
||||
profileSwitcher *services.ProfileSwitcher
|
||||
bundle *i18n.Bundle
|
||||
@@ -96,13 +95,17 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
// Debug patch, not for release: dumps heap/goroutine profiles and the
|
||||
// process tree to /tmp/nbgui for the memory consumption investigation.
|
||||
startMemProfiler(app)
|
||||
|
||||
profiles := services.NewProfiles(conn)
|
||||
// updater.Holder owns the typed update State; DaemonFeed feeds it and the
|
||||
// Update service is a thin Wails-bound facade over it plus the install RPCs.
|
||||
updaterHolder := updater.NewHolder(app.Event)
|
||||
update := services.NewUpdate(conn, updaterHolder)
|
||||
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
|
||||
notifier := notifications.New()
|
||||
notifier := newNotifier()
|
||||
compat := services.NewCompat(conn)
|
||||
// macOS shows no toast until permission is requested. Run it after
|
||||
// ApplicationStarted so the notifier's Startup has initialised the
|
||||
@@ -210,7 +213,7 @@ func main() {
|
||||
// requestNotificationAuthorization prompts for macOS notification permission.
|
||||
// The request blocks until the user responds (up to 3 minutes), so callers run
|
||||
// it in a goroutine. No-op on Linux/Windows.
|
||||
func requestNotificationAuthorization(notifier *notifications.NotificationService) {
|
||||
func requestNotificationAuthorization(notifier *Notifier) {
|
||||
authorized, err := notifier.CheckNotificationAuthorization()
|
||||
if err != nil {
|
||||
logrus.Debugf("check notification authorization: %v", err)
|
||||
|
||||
319
client/ui/memprof.go
Normal file
319
client/ui/memprof.go
Normal file
@@ -0,0 +1,319 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/pprof"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
)
|
||||
|
||||
// memProfOffsets are the snapshot times measured from application startup.
|
||||
var memProfOffsets = []time.Duration{0, 2 * time.Minute, 5 * time.Minute}
|
||||
|
||||
// memProfMaxDepth bounds the child walk so a cycle in the reported parent links
|
||||
// cannot spin forever.
|
||||
const memProfMaxDepth = 4
|
||||
|
||||
type memProfileSpec struct {
|
||||
profile string
|
||||
file string
|
||||
debug int
|
||||
}
|
||||
|
||||
var memProfileSpecs = []memProfileSpec{
|
||||
{profile: "heap", file: "heap.pprof", debug: 0},
|
||||
{profile: "heap", file: "heap.txt", debug: 1},
|
||||
{profile: "goroutine", file: "goroutine.txt", debug: 1},
|
||||
{profile: "threadcreate", file: "threadcreate.txt", debug: 1},
|
||||
}
|
||||
|
||||
var memProfStart = time.Now()
|
||||
|
||||
// startMemProfiler dumps a profile snapshot at every memProfOffsets mark, each
|
||||
// into its own timestamped directory under memProfBaseDir. The first runs once
|
||||
// the application is up so the window inventory sees the eagerly created
|
||||
// windows. Every failure is logged and never stops the GUI.
|
||||
func startMemProfiler(app *application.App) {
|
||||
log.Infof("memory profiler enabled, writing to %s (snapshots at %v after startup)", memProfBaseDir(), memProfOffsets)
|
||||
|
||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
go func() {
|
||||
started := time.Now()
|
||||
for _, offset := range memProfOffsets {
|
||||
if wait := time.Until(started.Add(offset)); wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
writeMemProfile(app)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// memProfBaseDir returns the directory holding the snapshot directories.
|
||||
func memProfBaseDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(os.TempDir(), "nbgui")
|
||||
}
|
||||
return "/tmp/nbgui"
|
||||
}
|
||||
|
||||
// writeMemProfile creates a <timestamp>-<pid> directory and fills it with the
|
||||
// runtime profiles, the memory statistics summary and the process tree.
|
||||
func writeMemProfile(app *application.App) {
|
||||
name := fmt.Sprintf("%s-%d", time.Now().Format("20060102-150405"), os.Getpid())
|
||||
dir := filepath.Join(memProfBaseDir(), name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
log.Warnf("create memory profile dir %s: %v", dir, err)
|
||||
return
|
||||
}
|
||||
|
||||
// The heap profile reports live objects as of the last collection, so force
|
||||
// one to keep inuse_space from counting garbage that is already unreachable.
|
||||
runtime.GC()
|
||||
|
||||
if err := writeMemStats(filepath.Join(dir, "memstats.txt"), app); err != nil {
|
||||
log.Warnf("write memory statistics: %v", err)
|
||||
}
|
||||
|
||||
if err := writeProcTree(filepath.Join(dir, "proctree.txt")); err != nil {
|
||||
log.Warnf("write process tree: %v", err)
|
||||
}
|
||||
|
||||
for _, spec := range memProfileSpecs {
|
||||
if err := writeMemProfileFile(spec, filepath.Join(dir, spec.file)); err != nil {
|
||||
log.Warnf("write %s profile: %v", spec.profile, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("memory profile written to %s", dir)
|
||||
}
|
||||
|
||||
// writeMemProfileFile writes a single runtime profile to path.
|
||||
func writeMemProfileFile(spec memProfileSpec, path string) error {
|
||||
p := pprof.Lookup(spec.profile)
|
||||
if p == nil {
|
||||
return fmt.Errorf("unknown profile %q", spec.profile)
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", path, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
log.Debugf("close %s: %v", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := p.WriteTo(f, spec.debug); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeMemStats dumps the runtime memory statistics next to the process
|
||||
// resident set size. A resident set much larger than Sys means the memory sits
|
||||
// outside the Go heap (webview, GTK, other cgo allocations), where the pprof
|
||||
// profiles cannot see it.
|
||||
func writeMemStats(path string, app *application.App) error {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "time: %s\n", time.Now().Format(time.RFC3339))
|
||||
fmt.Fprintf(&b, "uptime: %s\n", time.Since(memProfStart).Round(time.Second))
|
||||
fmt.Fprintf(&b, "pid: %d\n", os.Getpid())
|
||||
fmt.Fprintf(&b, "\n")
|
||||
|
||||
rss, vms := processMemory()
|
||||
fmt.Fprintf(&b, "process_rss: %s\n", rss)
|
||||
fmt.Fprintf(&b, "process_vms: %s\n", vms)
|
||||
fmt.Fprintf(&b, "\n")
|
||||
|
||||
fmt.Fprintf(&b, "sys: %s\n", formatMemBytes(m.Sys))
|
||||
fmt.Fprintf(&b, "heap_alloc: %s\n", formatMemBytes(m.HeapAlloc))
|
||||
fmt.Fprintf(&b, "heap_sys: %s\n", formatMemBytes(m.HeapSys))
|
||||
fmt.Fprintf(&b, "heap_inuse: %s\n", formatMemBytes(m.HeapInuse))
|
||||
fmt.Fprintf(&b, "heap_idle: %s\n", formatMemBytes(m.HeapIdle))
|
||||
fmt.Fprintf(&b, "heap_released: %s\n", formatMemBytes(m.HeapReleased))
|
||||
fmt.Fprintf(&b, "heap_objects: %d\n", m.HeapObjects)
|
||||
fmt.Fprintf(&b, "stack_inuse: %s\n", formatMemBytes(m.StackInuse))
|
||||
fmt.Fprintf(&b, "stack_sys: %s\n", formatMemBytes(m.StackSys))
|
||||
fmt.Fprintf(&b, "mspan_sys: %s\n", formatMemBytes(m.MSpanSys))
|
||||
fmt.Fprintf(&b, "mcache_sys: %s\n", formatMemBytes(m.MCacheSys))
|
||||
fmt.Fprintf(&b, "gc_sys: %s\n", formatMemBytes(m.GCSys))
|
||||
fmt.Fprintf(&b, "other_sys: %s\n", formatMemBytes(m.OtherSys))
|
||||
fmt.Fprintf(&b, "next_gc: %s\n", formatMemBytes(m.NextGC))
|
||||
fmt.Fprintf(&b, "num_gc: %d\n", m.NumGC)
|
||||
fmt.Fprintf(&b, "\n")
|
||||
|
||||
fmt.Fprintf(&b, "goroutines: %d\n", runtime.NumGoroutine())
|
||||
fmt.Fprintf(&b, "cgo_calls: %d\n", runtime.NumCgoCall())
|
||||
fmt.Fprintf(&b, "gomaxprocs: %d\n", runtime.GOMAXPROCS(0))
|
||||
fmt.Fprintf(&b, "\n")
|
||||
|
||||
writeWindowInventory(&b, app)
|
||||
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeWindowInventory lists the live Wails windows. A window that exists holds
|
||||
// a webview process even while hidden, so this tells apart a leaked window (the
|
||||
// count grows) from windows whose content grew (the count stays put).
|
||||
func writeWindowInventory(b *strings.Builder, app *application.App) {
|
||||
windows := app.Window.GetAll()
|
||||
fmt.Fprintf(b, "windows: %d\n", len(windows))
|
||||
for _, w := range windows {
|
||||
visible := "unknown"
|
||||
if ww, ok := w.(*application.WebviewWindow); ok {
|
||||
visible = strconv.FormatBool(ww.IsVisible())
|
||||
}
|
||||
fmt.Fprintf(b, " id=%-3d name=%-20q visible=%-7s minimised=%-5t focused=%t\n",
|
||||
w.ID(), w.Name(), visible, w.IsMinimised(), w.IsFocused())
|
||||
}
|
||||
}
|
||||
|
||||
// writeProcTree dumps this process and its descendants with their memory
|
||||
// footprint. The webview runs in child processes whose memory the Go runtime
|
||||
// profiles cannot see, so this is what attributes a footprint to a component.
|
||||
func writeProcTree(path string) error {
|
||||
self, err := process.NewProcess(int32(os.Getpid()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open own process: %w", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "time: %s\n", time.Now().Format(time.RFC3339))
|
||||
fmt.Fprintf(&b, "uptime: %s\n\n", time.Since(memProfStart).Round(time.Second))
|
||||
fmt.Fprintf(&b, "%-8s %-8s %-28s %12s %12s %12s %12s\n", "PID", "PPID", "NAME", "RSS", "VMS", "PSS", "PRIV_DIRTY")
|
||||
|
||||
var totalRSS, totalPSS, totalPrivate uint64
|
||||
walkProcTree(&b, self, 0, &totalRSS, &totalPSS, &totalPrivate)
|
||||
|
||||
fmt.Fprintf(&b, "\n%-8s %-8s %-28s %12s %12s %12s %12s\n", "", "", "TOTAL",
|
||||
formatKB(totalRSS), "", formatKB(totalPSS), formatKB(totalPrivate))
|
||||
fmt.Fprintf(&b, "\nPSS and PRIV_DIRTY come from /proc/<pid>/smaps_rollup and are Linux only.\n")
|
||||
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// walkProcTree appends one line per process, depth-first, accumulating totals.
|
||||
func walkProcTree(b *strings.Builder, p *process.Process, depth int, totalRSS, totalPSS, totalPrivate *uint64) {
|
||||
name, err := p.Name()
|
||||
if err != nil {
|
||||
name = "unknown"
|
||||
}
|
||||
|
||||
var rss, vms uint64
|
||||
if info, err := p.MemoryInfo(); err == nil {
|
||||
rss, vms = info.RSS, info.VMS
|
||||
}
|
||||
|
||||
pss, private := smapsRollup(p.Pid)
|
||||
*totalRSS += rss
|
||||
*totalPSS += pss
|
||||
*totalPrivate += private
|
||||
|
||||
ppid, err := p.Ppid()
|
||||
if err != nil {
|
||||
ppid = -1
|
||||
}
|
||||
|
||||
fmt.Fprintf(b, "%-8d %-8d %-28s %12s %12s %12s %12s\n", p.Pid, ppid,
|
||||
strings.Repeat(" ", depth)+name, formatKB(rss), formatKB(vms), formatKB(pss), formatKB(private))
|
||||
|
||||
if depth >= memProfMaxDepth {
|
||||
return
|
||||
}
|
||||
|
||||
children, err := p.Children()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, child := range children {
|
||||
walkProcTree(b, child, depth+1, totalRSS, totalPSS, totalPrivate)
|
||||
}
|
||||
}
|
||||
|
||||
// smapsRollup returns the proportional set size and private dirty bytes of pid,
|
||||
// both zero on platforms without /proc.
|
||||
func smapsRollup(pid int32) (uint64, uint64) {
|
||||
f, err := os.Open(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
log.Debugf("close smaps_rollup for %d: %v", pid, err)
|
||||
}
|
||||
}()
|
||||
|
||||
var pss, private uint64
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
kb, err := strconv.ParseUint(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "Pss:":
|
||||
pss = kb * 1024
|
||||
case "Private_Dirty:":
|
||||
private = kb * 1024
|
||||
}
|
||||
}
|
||||
return pss, private
|
||||
}
|
||||
|
||||
// processMemory returns the formatted resident and virtual size of this process.
|
||||
func processMemory() (string, string) {
|
||||
p, err := process.NewProcess(int32(os.Getpid()))
|
||||
if err != nil {
|
||||
unavailable := fmt.Sprintf("unavailable (%v)", err)
|
||||
return unavailable, unavailable
|
||||
}
|
||||
|
||||
info, err := p.MemoryInfo()
|
||||
if err != nil {
|
||||
unavailable := fmt.Sprintf("unavailable (%v)", err)
|
||||
return unavailable, unavailable
|
||||
}
|
||||
|
||||
return formatMemBytes(info.RSS), formatMemBytes(info.VMS)
|
||||
}
|
||||
|
||||
// formatMemBytes renders a byte count as megabytes with the raw value kept.
|
||||
func formatMemBytes(n uint64) string {
|
||||
return fmt.Sprintf("%8.1f MB (%d bytes)", float64(n)/(1024*1024), n)
|
||||
}
|
||||
|
||||
// formatKB renders a byte count as megabytes for the process tree columns, and
|
||||
// a dash when the platform did not report the value.
|
||||
func formatKB(n uint64) string {
|
||||
if n == 0 {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
||||
}
|
||||
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 {
|
||||
if _, err := os.Stat(s.path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Infof("no ui preferences file at %s; using defaults", s.path)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat preferences: %w", err)
|
||||
|
||||
@@ -33,12 +33,21 @@ type LoginResult struct {
|
||||
UserCode string `json:"userCode"`
|
||||
VerificationURI string `json:"verificationUri"`
|
||||
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.
|
||||
type WaitSSOParams struct {
|
||||
UserCode string `json:"userCode"`
|
||||
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.
|
||||
@@ -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.
|
||||
profileName := p.ProfileName
|
||||
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 active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
|
||||
// Address the active profile by ID (the daemon resolves it as a
|
||||
// handle); names can collide, the ID cannot.
|
||||
profileName = active.GetId()
|
||||
profileID = profileName
|
||||
if username == "" {
|
||||
username = active.GetUsername()
|
||||
}
|
||||
@@ -122,6 +136,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
UserCode: resp.GetUserCode(),
|
||||
VerificationURI: resp.GetVerificationURI(),
|
||||
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
||||
ProfileID: profileID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -242,6 +257,31 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"context"
|
||||
"os/user"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
@@ -151,11 +153,31 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
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
|
||||
|
||||
@@ -44,7 +44,7 @@ type TrayServices struct {
|
||||
Profiles *services.Profiles
|
||||
Networks *services.Networks
|
||||
DaemonFeed *services.DaemonFeed
|
||||
Notifier *notifications.NotificationService
|
||||
Notifier *Notifier
|
||||
Update *services.Update
|
||||
ProfileSwitcher *services.ProfileSwitcher
|
||||
WindowManager *services.WindowManager
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
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())
|
||||
if err != nil {
|
||||
log.Debugf("daemon compatibility probe: %v", err)
|
||||
|
||||
@@ -21,7 +21,7 @@ type trayUpdater struct {
|
||||
app *application.App
|
||||
window *application.WebviewWindow
|
||||
update *services.Update
|
||||
notifier *notifications.NotificationService
|
||||
notifier *Notifier
|
||||
loc *Localizer
|
||||
onIconChange func()
|
||||
// onMenuChange drives a full tray relayout: the update row lives in the
|
||||
@@ -36,7 +36,7 @@ type trayUpdater struct {
|
||||
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{
|
||||
app: app,
|
||||
window: window,
|
||||
|
||||
@@ -157,14 +157,14 @@ func NewManager(
|
||||
}
|
||||
|
||||
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 m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
||||
}
|
||||
|
||||
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 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
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
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
|
||||
// that 401s on every upstream request. Surface the misconfiguration
|
||||
@@ -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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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 m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
}
|
||||
|
||||
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 m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthNone, accountID, policyID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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 m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID)
|
||||
}
|
||||
|
||||
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 m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthNone, accountID, guardrailID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -473,7 +478,7 @@ func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, gu
|
||||
|
||||
// GetAllBudgetRules returns every account-level budget rule for the account.
|
||||
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 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.
|
||||
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 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
|
||||
// proxy config, so no reconcile is needed.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -513,7 +518,7 @@ func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule
|
||||
|
||||
// UpdateBudgetRule updates an existing account-level budget rule.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -536,7 +541,7 @@ func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule
|
||||
|
||||
// DeleteBudgetRule removes an account-level budget rule.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -561,7 +566,7 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -615,7 +620,7 @@ func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string
|
||||
// Returns the underlying status.NotFound when no row has been
|
||||
// bootstrapped yet (i.e. the account has no providers).
|
||||
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 m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
@@ -627,6 +632,22 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string)
|
||||
// the subdomain is picked from the curated wordlist avoiding
|
||||
// collisions on the same cluster. Idempotent: if a row already exists
|
||||
// it is returned untouched and the hint is ignored.
|
||||
// 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
|
||||
}
|
||||
var sErr *status.Error
|
||||
if !errors.As(err, &sErr) || sErr.Type() != status.NotFound {
|
||||
return fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
|
||||
}
|
||||
|
||||
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) {
|
||||
if accountID == "" {
|
||||
return nil, fmt.Errorf("bootstrap settings: account id is required")
|
||||
@@ -685,7 +706,7 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
|
||||
// counter view; permission gate is the same Read role that gates
|
||||
// every other agent-network surface.
|
||||
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 m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID)
|
||||
@@ -694,7 +715,7 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str
|
||||
// ListAccessLogs returns a paginated, server-side-filtered page of
|
||||
// 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) {
|
||||
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 m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
|
||||
@@ -704,7 +725,7 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri
|
||||
// agent-network access logs grouped by session, plus the total number of
|
||||
// sessions matching the filter.
|
||||
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 m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
|
||||
@@ -713,7 +734,7 @@ func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, user
|
||||
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
|
||||
@@ -787,8 +808,8 @@ func (m *managerImpl) RecordConsumption(ctx context.Context, accountID string, k
|
||||
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 {
|
||||
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetwork, op)
|
||||
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, module, op)
|
||||
if err != nil {
|
||||
return status.NewPermissionValidationError(err)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -24,13 +24,13 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/encryption"
|
||||
"github.com/netbirdio/netbird/formatter/hook"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
nbContext "github.com/netbirdio/netbird/management/server/context"
|
||||
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),
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
|
||||
if err != nil {
|
||||
@@ -215,6 +219,9 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
|
||||
mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer())
|
||||
log.Info("ProxyService registered on gRPC server")
|
||||
|
||||
// Register services contributed by external modules via the extension seam.
|
||||
registerExtensions(gRPCAPIHandler, s.grpcExtensions)
|
||||
|
||||
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()
|
||||
|
||||
// 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
|
||||
certManager *autocert.Manager
|
||||
update *version.Update
|
||||
@@ -257,6 +262,7 @@ func (s *BaseServer) Stop() error {
|
||||
s.proxyAuthClose()
|
||||
s.proxyAuthClose = nil
|
||||
}
|
||||
runExtensionShutdownHooks(ctx, s.grpcExtensions)
|
||||
_ = s.Store().Close(ctx)
|
||||
_ = s.EventStore().Close(ctx)
|
||||
if s.update != nil {
|
||||
|
||||
@@ -61,6 +61,8 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
|
||||
return &proto.NetworkMapEnvelope{
|
||||
Payload: &proto.NetworkMapEnvelope_Full{
|
||||
Full: &proto.NetworkMapComponentsFull{
|
||||
Serial: networkSerial(c.Network),
|
||||
Network: toAccountNetwork(c.Network),
|
||||
PeerConfig: in.PeerConfig,
|
||||
// components.Peers always contains the target peer
|
||||
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.Len(t, full.Peers, 1)
|
||||
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) {
|
||||
@@ -776,6 +779,12 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
|
||||
func emptyNetworkMapComponents() *types.NetworkMapComponents {
|
||||
return types.EmptyNetworkMapComponents(
|
||||
&types.NetworkMapComponents{
|
||||
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}},
|
||||
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,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ func (m *managerImpl) ValidateUserPermissions(
|
||||
return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), ctxEnriched, nil
|
||||
}
|
||||
|
||||
// ValidateRoleModuleAccess resolves an operation against the role's explicit
|
||||
// grant for the module, then the grant for its parent module when the module
|
||||
// is a dotted submodule, and finally the role's AutoAllowNew default.
|
||||
func (m *managerImpl) ValidateRoleModuleAccess(
|
||||
ctx context.Context,
|
||||
accountID string,
|
||||
@@ -89,7 +92,7 @@ func (m *managerImpl) ValidateRoleModuleAccess(
|
||||
module modules.Module,
|
||||
operation operations.Operation,
|
||||
) bool {
|
||||
if permissions, ok := role.Permissions[module]; ok {
|
||||
if permissions, ok := lookupModulePermissions(role, module); ok {
|
||||
if allowed, exists := permissions[operation]; exists {
|
||||
return allowed
|
||||
}
|
||||
@@ -100,6 +103,21 @@ func (m *managerImpl) ValidateRoleModuleAccess(
|
||||
return role.AutoAllowNew[operation]
|
||||
}
|
||||
|
||||
// lookupModulePermissions returns the role's explicit permission set for the
|
||||
// module, falling back to the parent module's set for dotted submodules. The
|
||||
// second return reports whether any explicit set was found.
|
||||
func lookupModulePermissions(role roles.RolePermissions, module modules.Module) (map[operations.Operation]bool, bool) {
|
||||
if permissions, ok := role.Permissions[module]; ok {
|
||||
return permissions, true
|
||||
}
|
||||
if parent, hasParent := module.Parent(); hasParent {
|
||||
if permissions, ok := role.Permissions[parent]; ok {
|
||||
return permissions, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
|
||||
if user.AccountID != accountID {
|
||||
return ctx, status.NewUserNotPartOfAccountError()
|
||||
@@ -119,7 +137,7 @@ func (m *managerImpl) GetPermissionsByRole(ctx context.Context, role types.UserR
|
||||
permissions := roles.Permissions{}
|
||||
|
||||
for k := range modules.All {
|
||||
if rolePermissions, ok := roleMap.Permissions[k]; ok {
|
||||
if rolePermissions, ok := lookupModulePermissions(roleMap, k); ok {
|
||||
permissions[k] = rolePermissions
|
||||
continue
|
||||
}
|
||||
|
||||
139
management/server/permissions/manager_test.go
Normal file
139
management/server/permissions/manager_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/roles"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
func TestValidateRoleModuleAccessSubmoduleCascade(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
fullAccess := map[operations.Operation]bool{
|
||||
operations.Read: true,
|
||||
operations.Create: true,
|
||||
operations.Update: true,
|
||||
operations.Delete: true,
|
||||
}
|
||||
readOnly := map[operations.Operation]bool{
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
}
|
||||
denyAll := map[operations.Operation]bool{
|
||||
operations.Read: false,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
}
|
||||
|
||||
t.Run("parent grant covers submodules", func(t *testing.T) {
|
||||
role := roles.RolePermissions{
|
||||
AutoAllowNew: denyAll,
|
||||
Permissions: roles.Permissions{modules.AgentNetwork: fullAccess},
|
||||
}
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Create),
|
||||
"parent full grant should allow create on a submodule")
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
|
||||
"parent full grant should allow read on a submodule")
|
||||
})
|
||||
|
||||
t.Run("submodule grant does not leak to parent or siblings", func(t *testing.T) {
|
||||
role := roles.RolePermissions{
|
||||
AutoAllowNew: denyAll,
|
||||
Permissions: roles.Permissions{modules.AgentNetworkUsage: readOnly},
|
||||
}
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
|
||||
"explicit submodule read should be allowed")
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Create),
|
||||
"read-only submodule grant should not allow create")
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetwork, operations.Read),
|
||||
"submodule grant should not grant the parent module")
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
|
||||
"submodule grant should not grant a sibling submodule")
|
||||
})
|
||||
|
||||
t.Run("explicit submodule entry wins over parent grant", func(t *testing.T) {
|
||||
role := roles.RolePermissions{
|
||||
AutoAllowNew: denyAll,
|
||||
Permissions: roles.Permissions{
|
||||
modules.AgentNetwork: fullAccess,
|
||||
modules.AgentNetworkLogs: denyAll,
|
||||
},
|
||||
}
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
|
||||
"explicit submodule deny should override the parent grant")
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
|
||||
"sibling submodules should still resolve through the parent grant")
|
||||
})
|
||||
|
||||
t.Run("auto allow applies when neither submodule nor parent is granted", func(t *testing.T) {
|
||||
role := roles.RolePermissions{
|
||||
AutoAllowNew: readOnly,
|
||||
}
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
|
||||
"auto-allow read should apply to submodules")
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Delete),
|
||||
"auto-allow should not grant unlisted operations")
|
||||
})
|
||||
}
|
||||
|
||||
// TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules pins the behavior the
|
||||
// submodule split must not change: every built-in role resolves the new
|
||||
// submodules exactly as it resolved the agent_network module before.
|
||||
func TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
submodules := []modules.Module{
|
||||
modules.AgentNetworkProviders,
|
||||
modules.AgentNetworkPolicies,
|
||||
modules.AgentNetworkGuardrails,
|
||||
modules.AgentNetworkBudgets,
|
||||
modules.AgentNetworkUsage,
|
||||
modules.AgentNetworkLogs,
|
||||
modules.AgentNetworkSettings,
|
||||
}
|
||||
allOperations := []operations.Operation{operations.Read, operations.Create, operations.Update, operations.Delete}
|
||||
|
||||
for _, role := range []types.UserRole{types.UserRoleOwner, types.UserRoleAdmin, types.UserRoleAuditor, types.UserRoleNetworkAdmin, types.UserRoleUser} {
|
||||
rolePermissions, ok := roles.RolesMap[role]
|
||||
require.True(t, ok, "role %s must exist in RolesMap", role)
|
||||
|
||||
for _, sub := range submodules {
|
||||
for _, op := range allOperations {
|
||||
expected := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, modules.AgentNetwork, op)
|
||||
actual := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, sub, op)
|
||||
assert.Equal(t, expected, actual, "role %s: %s on %s should match the agent_network module", role, op, sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPermissionsByRoleIncludesSubmodules(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
permissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAuditor)
|
||||
require.NoError(t, err, "auditor role must resolve")
|
||||
|
||||
usage, ok := permissions[modules.AgentNetworkUsage]
|
||||
require.True(t, ok, "permissions map should contain the usage submodule")
|
||||
assert.True(t, usage[operations.Read], "auditor should read the usage submodule")
|
||||
assert.False(t, usage[operations.Update], "auditor should not update the usage submodule")
|
||||
|
||||
adminPermissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAdmin)
|
||||
require.NoError(t, err, "admin role must resolve")
|
||||
providers, ok := adminPermissions[modules.AgentNetworkProviders]
|
||||
require.True(t, ok, "permissions map should contain the providers submodule")
|
||||
assert.True(t, providers[operations.Delete], "admin should delete on the providers submodule")
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package modules
|
||||
|
||||
import "strings"
|
||||
|
||||
type Module string
|
||||
|
||||
const (
|
||||
@@ -20,6 +22,17 @@ const (
|
||||
IdentityProviders Module = "identity_providers"
|
||||
Services Module = "services"
|
||||
AgentNetwork Module = "agent_network"
|
||||
|
||||
// Agent Network submodules. A role may grant one of these directly
|
||||
// or grant the AgentNetwork parent, which covers all of them (see
|
||||
// permissions.Manager cascade resolution).
|
||||
AgentNetworkProviders Module = "agent_network.providers"
|
||||
AgentNetworkPolicies Module = "agent_network.policies"
|
||||
AgentNetworkGuardrails Module = "agent_network.guardrails"
|
||||
AgentNetworkBudgets Module = "agent_network.budgets"
|
||||
AgentNetworkUsage Module = "agent_network.usage"
|
||||
AgentNetworkLogs Module = "agent_network.logs"
|
||||
AgentNetworkSettings Module = "agent_network.settings"
|
||||
)
|
||||
|
||||
var All = map[Module]struct{}{
|
||||
@@ -40,4 +53,21 @@ var All = map[Module]struct{}{
|
||||
IdentityProviders: {},
|
||||
Services: {},
|
||||
AgentNetwork: {},
|
||||
|
||||
AgentNetworkProviders: {},
|
||||
AgentNetworkPolicies: {},
|
||||
AgentNetworkGuardrails: {},
|
||||
AgentNetworkBudgets: {},
|
||||
AgentNetworkUsage: {},
|
||||
AgentNetworkLogs: {},
|
||||
AgentNetworkSettings: {},
|
||||
}
|
||||
|
||||
// Parent returns the module owning a dotted submodule name and true, or the
|
||||
// module itself and false when it has no parent.
|
||||
func (m Module) Parent() (Module, bool) {
|
||||
if i := strings.IndexByte(string(m), '.'); i > 0 {
|
||||
return Module(string(m)[:i]), true
|
||||
}
|
||||
return m, false
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ type ProxyAccessTokenGenerated struct {
|
||||
// CreateNewProxyAccessToken generates a new proxy access token.
|
||||
// Returns the token with hashed value stored and plain token for one-time display.
|
||||
func CreateNewProxyAccessToken(name string, expiresIn time.Duration, accountID *string, createdBy string) (*ProxyAccessTokenGenerated, error) {
|
||||
hashedToken, plainToken, err := generateProxyToken()
|
||||
hashedToken, plainToken, err := GenerateProxyToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -94,7 +94,10 @@ func CreateNewProxyAccessToken(name string, expiresIn time.Duration, accountID *
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateProxyToken() (HashedProxyToken, PlainProxyToken, error) {
|
||||
// GenerateProxyToken generates a new random proxy token, returning its SHA-256
|
||||
// hash (for storage) and the one-time plaintext. Exported so external modules
|
||||
// can mint tokens in the canonical proxy-token format.
|
||||
func GenerateProxyToken() (HashedProxyToken, PlainProxyToken, error) {
|
||||
secret, err := b.Random(ProxyTokenSecretLength)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -123,6 +124,22 @@ func TestCreateNewProxyAccessToken(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateProxyToken(t *testing.T) {
|
||||
hashed, plain, err := GenerateProxyToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := plain.Validate(); err != nil {
|
||||
t.Errorf("generated token failed Validate(): %v", err)
|
||||
}
|
||||
if plain.Hash() != hashed {
|
||||
t.Error("returned hashed token does not match Hash(plain)")
|
||||
}
|
||||
if !strings.HasPrefix(string(plain), ProxyTokenPrefix) {
|
||||
t.Errorf("token %q missing prefix %q", plain, ProxyTokenPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyAccessToken_IsExpired(t *testing.T) {
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
future := time.Now().Add(1 * time.Hour)
|
||||
|
||||
@@ -30,7 +30,23 @@ mkdir -p /usr/local/bin/
|
||||
$AGENT service install || true
|
||||
$AGENT service start || true
|
||||
|
||||
open $APP
|
||||
console_user=$(stat -f%Su /dev/console 2>/dev/null)
|
||||
case "$console_user" in
|
||||
""|root|loginwindow|_mbsetupuser)
|
||||
echo "No active GUI user session (console user: '${console_user:-none}'); skipping UI launch."
|
||||
;;
|
||||
*)
|
||||
uid=$(id -u "$console_user" 2>/dev/null)
|
||||
if [ -z "$uid" ]; then
|
||||
echo "Could not resolve uid for console user '$console_user'; skipping UI launch."
|
||||
else
|
||||
echo "Launching NetBird UI as console user $console_user (uid $uid)."
|
||||
if ! launchctl asuser "$uid" sudo -u "$console_user" -H open "$APP"; then
|
||||
echo "Failed to launch NetBird UI; if autostart is enabled it will start at next login."
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Finished Netbird installation successfully"
|
||||
exit 0 # all good
|
||||
|
||||
@@ -22,7 +22,6 @@ type Client interface {
|
||||
ExtendAuthSession(sysInfo *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error)
|
||||
GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error)
|
||||
GetPKCEAuthorizationFlow() (*proto.PKCEAuthorizationFlow, error)
|
||||
GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error)
|
||||
GetServerURL() string
|
||||
// IsHealthy returns the current connection status without blocking.
|
||||
// Used by the engine to monitor connectivity in the background.
|
||||
|
||||
@@ -436,49 +436,6 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNetworkMap return with the network map
|
||||
func (c *GrpcClient) GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error) {
|
||||
serverPubKey, err := c.getServerPublicKey()
|
||||
if err != nil {
|
||||
log.Debugf("failed getting Management Service public key: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancelStream := context.WithCancel(c.ctx)
|
||||
defer cancelStream()
|
||||
stream, err := c.connectToSyncStream(ctx, *serverPubKey, sysInfo)
|
||||
if err != nil {
|
||||
log.Debugf("failed to open Management Service stream: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = stream.CloseSend()
|
||||
}()
|
||||
|
||||
update, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
log.Debugf("Management stream has been closed by server: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
log.Debugf("disconnected from Management Service sync stream: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decryptedResp := &proto.SyncResponse{}
|
||||
err = encryption.DecryptMessage(*serverPubKey, c.key, update.Body, decryptedResp)
|
||||
if err != nil {
|
||||
log.Errorf("failed decrypting update message from Management Service: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if decryptedResp.GetNetworkMap() == nil {
|
||||
return nil, fmt.Errorf("invalid msg, required network map")
|
||||
}
|
||||
|
||||
return decryptedResp.GetNetworkMap(), nil
|
||||
}
|
||||
|
||||
func (c *GrpcClient) connectToSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info) (proto.ManagementService_SyncClient, error) {
|
||||
req := &proto.SyncRequest{Meta: infoToMetaData(sysInfo)}
|
||||
|
||||
|
||||
@@ -94,11 +94,6 @@ func (m *MockClient) HealthCheck() error {
|
||||
return m.HealthCheckFunc()
|
||||
}
|
||||
|
||||
// GetNetworkMap mock implementation of GetNetworkMap from Client interface.
|
||||
func (m *MockClient) GetNetworkMap(_ *system.Info) (*proto.NetworkMap, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetServerURL mock implementation of GetServerURL from mgm.Client interface
|
||||
func (m *MockClient) GetServerURL() string {
|
||||
if m.GetServerURLFunc == nil {
|
||||
|
||||
@@ -228,15 +228,17 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// decodeAccountNetwork never returns nil — Calculate() dereferences
|
||||
// c.Network unconditionally, and servers that predate the fix omit the field
|
||||
// entirely from the empty-components envelope.
|
||||
func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network {
|
||||
n := &types.Network{}
|
||||
if an == nil {
|
||||
return nil
|
||||
}
|
||||
n := &types.Network{
|
||||
Identifier: an.Identifier,
|
||||
Dns: an.Dns,
|
||||
Serial: an.Serial,
|
||||
return n
|
||||
}
|
||||
n.Identifier = an.Identifier
|
||||
n.Dns = an.Dns
|
||||
n.Serial = an.Serial
|
||||
if an.NetCidr != "" {
|
||||
if _, ipnet, err := net.ParseCIDR(an.NetCidr); err == nil && ipnet != nil {
|
||||
n.Net = *ipnet
|
||||
|
||||
@@ -221,6 +221,66 @@ func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) {
|
||||
"client-side Calculate must connect the same remote peers as the server")
|
||||
}
|
||||
|
||||
// TestEnvelopeToNetworkMap_EmptyComponents covers the graceful-degrade path
|
||||
// the server takes for a peer that is missing from the account or absent from
|
||||
// the validated-peers map. The legacy server short-circuited before
|
||||
// Calculate() and shipped a NetworkMap carrying only the account Network; the
|
||||
// components path runs Calculate() on the client instead, so the envelope must
|
||||
// carry Network or the client panics dereferencing a nil *types.Network.
|
||||
func TestEnvelopeToNetworkMap_EmptyComponents(t *testing.T) {
|
||||
localPeerKey := randomWgKey(t)
|
||||
c := types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
|
||||
PeerID: "peer-A",
|
||||
Network: &types.Network{
|
||||
Identifier: "net-empty",
|
||||
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
|
||||
Serial: 7,
|
||||
},
|
||||
Peers: map[string]*types.ComponentPeer{
|
||||
"peer-A": {ID: "peer-A", Key: localPeerKey, IP: netip.AddrFrom4([4]byte{100, 64, 0, 1})},
|
||||
},
|
||||
})
|
||||
|
||||
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
|
||||
Components: c,
|
||||
DNSDomain: "netbird.cloud",
|
||||
})
|
||||
require.NotNil(t, envelope.GetFull().Network, "empty envelope must carry the account Network")
|
||||
|
||||
wire, err := goproto.Marshal(envelope)
|
||||
require.NoError(t, err, "marshal envelope")
|
||||
var decoded proto.NetworkMapEnvelope
|
||||
require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope")
|
||||
|
||||
result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud")
|
||||
require.NoError(t, err, "EnvelopeToNetworkMap must degrade gracefully on empty components")
|
||||
require.Equal(t, uint64(7), result.NetworkMap.Serial)
|
||||
require.Empty(t, result.NetworkMap.RemotePeers, "unvalidated peer connects to nobody")
|
||||
}
|
||||
|
||||
// TestEnvelopeToNetworkMap_MissingNetwork simulates a server that omits
|
||||
// AccountNetwork from the envelope. Clients must degrade rather than panic, so
|
||||
// they survive talking to a management server that predates the encoder fix.
|
||||
func TestEnvelopeToNetworkMap_MissingNetwork(t *testing.T) {
|
||||
c, localPeerKey := buildSmokeComponents(t)
|
||||
|
||||
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
|
||||
Components: c,
|
||||
DNSDomain: "netbird.cloud",
|
||||
})
|
||||
envelope.GetFull().Network = nil
|
||||
|
||||
wire, err := goproto.Marshal(envelope)
|
||||
require.NoError(t, err, "marshal envelope")
|
||||
var decoded proto.NetworkMapEnvelope
|
||||
require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope")
|
||||
|
||||
result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud")
|
||||
require.NoError(t, err, "a missing AccountNetwork must not panic the client")
|
||||
require.NotNil(t, result.Components.Network)
|
||||
require.NotEmpty(t, result.NetworkMap.RemotePeers, "the rest of the snapshot stays usable")
|
||||
}
|
||||
|
||||
// buildSmokeComponents returns a minimal NetworkMapComponents (2 peers, 1
|
||||
// group, 1 allow policy) plus the receiving peer's WG public key. Sufficient
|
||||
// to validate the encode → marshal → decode → Calculate pipeline produces
|
||||
|
||||
Reference in New Issue
Block a user