Compare commits
24 Commits
prototype/
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2d7121695 | ||
|
|
3288c4414f | ||
|
|
f3b0439211 | ||
|
|
ae801d77fb | ||
|
|
4545ab9a52 | ||
|
|
7f08983207 | ||
|
|
eddea14521 | ||
|
|
b9ef214ea5 | ||
|
|
709e24eb6f | ||
|
|
bf83549db2 | ||
|
|
804a3871fe | ||
|
|
6654e2dbf7 | ||
|
|
64d1edce27 | ||
|
|
bf0698e5aa | ||
|
|
fc15625963 | ||
|
|
a75dde33b9 | ||
|
|
d80d47a469 | ||
|
|
bb46e438aa | ||
|
|
11ba253ffb | ||
|
|
14fe7c29cb | ||
|
|
158f3aceff | ||
|
|
bfa776c155 | ||
|
|
885b5c68ad | ||
|
|
b1ebac795d |
1
.gitignore
vendored
@@ -31,4 +31,3 @@ infrastructure_files/setup-*.env
|
||||
.DS_Store
|
||||
vendor/
|
||||
/netbird
|
||||
client/ui/ui
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
111
client/internal/dns/host_darwin_test.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build (darwin && !ios) || dragonfly || freebsd || netbsd || openbsd
|
||||
//go:build dragonfly || freebsd || netbsd || openbsd
|
||||
|
||||
package networkmonitor
|
||||
|
||||
|
||||
344
client/internal/networkmonitor/check_change_darwin.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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{} {
|
||||
|
||||
@@ -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,
|
||||
|
||||
8
client/internal/routemanager/systemops/flush_nonbsd.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
29
client/netbird-electron/.gitignore
vendored
@@ -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*
|
||||
|
Before Width: | Height: | Size: 504 B |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 319 B |
@@ -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 |
|
Before Width: | Height: | Size: 319 B |
|
Before Width: | Height: | Size: 319 B |
@@ -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 |
|
Before Width: | Height: | Size: 319 B |
@@ -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 |
|
Before Width: | Height: | Size: 563 B |
@@ -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 |
|
Before Width: | Height: | Size: 456 B |
@@ -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 |
|
Before Width: | Height: | Size: 539 B |
@@ -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 |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 530 B |
@@ -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 |
|
Before Width: | Height: | Size: 319 B |
@@ -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 |
|
Before Width: | Height: | Size: 535 B |
@@ -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 |
|
Before Width: | Height: | Size: 555 B |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 581 B |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 461 B |
@@ -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 |
|
Before Width: | Height: | Size: 530 B |
@@ -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 |
|
Before Width: | Height: | Size: 563 B |
@@ -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 |
|
Before Width: | Height: | Size: 319 B |
|
Before Width: | Height: | Size: 319 B |
|
Before Width: | Height: | Size: 490 B |
@@ -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 |
|
Before Width: | Height: | Size: 487 B |
@@ -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 |
@@ -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 };
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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)),
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||