Compare commits

..

24 Commits

Author SHA1 Message Date
Viktor Liu
b2d7121695 Add logging around routes 2025-10-28 14:20:15 +01:00
Viktor Liu
3288c4414f Merge branch 'shutdown-block' into feature/detect-mac-wakeup 2025-10-27 23:20:25 +01:00
Viktor Liu
f3b0439211 Merge branch 'main' into feature/detect-mac-wakeup 2025-10-27 23:20:19 +01:00
Viktor Liu
ae801d77fb Block on all subsystems on shutdown 2025-10-27 23:15:47 +01:00
Pascal Fischer
4545ab9a52 [management] rewire account manager to permissions manager (#4673) 2025-10-27 22:59:35 +01:00
Bethuel Mmbaga
7f08983207 Include expired and routing peers in DNS record filtering (#4708) 2025-10-27 22:16:17 +03:00
Viktor Liu
eddea14521 [client] Clean up bsd routes independently of the state file (#4688) 2025-10-27 18:54:00 +01:00
Viktor Liu
b9ef214ea5 [client] Fix macOS state-based dns cleanup (#4701) 2025-10-27 18:35:32 +01:00
Bethuel Mmbaga
709e24eb6f [signal] Fix HTTP/WebSocket proxy not using custom certificates (#4644)
This pull request fixes a bug where the HTTP/WebSocket proxy server was not using custom TLS certificates when provided via --cert-file and --cert-key flags. Previously, only the gRPC server had TLS enabled with custom certificates, while the HTTP/WebSocket proxy ran without TLS.
2025-10-24 15:40:20 +03:00
Viktor Liu
bf83549db2 Merge branch 'main' into feature/detect-mac-wakeup 2025-10-23 17:09:06 +02:00
Viktor Liu
804a3871fe Merge branch 'fix-deprecated-grpc' into feature/detect-mac-wakeup 2025-10-23 17:08:12 +02:00
Viktor Liu
6654e2dbf7 [client] Fix active profile name in debug bundle (#4689) 2025-10-23 17:07:52 +02:00
Viktor Liu
64d1edce27 Merge branch 'bsd-route-cleanup' into feature/detect-mac-wakeup 2025-10-23 17:07:22 +02:00
Viktor Liu
bf0698e5aa Clean up bsd routes independently of the state file 2025-10-23 16:42:23 +02:00
Viktor Liu
fc15625963 Clean up failed conn hooks 2025-10-23 15:35:45 +02:00
Viktor Liu
a75dde33b9 Fix happy eyeballs for grpc 2025-10-23 13:14:37 +02:00
Bethuel Mmbaga
d80d47a469 [management] Add peer disapproval reason (#4468) 2025-10-22 12:46:22 +03:00
Viktor Liu
bb46e438aa Add gw change polling and time drift detection (informational only) 2025-10-21 12:19:35 +02:00
Zoltán Papp
11ba253ffb Merge branch 'main' into feature/detect-mac-wakeup 2025-10-16 17:16:19 +02:00
Zoltan Papp
14fe7c29cb Change log levels 2025-10-14 18:13:52 +02:00
Zoltan Papp
158f3aceff Handle better sleep period 2025-10-14 12:24:39 +02:00
Zoltan Papp
bfa776c155 Update client/internal/networkmonitor/check_change_darwin.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-14 12:18:00 +02:00
Zoltan Papp
885b5c68ad Update client/internal/networkmonitor/monitor.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-14 12:17:51 +02:00
Zoltan Papp
b1ebac795d Extend Darwin network monitoring with wakeup detection 2025-10-14 12:14:08 +02:00
230 changed files with 1037 additions and 44456 deletions

1
.gitignore vendored
View File

@@ -31,4 +31,3 @@ infrastructure_files/setup-*.env
.DS_Store
vendor/
/netbird
client/ui/ui

View File

@@ -307,8 +307,14 @@ func getStatusOutput(cmd *cobra.Command, anon bool) string {
if err != nil {
cmd.PrintErrf("Failed to get status: %v\n", err)
} else {
pm := profilemanager.NewProfileManager()
var profName string
if activeProf, err := pm.GetActiveProfile(); err == nil {
profName = activeProf.Name
}
statusOutputString = nbstatus.ParseToFullDetailSummary(
nbstatus.ConvertToStatusOutputOverview(statusResp, anon, "", nil, nil, nil, "", ""),
nbstatus.ConvertToStatusOutputOverview(statusResp, anon, "", nil, nil, nil, "", profName),
)
}
return statusOutputString

View File

@@ -4,12 +4,15 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"runtime"
"time"
"github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/keepalive"
@@ -17,6 +20,9 @@ import (
"github.com/netbirdio/netbird/util/embeddedroots"
)
// ErrConnectionShutdown indicates that the connection entered shutdown state before becoming ready
var ErrConnectionShutdown = errors.New("connection shutdown before ready")
// Backoff returns a backoff configuration for gRPC calls
func Backoff(ctx context.Context) backoff.BackOff {
b := backoff.NewExponentialBackOff()
@@ -25,6 +31,26 @@ func Backoff(ctx context.Context) backoff.BackOff {
return backoff.WithContext(b, ctx)
}
// waitForConnectionReady blocks until the connection becomes ready or fails.
// Returns an error if the connection times out, is cancelled, or enters shutdown state.
func waitForConnectionReady(ctx context.Context, conn *grpc.ClientConn) error {
conn.Connect()
state := conn.GetState()
for state != connectivity.Ready && state != connectivity.Shutdown {
if !conn.WaitForStateChange(ctx, state) {
return fmt.Errorf("wait state change from %s: %w", state, ctx.Err())
}
state = conn.GetState()
}
if state == connectivity.Shutdown {
return ErrConnectionShutdown
}
return nil
}
// CreateConnection creates a gRPC client connection with the appropriate transport options.
// The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal").
func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, component string) (*grpc.ClientConn, error) {
@@ -42,22 +68,24 @@ func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, compone
}))
}
connCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
conn, err := grpc.DialContext(
connCtx,
conn, err := grpc.NewClient(
addr,
transportOption,
WithCustomDialer(tlsEnabled, component),
grpc.WithBlock(),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
}),
)
if err != nil {
log.Printf("DialContext error: %v", err)
return nil, fmt.Errorf("new client: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := waitForConnectionReady(ctx, conn); err != nil {
_ = conn.Close()
return nil, err
}

View File

@@ -18,7 +18,7 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
func WithCustomDialer(_ bool, _ string) grpc.DialOption {
return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
if runtime.GOOS == "linux" {
currentUser, err := user.Current()
@@ -36,7 +36,6 @@ func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
if err != nil {
log.Errorf("Failed to dial: %s", err)
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
}
return conn, nil

View File

@@ -25,6 +25,7 @@ import (
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/stdnet"
nbnet "github.com/netbirdio/netbird/client/net"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/system"
@@ -34,7 +35,6 @@ import (
relayClient "github.com/netbirdio/netbird/shared/relay/client"
signal "github.com/netbirdio/netbird/shared/signal/client"
"github.com/netbirdio/netbird/util"
nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/version"
)
@@ -289,15 +289,18 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
}
<-engineCtx.Done()
c.engineMutex.Lock()
if c.engine != nil && c.engine.wgInterface != nil {
log.Infof("ensuring %s is removed, Netbird engine context cancelled", c.engine.wgInterface.Name())
if err := c.engine.Stop(); err != nil {
engine := c.engine
c.engine = nil
c.engineMutex.Unlock()
if engine != nil && engine.wgInterface != nil {
log.Infof("ensuring %s is removed, Netbird engine context cancelled", engine.wgInterface.Name())
if err := engine.Stop(); err != nil {
log.Errorf("Failed to stop engine: %v", err)
}
c.engine = nil
}
c.engineMutex.Unlock()
c.statusRecorder.ClientTeardown()
backOff.Reset()
@@ -382,19 +385,12 @@ func (c *ConnectClient) Status() StatusType {
}
func (c *ConnectClient) Stop() error {
if c == nil {
return nil
engine := c.Engine()
if engine != nil {
if err := engine.Stop(); err != nil {
return fmt.Errorf("stop engine: %w", err)
}
}
c.engineMutex.Lock()
defer c.engineMutex.Unlock()
if c.engine == nil {
return nil
}
if err := c.engine.Stop(); err != nil {
return fmt.Errorf("stop engine: %w", err)
}
return nil
}

View File

@@ -47,7 +47,7 @@ nftables.txt: Anonymized nftables rules with packet counters, if --system-info f
resolved_domains.txt: Anonymized resolved domain IP addresses from the status recorder.
config.txt: Anonymized configuration information of the NetBird client.
network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules.
state.json: Anonymized client state dump containing netbird states.
state.json: Anonymized client state dump containing netbird states for the active profile.
mutex.prof: Mutex profiling information.
goroutine.prof: Goroutine profiling information.
block.prof: Block profiling information.
@@ -564,6 +564,8 @@ func (g *BundleGenerator) addStateFile() error {
return nil
}
log.Debugf("Adding state file from: %s", path)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {

View File

@@ -13,6 +13,7 @@ import (
"strings"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
"github.com/netbirdio/netbird/client/internal/statemanager"
)
@@ -50,28 +51,21 @@ func (s *systemConfigurator) supportCustomPort() bool {
}
func (s *systemConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error {
var err error
if err := stateManager.UpdateState(&ShutdownState{}); err != nil {
log.Errorf("failed to update shutdown state: %s", err)
}
var (
searchDomains []string
matchDomains []string
)
err = s.recordSystemDNSSettings(true)
if err != nil {
if err := s.recordSystemDNSSettings(true); err != nil {
log.Errorf("unable to update record of System's DNS config: %s", err.Error())
}
if config.RouteAll {
searchDomains = append(searchDomains, "\"\"")
err = s.addLocalDNS()
if err != nil {
log.Infof("failed to enable split DNS")
if err := s.addLocalDNS(); err != nil {
log.Warnf("failed to add local DNS: %v", err)
}
s.updateState(stateManager)
}
for _, dConf := range config.Domains {
@@ -86,6 +80,7 @@ func (s *systemConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *
}
matchKey := getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix)
var err error
if len(matchDomains) != 0 {
err = s.addMatchDomains(matchKey, strings.Join(matchDomains, " "), config.ServerIP, config.ServerPort)
} else {
@@ -95,6 +90,7 @@ func (s *systemConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *
if err != nil {
return fmt.Errorf("add match domains: %w", err)
}
s.updateState(stateManager)
searchKey := getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix)
if len(searchDomains) != 0 {
@@ -106,6 +102,7 @@ func (s *systemConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *
if err != nil {
return fmt.Errorf("add search domains: %w", err)
}
s.updateState(stateManager)
if err := s.flushDNSCache(); err != nil {
log.Errorf("failed to flush DNS cache: %v", err)
@@ -114,6 +111,12 @@ func (s *systemConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *
return nil
}
func (s *systemConfigurator) updateState(stateManager *statemanager.Manager) {
if err := stateManager.UpdateState(&ShutdownState{CreatedKeys: maps.Keys(s.createdKeys)}); err != nil {
log.Errorf("failed to update shutdown state: %s", err)
}
}
func (s *systemConfigurator) string() string {
return "scutil"
}
@@ -167,18 +170,20 @@ func (s *systemConfigurator) removeKeyFromSystemConfig(key string) error {
func (s *systemConfigurator) addLocalDNS() error {
if !s.systemDNSSettings.ServerIP.IsValid() || len(s.systemDNSSettings.Domains) == 0 {
if err := s.recordSystemDNSSettings(true); err != nil {
log.Errorf("Unable to get system DNS configuration")
return fmt.Errorf("recordSystemDNSSettings(): %w", err)
}
}
localKey := getKeyWithInput(netbirdDNSStateKeyFormat, localSuffix)
if s.systemDNSSettings.ServerIP.IsValid() && len(s.systemDNSSettings.Domains) != 0 {
err := s.addSearchDomains(localKey, strings.Join(s.systemDNSSettings.Domains, " "), s.systemDNSSettings.ServerIP, s.systemDNSSettings.ServerPort)
if err != nil {
return fmt.Errorf("couldn't add local network DNS conf: %w", err)
}
} else {
if !s.systemDNSSettings.ServerIP.IsValid() || len(s.systemDNSSettings.Domains) == 0 {
log.Info("Not enabling local DNS server")
return nil
}
if err := s.addSearchDomains(
localKey,
strings.Join(s.systemDNSSettings.Domains, " "), s.systemDNSSettings.ServerIP, s.systemDNSSettings.ServerPort,
); err != nil {
return fmt.Errorf("add search domains: %w", err)
}
return nil

View File

@@ -0,0 +1,111 @@
//go:build !ios
package dns
import (
"context"
"net/netip"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/statemanager"
)
func TestDarwinDNSUncleanShutdownCleanup(t *testing.T) {
if testing.Short() {
t.Skip("skipping scutil integration test in short mode")
}
tmpDir := t.TempDir()
stateFile := filepath.Join(tmpDir, "state.json")
sm := statemanager.New(stateFile)
sm.RegisterState(&ShutdownState{})
sm.Start()
defer func() {
require.NoError(t, sm.Stop(context.Background()))
}()
configurator := &systemConfigurator{
createdKeys: make(map[string]struct{}),
}
config := HostDNSConfig{
ServerIP: netip.MustParseAddr("100.64.0.1"),
ServerPort: 53,
RouteAll: true,
Domains: []DomainConfig{
{Domain: "example.com", MatchOnly: true},
},
}
err := configurator.applyDNSConfig(config, sm)
require.NoError(t, err)
require.NoError(t, sm.PersistState(context.Background()))
searchKey := getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix)
matchKey := getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix)
localKey := getKeyWithInput(netbirdDNSStateKeyFormat, localSuffix)
defer func() {
for _, key := range []string{searchKey, matchKey, localKey} {
_ = removeTestDNSKey(key)
}
}()
for _, key := range []string{searchKey, matchKey, localKey} {
exists, err := checkDNSKeyExists(key)
require.NoError(t, err)
if exists {
t.Logf("Key %s exists before cleanup", key)
}
}
sm2 := statemanager.New(stateFile)
sm2.RegisterState(&ShutdownState{})
err = sm2.LoadState(&ShutdownState{})
require.NoError(t, err)
state := sm2.GetState(&ShutdownState{})
if state == nil {
t.Skip("State not saved, skipping cleanup test")
}
shutdownState, ok := state.(*ShutdownState)
require.True(t, ok)
err = shutdownState.Cleanup()
require.NoError(t, err)
for _, key := range []string{searchKey, matchKey, localKey} {
exists, err := checkDNSKeyExists(key)
require.NoError(t, err)
assert.False(t, exists, "Key %s should NOT exist after cleanup", key)
}
}
func checkDNSKeyExists(key string) (bool, error) {
cmd := exec.Command(scutilPath)
cmd.Stdin = strings.NewReader("show " + key + "\nquit\n")
output, err := cmd.CombinedOutput()
if err != nil {
if strings.Contains(string(output), "No such key") {
return false, nil
}
return false, err
}
return !strings.Contains(string(output), "No such key"), nil
}
func removeTestDNSKey(key string) error {
cmd := exec.Command(scutilPath)
cmd.Stdin = strings.NewReader("remove " + key + "\nquit\n")
_, err := cmd.CombinedOutput()
return err
}

View File

@@ -179,13 +179,7 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
log.Infof("removed %s as main DNS forwarder for this peer", config.ServerIP)
}
if err := stateManager.UpdateState(&ShutdownState{
Guid: r.guid,
GPO: r.gpo,
NRPTEntryCount: r.nrptEntryCount,
}); err != nil {
log.Errorf("failed to update shutdown state: %s", err)
}
r.updateState(stateManager)
var searchDomains, matchDomains []string
for _, dConf := range config.Domains {
@@ -212,13 +206,7 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
r.nrptEntryCount = 0
}
if err := stateManager.UpdateState(&ShutdownState{
Guid: r.guid,
GPO: r.gpo,
NRPTEntryCount: r.nrptEntryCount,
}); err != nil {
log.Errorf("failed to update shutdown state: %s", err)
}
r.updateState(stateManager)
if err := r.updateSearchDomains(searchDomains); err != nil {
return fmt.Errorf("update search domains: %w", err)
@@ -229,6 +217,16 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
return nil
}
func (r *registryConfigurator) updateState(stateManager *statemanager.Manager) {
if err := stateManager.UpdateState(&ShutdownState{
Guid: r.guid,
GPO: r.gpo,
NRPTEntryCount: r.nrptEntryCount,
}); err != nil {
log.Errorf("failed to update shutdown state: %s", err)
}
}
func (r *registryConfigurator) addDNSSetupForAll(ip netip.Addr) error {
if err := r.setInterfaceRegistryKeyStringValue(interfaceConfigNameServerKey, ip.String()); err != nil {
return fmt.Errorf("adding dns setup for all failed: %w", err)

View File

@@ -65,8 +65,9 @@ type hostManagerWithOriginalNS interface {
// DefaultServer dns server object
type DefaultServer struct {
ctx context.Context
ctxCancel context.CancelFunc
ctx context.Context
ctxCancel context.CancelFunc
shutdownWg sync.WaitGroup
// disableSys disables system DNS management (e.g., /etc/resolv.conf updates) while keeping the DNS service running.
// This is different from ServiceEnable=false from management which completely disables the DNS service.
disableSys bool
@@ -318,6 +319,7 @@ func (s *DefaultServer) DnsIP() netip.Addr {
// Stop stops the server
func (s *DefaultServer) Stop() {
s.ctxCancel()
s.shutdownWg.Wait()
s.mux.Lock()
defer s.mux.Unlock()
@@ -507,8 +509,9 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
s.applyHostConfig()
s.shutdownWg.Add(1)
go func() {
// persist dns state right away
defer s.shutdownWg.Done()
if err := s.stateManager.PersistState(s.ctx); err != nil {
log.Errorf("Failed to persist dns state: %v", err)
}

View File

@@ -7,6 +7,7 @@ import (
)
type ShutdownState struct {
CreatedKeys []string
}
func (s *ShutdownState) Name() string {
@@ -19,6 +20,10 @@ func (s *ShutdownState) Cleanup() error {
return fmt.Errorf("create host manager: %w", err)
}
for _, key := range s.CreatedKeys {
manager.createdKeys[key] = struct{}{}
}
if err := manager.restoreUncleanShutdownDNS(); err != nil {
return fmt.Errorf("restore unclean shutdown dns: %w", err)
}

View File

@@ -200,8 +200,10 @@ type Engine struct {
flowManager nftypes.FlowManager
// WireGuard interface monitor
wgIfaceMonitor *WGIfaceMonitor
wgIfaceMonitorWg sync.WaitGroup
wgIfaceMonitor *WGIfaceMonitor
// shutdownWg tracks all long-running goroutines to ensure clean shutdown
shutdownWg sync.WaitGroup
// dns forwarder port
dnsFwdPort uint16
@@ -326,10 +328,6 @@ func (e *Engine) Stop() error {
e.cancel()
}
// very ugly but we want to remove peers from the WireGuard interface first before removing interface.
// Removing peers happens in the conn.Close() asynchronously
time.Sleep(500 * time.Millisecond)
e.close()
// stop flow manager after wg interface is gone
@@ -337,8 +335,6 @@ func (e *Engine) Stop() error {
e.flowManager.Close()
}
log.Infof("stopped Netbird Engine")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
@@ -349,12 +345,52 @@ func (e *Engine) Stop() error {
log.Errorf("failed to persist state: %v", err)
}
// Stop WireGuard interface monitor and wait for it to exit
e.wgIfaceMonitorWg.Wait()
timeout := e.calculateShutdownTimeout()
log.Debugf("waiting for goroutines to finish with timeout: %v", timeout)
shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil {
log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout)
}
log.Infof("stopped Netbird Engine")
return nil
}
// calculateShutdownTimeout returns shutdown timeout: 10s base + 100ms per peer, capped at 30s.
func (e *Engine) calculateShutdownTimeout() time.Duration {
peerCount := len(e.peerStore.PeersPubKey())
baseTimeout := 10 * time.Second
perPeerTimeout := time.Duration(peerCount) * 100 * time.Millisecond
timeout := baseTimeout + perPeerTimeout
maxTimeout := 30 * time.Second
if timeout > maxTimeout {
timeout = maxTimeout
}
return timeout
}
// waitWithContext waits for WaitGroup with timeout, returns ctx.Err() on timeout.
func waitWithContext(ctx context.Context, wg *sync.WaitGroup) error {
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Start creates a new WireGuard tunnel interface and listens to events from Signal and Management services
// Connections to remote peers are not established here.
// However, they will be established once an event with a list of peers to connect to will be received from Management Service
@@ -484,14 +520,14 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
// monitor WireGuard interface lifecycle and restart engine on changes
e.wgIfaceMonitor = NewWGIfaceMonitor()
e.wgIfaceMonitorWg.Add(1)
e.shutdownWg.Add(1)
go func() {
defer e.wgIfaceMonitorWg.Done()
defer e.shutdownWg.Done()
if shouldRestart, err := e.wgIfaceMonitor.Start(e.ctx, e.wgInterface.Name()); shouldRestart {
log.Infof("WireGuard interface monitor: %s, restarting engine", err)
e.restartEngine()
e.triggerClientRestart()
} else if err != nil {
log.Warnf("WireGuard interface monitor: %s", err)
}
@@ -892,7 +928,9 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
if err != nil {
return fmt.Errorf("create ssh server: %w", err)
}
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
// blocking
err = e.sshServer.Start()
if err != nil {
@@ -950,7 +988,9 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
// receiveManagementEvents connects to the Management Service event stream to receive updates from the management service
// E.g. when a new peer has been registered and we are allowed to connect to it.
func (e *Engine) receiveManagementEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
info, err := system.GetInfoWithChecks(e.ctx, e.checks)
if err != nil {
log.Warnf("failed to get system info with checks: %v", err)
@@ -1368,7 +1408,9 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
// receiveSignalEvents connects to the Signal Service event stream to negotiate connection with remote peers
func (e *Engine) receiveSignalEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
// connect to a stream of messages coming from the signal server
err := e.signal.Receive(e.ctx, func(msg *sProto.Message) error {
e.syncMsgMux.Lock()
@@ -1724,8 +1766,10 @@ func (e *Engine) probeICE(stuns, turns []*stun.URI) []relay.ProbeResult {
)
}
// restartEngine restarts the engine by cancelling the client context
func (e *Engine) restartEngine() {
// triggerClientRestart triggers a full client restart by cancelling the client context.
// Note: This does NOT just restart the engine - it cancels the entire client context,
// which causes the connect client's retry loop to create a completely new engine.
func (e *Engine) triggerClientRestart() {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
@@ -1747,7 +1791,9 @@ func (e *Engine) startNetworkMonitor() {
}
e.networkMonitor = networkmonitor.New()
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
if err := e.networkMonitor.Listen(e.ctx); err != nil {
if errors.Is(err, context.Canceled) {
log.Infof("network monitor stopped")
@@ -1757,8 +1803,8 @@ func (e *Engine) startNetworkMonitor() {
return
}
log.Infof("Network monitor: detected network change, restarting engine")
e.restartEngine()
log.Infof("Network monitor: detected network change, triggering client restart")
e.triggerClientRestart()
}()
}

View File

@@ -24,6 +24,7 @@ import (
// Manager handles netflow tracking and logging
type Manager struct {
mux sync.Mutex
shutdownWg sync.WaitGroup
logger nftypes.FlowLogger
flowConfig *nftypes.FlowConfig
conntrack nftypes.ConnTracker
@@ -105,8 +106,15 @@ func (m *Manager) resetClient() error {
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
go m.receiveACKs(ctx, flowClient)
go m.startSender(ctx)
m.shutdownWg.Add(2)
go func() {
defer m.shutdownWg.Done()
m.receiveACKs(ctx, flowClient)
}()
go func() {
defer m.shutdownWg.Done()
m.startSender(ctx)
}()
return nil
}
@@ -176,11 +184,12 @@ func (m *Manager) Update(update *nftypes.FlowConfig) error {
// Close cleans up all resources
func (m *Manager) Close() {
m.mux.Lock()
defer m.mux.Unlock()
if err := m.disableFlow(); err != nil {
log.Warnf("failed to disable flow manager: %v", err)
}
m.mux.Unlock()
m.shutdownWg.Wait()
}
// GetLogger returns the flow logger

View File

@@ -1,4 +1,4 @@
//go:build (darwin && !ios) || dragonfly || freebsd || netbsd || openbsd
//go:build dragonfly || freebsd || netbsd || openbsd
package networkmonitor

View File

@@ -0,0 +1,344 @@
//go:build darwin && !ios
package networkmonitor
import (
"context"
"errors"
"fmt"
"hash/fnv"
"net/netip"
"os/exec"
"syscall"
"time"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/net/route"
"golang.org/x/sys/unix"
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
)
// todo: refactor to not use static functions
func checkChange(ctx context.Context, nexthopv4, nexthopv6 systemops.Nexthop) error {
fd, err := unix.Socket(syscall.AF_ROUTE, syscall.SOCK_RAW, syscall.AF_UNSPEC)
if err != nil {
return fmt.Errorf("open routing socket: %v", err)
}
defer func() {
err := unix.Close(fd)
if err != nil && !errors.Is(err, unix.EBADF) {
log.Warnf("Network monitor: failed to close routing socket: %v", err)
}
}()
routeChanged := make(chan struct{})
go func() {
routeCheck(ctx, fd, nexthopv4, nexthopv6)
close(routeChanged)
}()
wakeUp := make(chan struct{})
go func() {
wakeUpListen(ctx)
close(wakeUp)
}()
gatewayChanged := make(chan string)
go func() {
gatewayPoll(ctx, nexthopv4, nexthopv6, gatewayChanged)
}()
select {
case <-ctx.Done():
return ctx.Err()
case <-routeChanged:
log.Infof("route change detected via routing socket")
return nil
case <-wakeUp:
log.Infof("wakeup detected via sleep hash change")
return nil
case reason := <-gatewayChanged:
log.Infof("gateway change detected via polling: %s", reason)
return nil
}
}
func routeCheck(ctx context.Context, fd int, nexthopv4 systemops.Nexthop, nexthopv6 systemops.Nexthop) {
for {
if ctx.Err() != nil {
return
}
buf := make([]byte, 2048)
n, err := unix.Read(fd, buf)
if err != nil {
if !errors.Is(err, unix.EBADF) && !errors.Is(err, unix.EINVAL) {
log.Warnf("Network monitor: failed to read from routing socket: %v", err)
}
continue
}
if n < unix.SizeofRtMsghdr {
log.Debugf("Network monitor: read from routing socket returned less than expected: %d bytes", n)
continue
}
msg := (*unix.RtMsghdr)(unsafe.Pointer(&buf[0]))
switch msg.Type {
// handle route changes
case unix.RTM_ADD, syscall.RTM_DELETE:
route, err := parseRouteMessage(buf[:n])
if err != nil {
log.Debugf("Network monitor: error parsing routing message: %v", err)
continue
}
if route.Dst.Bits() != 0 {
continue
}
intf := "<nil>"
if route.Interface != nil {
intf = route.Interface.Name
}
switch msg.Type {
case unix.RTM_ADD:
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
return
case unix.RTM_DELETE:
if nexthopv4.Intf != nil && route.Gw.Compare(nexthopv4.IP) == 0 || nexthopv6.Intf != nil && route.Gw.Compare(nexthopv6.IP) == 0 {
log.Infof("Network monitor: default route removed: via %s, interface %s", route.Gw, intf)
return
}
}
}
}
}
func parseRouteMessage(buf []byte) (*systemops.Route, error) {
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
if err != nil {
return nil, fmt.Errorf("parse RIB: %v", err)
}
if len(msgs) != 1 {
return nil, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
}
msg, ok := msgs[0].(*route.RouteMessage)
if !ok {
return nil, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
}
return systemops.MsgToRoute(msg)
}
func wakeUpListen(ctx context.Context) {
log.Infof("start to watch for system wakeups")
var (
initialHash uint32
err error
)
// Keep retrying until initial sysctl succeeds or context is canceled
for {
select {
case <-ctx.Done():
log.Info("exit from wakeUpListen initial hash detection due to context cancellation")
return
default:
initialHash, err = readSleepTimeHash()
if err != nil {
log.Errorf("failed to detect initial sleep time: %v", err)
select {
case <-ctx.Done():
log.Info("exit from wakeUpListen initial hash detection due to context cancellation")
return
case <-time.After(3 * time.Second):
continue
}
}
log.Infof("initial wakeup hash: %d", initialHash)
break
}
break
}
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
lastCheck := time.Now()
const maxTickerDrift = 1 * time.Minute
for {
select {
case <-ctx.Done():
log.Info("context canceled, stopping wakeUpListen")
return
case <-ticker.C:
now := time.Now()
elapsed := now.Sub(lastCheck)
// If more time passed than expected, system likely slept (informational only)
if elapsed > maxTickerDrift {
upOut, err := exec.Command("uptime").Output()
if err != nil {
log.Errorf("failed to run uptime command: %v", err)
upOut = []byte("unknown")
}
log.Infof("Time drift detected (potential wakeup): expected ~5s, actual %s, uptime: %s", elapsed, upOut)
currentV4, errV4 := systemops.GetNextHop(netip.IPv4Unspecified())
currentV6, errV6 := systemops.GetNextHop(netip.IPv6Unspecified())
if errV4 == nil {
log.Infof("Current IPv4 default gateway: %s via %s", currentV4.IP, currentV4.Intf.Name)
} else {
log.Debugf("No IPv4 default gateway: %v", errV4)
}
if errV6 == nil {
log.Infof("Current IPv6 default gateway: %s via %s", currentV6.IP, currentV6.Intf.Name)
} else {
log.Debugf("No IPv6 default gateway: %v", errV6)
}
}
newHash, err := readSleepTimeHash()
if err != nil {
log.Errorf("failed to read sleep time hash: %v", err)
lastCheck = now
continue
}
if newHash == initialHash {
log.Debugf("no wakeup detected (hash unchanged: %d, time drift: %s)", initialHash, elapsed)
lastCheck = now
continue
}
upOut, err := exec.Command("uptime").Output()
if err != nil {
log.Errorf("failed to run uptime command: %v", err)
upOut = []byte("unknown")
}
log.Infof("Wakeup detected via hash change: %d -> %d, uptime: %s", initialHash, newHash, upOut)
currentV4, errV4 := systemops.GetNextHop(netip.IPv4Unspecified())
currentV6, errV6 := systemops.GetNextHop(netip.IPv6Unspecified())
if errV4 == nil {
log.Infof("Current IPv4 default gateway after wakeup: %s via %s", currentV4.IP, currentV4.Intf.Name)
} else {
log.Debugf("No IPv4 default gateway after wakeup: %v", errV4)
}
if errV6 == nil {
log.Infof("Current IPv6 default gateway after wakeup: %s via %s", currentV6.IP, currentV6.Intf.Name)
} else {
log.Debugf("No IPv6 default gateway after wakeup: %v", errV6)
}
return
}
}
}
func readSleepTimeHash() (uint32, error) {
cmd := exec.Command("sysctl", "kern.sleeptime")
out, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("failed to run sysctl: %w", err)
}
h, err := hash(out)
if err != nil {
return 0, fmt.Errorf("failed to compute hash: %w", err)
}
return h, nil
}
func hash(data []byte) (uint32, error) {
hasher := fnv.New32a()
if _, err := hasher.Write(data); err != nil {
return 0, err
}
return hasher.Sum32(), nil
}
// gatewayPoll polls the default gateway every 5 seconds to detect changes that might be missed by routing socket or wake-up detection.
func gatewayPoll(ctx context.Context, initialV4, initialV6 systemops.Nexthop, changed chan<- string) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
log.Infof("Gateway polling started - initial v4: %s via %v, v6: %s via %v",
initialV4.IP, initialV4.Intf, initialV6.IP, initialV6.Intf)
for {
select {
case <-ctx.Done():
log.Debug("context canceled, stopping gateway polling")
return
case <-ticker.C:
currentV4, errV4 := systemops.GetNextHop(netip.IPv4Unspecified())
currentV6, errV6 := systemops.GetNextHop(netip.IPv6Unspecified())
var reason string
if errV4 == nil && initialV4.IP.IsValid() {
if currentV4.IP.Compare(initialV4.IP) != 0 {
reason = fmt.Sprintf("IPv4 gateway changed from %s to %s", initialV4.IP, currentV4.IP)
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
if initialV4.Intf != nil && currentV4.Intf != nil && currentV4.Intf.Name != initialV4.Intf.Name {
reason = fmt.Sprintf("IPv4 interface changed from %s to %s", initialV4.Intf.Name, currentV4.Intf.Name)
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
} else if errV4 == nil && !initialV4.IP.IsValid() {
reason = "IPv4 gateway appeared"
log.Infof("Gateway poll detected change: %s (new: %s)", reason, currentV4.IP)
changed <- reason
return
} else if errV4 != nil && initialV4.IP.IsValid() {
reason = "IPv4 gateway disappeared"
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
if errV6 == nil && initialV6.IP.IsValid() {
if currentV6.IP.Compare(initialV6.IP) != 0 {
reason = fmt.Sprintf("IPv6 gateway changed from %s to %s", initialV6.IP, currentV6.IP)
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
if initialV6.Intf != nil && currentV6.Intf != nil && currentV6.Intf.Name != initialV6.Intf.Name {
reason = fmt.Sprintf("IPv6 interface changed from %s to %s", initialV6.Intf.Name, currentV6.Intf.Name)
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
} else if errV6 == nil && !initialV6.IP.IsValid() {
reason = "IPv6 gateway appeared"
log.Infof("Gateway poll detected change: %s (new: %s)", reason, currentV6.IP)
changed <- reason
return
} else if errV6 != nil && initialV6.IP.IsValid() {
reason = "IPv6 gateway disappeared"
log.Infof("Gateway poll detected change: %s", reason)
changed <- reason
return
}
log.Debugf("Gateway poll: no change detected")
}
}
}

View File

@@ -88,6 +88,7 @@ func (nw *NetworkMonitor) Listen(ctx context.Context) (err error) {
event := make(chan struct{}, 1)
go nw.checkChanges(ctx, event, nexthop4, nexthop6)
log.Infof("start watching for network changes")
// debounce changes
timer := time.NewTimer(0)
timer.Stop()

View File

@@ -19,11 +19,11 @@ type SRWatcher struct {
signalClient chNotifier
relayManager chNotifier
listeners map[chan struct{}]struct{}
mu sync.Mutex
iFaceDiscover stdnet.ExternalIFaceDiscover
iceConfig ice.Config
listeners map[chan struct{}]struct{}
mu sync.Mutex
shutdownWg sync.WaitGroup
iFaceDiscover stdnet.ExternalIFaceDiscover
iceConfig ice.Config
cancelIceMonitor context.CancelFunc
}
@@ -52,7 +52,11 @@ func (w *SRWatcher) Start() {
w.cancelIceMonitor = cancel
iceMonitor := NewICEMonitor(w.iFaceDiscover, w.iceConfig, GetICEMonitorPeriod())
go iceMonitor.Start(ctx, w.onICEChanged)
w.shutdownWg.Add(1)
go func() {
defer w.shutdownWg.Done()
iceMonitor.Start(ctx, w.onICEChanged)
}()
w.signalClient.SetOnReconnectedListener(w.onReconnected)
w.relayManager.SetOnReconnectedListener(w.onReconnected)
@@ -60,14 +64,16 @@ func (w *SRWatcher) Start() {
func (w *SRWatcher) Close() {
w.mu.Lock()
defer w.mu.Unlock()
if w.cancelIceMonitor == nil {
w.mu.Unlock()
return
}
w.cancelIceMonitor()
w.signalClient.SetOnReconnectedListener(nil)
w.relayManager.SetOnReconnectedListener(nil)
w.mu.Unlock()
w.shutdownWg.Wait()
}
func (w *SRWatcher) NewListener() chan struct{} {

View File

@@ -78,6 +78,7 @@ type DefaultManager struct {
ctx context.Context
stop context.CancelFunc
mux sync.Mutex
shutdownWg sync.WaitGroup
clientNetworks map[route.HAUniqueID]*client.Watcher
routeSelector *routeselector.RouteSelector
serverRouter *server.Router
@@ -106,7 +107,7 @@ type DefaultManager struct {
func NewManager(config ManagerConfig) *DefaultManager {
mCTX, cancel := context.WithCancel(config.Context)
notifier := notifier.NewNotifier()
sysOps := systemops.NewSysOps(config.WGInterface, notifier)
sysOps := systemops.New(config.WGInterface, notifier)
if runtime.GOOS == "windows" && config.WGInterface != nil {
nbnet.SetVPNInterfaceName(config.WGInterface.Name())
@@ -273,6 +274,7 @@ func (m *DefaultManager) SetFirewall(firewall firewall.Manager) error {
// Stop stops the manager watchers and clean firewall rules
func (m *DefaultManager) Stop(stateManager *statemanager.Manager) {
m.stop()
m.shutdownWg.Wait()
if m.serverRouter != nil {
m.serverRouter.CleanUp()
}
@@ -474,7 +476,11 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) {
}
clientNetworkWatcher := client.NewWatcher(config)
m.clientNetworks[id] = clientNetworkWatcher
go clientNetworkWatcher.Start()
m.shutdownWg.Add(1)
go func() {
defer m.shutdownWg.Done()
clientNetworkWatcher.Start()
}()
clientNetworkWatcher.SendUpdate(client.RoutesUpdate{Routes: routes})
}
@@ -516,7 +522,11 @@ func (m *DefaultManager) updateClientNetworks(updateSerial uint64, networks rout
}
clientNetworkWatcher = client.NewWatcher(config)
m.clientNetworks[id] = clientNetworkWatcher
go clientNetworkWatcher.Start()
m.shutdownWg.Add(1)
go func() {
defer m.shutdownWg.Done()
clientNetworkWatcher.Start()
}()
}
update := client.RoutesUpdate{
UpdateSerial: updateSerial,

View File

@@ -0,0 +1,8 @@
//go:build !((darwin && !ios) || dragonfly || freebsd || netbsd || openbsd)
package systemops
// FlushMarkedRoutes is a no-op on non-BSD platforms.
func (r *SysOps) FlushMarkedRoutes() error {
return nil
}

View File

@@ -13,11 +13,11 @@ func (s *ShutdownState) Name() string {
}
func (s *ShutdownState) Cleanup() error {
sysops := NewSysOps(nil, nil)
sysops.refCounter = refcounter.New[netip.Prefix, struct{}, Nexthop](nil, sysops.removeFromRouteTable)
sysops.refCounter.LoadData((*ExclusionCounter)(s))
sysOps := New(nil, nil)
sysOps.refCounter = refcounter.New[netip.Prefix, struct{}, Nexthop](nil, sysOps.removeFromRouteTable)
sysOps.refCounter.LoadData((*ExclusionCounter)(s))
return sysops.refCounter.Flush()
return sysOps.refCounter.Flush()
}
func (s *ShutdownState) MarshalJSON() ([]byte, error) {

View File

@@ -83,7 +83,7 @@ type SysOps struct {
localSubnetsCacheTime time.Time
}
func NewSysOps(wgInterface wgIface, notifier *notifier.Notifier) *SysOps {
func New(wgInterface wgIface, notifier *notifier.Notifier) *SysOps {
return &SysOps{
wgInterface: wgInterface,
notifier: notifier,

View File

@@ -42,7 +42,7 @@ func TestConcurrentRoutes(t *testing.T) {
_, intf = setupDummyInterface(t)
nexthop = Nexthop{netip.Addr{}, intf}
r := NewSysOps(nil, nil)
r := New(nil, nil)
var wg sync.WaitGroup
for i := 0; i < 1024; i++ {
@@ -146,7 +146,7 @@ func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR strin
nexthop := Nexthop{netip.Addr{}, netIntf}
r := NewSysOps(nil, nil)
r := New(nil, nil)
err = r.addToRouteTable(prefix, nexthop)
require.NoError(t, err, "Failed to add route to table")

View File

@@ -143,7 +143,7 @@ func TestAddVPNRoute(t *testing.T) {
wgInterface := createWGInterface(t, fmt.Sprintf("utun53%d", n), "100.65.75.2/24", 33100+n)
r := NewSysOps(wgInterface, nil)
r := New(wgInterface, nil)
advancedRouting := nbnet.AdvancedRouting()
err := r.SetupRouting(nil, nil, advancedRouting)
require.NoError(t, err)
@@ -342,7 +342,7 @@ func TestAddRouteToNonVPNIntf(t *testing.T) {
wgInterface := createWGInterface(t, fmt.Sprintf("utun54%d", n), "100.65.75.2/24", 33200+n)
r := NewSysOps(wgInterface, nil)
r := New(wgInterface, nil)
advancedRouting := nbnet.AdvancedRouting()
err := r.SetupRouting(nil, nil, advancedRouting)
require.NoError(t, err)
@@ -486,7 +486,7 @@ func setupTestEnv(t *testing.T) {
assert.NoError(t, wgInterface.Close())
})
r := NewSysOps(wgInterface, nil)
r := New(wgInterface, nil)
advancedRouting := nbnet.AdvancedRouting()
err := r.SetupRouting(nil, nil, advancedRouting)
require.NoError(t, err, "setupRouting should not return err")

View File

@@ -7,19 +7,39 @@ import (
"fmt"
"net"
"net/netip"
"os"
"strconv"
"syscall"
"time"
"unsafe"
"github.com/cenkalti/backoff/v4"
"github.com/hashicorp/go-multierror"
log "github.com/sirupsen/logrus"
"golang.org/x/net/route"
"golang.org/x/sys/unix"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/internal/statemanager"
)
const (
envRouteProtoFlag = "NB_ROUTE_PROTO_FLAG"
)
var routeProtoFlag int
func init() {
switch os.Getenv(envRouteProtoFlag) {
case "2":
routeProtoFlag = unix.RTF_PROTO2
case "3":
routeProtoFlag = unix.RTF_PROTO3
default:
routeProtoFlag = unix.RTF_PROTO1
}
}
func (r *SysOps) SetupRouting(initAddresses []net.IP, stateManager *statemanager.Manager, advancedRouting bool) error {
return r.setupRefCounter(initAddresses, stateManager)
}
@@ -28,12 +48,88 @@ func (r *SysOps) CleanupRouting(stateManager *statemanager.Manager, advancedRout
return r.cleanupRefCounter(stateManager)
}
// FlushMarkedRoutes removes single IP exclusion routes marked with the configured RTF_PROTO flag.
func (r *SysOps) FlushMarkedRoutes() error {
rib, err := retryFetchRIB()
if err != nil {
return fmt.Errorf("fetch routing table: %w", err)
}
msgs, err := route.ParseRIB(route.RIBTypeRoute, rib)
if err != nil {
return fmt.Errorf("parse routing table: %w", err)
}
var merr *multierror.Error
flushedCount := 0
for _, msg := range msgs {
rtMsg, ok := msg.(*route.RouteMessage)
if !ok {
continue
}
if rtMsg.Flags&routeProtoFlag == 0 {
continue
}
routeInfo, err := MsgToRoute(rtMsg)
if err != nil {
log.Debugf("Skipping route flush: %v", err)
continue
}
if !routeInfo.Dst.IsValid() || !routeInfo.Dst.IsSingleIP() {
continue
}
nexthop := Nexthop{
IP: routeInfo.Gw,
Intf: routeInfo.Interface,
}
if err := r.removeFromRouteTable(routeInfo.Dst, nexthop); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove route %s: %w", routeInfo.Dst, err))
continue
}
flushedCount++
log.Debugf("Flushed marked route: %s", routeInfo.Dst)
}
if flushedCount > 0 {
log.Infof("Flushed %d residual NetBird routes from previous session", flushedCount)
}
return nberrors.FormatErrorOrNil(merr)
}
func (r *SysOps) addToRouteTable(prefix netip.Prefix, nexthop Nexthop) error {
if prefix.IsSingleIP() {
log.Debugf("Adding single IP route: %s via %s", prefix, formatNexthop(nexthop))
}
return r.routeSocket(unix.RTM_ADD, prefix, nexthop)
}
func (r *SysOps) removeFromRouteTable(prefix netip.Prefix, nexthop Nexthop) error {
return r.routeSocket(unix.RTM_DELETE, prefix, nexthop)
if prefix.IsSingleIP() {
log.Debugf("Removing single IP route: %s via %s", prefix, formatNexthop(nexthop))
}
if err := r.routeSocket(unix.RTM_DELETE, prefix, nexthop); err != nil {
return err
}
if prefix.IsSingleIP() {
log.Debugf("Route removal completed for %s, verifying...", prefix)
if exists := r.verifyRouteRemoved(prefix); exists {
log.Warnf("Route %s still exists in routing table after removal", prefix)
} else {
log.Debugf("Verified route %s successfully removed", prefix)
}
}
return nil
}
func (r *SysOps) routeSocket(action int, prefix netip.Prefix, nexthop Nexthop) error {
@@ -105,7 +201,7 @@ func (r *SysOps) routeOp(action int, prefix netip.Prefix, nexthop Nexthop) func(
func (r *SysOps) buildRouteMessage(action int, prefix netip.Prefix, nexthop Nexthop) (msg *route.RouteMessage, err error) {
msg = &route.RouteMessage{
Type: action,
Flags: unix.RTF_UP,
Flags: unix.RTF_UP | routeProtoFlag,
Version: unix.RTM_VERSION,
Seq: r.getSeq(),
}
@@ -200,3 +296,51 @@ func prefixToRouteNetmask(prefix netip.Prefix) (route.Addr, error) {
return nil, fmt.Errorf("unknown IP version in prefix: %s", prefix.Addr().String())
}
// formatNexthop returns a string representation of the nexthop for logging.
func formatNexthop(nexthop Nexthop) string {
if nexthop.IP.IsValid() {
return nexthop.IP.String()
}
if nexthop.Intf != nil {
return nexthop.Intf.Name
}
return "direct"
}
// verifyRouteRemoved checks if a route still exists in the routing table.
func (r *SysOps) verifyRouteRemoved(prefix netip.Prefix) bool {
rib, err := retryFetchRIB()
if err != nil {
log.Debugf("Failed to fetch RIB for route verification: %v", err)
return false
}
msgs, err := route.ParseRIB(route.RIBTypeRoute, rib)
if err != nil {
log.Debugf("Failed to parse RIB for route verification: %v", err)
return false
}
for _, msg := range msgs {
rtMsg, ok := msg.(*route.RouteMessage)
if !ok {
continue
}
if rtMsg.Flags&routeProtoFlag == 0 {
continue
}
routeInfo, err := MsgToRoute(rtMsg)
if err != nil {
continue
}
if routeInfo.Dst == prefix {
return true
}
}
return false
}

View File

@@ -295,7 +295,7 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage,
data, err := os.ReadFile(m.filePath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
log.Debug("state file does not exist")
log.Debugf("state file %s does not exist", m.filePath)
return nil, nil // nolint:nilnil
}
return nil, fmt.Errorf("read state file: %w", err)

View File

@@ -17,8 +17,7 @@ type Conn struct {
ID hooks.ConnectionID
}
// Close overrides the net.Conn Close method to execute all registered hooks after closing the connection
// Close overrides the net.Conn Close method to execute all registered hooks before closing the connection.
// Close overrides the net.Conn Close method to execute all registered hooks after closing the connection.
func (c *Conn) Close() error {
return closeConn(c.ID, c.Conn)
}
@@ -29,7 +28,7 @@ type TCPConn struct {
ID hooks.ConnectionID
}
// Close overrides the net.TCPConn Close method to execute all registered hooks before closing the connection.
// Close overrides the net.TCPConn Close method to execute all registered hooks after closing the connection.
func (c *TCPConn) Close() error {
return closeConn(c.ID, c.TCPConn)
}
@@ -37,13 +36,16 @@ func (c *TCPConn) Close() error {
// closeConn is a helper function to close connections and execute close hooks.
func closeConn(id hooks.ConnectionID, conn io.Closer) error {
err := conn.Close()
cleanupConnID(id)
return err
}
// cleanupConnID executes close hooks for a connection ID.
func cleanupConnID(id hooks.ConnectionID) {
closeHooks := hooks.GetCloseHooks()
for _, hook := range closeHooks {
if err := hook(id); err != nil {
log.Errorf("Error executing close hook: %v", err)
}
}
return err
}

View File

@@ -74,7 +74,6 @@ func DialTCP(network string, laddr, raddr *net.TCPAddr) (transport.TCPConn, erro
}
return &TCPConn{TCPConn: tcpConn, ID: c.ID}, nil
}
if err := conn.Close(); err != nil {
log.Errorf("failed to close connection: %v", err)
}

View File

@@ -30,6 +30,7 @@ func (d *Dialer) DialContext(ctx context.Context, network, address string) (net.
conn, err := d.Dialer.DialContext(ctx, network, address)
if err != nil {
cleanupConnID(connID)
return nil, fmt.Errorf("d.Dialer.DialContext: %w", err)
}
@@ -64,7 +65,7 @@ func callDialerHooks(ctx context.Context, connID hooks.ConnectionID, address str
ips, err := resolver.LookupIPAddr(ctx, host)
if err != nil {
return fmt.Errorf("failed to resolve address %s: %w", address, err)
return fmt.Errorf("resolve address %s: %w", address, err)
}
log.Debugf("Dialer resolved IPs for %s: %v", address, ips)

View File

@@ -48,7 +48,7 @@ func (c *PacketConn) WriteTo(b []byte, addr net.Addr) (n int, err error) {
return c.PacketConn.WriteTo(b, addr)
}
// Close overrides the net.PacketConn Close method to execute all registered hooks before closing the connection.
// Close overrides the net.PacketConn Close method to execute all registered hooks after closing the connection.
func (c *PacketConn) Close() error {
defer c.seenAddrs.Clear()
return closeConn(c.ID, c.PacketConn)
@@ -69,7 +69,7 @@ func (c *UDPConn) WriteTo(b []byte, addr net.Addr) (n int, err error) {
return c.UDPConn.WriteTo(b, addr)
}
// Close overrides the net.UDPConn Close method to execute all registered hooks before closing the connection.
// Close overrides the net.UDPConn Close method to execute all registered hooks after closing the connection.
func (c *UDPConn) Close() error {
defer c.seenAddrs.Clear()
return closeConn(c.ID, c.UDPConn)

View File

@@ -1,29 +0,0 @@
# Dependencies
node_modules/
package-lock.json
# Build outputs
dist/
release/
*.tsbuildinfo
# Editor
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="6" d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/></svg>

Before

Width:  |  Height:  |  Size: 392 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 20v-9m2-4a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4zm.12-3.12L16 2"/><path d="M21 21a4 4 0 0 0-3.81-4M21 5a4 4 0 0 1-3.55 3.97M22 13h-4M3 21a4 4 0 0 1 3.81-4M3 5a4 4 0 0 0 3.55 3.97M6 13H2M8 2l1.88 1.88M9 7.13V6a3 3 0 1 1 6 0v1.13"/></g></svg>

Before

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73zm1 .27V12"/><path d="M3.29 7L12 12l8.71-5M7.5 4.27l9 5.15"/></g></svg>

Before

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"><path d="M12 20v-9m2-4a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4zm.12-3.12L16 2"/><path d="M21 21a4 4 0 0 0-3.81-4M21 5a4 4 0 0 1-3.55 3.97M22 13h-4M3 21a4 4 0 0 1 3.81-4M3 5a4 4 0 0 0 3.55 3.97M6 13H2M8 2l1.88 1.88M9 7.13V6a3 3 0 1 1 6 0v1.13"/></g></svg>

Before

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"><path d="M12 20v-9m2-4a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4zm.12-3.12L16 2"/><path d="M21 21a4 4 0 0 0-3.81-4M21 5a4 4 0 0 1-3.55 3.97M22 13h-4M3 21a4 4 0 0 1 3.81-4M3 5a4 4 0 0 0 3.55 3.97M6 13H2M8 2l1.88 1.88M9 7.13V6a3 3 0 1 1 6 0v1.13"/></g></svg>

Before

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="6" d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/></svg>

Before

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m10 17l5-5l-5-5m5 5H3m12-9h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/></svg>

Before

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4m0-4h.01"/></g></svg>

Before

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="6" height="6" x="16" y="16" rx="1"/><rect width="6" height="6" x="2" y="16" rx="1"/><rect width="6" height="6" x="9" y="2" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3m-7-4V8"/></g></svg>

Before

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m16 16l2 2l4-4"/><path d="M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14M7.5 4.27l9 5.15"/><path d="M3.29 7L12 12l8.71-5M12 22V12"/></g></svg>

Before

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 2v10m6.4-5.4a9 9 0 1 1-12.77.04"/></svg>

Before

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 555 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.36 6.64A9 9 0 0 1 20.77 15M6.16 6.16a9 9 0 1 0 12.68 12.68M12 2v4M2 2l20 20"/></svg>

Before

Width:  |  Height:  |  Size: 273 B

View File

@@ -1,14 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2v10" />
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
</svg>

Before

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 581 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></g></svg>

Before

Width:  |  Height:  |  Size: 312 B

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="16.000000pt" height="16.000000pt" viewBox="0 0 16.000000 16.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<g transform="translate(0.000000,16.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M0 80 l0 -80 80 0 80 0 0 80 0 80 -80 0 -80 0 0 -80z m70 60 c0 -5
-9 -10 -20 -10 -17 0 -20 -7 -20 -50 0 -43 3 -50 20 -50 11 0 20 -4 20 -10 0
-5 -13 -10 -30 -10 l-30 0 0 70 0 70 30 0 c17 0 30 -4 30 -10z m65 -40 c17
-19 17 -21 0 -40 -21 -24 -40 -26 -31 -5 4 11 -2 15 -24 15 -17 0 -30 5 -30
10 0 6 13 10 30 10 22 0 28 4 24 15 -9 21 10 19 31 -5z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 869 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>

Before

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 0 1 9-9a9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5m5 4a9 9 0 0 1-9 9a9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></g></svg>

Before

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0a2.34 2.34 0 0 0 3.319 1.915a2.34 2.34 0 0 1 2.33 4.033a2.34 2.34 0 0 0 0 3.831a2.34 2.34 0 0 1-2.33 4.033a2.34 2.34 0 0 0-3.319 1.915a2.34 2.34 0 0 1-4.659 0a2.34 2.34 0 0 0-3.32-1.915a2.34 2.34 0 0 1-2.33-4.033a2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></g></svg>

Before

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="#ffffff"/></g></svg>

Before

Width:  |  Height:  |  Size: 398 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/></svg>

Before

Width:  |  Height:  |  Size: 392 B

View File

@@ -1,385 +0,0 @@
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const path = require('path');
const os = require('os');
const { app } = require('electron');
class DaemonClient {
constructor(address) {
this.address = address;
// Path to proto file - use resourcesPath for packaged app, or relative path for dev
const isPackaged = app && app.isPackaged;
this.protoPath = isPackaged
? path.join(process.resourcesPath, 'proto/daemon.proto')
: path.join(__dirname, '../../proto/daemon.proto');
this.client = null;
this.initializeClient();
}
initializeClient() {
try {
const packageDefinition = protoLoader.loadSync(this.protoPath, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
const DaemonService = protoDescriptor.daemon.DaemonService;
// Create client with Unix socket or TCP
const credentials = grpc.credentials.createInsecure();
this.client = new DaemonService(this.address, credentials);
console.log(`gRPC client initialized with address: ${this.address}`);
} catch (error) {
console.error('Failed to initialize gRPC client:', error);
}
}
promisifyCall(method, request = {}) {
return new Promise((resolve, reject) => {
if (!this.client) {
reject(new Error('gRPC client not initialized'));
return;
}
try {
this.client[method](request, (error, response) => {
if (error) {
const enhancedError = {
...error,
method,
message: error.message || 'Unknown gRPC error',
code: error.code,
};
reject(enhancedError);
} else {
resolve(response);
}
});
} catch (error) {
console.error(`gRPC call ${method} failed synchronously:`, error);
reject({
method,
message: error.message,
code: error.code || 'UNKNOWN',
originalError: error,
});
}
});
}
async getStatus() {
try {
const response = await this.promisifyCall('Status', {});
return {
status: response.status || 'Unknown',
version: response.daemonVersion || '0.0.0'
};
} catch (error) {
console.error('getStatus error:', error);
return {
status: 'Error',
version: '0.0.0'
};
}
}
async login() {
try {
const response = await this.promisifyCall('Login', {});
return {
needsSSOLogin: response.needsSSOLogin || false,
userCode: response.userCode || '',
verificationURI: response.verificationURI || '',
verificationURIComplete: response.verificationURIComplete || ''
};
} catch (error) {
console.error('login error:', error);
throw error;
}
}
async waitSSOLogin(userCode) {
try {
const hostname = os.hostname();
const response = await this.promisifyCall('WaitSSOLogin', {
userCode,
hostname
});
return {
email: response.email || ''
};
} catch (error) {
console.error('waitSSOLogin error:', error);
throw error;
}
}
async up() {
await this.promisifyCall('Up', {});
}
async down() {
await this.promisifyCall('Down', {});
}
async getConfig() {
try {
const username = os.userInfo().username;
// Get active profile name
const profiles = await this.listProfiles();
const activeProfile = profiles.find(p => p.active);
const profileName = activeProfile?.name || 'default';
const response = await this.promisifyCall('GetConfig', { username, profileName });
return {
managementUrl: response.managementUrl || '',
preSharedKey: response.preSharedKey || '',
interfaceName: response.interfaceName || '',
wireguardPort: response.wireguardPort || 51820,
mtu: response.mtu || 1280,
serverSSHAllowed: response.serverSSHAllowed || false,
autoConnect: !response.disableAutoConnect, // Invert the daemon's disableAutoConnect
rosenpassEnabled: response.rosenpassEnabled || false,
rosenpassPermissive: response.rosenpassPermissive || false,
lazyConnectionEnabled: response.lazyConnectionEnabled || false,
blockInbound: response.blockInbound || false,
networkMonitor: response.networkMonitor || false,
disableDns: response.disable_dns || false,
disableClientRoutes: response.disable_client_routes || false,
disableServerRoutes: response.disable_server_routes || false,
blockLanAccess: response.block_lan_access || false,
};
} catch (error) {
console.error('getConfig error:', error);
// Return default config on error
return {
managementUrl: '',
preSharedKey: '',
interfaceName: 'wt0',
wireguardPort: 51820,
mtu: 1280,
serverSSHAllowed: false,
autoConnect: false,
rosenpassEnabled: false,
rosenpassPermissive: false,
lazyConnectionEnabled: false,
blockInbound: false,
networkMonitor: true,
disableDns: false,
disableClientRoutes: false,
disableServerRoutes: false,
blockLanAccess: false,
};
}
}
async updateConfig(config) {
try {
const username = os.userInfo().username;
// Get active profile name
const profiles = await this.listProfiles();
const activeProfile = profiles.find(p => p.active);
const profileName = activeProfile?.name || 'default';
// Build the SetConfigRequest with proper field names matching proto
const request = {
username,
profileName,
};
// Map config fields to proto field names (snake_case for gRPC)
if (config.managementUrl !== undefined) request.managementUrl = config.managementUrl;
if (config.interfaceName !== undefined) request.interfaceName = config.interfaceName;
if (config.wireguardPort !== undefined) request.wireguardPort = config.wireguardPort;
if (config.preSharedKey !== undefined) request.optionalPreSharedKey = config.preSharedKey;
if (config.mtu !== undefined) request.mtu = config.mtu;
if (config.serverSSHAllowed !== undefined) request.serverSSHAllowed = config.serverSSHAllowed;
if (config.autoConnect !== undefined) request.disableAutoConnect = !config.autoConnect; // Invert for daemon
if (config.rosenpassEnabled !== undefined) request.rosenpassEnabled = config.rosenpassEnabled;
if (config.rosenpassPermissive !== undefined) request.rosenpassPermissive = config.rosenpassPermissive;
if (config.lazyConnectionEnabled !== undefined) request.lazyConnectionEnabled = config.lazyConnectionEnabled;
if (config.blockInbound !== undefined) request.block_inbound = config.blockInbound;
if (config.networkMonitor !== undefined) request.networkMonitor = config.networkMonitor;
if (config.disableDns !== undefined) request.disable_dns = config.disableDns;
if (config.disableClientRoutes !== undefined) request.disable_client_routes = config.disableClientRoutes;
if (config.disableServerRoutes !== undefined) request.disable_server_routes = config.disableServerRoutes;
if (config.blockLanAccess !== undefined) request.block_lan_access = config.blockLanAccess;
await this.promisifyCall('SetConfig', request);
} catch (error) {
console.error('updateConfig error:', error);
throw error;
}
}
async listProfiles() {
try {
const username = os.userInfo().username;
const response = await this.promisifyCall('ListProfiles', { username });
console.log('Raw gRPC response profiles:', JSON.stringify(response.profiles, null, 2));
const mapped = (response.profiles || []).map((profile) => ({
id: profile.id || profile.name, // Use name as id if id is not provided
name: profile.name,
email: profile.email,
active: profile.is_active || false, // gRPC uses snake_case: is_active
}));
console.log('Mapped profiles:', JSON.stringify(mapped, null, 2));
return mapped;
} catch (error) {
console.error('listProfiles error:', error);
// Return empty array on error instead of throwing
if (error.code === 'EPIPE' || error.code === 'ECONNREFUSED') {
console.warn('gRPC connection lost, returning empty profiles list');
}
return [];
}
}
async switchProfile(profileName) {
try {
console.log('gRPC client: switchProfile called with profileName:', profileName);
const username = os.userInfo().username;
const result = await this.promisifyCall('SwitchProfile', { profileName, username });
console.log('gRPC client: switchProfile result:', result);
return result;
} catch (error) {
console.error('switchProfile error:', error);
throw error;
}
}
async addProfile(profileName) {
try {
const username = os.userInfo().username;
await this.promisifyCall('AddProfile', { username, profileName });
} catch (error) {
console.error('addProfile error:', error);
throw error;
}
}
async removeProfile(profileName) {
try {
const username = os.userInfo().username;
await this.promisifyCall('RemoveProfile', { username, profileName });
} catch (error) {
console.error('removeProfile error:', error);
throw error;
}
}
async logout() {
try {
await this.promisifyCall('Logout', {});
} catch (error) {
console.error('logout error:', error);
throw error;
}
}
async createDebugBundle(anonymize = true) {
try {
const response = await this.promisifyCall('DebugBundle', {
anonymize,
systemInfo: true,
status: '',
logFileCount: 5
});
return response.path || '';
} catch (error) {
console.error('createDebugBundle error:', error);
throw error;
}
}
async getPeers() {
try {
console.log('[getPeers] Calling Status RPC with getFullPeerStatus: true');
const response = await this.promisifyCall('Status', {
getFullPeerStatus: true,
shouldRunProbes: false,
});
console.log('[getPeers] Status response:', JSON.stringify({
status: response.status,
hasFullStatus: !!response.fullStatus,
peersCount: response.fullStatus?.peers?.length || 0
}));
// Extract peers from fullStatus
const peers = response.fullStatus?.peers || [];
console.log(`[getPeers] Found ${peers.length} peers`);
// Map the peers to the format expected by the UI
const mapped = peers.map(peer => ({
ip: peer.IP || '',
pubKey: peer.pubKey || '',
connStatus: peer.connStatus || 'Disconnected',
connStatusUpdate: peer.connStatusUpdate ? new Date(peer.connStatusUpdate.seconds * 1000).toISOString() : '',
relayed: peer.relayed || false,
localIceCandidateType: peer.localIceCandidateType || '',
remoteIceCandidateType: peer.remoteIceCandidateType || '',
fqdn: peer.fqdn || '',
localIceCandidateEndpoint: peer.localIceCandidateEndpoint || '',
remoteIceCandidateEndpoint: peer.remoteIceCandidateEndpoint || '',
lastWireguardHandshake: peer.lastWireguardHandshake ? new Date(peer.lastWireguardHandshake.seconds * 1000).toISOString() : '',
bytesRx: peer.bytesRx || 0,
bytesTx: peer.bytesTx || 0,
rosenpassEnabled: peer.rosenpassEnabled || false,
networks: peer.networks || [],
latency: peer.latency ? (peer.latency.seconds * 1000 + peer.latency.nanos / 1000000) : 0,
relayAddress: peer.relayAddress || '',
}));
console.log('[getPeers] Returning mapped peers:', JSON.stringify(mapped.map(p => ({ ip: p.ip, fqdn: p.fqdn, connStatus: p.connStatus }))));
return mapped;
} catch (error) {
console.error('getPeers error:', error);
return [];
}
}
async getLocalPeer() {
try {
const response = await this.promisifyCall('Status', {
getFullPeerStatus: true,
shouldRunProbes: false,
});
const localPeer = response.fullStatus?.localPeerState;
if (!localPeer) {
console.log('[getLocalPeer] No local peer state found');
return null;
}
const mapped = {
ip: localPeer.IP || '',
pubKey: localPeer.pubKey || '',
fqdn: localPeer.fqdn || '',
kernelInterface: localPeer.kernelInterface || false,
rosenpassEnabled: localPeer.rosenpassEnabled || false,
rosenpassPermissive: localPeer.rosenpassPermissive || false,
networks: localPeer.networks || [],
};
console.log('[getLocalPeer] Local peer:', JSON.stringify({ ip: mapped.ip, fqdn: mapped.fqdn }));
return mapped;
} catch (error) {
console.error('getLocalPeer error:', error);
return null;
}
}
}
module.exports = { DaemonClient };

View File

@@ -1,683 +0,0 @@
const { app, BrowserWindow, ipcMain, Tray, Menu, screen, shell, dialog } = require('electron');
const path = require('path');
const { exec } = require('child_process');
const util = require('util');
const { DaemonClient } = require('./grpc-client.cjs');
const execPromise = util.promisify(exec);
// Daemon address - Unix socket on Linux/BSD/macOS, TCP on Windows
const DAEMON_ADDR = process.platform === 'win32'
? 'localhost:41731'
: 'unix:///var/run/netbird.sock';
let mainWindow = null;
let tray = null;
let daemonClient = null;
let daemonVersion = '0.0.0';
// Parse command line arguments for expert mode
const expertMode = process.argv.includes('--expert-mode') || process.argv.includes('--expert');
function createWindow() {
mainWindow = new BrowserWindow({
width: 520,
height: 800,
resizable: false,
title: 'NetBird',
backgroundColor: '#1a1a1a',
autoHideMenuBar: true,
frame: true,
show: false, // Don't show initially
skipTaskbar: true, // Hide from taskbar
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
nodeIntegration: false,
contextIsolation: true,
},
});
// Load the app
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools(); // Temporarily enabled for debugging
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
// Hide window when it loses focus
mainWindow.on('blur', () => {
if (!mainWindow.webContents.isDevToolsOpened()) {
mainWindow.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
let connectionState = 'disconnected'; // 'disconnected', 'connecting', 'connected', 'disconnecting'
let pulseState = false; // For pulsating animation
let pulseInterval = null;
function createTray() {
const iconPath = path.join(__dirname, 'assets', 'netbird-systemtray-disconnected-white-monochrome.png');
tray = new Tray(iconPath);
updateTrayMenu();
tray.setToolTip('NetBird - Disconnected');
tray.on('click', () => {
toggleWindow();
});
}
function getStatusLabel() {
let indicator = '⚪'; // Gray circle
let statusText = 'Disconnected';
switch (connectionState) {
case 'disconnected':
indicator = '⚪';
statusText = 'Disconnected';
break;
case 'connecting':
indicator = pulseState ? '🟢' : '⚪';
statusText = 'Connecting...';
break;
case 'connected':
indicator = '🟢';
statusText = 'Connected';
break;
case 'disconnecting':
indicator = pulseState ? '🟢' : '⚪';
statusText = 'Disconnecting...';
break;
}
return `${indicator} ${statusText}`;
}
function startPulseAnimation() {
if (pulseInterval) {
clearInterval(pulseInterval);
}
pulseInterval = setInterval(() => {
pulseState = !pulseState;
updateTrayMenu();
}, 500); // Pulse every 500ms
}
function stopPulseAnimation() {
if (pulseInterval) {
clearInterval(pulseInterval);
pulseInterval = null;
}
pulseState = false;
}
function setConnectionState(state) {
connectionState = state;
// Start/stop pulse animation based on state
if (state === 'connecting' || state === 'disconnecting') {
startPulseAnimation();
} else {
stopPulseAnimation();
}
updateTrayMenu();
updateTrayIcon();
}
async function updateTrayMenu() {
// Fetch version from daemon
try {
const statusInfo = await daemonClient.getStatus();
if (statusInfo.version) {
daemonVersion = statusInfo.version;
}
} catch (error) {
console.error('Failed to get version:', error);
}
const connectDisconnectIcon = connectionState === 'connected' || connectionState === 'disconnecting'
? path.join(__dirname, 'assets', 'power-off-icon.png')
: path.join(__dirname, 'assets', 'power-icon.png');
const connectDisconnectLabel = connectionState === 'connected' || connectionState === 'disconnecting'
? 'Disconnect'
: 'Connect';
const menuTemplate = [
{
label: getStatusLabel(),
enabled: false
},
{ type: 'separator' },
{
label: connectDisconnectLabel,
icon: connectDisconnectIcon,
enabled: connectionState === 'disconnected' || connectionState === 'connected',
click: async () => {
if (connectionState === 'connected') {
setConnectionState('disconnecting');
try {
await daemonClient.down();
setConnectionState('disconnected');
} catch (error) {
console.error('Disconnect error:', error);
setConnectionState('connected');
}
} else if (connectionState === 'disconnected') {
setConnectionState('connecting');
try {
// Step 1: Call login to check if SSO is needed
console.log('[Tray] Calling login...');
const loginResp = await daemonClient.login();
console.log('[Tray] Login response:', loginResp);
// Step 2: If SSO login is needed, open browser and wait
if (loginResp.needsSSOLogin) {
console.log('[Tray] SSO login required, opening browser...');
// Open the verification URL in the default browser
if (loginResp.verificationURIComplete) {
await shell.openExternal(loginResp.verificationURIComplete);
console.log('[Tray] Opened URL:', loginResp.verificationURIComplete);
}
// Wait for user to complete login in browser
console.log('[Tray] Waiting for SSO login completion...');
const waitResp = await daemonClient.waitSSOLogin(loginResp.userCode);
console.log('[Tray] SSO login completed, email:', waitResp.email);
}
// Step 3: Call Up to connect
console.log('[Tray] Calling Up to connect...');
await daemonClient.up();
console.log('[Tray] Connected successfully');
setConnectionState('connected');
} catch (error) {
console.error('Connect error:', error);
setConnectionState('disconnected');
}
}
}
},
{ type: 'separator' },
{
label: 'Show',
icon: path.join(__dirname, 'assets', 'netbird-systemtray-disconnected-white-monochrome.png'),
click: () => {
showWindow();
}
}
];
// Add expert mode menu items
if (expertMode) {
menuTemplate.push({ type: 'separator' });
// Profiles submenu - load from daemon
let profiles = [];
try {
profiles = await daemonClient.listProfiles();
} catch (error) {
console.error('Failed to load profiles:', error);
}
const profilesSubmenu = profiles.map(profile => ({
label: profile.email ? `${profile.name} (${profile.email})` : profile.name,
type: 'radio',
checked: profile.active,
click: async () => {
try {
await daemonClient.switchProfile(profile.name);
updateTrayMenu(); // Refresh menu after profile switch
} catch (error) {
console.error('Failed to switch profile:', error);
}
}
}));
profilesSubmenu.push({ type: 'separator' });
profilesSubmenu.push({
label: 'Add New Profile...',
click: () => {
console.log('Add new profile - TODO: implement dialog');
// TODO: Show dialog to add new profile
}
});
menuTemplate.push({
label: 'Profiles',
icon: path.join(__dirname, 'assets', 'profiles-icon.png'),
submenu: profilesSubmenu
});
// Settings submenu - load from daemon
let config = {};
try {
config = await daemonClient.getConfig();
} catch (error) {
console.error('Failed to load config:', error);
// Use defaults if loading fails
config = {
autoConnect: false,
networkMonitor: true,
disableDns: false,
blockLanAccess: false,
};
}
menuTemplate.push({
label: 'Settings',
icon: path.join(__dirname, 'assets', 'settings-icon.png'),
submenu: [
{
label: 'Auto Connect',
type: 'checkbox',
checked: config.autoConnect || false,
click: async (menuItem) => {
console.log('Auto Connect:', menuItem.checked);
try {
await daemonClient.updateConfig({ autoConnect: menuItem.checked });
} catch (error) {
console.error('Failed to update autoConnect:', error);
}
}
},
{
label: 'Network Monitor',
type: 'checkbox',
checked: config.networkMonitor !== undefined ? config.networkMonitor : true,
click: async (menuItem) => {
console.log('Network Monitor:', menuItem.checked);
try {
await daemonClient.updateConfig({ networkMonitor: menuItem.checked });
} catch (error) {
console.error('Failed to update networkMonitor:', error);
}
}
},
{
label: 'Disable DNS',
type: 'checkbox',
checked: config.disableDns || false,
click: async (menuItem) => {
console.log('Disable DNS:', menuItem.checked);
try {
await daemonClient.updateConfig({ disableDns: menuItem.checked });
} catch (error) {
console.error('Failed to update disableDns:', error);
}
}
},
{
label: 'Block LAN Access',
type: 'checkbox',
checked: config.blockLanAccess || false,
click: async (menuItem) => {
console.log('Block LAN Access:', menuItem.checked);
try {
await daemonClient.updateConfig({ blockLanAccess: menuItem.checked });
} catch (error) {
console.error('Failed to update blockLanAccess:', error);
}
}
}
]
});
// Networks button
menuTemplate.push({
label: 'Networks',
icon: path.join(__dirname, 'assets', 'networks-icon.png'),
click: () => {
showWindow('networks');
}
});
// Exit Nodes button
menuTemplate.push({
label: 'Exit Nodes',
icon: path.join(__dirname, 'assets', 'exit-node-icon.png'),
click: () => {
showWindow('networks'); // Assuming exit nodes is part of networks tab
}
});
}
// Add Debug (available in both modes)
menuTemplate.push({ type: 'separator' });
menuTemplate.push({
label: 'Debug',
icon: path.join(__dirname, 'assets', 'debug-icon.png'),
click: () => {
showWindow('debug');
}
});
// Add About and Quit
menuTemplate.push({ type: 'separator' });
menuTemplate.push({
label: 'About',
icon: path.join(__dirname, 'assets', 'info-icon.png'),
submenu: [
{
label: `Version: ${daemonVersion}`,
icon: path.join(__dirname, 'assets', 'version-icon.png'),
enabled: false
},
{
label: 'Check for Updates',
icon: path.join(__dirname, 'assets', 'refresh-icon.png'),
click: () => {
// TODO: Implement update check
console.log('Checking for updates...');
}
}
]
});
menuTemplate.push({ type: 'separator' });
menuTemplate.push({
label: 'Quit',
icon: path.join(__dirname, 'assets', 'quit-icon.png'),
click: () => {
app.quit();
}
});
const contextMenu = Menu.buildFromTemplate(menuTemplate);
tray.setContextMenu(contextMenu);
}
function updateTrayIcon() {
let iconName = 'netbird-systemtray-disconnected-white-monochrome.png';
let tooltip = 'NetBird - Disconnected';
switch (connectionState) {
case 'disconnected':
iconName = 'netbird-systemtray-disconnected-white-monochrome.png';
tooltip = 'NetBird - Disconnected';
break;
case 'connecting':
iconName = 'netbird-systemtray-connecting-white-monochrome.png';
tooltip = 'NetBird - Connecting...';
break;
case 'connected':
iconName = 'netbird-systemtray-connected-white-monochrome.png';
tooltip = 'NetBird - Connected';
break;
case 'disconnecting':
iconName = 'netbird-systemtray-connecting-white-monochrome.png';
tooltip = 'NetBird - Disconnecting...';
break;
}
const iconPath = path.join(__dirname, 'assets', iconName);
tray.setImage(iconPath);
tray.setToolTip(tooltip);
}
async function syncConnectionState() {
try {
const statusInfo = await daemonClient.getStatus();
const daemonStatus = statusInfo.status || 'Disconnected';
// Map daemon status to our connection state
let newState = 'disconnected';
if (daemonStatus === 'Connected') {
newState = 'connected';
} else if (daemonStatus === 'Connecting') {
newState = 'connecting';
} else {
newState = 'disconnected';
}
// Only update if state changed to avoid unnecessary menu rebuilds
if (newState !== connectionState) {
console.log(`[Tray] Connection state changed: ${connectionState} -> ${newState}`);
setConnectionState(newState);
}
} catch (error) {
console.error('[Tray] Failed to sync connection state:', error);
// On error, assume disconnected
if (connectionState !== 'disconnected') {
setConnectionState('disconnected');
}
}
}
function toggleWindow() {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
showWindow();
}
}
function showWindow(page) {
const windowBounds = mainWindow.getBounds();
const trayBounds = tray.getBounds();
// Calculate position (center horizontally under tray icon)
const x = Math.round(trayBounds.x + (trayBounds.width / 2) - (windowBounds.width / 2));
const y = Math.round(trayBounds.y + trayBounds.height + 4);
mainWindow.setPosition(x, y, false);
mainWindow.show();
mainWindow.focus();
// Send page navigation message to renderer if page is specified
if (page) {
mainWindow.webContents.send('navigate-to-page', page);
}
}
app.whenReady().then(async () => {
// Initialize gRPC client
daemonClient = new DaemonClient(DAEMON_ADDR);
createWindow();
createTray();
// Initialize connection state from daemon
await syncConnectionState();
// Poll daemon status every 3 seconds to keep tray updated
setInterval(async () => {
await syncConnectionState();
}, 3000);
});
app.on('window-all-closed', (e) => {
// Prevent app from quitting - tray app should stay running
e.preventDefault();
});
// IPC Handlers for NetBird daemon communication via gRPC
ipcMain.handle('netbird:connect', async () => {
try {
// Check if already connected
const status = await daemonClient.getStatus();
if (status.status === 'Connected') {
console.log('Already connected');
return { success: true };
}
// Step 1: Call login to check if SSO is needed
console.log('Calling login...');
const loginResp = await daemonClient.login();
console.log('Login response:', loginResp);
// Step 2: If SSO login is needed, open browser and wait
if (loginResp.needsSSOLogin) {
console.log('SSO login required, opening browser...');
// Open the verification URL in the default browser
if (loginResp.verificationURIComplete) {
const { shell } = require('electron');
await shell.openExternal(loginResp.verificationURIComplete);
console.log('Opened URL:', loginResp.verificationURIComplete);
}
// Wait for user to complete login in browser
console.log('Waiting for SSO login completion...');
const waitResp = await daemonClient.waitSSOLogin(loginResp.userCode);
console.log('SSO login completed, email:', waitResp.email);
}
// Step 3: Call Up to connect
console.log('Calling Up to connect...');
await daemonClient.up();
console.log('Connected successfully');
return { success: true };
} catch (error) {
console.error('Connection error:', error);
throw new Error(error.message || 'Failed to connect');
}
});
ipcMain.handle('netbird:disconnect', async () => {
try {
await daemonClient.down();
return { success: true };
} catch (error) {
throw new Error(error.message);
}
});
ipcMain.handle('netbird:logout', async () => {
try {
await daemonClient.logout();
return { success: true };
} catch (error) {
throw new Error(error.message);
}
});
ipcMain.handle('netbird:status', async () => {
try {
const statusInfo = await daemonClient.getStatus();
return {
status: statusInfo.status,
version: statusInfo.version,
daemon: 'Connected'
};
} catch (error) {
return {
status: 'Disconnected',
version: '0.0.0',
daemon: 'Disconnected'
};
}
});
ipcMain.handle('netbird:get-config', async () => {
try {
return await daemonClient.getConfig();
} catch (error) {
throw new Error(error.message);
}
});
ipcMain.handle('netbird:update-config', async (event, config) => {
try {
await daemonClient.updateConfig(config);
return { success: true };
} catch (error) {
throw new Error(error.message);
}
});
ipcMain.handle('netbird:get-networks', async () => {
try {
// TODO: Implement networks retrieval via gRPC
return [];
} catch (error) {
return [];
}
});
ipcMain.handle('netbird:toggle-network', async (event, networkId) => {
try {
// TODO: Implement network toggle via gRPC
return { success: true };
} catch (error) {
throw new Error(error.message);
}
});
ipcMain.handle('netbird:get-profiles', async () => {
try {
return await daemonClient.listProfiles();
} catch (error) {
console.error('get-profiles error:', error);
return [];
}
});
ipcMain.handle('netbird:switch-profile', async (event, profileId) => {
try {
await daemonClient.switchProfile(profileId);
return { success: true };
} catch (error) {
throw new Error(error.message);
}
});
ipcMain.handle('netbird:delete-profile', async (event, profileId) => {
try {
await daemonClient.removeProfile(profileId);
return { success: true };
} catch (error) {
throw new Error(error.message);
}
});
ipcMain.handle('netbird:add-profile', async (event, name) => {
try {
await daemonClient.addProfile(name);
return { success: true };
} catch (error) {
throw new Error(error.message);
}
});
ipcMain.handle('netbird:remove-profile', async (event, profileId) => {
try {
await daemonClient.removeProfile(profileId);
return { success: true };
} catch (error) {
throw new Error(error.message);
}
});
ipcMain.handle('netbird:get-peers', async () => {
try {
return await daemonClient.getPeers();
} catch (error) {
console.error('get-peers error:', error);
return [];
}
});
ipcMain.handle('netbird:get-local-peer', async () => {
try {
return await daemonClient.getLocalPeer();
} catch (error) {
console.error('get-local-peer error:', error);
return null;
}
});
ipcMain.handle('netbird:get-expert-mode', async () => {
return expertMode;
});

View File

@@ -1,21 +0,0 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
connect: () => ipcRenderer.invoke('netbird:connect'),
disconnect: () => ipcRenderer.invoke('netbird:disconnect'),
logout: () => ipcRenderer.invoke('netbird:logout'),
getStatus: () => ipcRenderer.invoke('netbird:status'),
getConfig: () => ipcRenderer.invoke('netbird:get-config'),
updateConfig: (config) => ipcRenderer.invoke('netbird:update-config', config),
getNetworks: () => ipcRenderer.invoke('netbird:get-networks'),
toggleNetwork: (networkId) => ipcRenderer.invoke('netbird:toggle-network', networkId),
getProfiles: () => ipcRenderer.invoke('netbird:get-profiles'),
switchProfile: (profileId) => ipcRenderer.invoke('netbird:switch-profile', profileId),
deleteProfile: (profileId) => ipcRenderer.invoke('netbird:delete-profile', profileId),
addProfile: (name) => ipcRenderer.invoke('netbird:add-profile', name),
removeProfile: (profileId) => ipcRenderer.invoke('netbird:remove-profile', profileId),
getPeers: () => ipcRenderer.invoke('netbird:get-peers'),
getLocalPeer: () => ipcRenderer.invoke('netbird:get-local-peer'),
getExpertMode: () => ipcRenderer.invoke('netbird:get-expert-mode'),
onNavigateToPage: (callback) => ipcRenderer.on('navigate-to-page', (event, page) => callback(page)),
});

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/netbird-full.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetBird</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,77 +0,0 @@
{
"name": "netbird-electron",
"version": "1.0.0",
"description": "NetBird Desktop Client",
"type": "module",
"main": "electron/main.cjs",
"homepage": "https://netbird.io",
"author": {
"name": "NetBird",
"email": "hello@netbird.io"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"electron:build": "vite build && electron-builder"
},
"build": {
"appId": "io.netbird.client",
"productName": "NetBird",
"directories": {
"buildResources": "assets",
"output": "dist"
},
"files": [
"dist/**/*",
"electron/**/*",
"package.json"
],
"extraResources": [
{
"from": "../proto",
"to": "proto",
"filter": ["**/*"]
}
],
"linux": {
"target": [
"AppImage",
"deb"
],
"category": "Network",
"maintainer": "NetBird <hello@netbird.io>"
},
"mac": {
"target": [
"zip"
],
"category": "public.app-category.utilities"
}
},
"dependencies": {
"@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0",
"framer-motion": "^11.0.0",
"lottie-react": "^2.4.1",
"lucide-react": "^0.263.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.11.2",
"zustand": "^4.3.8"
},
"devDependencies": {
"@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.14",
"concurrently": "^8.0.1",
"electron": "^25.0.1",
"electron-builder": "^24.4.0",
"postcss": "^8.4.23",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.4",
"vite": "^4.3.9",
"wait-on": "^7.0.1"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,570 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import Lottie, { LottieRefCurrentProps } from 'lottie-react';
import {
Settings, Network, Users, Bug, UserCircle,
Home, Copy, Check, ChevronDown, Route
} from 'lucide-react';
import { useStore } from './store/useStore';
import Overview from './pages/Overview';
import SettingsPage from './pages/Settings';
import Networks from './pages/Networks';
import Profiles from './pages/Profiles';
import Peers from './pages/Peers';
import Debug from './pages/Debug';
import animationData from './assets/button-full.json';
import netbirdLogo from './assets/netbird-full.svg';
type Page = 'overview' | 'settings' | 'networks' | 'profiles' | 'debug' | 'peers';
export default function App() {
const [currentPage, setCurrentPage] = useState<Page>('overview');
const [copiedIp, setCopiedIp] = useState(false);
const [copiedFqdn, setCopiedFqdn] = useState(false);
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
const expertMode = useStore((state) => state.expertMode);
const lottieRef = useRef<LottieRefCurrentProps>(null);
const profileDropdownRef = useRef<HTMLDivElement>(null);
const connected = useStore((state) => state.connected);
const profiles = useStore((state) => state.profiles);
const activeProfile = useStore((state) => state.activeProfile);
const switchProfile = useStore((state) => state.switchProfile);
useEffect(() => {
// Always start on overview page
setCurrentPage('overview');
// Initialize app
useStore.getState().refreshStatus();
useStore.getState().refreshConfig();
useStore.getState().refreshExpertMode();
useStore.getState().refreshPeers();
useStore.getState().refreshLocalPeer();
useStore.getState().refreshProfiles();
// Set up periodic status refresh
const interval = setInterval(() => {
useStore.getState().refreshStatus();
if (useStore.getState().connected) {
useStore.getState().refreshPeers();
useStore.getState().refreshLocalPeer();
}
}, 3000);
// Listen for navigation messages from tray
if (window.electronAPI?.onNavigateToPage) {
window.electronAPI.onNavigateToPage((page: string) => {
console.log('Navigation request from tray:', page);
setCurrentPage(page as Page);
});
}
return () => {
clearInterval(interval);
};
}, []);
// Handle animation based on connection state
useEffect(() => {
if (lottieRef.current) {
if (connected) {
// Play connect animation (frames 0-142)
lottieRef.current.goToAndPlay(0, true);
lottieRef.current.setSpeed(1.5);
} else {
// Play disconnect animation (frames 143-339) or stay at disconnected state
if (lottieRef.current.currentFrame > 142) {
// Already in disconnected state
lottieRef.current.goToAndStop(339, true);
} else {
// Play disconnect animation
lottieRef.current.goToAndPlay(143, true);
lottieRef.current.setSpeed(1.5);
}
}
}
}, [connected]);
// Handle click outside profile dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (profileDropdownRef.current && !profileDropdownRef.current.contains(event.target as Node)) {
setProfileDropdownOpen(false);
}
};
if (profileDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [profileDropdownOpen]);
const navItems = [
{ id: 'overview' as Page, icon: Home, label: 'Overview' },
{ id: 'peers' as Page, icon: Users, label: 'Peers' },
{ id: 'networks' as Page, icon: Network, label: 'Networks' },
{ id: 'profiles' as Page, icon: UserCircle, label: 'Profiles' },
{ id: 'settings' as Page, icon: Settings, label: 'Settings' },
{ id: 'debug' as Page, icon: Bug, label: 'Debug' },
];
const renderPage = () => {
switch (currentPage) {
case 'overview':
return <Overview onNavigate={setCurrentPage} />;
case 'settings':
return <SettingsPage />;
case 'networks':
return <Networks />;
case 'profiles':
return <Profiles />;
case 'peers':
return <Peers />;
case 'debug':
return <Debug onBack={() => setCurrentPage('overview')} />;
default:
return <Overview onNavigate={setCurrentPage} />;
}
};
const status = useStore((state) => state.status);
const loading = useStore((state) => state.loading);
const version = useStore((state) => state.version);
const connect = useStore((state) => state.connect);
const disconnect = useStore((state) => state.disconnect);
const handleClick = () => {
if (loading) return;
if (connected) {
disconnect();
} else {
connect();
}
};
// Clean, user-friendly UI with connect button as centerpiece
const peers = useStore((state) => state.peers);
const connectedPeersCount = peers.filter(p => p.connStatus === 'Connected').length;
const localPeer = useStore((state) => state.localPeer);
const handleDebugClick = () => {
setCurrentPage('debug');
};
const handlePeersClick = () => {
setCurrentPage('peers');
};
const handleCopyIp = async () => {
if (localPeer?.ip) {
await navigator.clipboard.writeText(localPeer.ip);
setCopiedIp(true);
setTimeout(() => setCopiedIp(false), 2000);
}
};
const handleCopyFqdn = async () => {
if (localPeer?.fqdn) {
await navigator.clipboard.writeText(localPeer.fqdn);
setCopiedFqdn(true);
setTimeout(() => setCopiedFqdn(false), 2000);
}
};
// If debug page is active, render it
if (currentPage === 'debug') {
return (
<div className="flex items-center justify-center h-screen bg-gray-bg overflow-hidden">
<Debug onBack={() => setCurrentPage('overview')} />
</div>
);
}
// If peers page is active, render it
if (currentPage === 'peers') {
return (
<div className="flex items-center justify-center h-screen bg-gray-bg overflow-hidden">
<Peers onBack={() => setCurrentPage('overview')} />
</div>
);
}
// Bottom Navigation Bar Component
const BottomNav = () => {
if (!expertMode) return null;
return (
<div className="absolute bottom-0 left-0 right-0 h-16 nb-frosted border-t border-nb-orange/20 flex items-center justify-around px-4 backdrop-blur-md z-50">
<button
onClick={() => setCurrentPage('overview')}
className={`flex flex-col items-center gap-1 px-4 py-2 rounded-lg transition-all ${
currentPage === 'overview'
? 'text-nb-orange bg-nb-orange/10'
: 'text-text-muted hover:text-nb-orange hover:bg-nb-orange/5'
}`}
>
<Home className="w-5 h-5" />
<span className="text-xs font-medium">Home</span>
</button>
<button
onClick={() => setCurrentPage('networks')}
className={`flex flex-col items-center gap-1 px-4 py-2 rounded-lg transition-all ${
currentPage === 'networks'
? 'text-nb-orange bg-nb-orange/10'
: 'text-text-muted hover:text-nb-orange hover:bg-nb-orange/5'
}`}
>
<Network className="w-5 h-5" />
<span className="text-xs font-medium">Networks</span>
</button>
<button
onClick={() => setCurrentPage('settings')}
className={`flex flex-col items-center gap-1 px-4 py-2 rounded-lg transition-all ${
currentPage === 'settings'
? 'text-nb-orange bg-nb-orange/10'
: 'text-text-muted hover:text-nb-orange hover:bg-nb-orange/5'
}`}
>
<Settings className="w-5 h-5" />
<span className="text-xs font-medium">Settings</span>
</button>
</div>
);
};
// If profiles page is active, render it
if (currentPage === 'profiles') {
return (
<div className="h-screen w-screen bg-gray-bg overflow-hidden relative flex flex-col">
<div className="flex-1 overflow-auto pb-20">
<Profiles onBack={() => setCurrentPage('overview')} />
</div>
<BottomNav />
</div>
);
}
// If settings page is active, render it
if (currentPage === 'settings') {
return (
<div className="h-screen w-screen bg-gray-bg overflow-hidden relative flex flex-col">
<div className="flex-1 overflow-auto pb-20">
<SettingsPage onBack={() => setCurrentPage('overview')} />
</div>
<BottomNav />
</div>
);
}
// If networks page is active, render it
if (currentPage === 'networks') {
return (
<div className="h-screen w-screen bg-gray-bg overflow-hidden relative flex flex-col">
<div className="flex-1 overflow-auto pb-20">
<Networks onBack={() => setCurrentPage('overview')} />
</div>
<BottomNav />
</div>
);
}
// Otherwise render main overview UI
return (
<div className="h-screen w-screen bg-gray-bg overflow-hidden relative flex flex-col">
{/* Main Content - Scrollable */}
<div className="flex-1 overflow-auto pb-20">
{/* Main Content Container */}
<div className="p-4 w-full min-h-full flex flex-col">
{/* Main scrollable content */}
<div className="flex-1 space-y-6">
{/* NetBird Logo */}
<div className="flex justify-center">
<img
src={netbirdLogo}
alt="NetBird"
className="h-12 w-auto opacity-90"
/>
</div>
{/* Connection Status Badge */}
<div className="flex justify-center">
<motion.div
animate={{
scale: connected ? [1, 1.05, 1] : 1,
}}
transition={{ duration: 2, repeat: connected ? Infinity : 0 }}
className={`px-6 py-2 rounded-full text-lg font-bold transition-all ${
connected
? 'bg-nb-orange/20 text-nb-orange nb-border-strong orange-pulse'
: loading
? 'bg-nb-orange/10 text-nb-orange border border-nb-orange/30'
: 'bg-gray-bg-card text-text-muted border border-nb-orange/20'
}`}
>
{status}
</motion.div>
</div>
{/* Main Lottie Animation Button - Centerpiece */}
<div className="flex justify-center py-4">
<button
onClick={handleClick}
disabled={loading}
className={`relative transition-all duration-300 ${
loading ? 'opacity-80 cursor-wait' : 'hover:scale-105 active:scale-95 cursor-pointer'
}`}
style={{ width: '240px', height: '240px' }}
title={connected ? 'Click to disconnect' : 'Click to connect'}
>
<Lottie
lottieRef={lottieRef}
animationData={animationData}
loop={false}
autoplay={false}
style={{
width: '100%',
height: '100%',
filter: 'brightness(0) saturate(100%) invert(57%) sepia(98%) saturate(2548%) hue-rotate(345deg) brightness(101%) contrast(94%)',
}}
rendererSettings={{
preserveAspectRatio: 'xMidYMid meet',
clearCanvas: false,
}}
/>
</button>
</div>
{/* Profile Dropdown - Expert Mode Only */}
{expertMode && activeProfile && (
<div ref={profileDropdownRef} className="relative flex justify-center mb-4">
<button
onClick={() => setProfileDropdownOpen(!profileDropdownOpen)}
className="flex items-center gap-2 px-4 py-2 nb-frosted rounded-lg hover:bg-nb-orange/10 transition-all"
>
<UserCircle className="w-4 h-4 text-nb-orange" />
<span className="text-sm font-medium text-text-light">
{activeProfile.name}
{activeProfile.email && (
<span className="text-text-muted ml-1">({activeProfile.email})</span>
)}
</span>
<ChevronDown className={`w-4 h-4 text-text-muted transition-transform ${profileDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown Menu */}
{profileDropdownOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute top-full mt-2 w-64 nb-card rounded-lg shadow-lg z-50 overflow-hidden flex flex-col"
>
<div className="overflow-y-auto max-h-40">
{profiles.map((profile) => (
<button
key={profile.id}
onClick={() => {
switchProfile(profile.id);
setProfileDropdownOpen(false);
}}
className={`w-full flex items-center gap-3 px-4 py-3 hover:bg-nb-orange/10 transition-colors ${
profile.active ? 'bg-nb-orange/5' : ''
}`}
>
<UserCircle className={`w-4 h-4 ${profile.active ? 'text-nb-orange' : 'text-text-muted'}`} />
<div className="flex-1 text-left">
<div className={`text-sm font-medium ${profile.active ? 'text-nb-orange' : 'text-text-light'}`}>
{profile.name}
</div>
{profile.email && (
<div className="text-xs text-text-muted">({profile.email})</div>
)}
</div>
{profile.active && (
<Check className="w-4 h-4 text-nb-orange" />
)}
</button>
))}
</div>
{/* Divider */}
<div className="border-t border-nb-orange/20" />
{/* Manage Profiles Button */}
<button
onClick={() => {
setCurrentPage('profiles');
setProfileDropdownOpen(false);
}}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-nb-orange/10 transition-colors"
>
<Settings className="w-4 h-4 text-text-muted" />
<div className="flex-1 text-left">
<div className="text-sm font-medium text-text-light">
Manage Profiles
</div>
</div>
</button>
</motion.div>
)}
</div>
)}
{/* Connection Info - Only when connected */}
{connected && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-3 border-t border-nb-orange/20 pt-4"
>
{/* Local Peer Info */}
{localPeer && (
<div className="p-3 nb-frosted rounded-lg">
<div className="text-center">
<div className="text-xs text-text-muted uppercase mb-1">Your NetBird IP</div>
<div className="flex items-center justify-center gap-2">
<div className="text-lg font-semibold text-nb-orange">{localPeer.ip}</div>
<button
onClick={handleCopyIp}
className="p-1 hover:bg-nb-orange/10 rounded transition-colors"
title="Copy IP"
>
{copiedIp ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-text-muted hover:text-nb-orange" />
)}
</button>
</div>
{localPeer.fqdn && (
<div className="flex items-center justify-center gap-2 mt-1">
<div className="text-xs text-text-muted">{localPeer.fqdn}</div>
<button
onClick={handleCopyFqdn}
className="p-1 hover:bg-nb-orange/10 rounded transition-colors"
title="Copy FQDN"
>
{copiedFqdn ? (
<Check className="w-3 h-3 text-green-500" />
) : (
<Copy className="w-3 h-3 text-text-muted hover:text-nb-orange" />
)}
</button>
</div>
)}
</div>
</div>
)}
{/* Connected Peers Counter */}
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handlePeersClick}
className="w-full flex items-center justify-center gap-3 p-3 nb-frosted rounded-lg hover:bg-nb-orange/10 transition-all cursor-pointer"
>
<Users className="w-5 h-5 text-nb-orange" />
<span className="text-lg font-semibold">
<span className="text-nb-orange">{connectedPeersCount}</span>
<span className="text-text-muted"> / {peers.length}</span>
<span className="text-sm text-text-muted ml-2">peers</span>
</span>
</motion.button>
</motion.div>
)}
{/* Helpful hint when disconnected */}
{!connected && !loading && (
<div className="text-center text-sm text-text-muted">
Click the button to establish secure connection
</div>
)}
</div>
{/* Version Info - Bottom, subtle - Fixed at bottom */}
<div className="flex items-center justify-center gap-3 pt-2 mt-4 border-t border-nb-orange/10">
<div className="text-center text-xs text-text-muted/60">
NetBird v{version}
</div>
<button
onClick={handleDebugClick}
className="p-1 hover:bg-nb-orange/10 rounded transition-colors"
title="Debug Tools"
>
<Bug className="w-3 h-3 text-text-muted/40 hover:text-nb-orange/60" />
</button>
</div>
</div>
</div>
<BottomNav />
{/* DISABLED UI - Keeping code for future use */}
{false && (
<>
{/* Sidebar */}
<motion.div
initial={{ x: -300 }}
animate={{ x: 0 }}
className="w-64 nb-sidebar flex flex-col"
>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-2">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = currentPage === item.id;
return (
<motion.button
key={item.id}
whileHover={{ x: 4 }}
whileTap={{ scale: 0.98 }}
onClick={() => setCurrentPage(item.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all nb-nav-item ${
isActive
? 'nb-nav-active'
: 'text-text-muted hover:text-text-light border-l-3 border-transparent'
}`}
>
<Icon className={`w-5 h-5 ${isActive ? 'text-nb-orange' : ''}`} />
<span className="font-medium">{item.label}</span>
</motion.button>
);
})}
</nav>
{/* Footer */}
<div className="p-4 border-t border-nb-orange/20">
<div className="text-xs text-text-muted text-center">
NetBird Client v1.0.0
</div>
{expertMode && (
<div className="mt-2 px-2 py-1 bg-nb-orange/20 border border-nb-orange/40 rounded text-xs text-nb-orange text-center font-semibold">
EXPERT MODE
</div>
)}
</div>
</motion.div>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
<motion.div
key={currentPage}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
className="flex-1 overflow-auto"
>
{renderPage()}
</motion.div>
</div>
</>
)}
</div>
);
}

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