From 407e9d304b839c934fa7b30f42d01d7777651f9e Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:09:55 +0900 Subject: [PATCH 01/80] [client] Move macOS sleep detection into the daemon (purego) (#5926) --- client/internal/sleep/detector_darwin.go | 435 ++++++--- client/proto/daemon.pb.go | 1131 ++++++++++------------ client/proto/daemon.proto | 16 - client/proto/daemon_grpc.pb.go | 438 ++++----- client/server/server.go | 1 + client/server/sleep.go | 75 +- client/ui/client_ui.go | 60 -- go.mod | 2 +- 8 files changed, 1066 insertions(+), 1092 deletions(-) diff --git a/client/internal/sleep/detector_darwin.go b/client/internal/sleep/detector_darwin.go index 3d6747ed1..ef495bded 100644 --- a/client/internal/sleep/detector_darwin.go +++ b/client/internal/sleep/detector_darwin.go @@ -2,217 +2,358 @@ package sleep -/* -#cgo LDFLAGS: -framework IOKit -framework CoreFoundation -#include -#include -#include - -extern void sleepCallbackBridge(); -extern void poweredOnCallbackBridge(); -extern void suspendedCallbackBridge(); -extern void resumedCallbackBridge(); - - -// C global variables for IOKit state -static IONotificationPortRef g_notifyPortRef = NULL; -static io_object_t g_notifierObject = 0; -static io_object_t g_generalInterestNotifier = 0; -static io_connect_t g_rootPort = 0; -static CFRunLoopRef g_runLoop = NULL; - -static void sleepCallback(void* refCon, io_service_t service, natural_t messageType, void* messageArgument) { - switch (messageType) { - case kIOMessageSystemWillSleep: - sleepCallbackBridge(); - IOAllowPowerChange(g_rootPort, (long)messageArgument); - break; - case kIOMessageSystemHasPoweredOn: - poweredOnCallbackBridge(); - break; - case kIOMessageServiceIsSuspended: - suspendedCallbackBridge(); - break; - case kIOMessageServiceIsResumed: - resumedCallbackBridge(); - break; - default: - break; - } -} - -static void registerNotifications() { - g_rootPort = IORegisterForSystemPower( - NULL, - &g_notifyPortRef, - (IOServiceInterestCallback)sleepCallback, - &g_notifierObject - ); - - if (g_rootPort == 0) { - return; - } - - CFRunLoopAddSource(CFRunLoopGetCurrent(), - IONotificationPortGetRunLoopSource(g_notifyPortRef), - kCFRunLoopCommonModes); - - g_runLoop = CFRunLoopGetCurrent(); - CFRunLoopRun(); -} - -static void unregisterNotifications() { - CFRunLoopRemoveSource(g_runLoop, - IONotificationPortGetRunLoopSource(g_notifyPortRef), - kCFRunLoopCommonModes); - - IODeregisterForSystemPower(&g_notifierObject); - IOServiceClose(g_rootPort); - IONotificationPortDestroy(g_notifyPortRef); - CFRunLoopStop(g_runLoop); - - g_notifyPortRef = NULL; - g_notifierObject = 0; - g_rootPort = 0; - g_runLoop = NULL; -} - -*/ -import "C" - import ( - "context" "fmt" "runtime" "sync" "time" + "unsafe" + "github.com/ebitengine/purego" log "github.com/sirupsen/logrus" ) -var ( - serviceRegistry = make(map[*Detector]struct{}) - serviceRegistryMu sync.Mutex +// IOKit message types from IOKit/IOMessage.h. +const ( + kIOMessageCanSystemSleep uintptr = 0xe0000270 + kIOMessageSystemWillSleep uintptr = 0xe0000280 + kIOMessageSystemHasPoweredOn uintptr = 0xe0000300 ) -//export sleepCallbackBridge -func sleepCallbackBridge() { - log.Info("sleepCallbackBridge event triggered") +var ( + ioKit iokitFuncs + cf cfFuncs + cfCommonModes uintptr - serviceRegistryMu.Lock() - defer serviceRegistryMu.Unlock() + libInitOnce sync.Once + libInitErr error - for svc := range serviceRegistry { - svc.triggerCallback(EventTypeSleep) - } + // callbackThunk is the single C-callable trampoline registered with IOKit. + callbackThunk uintptr + + serviceRegistry = make(map[*Detector]struct{}) + serviceRegistryMu sync.Mutex + session *runLoopSession + + // lifecycleMu serializes Register/Deregister so a new registration can't + // start a second runloop while a previous teardown is still pending. + lifecycleMu sync.Mutex +) + +// iokitFuncs holds IOKit symbols resolved once at init. +type iokitFuncs struct { + IORegisterForSystemPower func(refcon uintptr, portRef *uintptr, callback uintptr, notifier *uintptr) uintptr + IODeregisterForSystemPower func(notifier *uintptr) int32 + IOAllowPowerChange func(kernelPort uintptr, notificationID uintptr) int32 + IOServiceClose func(connect uintptr) int32 + IONotificationPortGetRunLoopSource func(port uintptr) uintptr + IONotificationPortDestroy func(port uintptr) } -//export resumedCallbackBridge -func resumedCallbackBridge() { - log.Info("resumedCallbackBridge event triggered") +// cfFuncs holds CoreFoundation symbols resolved once at init. +type cfFuncs struct { + CFRunLoopGetCurrent func() uintptr + CFRunLoopRun func() + CFRunLoopStop func(rl uintptr) + CFRunLoopAddSource func(rl, source, mode uintptr) + CFRunLoopRemoveSource func(rl, source, mode uintptr) } -//export suspendedCallbackBridge -func suspendedCallbackBridge() { - log.Info("suspendedCallbackBridge event triggered") +// runLoopSession bundles the handles owned by one CFRunLoop lifetime. A nil +// session means no runloop is active and the next Register must start one. +type runLoopSession struct { + rl uintptr + port uintptr + notifier uintptr + rp uintptr } -//export poweredOnCallbackBridge -func poweredOnCallbackBridge() { - log.Info("poweredOnCallbackBridge event triggered") - serviceRegistryMu.Lock() - defer serviceRegistryMu.Unlock() - - for svc := range serviceRegistry { - svc.triggerCallback(EventTypeWakeUp) - } +// detectorSnapshot pins a detector's callback and done channel so dispatch +// runs with values valid at snapshot time, even if a concurrent +// Deregister/Register rewrites the detector's fields. +type detectorSnapshot struct { + detector *Detector + callback func(event EventType) + done <-chan struct{} } +// Detector delivers sleep and wake events to a registered callback. type Detector struct { callback func(event EventType) - ctx context.Context - cancel context.CancelFunc -} - -func NewDetector() (*Detector, error) { - return &Detector{}, nil + done chan struct{} } +// Register installs callback for power events. The first registration starts +// the CFRunLoop on a dedicated OS-locked thread and blocks until IOKit +// registration succeeds or fails; subsequent registrations just add to the +// dispatch set. func (d *Detector) Register(callback func(event EventType)) error { - serviceRegistryMu.Lock() - defer serviceRegistryMu.Unlock() + lifecycleMu.Lock() + defer lifecycleMu.Unlock() + serviceRegistryMu.Lock() if _, exists := serviceRegistry[d]; exists { + serviceRegistryMu.Unlock() return fmt.Errorf("detector service already registered") } - d.callback = callback + d.done = make(chan struct{}) + serviceRegistry[d] = struct{}{} + needSetup := session == nil + serviceRegistryMu.Unlock() - d.ctx, d.cancel = context.WithCancel(context.Background()) - - if len(serviceRegistry) > 0 { - serviceRegistry[d] = struct{}{} + if !needSetup { return nil } - serviceRegistry[d] = struct{}{} - - // CFRunLoop must run on a single fixed OS thread - go func() { - runtime.LockOSThread() - defer runtime.UnlockOSThread() - - C.registerNotifications() - }() + errCh := make(chan error, 1) + go runRunLoop(errCh) + if err := <-errCh; err != nil { + serviceRegistryMu.Lock() + delete(serviceRegistry, d) + close(d.done) + d.done = nil + serviceRegistryMu.Unlock() + return err + } log.Info("sleep detection service started on macOS") return nil } -// Deregister removes the detector. When the last detector is removed, IOKit registration is torn down -// and the runloop is stopped and cleaned up. +// Deregister removes the detector. When the last detector leaves, IOKit +// notifications are torn down and the runloop is stopped. func (d *Detector) Deregister() error { + lifecycleMu.Lock() + defer lifecycleMu.Unlock() + serviceRegistryMu.Lock() - defer serviceRegistryMu.Unlock() - _, exists := serviceRegistry[d] - if !exists { + if _, exists := serviceRegistry[d]; !exists { + serviceRegistryMu.Unlock() return nil } - - // cancel and remove this detector - d.cancel() + close(d.done) delete(serviceRegistry, d) - // If other Detectors still exist, leave IOKit running if len(serviceRegistry) > 0 { + serviceRegistryMu.Unlock() return nil } + sess := session + serviceRegistryMu.Unlock() log.Info("sleep detection service stopping (deregister)") - // Deregister IOKit notifications, stop runloop, and free resources - C.unregisterNotifications() + if sess == nil { + return nil + } + + if sess.rl != 0 && sess.port != 0 { + source := ioKit.IONotificationPortGetRunLoopSource(sess.port) + cf.CFRunLoopRemoveSource(sess.rl, source, cfCommonModes) + } + if sess.notifier != 0 { + n := sess.notifier + ioKit.IODeregisterForSystemPower(&n) + } + + // Clear session only after IODeregisterForSystemPower returns so any + // in-flight powerCallback can still look up session.rp to ack sleep. + serviceRegistryMu.Lock() + session = nil + serviceRegistryMu.Unlock() + + if sess.rp != 0 { + ioKit.IOServiceClose(sess.rp) + } + if sess.port != 0 { + ioKit.IONotificationPortDestroy(sess.port) + } + if sess.rl != 0 { + cf.CFRunLoopStop(sess.rl) + } return nil } -func (d *Detector) triggerCallback(event EventType) { - doneChan := make(chan struct{}) +func (d *Detector) triggerCallback(event EventType, cb func(event EventType), done <-chan struct{}) { + if cb == nil || done == nil { + return + } + select { + case <-done: + return + default: + } + + doneChan := make(chan struct{}) timeout := time.NewTimer(500 * time.Millisecond) defer timeout.Stop() - cb := d.callback - go func(callback func(event EventType)) { + go func() { + defer close(doneChan) + defer func() { + if r := recover(); r != nil { + log.Errorf("panic in sleep callback: %v", r) + } + }() log.Info("sleep detection event fired") - callback(event) - close(doneChan) - }(cb) + cb(event) + }() select { case <-doneChan: - case <-d.ctx.Done(): + case <-done: case <-timeout.C: - log.Warnf("sleep callback timed out") + log.Warn("sleep callback timed out") } } + +// NewDetector initializes IOKit/CoreFoundation bindings and returns a Detector. +func NewDetector() (*Detector, error) { + if err := initLibs(); err != nil { + return nil, err + } + return &Detector{}, nil +} + +func initLibs() error { + libInitOnce.Do(func() { + iokit, err := purego.Dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + libInitErr = fmt.Errorf("dlopen IOKit: %w", err) + return + } + cfLib, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + libInitErr = fmt.Errorf("dlopen CoreFoundation: %w", err) + return + } + + purego.RegisterLibFunc(&ioKit.IORegisterForSystemPower, iokit, "IORegisterForSystemPower") + purego.RegisterLibFunc(&ioKit.IODeregisterForSystemPower, iokit, "IODeregisterForSystemPower") + purego.RegisterLibFunc(&ioKit.IOAllowPowerChange, iokit, "IOAllowPowerChange") + purego.RegisterLibFunc(&ioKit.IOServiceClose, iokit, "IOServiceClose") + purego.RegisterLibFunc(&ioKit.IONotificationPortGetRunLoopSource, iokit, "IONotificationPortGetRunLoopSource") + purego.RegisterLibFunc(&ioKit.IONotificationPortDestroy, iokit, "IONotificationPortDestroy") + + purego.RegisterLibFunc(&cf.CFRunLoopGetCurrent, cfLib, "CFRunLoopGetCurrent") + purego.RegisterLibFunc(&cf.CFRunLoopRun, cfLib, "CFRunLoopRun") + purego.RegisterLibFunc(&cf.CFRunLoopStop, cfLib, "CFRunLoopStop") + purego.RegisterLibFunc(&cf.CFRunLoopAddSource, cfLib, "CFRunLoopAddSource") + purego.RegisterLibFunc(&cf.CFRunLoopRemoveSource, cfLib, "CFRunLoopRemoveSource") + + modeAddr, err := purego.Dlsym(cfLib, "kCFRunLoopCommonModes") + if err != nil { + libInitErr = fmt.Errorf("dlsym kCFRunLoopCommonModes: %w", err) + return + } + // Launder the uintptr-to-pointer conversion through a Go variable so + // go vet's unsafeptr analyzer doesn't flag a system-library global. + cfCommonModes = **(**uintptr)(unsafe.Pointer(&modeAddr)) + + // NewCallback slots are a finite, non-reclaimable resource, so register + // a single thunk that dispatches to the current Detector set. + callbackThunk = purego.NewCallback(powerCallback) + }) + return libInitErr +} + +// powerCallback is the IOServiceInterestCallback trampoline, invoked on the +// runloop thread. A Go panic crossing the purego boundary has undefined +// behavior, so contain it here. +func powerCallback(refcon, service, messageType, messageArgument uintptr) uintptr { + defer func() { + if r := recover(); r != nil { + log.Errorf("panic in sleep powerCallback: %v", r) + } + }() + switch messageType { + case kIOMessageCanSystemSleep: + // Not acknowledging forces a 30s IOKit timeout before idle sleep. + allowPowerChange(messageArgument) + case kIOMessageSystemWillSleep: + dispatchEvent(EventTypeSleep) + allowPowerChange(messageArgument) + case kIOMessageSystemHasPoweredOn: + dispatchEvent(EventTypeWakeUp) + } + return 0 +} + +func allowPowerChange(messageArgument uintptr) { + serviceRegistryMu.Lock() + var port uintptr + if session != nil { + port = session.rp + } + serviceRegistryMu.Unlock() + if port != 0 { + ioKit.IOAllowPowerChange(port, messageArgument) + } +} + +func dispatchEvent(event EventType) { + serviceRegistryMu.Lock() + snaps := make([]detectorSnapshot, 0, len(serviceRegistry)) + for d := range serviceRegistry { + snaps = append(snaps, detectorSnapshot{ + detector: d, + callback: d.callback, + done: d.done, + }) + } + serviceRegistryMu.Unlock() + + for _, s := range snaps { + s.detector.triggerCallback(event, s.callback, s.done) + } +} + +// runRunLoop owns the OS-locked thread that CFRunLoop is pinned to. Setup +// result is reported on errCh so Register can surface failures synchronously. +func runRunLoop(errCh chan<- error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + sess, err := setupSession() + if err == nil { + serviceRegistryMu.Lock() + session = sess + serviceRegistryMu.Unlock() + } + errCh <- err + if err != nil { + return + } + + defer func() { + if r := recover(); r != nil { + log.Errorf("panic in sleep runloop: %v", r) + } + }() + cf.CFRunLoopRun() +} + +// setupSession performs the IOKit registration on the current thread. Panics +// are converted to errors so runRunLoop never leaves errCh unsent. +func setupSession() (s *runLoopSession, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic during runloop setup: %v", r) + } + }() + + var portRef, notifier uintptr + rp := ioKit.IORegisterForSystemPower(0, &portRef, callbackThunk, ¬ifier) + if rp == 0 { + return nil, fmt.Errorf("IORegisterForSystemPower returned zero") + } + + rl := cf.CFRunLoopGetCurrent() + source := ioKit.IONotificationPortGetRunLoopSource(portRef) + cf.CFRunLoopAddSource(rl, source, cfCommonModes) + + return &runLoopSession{rl: rl, port: portRef, notifier: notifier, rp: rp}, nil +} diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 6506307d3..31658d5a1 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -143,56 +143,6 @@ func (ExposeProtocol) EnumDescriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{1} } -// avoid collision with loglevel enum -type OSLifecycleRequest_CycleType int32 - -const ( - OSLifecycleRequest_UNKNOWN OSLifecycleRequest_CycleType = 0 - OSLifecycleRequest_SLEEP OSLifecycleRequest_CycleType = 1 - OSLifecycleRequest_WAKEUP OSLifecycleRequest_CycleType = 2 -) - -// Enum value maps for OSLifecycleRequest_CycleType. -var ( - OSLifecycleRequest_CycleType_name = map[int32]string{ - 0: "UNKNOWN", - 1: "SLEEP", - 2: "WAKEUP", - } - OSLifecycleRequest_CycleType_value = map[string]int32{ - "UNKNOWN": 0, - "SLEEP": 1, - "WAKEUP": 2, - } -) - -func (x OSLifecycleRequest_CycleType) Enum() *OSLifecycleRequest_CycleType { - p := new(OSLifecycleRequest_CycleType) - *p = x - return p -} - -func (x OSLifecycleRequest_CycleType) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (OSLifecycleRequest_CycleType) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[2].Descriptor() -} - -func (OSLifecycleRequest_CycleType) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[2] -} - -func (x OSLifecycleRequest_CycleType) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use OSLifecycleRequest_CycleType.Descriptor instead. -func (OSLifecycleRequest_CycleType) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{1, 0} -} - type SystemEvent_Severity int32 const ( @@ -229,11 +179,11 @@ func (x SystemEvent_Severity) String() string { } func (SystemEvent_Severity) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[3].Descriptor() + return file_daemon_proto_enumTypes[2].Descriptor() } func (SystemEvent_Severity) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[3] + return &file_daemon_proto_enumTypes[2] } func (x SystemEvent_Severity) Number() protoreflect.EnumNumber { @@ -242,7 +192,7 @@ func (x SystemEvent_Severity) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Severity.Descriptor instead. func (SystemEvent_Severity) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{53, 0} + return file_daemon_proto_rawDescGZIP(), []int{51, 0} } type SystemEvent_Category int32 @@ -284,11 +234,11 @@ func (x SystemEvent_Category) String() string { } func (SystemEvent_Category) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[4].Descriptor() + return file_daemon_proto_enumTypes[3].Descriptor() } func (SystemEvent_Category) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[4] + return &file_daemon_proto_enumTypes[3] } func (x SystemEvent_Category) Number() protoreflect.EnumNumber { @@ -297,7 +247,7 @@ func (x SystemEvent_Category) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Category.Descriptor instead. func (SystemEvent_Category) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{53, 1} + return file_daemon_proto_rawDescGZIP(), []int{51, 1} } type EmptyRequest struct { @@ -336,86 +286,6 @@ func (*EmptyRequest) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{0} } -type OSLifecycleRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Type OSLifecycleRequest_CycleType `protobuf:"varint,1,opt,name=type,proto3,enum=daemon.OSLifecycleRequest_CycleType" json:"type,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *OSLifecycleRequest) Reset() { - *x = OSLifecycleRequest{} - mi := &file_daemon_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *OSLifecycleRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OSLifecycleRequest) ProtoMessage() {} - -func (x *OSLifecycleRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use OSLifecycleRequest.ProtoReflect.Descriptor instead. -func (*OSLifecycleRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{1} -} - -func (x *OSLifecycleRequest) GetType() OSLifecycleRequest_CycleType { - if x != nil { - return x.Type - } - return OSLifecycleRequest_UNKNOWN -} - -type OSLifecycleResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *OSLifecycleResponse) Reset() { - *x = OSLifecycleResponse{} - mi := &file_daemon_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *OSLifecycleResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OSLifecycleResponse) ProtoMessage() {} - -func (x *OSLifecycleResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use OSLifecycleResponse.ProtoReflect.Descriptor instead. -func (*OSLifecycleResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{2} -} - type LoginRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // setupKey netbird setup key. @@ -478,7 +348,7 @@ type LoginRequest struct { func (x *LoginRequest) Reset() { *x = LoginRequest{} - mi := &file_daemon_proto_msgTypes[3] + mi := &file_daemon_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -490,7 +360,7 @@ func (x *LoginRequest) String() string { func (*LoginRequest) ProtoMessage() {} func (x *LoginRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[3] + mi := &file_daemon_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -503,7 +373,7 @@ func (x *LoginRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead. func (*LoginRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{3} + return file_daemon_proto_rawDescGZIP(), []int{1} } func (x *LoginRequest) GetSetupKey() string { @@ -792,7 +662,7 @@ type LoginResponse struct { func (x *LoginResponse) Reset() { *x = LoginResponse{} - mi := &file_daemon_proto_msgTypes[4] + mi := &file_daemon_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -804,7 +674,7 @@ func (x *LoginResponse) String() string { func (*LoginResponse) ProtoMessage() {} func (x *LoginResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[4] + mi := &file_daemon_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -817,7 +687,7 @@ func (x *LoginResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead. func (*LoginResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{4} + return file_daemon_proto_rawDescGZIP(), []int{2} } func (x *LoginResponse) GetNeedsSSOLogin() bool { @@ -858,7 +728,7 @@ type WaitSSOLoginRequest struct { func (x *WaitSSOLoginRequest) Reset() { *x = WaitSSOLoginRequest{} - mi := &file_daemon_proto_msgTypes[5] + mi := &file_daemon_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -870,7 +740,7 @@ func (x *WaitSSOLoginRequest) String() string { func (*WaitSSOLoginRequest) ProtoMessage() {} func (x *WaitSSOLoginRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[5] + mi := &file_daemon_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -883,7 +753,7 @@ func (x *WaitSSOLoginRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitSSOLoginRequest.ProtoReflect.Descriptor instead. func (*WaitSSOLoginRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{5} + return file_daemon_proto_rawDescGZIP(), []int{3} } func (x *WaitSSOLoginRequest) GetUserCode() string { @@ -909,7 +779,7 @@ type WaitSSOLoginResponse struct { func (x *WaitSSOLoginResponse) Reset() { *x = WaitSSOLoginResponse{} - mi := &file_daemon_proto_msgTypes[6] + mi := &file_daemon_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -921,7 +791,7 @@ func (x *WaitSSOLoginResponse) String() string { func (*WaitSSOLoginResponse) ProtoMessage() {} func (x *WaitSSOLoginResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[6] + mi := &file_daemon_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -934,7 +804,7 @@ func (x *WaitSSOLoginResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitSSOLoginResponse.ProtoReflect.Descriptor instead. func (*WaitSSOLoginResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{6} + return file_daemon_proto_rawDescGZIP(), []int{4} } func (x *WaitSSOLoginResponse) GetEmail() string { @@ -954,7 +824,7 @@ type UpRequest struct { func (x *UpRequest) Reset() { *x = UpRequest{} - mi := &file_daemon_proto_msgTypes[7] + mi := &file_daemon_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -966,7 +836,7 @@ func (x *UpRequest) String() string { func (*UpRequest) ProtoMessage() {} func (x *UpRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[7] + mi := &file_daemon_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -979,7 +849,7 @@ func (x *UpRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpRequest.ProtoReflect.Descriptor instead. func (*UpRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{7} + return file_daemon_proto_rawDescGZIP(), []int{5} } func (x *UpRequest) GetProfileName() string { @@ -1004,7 +874,7 @@ type UpResponse struct { func (x *UpResponse) Reset() { *x = UpResponse{} - mi := &file_daemon_proto_msgTypes[8] + mi := &file_daemon_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1016,7 +886,7 @@ func (x *UpResponse) String() string { func (*UpResponse) ProtoMessage() {} func (x *UpResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[8] + mi := &file_daemon_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1029,7 +899,7 @@ func (x *UpResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpResponse.ProtoReflect.Descriptor instead. func (*UpResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{8} + return file_daemon_proto_rawDescGZIP(), []int{6} } type StatusRequest struct { @@ -1044,7 +914,7 @@ type StatusRequest struct { func (x *StatusRequest) Reset() { *x = StatusRequest{} - mi := &file_daemon_proto_msgTypes[9] + mi := &file_daemon_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1056,7 +926,7 @@ func (x *StatusRequest) String() string { func (*StatusRequest) ProtoMessage() {} func (x *StatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[9] + mi := &file_daemon_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1069,7 +939,7 @@ func (x *StatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StatusRequest.ProtoReflect.Descriptor instead. func (*StatusRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{9} + return file_daemon_proto_rawDescGZIP(), []int{7} } func (x *StatusRequest) GetGetFullPeerStatus() bool { @@ -1106,7 +976,7 @@ type StatusResponse struct { func (x *StatusResponse) Reset() { *x = StatusResponse{} - mi := &file_daemon_proto_msgTypes[10] + mi := &file_daemon_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1118,7 +988,7 @@ func (x *StatusResponse) String() string { func (*StatusResponse) ProtoMessage() {} func (x *StatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[10] + mi := &file_daemon_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1131,7 +1001,7 @@ func (x *StatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StatusResponse.ProtoReflect.Descriptor instead. func (*StatusResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{10} + return file_daemon_proto_rawDescGZIP(), []int{8} } func (x *StatusResponse) GetStatus() string { @@ -1163,7 +1033,7 @@ type DownRequest struct { func (x *DownRequest) Reset() { *x = DownRequest{} - mi := &file_daemon_proto_msgTypes[11] + mi := &file_daemon_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1175,7 +1045,7 @@ func (x *DownRequest) String() string { func (*DownRequest) ProtoMessage() {} func (x *DownRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[11] + mi := &file_daemon_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1188,7 +1058,7 @@ func (x *DownRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DownRequest.ProtoReflect.Descriptor instead. func (*DownRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{11} + return file_daemon_proto_rawDescGZIP(), []int{9} } type DownResponse struct { @@ -1199,7 +1069,7 @@ type DownResponse struct { func (x *DownResponse) Reset() { *x = DownResponse{} - mi := &file_daemon_proto_msgTypes[12] + mi := &file_daemon_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1211,7 +1081,7 @@ func (x *DownResponse) String() string { func (*DownResponse) ProtoMessage() {} func (x *DownResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[12] + mi := &file_daemon_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1224,7 +1094,7 @@ func (x *DownResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DownResponse.ProtoReflect.Descriptor instead. func (*DownResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{12} + return file_daemon_proto_rawDescGZIP(), []int{10} } type GetConfigRequest struct { @@ -1237,7 +1107,7 @@ type GetConfigRequest struct { func (x *GetConfigRequest) Reset() { *x = GetConfigRequest{} - mi := &file_daemon_proto_msgTypes[13] + mi := &file_daemon_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1249,7 +1119,7 @@ func (x *GetConfigRequest) String() string { func (*GetConfigRequest) ProtoMessage() {} func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[13] + mi := &file_daemon_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1262,7 +1132,7 @@ func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigRequest.ProtoReflect.Descriptor instead. func (*GetConfigRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{13} + return file_daemon_proto_rawDescGZIP(), []int{11} } func (x *GetConfigRequest) GetProfileName() string { @@ -1318,7 +1188,7 @@ type GetConfigResponse struct { func (x *GetConfigResponse) Reset() { *x = GetConfigResponse{} - mi := &file_daemon_proto_msgTypes[14] + mi := &file_daemon_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1330,7 +1200,7 @@ func (x *GetConfigResponse) String() string { func (*GetConfigResponse) ProtoMessage() {} func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[14] + mi := &file_daemon_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1343,7 +1213,7 @@ func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigResponse.ProtoReflect.Descriptor instead. func (*GetConfigResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{14} + return file_daemon_proto_rawDescGZIP(), []int{12} } func (x *GetConfigResponse) GetManagementUrl() string { @@ -1555,7 +1425,7 @@ type PeerState struct { func (x *PeerState) Reset() { *x = PeerState{} - mi := &file_daemon_proto_msgTypes[15] + mi := &file_daemon_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1567,7 +1437,7 @@ func (x *PeerState) String() string { func (*PeerState) ProtoMessage() {} func (x *PeerState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[15] + mi := &file_daemon_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1580,7 +1450,7 @@ func (x *PeerState) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerState.ProtoReflect.Descriptor instead. func (*PeerState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{15} + return file_daemon_proto_rawDescGZIP(), []int{13} } func (x *PeerState) GetIP() string { @@ -1725,7 +1595,7 @@ type LocalPeerState struct { func (x *LocalPeerState) Reset() { *x = LocalPeerState{} - mi := &file_daemon_proto_msgTypes[16] + mi := &file_daemon_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1737,7 +1607,7 @@ func (x *LocalPeerState) String() string { func (*LocalPeerState) ProtoMessage() {} func (x *LocalPeerState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[16] + mi := &file_daemon_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1750,7 +1620,7 @@ func (x *LocalPeerState) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalPeerState.ProtoReflect.Descriptor instead. func (*LocalPeerState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{16} + return file_daemon_proto_rawDescGZIP(), []int{14} } func (x *LocalPeerState) GetIP() string { @@ -1814,7 +1684,7 @@ type SignalState struct { func (x *SignalState) Reset() { *x = SignalState{} - mi := &file_daemon_proto_msgTypes[17] + mi := &file_daemon_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1826,7 +1696,7 @@ func (x *SignalState) String() string { func (*SignalState) ProtoMessage() {} func (x *SignalState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[17] + mi := &file_daemon_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1839,7 +1709,7 @@ func (x *SignalState) ProtoReflect() protoreflect.Message { // Deprecated: Use SignalState.ProtoReflect.Descriptor instead. func (*SignalState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{17} + return file_daemon_proto_rawDescGZIP(), []int{15} } func (x *SignalState) GetURL() string { @@ -1875,7 +1745,7 @@ type ManagementState struct { func (x *ManagementState) Reset() { *x = ManagementState{} - mi := &file_daemon_proto_msgTypes[18] + mi := &file_daemon_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1887,7 +1757,7 @@ func (x *ManagementState) String() string { func (*ManagementState) ProtoMessage() {} func (x *ManagementState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[18] + mi := &file_daemon_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1900,7 +1770,7 @@ func (x *ManagementState) ProtoReflect() protoreflect.Message { // Deprecated: Use ManagementState.ProtoReflect.Descriptor instead. func (*ManagementState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{18} + return file_daemon_proto_rawDescGZIP(), []int{16} } func (x *ManagementState) GetURL() string { @@ -1936,7 +1806,7 @@ type RelayState struct { func (x *RelayState) Reset() { *x = RelayState{} - mi := &file_daemon_proto_msgTypes[19] + mi := &file_daemon_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1948,7 +1818,7 @@ func (x *RelayState) String() string { func (*RelayState) ProtoMessage() {} func (x *RelayState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[19] + mi := &file_daemon_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1961,7 +1831,7 @@ func (x *RelayState) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayState.ProtoReflect.Descriptor instead. func (*RelayState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{19} + return file_daemon_proto_rawDescGZIP(), []int{17} } func (x *RelayState) GetURI() string { @@ -1997,7 +1867,7 @@ type NSGroupState struct { func (x *NSGroupState) Reset() { *x = NSGroupState{} - mi := &file_daemon_proto_msgTypes[20] + mi := &file_daemon_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2009,7 +1879,7 @@ func (x *NSGroupState) String() string { func (*NSGroupState) ProtoMessage() {} func (x *NSGroupState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[20] + mi := &file_daemon_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2022,7 +1892,7 @@ func (x *NSGroupState) ProtoReflect() protoreflect.Message { // Deprecated: Use NSGroupState.ProtoReflect.Descriptor instead. func (*NSGroupState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{20} + return file_daemon_proto_rawDescGZIP(), []int{18} } func (x *NSGroupState) GetServers() []string { @@ -2067,7 +1937,7 @@ type SSHSessionInfo struct { func (x *SSHSessionInfo) Reset() { *x = SSHSessionInfo{} - mi := &file_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2079,7 +1949,7 @@ func (x *SSHSessionInfo) String() string { func (*SSHSessionInfo) ProtoMessage() {} func (x *SSHSessionInfo) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2092,7 +1962,7 @@ func (x *SSHSessionInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use SSHSessionInfo.ProtoReflect.Descriptor instead. func (*SSHSessionInfo) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{21} + return file_daemon_proto_rawDescGZIP(), []int{19} } func (x *SSHSessionInfo) GetUsername() string { @@ -2141,7 +2011,7 @@ type SSHServerState struct { func (x *SSHServerState) Reset() { *x = SSHServerState{} - mi := &file_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2153,7 +2023,7 @@ func (x *SSHServerState) String() string { func (*SSHServerState) ProtoMessage() {} func (x *SSHServerState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2166,7 +2036,7 @@ func (x *SSHServerState) ProtoReflect() protoreflect.Message { // Deprecated: Use SSHServerState.ProtoReflect.Descriptor instead. func (*SSHServerState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{22} + return file_daemon_proto_rawDescGZIP(), []int{20} } func (x *SSHServerState) GetEnabled() bool { @@ -2202,7 +2072,7 @@ type FullStatus struct { func (x *FullStatus) Reset() { *x = FullStatus{} - mi := &file_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2214,7 +2084,7 @@ func (x *FullStatus) String() string { func (*FullStatus) ProtoMessage() {} func (x *FullStatus) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2227,7 +2097,7 @@ func (x *FullStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use FullStatus.ProtoReflect.Descriptor instead. func (*FullStatus) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{23} + return file_daemon_proto_rawDescGZIP(), []int{21} } func (x *FullStatus) GetManagementState() *ManagementState { @@ -2309,7 +2179,7 @@ type ListNetworksRequest struct { func (x *ListNetworksRequest) Reset() { *x = ListNetworksRequest{} - mi := &file_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2321,7 +2191,7 @@ func (x *ListNetworksRequest) String() string { func (*ListNetworksRequest) ProtoMessage() {} func (x *ListNetworksRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2334,7 +2204,7 @@ func (x *ListNetworksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNetworksRequest.ProtoReflect.Descriptor instead. func (*ListNetworksRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{24} + return file_daemon_proto_rawDescGZIP(), []int{22} } type ListNetworksResponse struct { @@ -2346,7 +2216,7 @@ type ListNetworksResponse struct { func (x *ListNetworksResponse) Reset() { *x = ListNetworksResponse{} - mi := &file_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2358,7 +2228,7 @@ func (x *ListNetworksResponse) String() string { func (*ListNetworksResponse) ProtoMessage() {} func (x *ListNetworksResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2371,7 +2241,7 @@ func (x *ListNetworksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNetworksResponse.ProtoReflect.Descriptor instead. func (*ListNetworksResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{25} + return file_daemon_proto_rawDescGZIP(), []int{23} } func (x *ListNetworksResponse) GetRoutes() []*Network { @@ -2392,7 +2262,7 @@ type SelectNetworksRequest struct { func (x *SelectNetworksRequest) Reset() { *x = SelectNetworksRequest{} - mi := &file_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2404,7 +2274,7 @@ func (x *SelectNetworksRequest) String() string { func (*SelectNetworksRequest) ProtoMessage() {} func (x *SelectNetworksRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2417,7 +2287,7 @@ func (x *SelectNetworksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectNetworksRequest.ProtoReflect.Descriptor instead. func (*SelectNetworksRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{26} + return file_daemon_proto_rawDescGZIP(), []int{24} } func (x *SelectNetworksRequest) GetNetworkIDs() []string { @@ -2449,7 +2319,7 @@ type SelectNetworksResponse struct { func (x *SelectNetworksResponse) Reset() { *x = SelectNetworksResponse{} - mi := &file_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2461,7 +2331,7 @@ func (x *SelectNetworksResponse) String() string { func (*SelectNetworksResponse) ProtoMessage() {} func (x *SelectNetworksResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2474,7 +2344,7 @@ func (x *SelectNetworksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectNetworksResponse.ProtoReflect.Descriptor instead. func (*SelectNetworksResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{27} + return file_daemon_proto_rawDescGZIP(), []int{25} } type IPList struct { @@ -2486,7 +2356,7 @@ type IPList struct { func (x *IPList) Reset() { *x = IPList{} - mi := &file_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2498,7 +2368,7 @@ func (x *IPList) String() string { func (*IPList) ProtoMessage() {} func (x *IPList) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2511,7 +2381,7 @@ func (x *IPList) ProtoReflect() protoreflect.Message { // Deprecated: Use IPList.ProtoReflect.Descriptor instead. func (*IPList) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{28} + return file_daemon_proto_rawDescGZIP(), []int{26} } func (x *IPList) GetIps() []string { @@ -2534,7 +2404,7 @@ type Network struct { func (x *Network) Reset() { *x = Network{} - mi := &file_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2546,7 +2416,7 @@ func (x *Network) String() string { func (*Network) ProtoMessage() {} func (x *Network) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2559,7 +2429,7 @@ func (x *Network) ProtoReflect() protoreflect.Message { // Deprecated: Use Network.ProtoReflect.Descriptor instead. func (*Network) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{29} + return file_daemon_proto_rawDescGZIP(), []int{27} } func (x *Network) GetID() string { @@ -2611,7 +2481,7 @@ type PortInfo struct { func (x *PortInfo) Reset() { *x = PortInfo{} - mi := &file_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2623,7 +2493,7 @@ func (x *PortInfo) String() string { func (*PortInfo) ProtoMessage() {} func (x *PortInfo) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2636,7 +2506,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo.ProtoReflect.Descriptor instead. func (*PortInfo) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{30} + return file_daemon_proto_rawDescGZIP(), []int{28} } func (x *PortInfo) GetPortSelection() isPortInfo_PortSelection { @@ -2693,7 +2563,7 @@ type ForwardingRule struct { func (x *ForwardingRule) Reset() { *x = ForwardingRule{} - mi := &file_daemon_proto_msgTypes[31] + mi := &file_daemon_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2705,7 +2575,7 @@ func (x *ForwardingRule) String() string { func (*ForwardingRule) ProtoMessage() {} func (x *ForwardingRule) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[31] + mi := &file_daemon_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2718,7 +2588,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead. func (*ForwardingRule) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{31} + return file_daemon_proto_rawDescGZIP(), []int{29} } func (x *ForwardingRule) GetProtocol() string { @@ -2765,7 +2635,7 @@ type ForwardingRulesResponse struct { func (x *ForwardingRulesResponse) Reset() { *x = ForwardingRulesResponse{} - mi := &file_daemon_proto_msgTypes[32] + mi := &file_daemon_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2777,7 +2647,7 @@ func (x *ForwardingRulesResponse) String() string { func (*ForwardingRulesResponse) ProtoMessage() {} func (x *ForwardingRulesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[32] + mi := &file_daemon_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2790,7 +2660,7 @@ func (x *ForwardingRulesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRulesResponse.ProtoReflect.Descriptor instead. func (*ForwardingRulesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{32} + return file_daemon_proto_rawDescGZIP(), []int{30} } func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule { @@ -2813,7 +2683,7 @@ type DebugBundleRequest struct { func (x *DebugBundleRequest) Reset() { *x = DebugBundleRequest{} - mi := &file_daemon_proto_msgTypes[33] + mi := &file_daemon_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2825,7 +2695,7 @@ func (x *DebugBundleRequest) String() string { func (*DebugBundleRequest) ProtoMessage() {} func (x *DebugBundleRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[33] + mi := &file_daemon_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2838,7 +2708,7 @@ func (x *DebugBundleRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DebugBundleRequest.ProtoReflect.Descriptor instead. func (*DebugBundleRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{33} + return file_daemon_proto_rawDescGZIP(), []int{31} } func (x *DebugBundleRequest) GetAnonymize() bool { @@ -2880,7 +2750,7 @@ type DebugBundleResponse struct { func (x *DebugBundleResponse) Reset() { *x = DebugBundleResponse{} - mi := &file_daemon_proto_msgTypes[34] + mi := &file_daemon_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2892,7 +2762,7 @@ func (x *DebugBundleResponse) String() string { func (*DebugBundleResponse) ProtoMessage() {} func (x *DebugBundleResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[34] + mi := &file_daemon_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2905,7 +2775,7 @@ func (x *DebugBundleResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DebugBundleResponse.ProtoReflect.Descriptor instead. func (*DebugBundleResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{34} + return file_daemon_proto_rawDescGZIP(), []int{32} } func (x *DebugBundleResponse) GetPath() string { @@ -2937,7 +2807,7 @@ type GetLogLevelRequest struct { func (x *GetLogLevelRequest) Reset() { *x = GetLogLevelRequest{} - mi := &file_daemon_proto_msgTypes[35] + mi := &file_daemon_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2949,7 +2819,7 @@ func (x *GetLogLevelRequest) String() string { func (*GetLogLevelRequest) ProtoMessage() {} func (x *GetLogLevelRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[35] + mi := &file_daemon_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2962,7 +2832,7 @@ func (x *GetLogLevelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogLevelRequest.ProtoReflect.Descriptor instead. func (*GetLogLevelRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{35} + return file_daemon_proto_rawDescGZIP(), []int{33} } type GetLogLevelResponse struct { @@ -2974,7 +2844,7 @@ type GetLogLevelResponse struct { func (x *GetLogLevelResponse) Reset() { *x = GetLogLevelResponse{} - mi := &file_daemon_proto_msgTypes[36] + mi := &file_daemon_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2986,7 +2856,7 @@ func (x *GetLogLevelResponse) String() string { func (*GetLogLevelResponse) ProtoMessage() {} func (x *GetLogLevelResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[36] + mi := &file_daemon_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2999,7 +2869,7 @@ func (x *GetLogLevelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogLevelResponse.ProtoReflect.Descriptor instead. func (*GetLogLevelResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{36} + return file_daemon_proto_rawDescGZIP(), []int{34} } func (x *GetLogLevelResponse) GetLevel() LogLevel { @@ -3018,7 +2888,7 @@ type SetLogLevelRequest struct { func (x *SetLogLevelRequest) Reset() { *x = SetLogLevelRequest{} - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3030,7 +2900,7 @@ func (x *SetLogLevelRequest) String() string { func (*SetLogLevelRequest) ProtoMessage() {} func (x *SetLogLevelRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3043,7 +2913,7 @@ func (x *SetLogLevelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLogLevelRequest.ProtoReflect.Descriptor instead. func (*SetLogLevelRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{37} + return file_daemon_proto_rawDescGZIP(), []int{35} } func (x *SetLogLevelRequest) GetLevel() LogLevel { @@ -3061,7 +2931,7 @@ type SetLogLevelResponse struct { func (x *SetLogLevelResponse) Reset() { *x = SetLogLevelResponse{} - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3073,7 +2943,7 @@ func (x *SetLogLevelResponse) String() string { func (*SetLogLevelResponse) ProtoMessage() {} func (x *SetLogLevelResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3086,7 +2956,7 @@ func (x *SetLogLevelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLogLevelResponse.ProtoReflect.Descriptor instead. func (*SetLogLevelResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{38} + return file_daemon_proto_rawDescGZIP(), []int{36} } // State represents a daemon state entry @@ -3099,7 +2969,7 @@ type State struct { func (x *State) Reset() { *x = State{} - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3111,7 +2981,7 @@ func (x *State) String() string { func (*State) ProtoMessage() {} func (x *State) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3124,7 +2994,7 @@ func (x *State) ProtoReflect() protoreflect.Message { // Deprecated: Use State.ProtoReflect.Descriptor instead. func (*State) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{39} + return file_daemon_proto_rawDescGZIP(), []int{37} } func (x *State) GetName() string { @@ -3143,7 +3013,7 @@ type ListStatesRequest struct { func (x *ListStatesRequest) Reset() { *x = ListStatesRequest{} - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3155,7 +3025,7 @@ func (x *ListStatesRequest) String() string { func (*ListStatesRequest) ProtoMessage() {} func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3168,7 +3038,7 @@ func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesRequest.ProtoReflect.Descriptor instead. func (*ListStatesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{40} + return file_daemon_proto_rawDescGZIP(), []int{38} } // ListStatesResponse contains a list of states @@ -3181,7 +3051,7 @@ type ListStatesResponse struct { func (x *ListStatesResponse) Reset() { *x = ListStatesResponse{} - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3193,7 +3063,7 @@ func (x *ListStatesResponse) String() string { func (*ListStatesResponse) ProtoMessage() {} func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3206,7 +3076,7 @@ func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesResponse.ProtoReflect.Descriptor instead. func (*ListStatesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{41} + return file_daemon_proto_rawDescGZIP(), []int{39} } func (x *ListStatesResponse) GetStates() []*State { @@ -3227,7 +3097,7 @@ type CleanStateRequest struct { func (x *CleanStateRequest) Reset() { *x = CleanStateRequest{} - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3239,7 +3109,7 @@ func (x *CleanStateRequest) String() string { func (*CleanStateRequest) ProtoMessage() {} func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3252,7 +3122,7 @@ func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateRequest.ProtoReflect.Descriptor instead. func (*CleanStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{42} + return file_daemon_proto_rawDescGZIP(), []int{40} } func (x *CleanStateRequest) GetStateName() string { @@ -3279,7 +3149,7 @@ type CleanStateResponse struct { func (x *CleanStateResponse) Reset() { *x = CleanStateResponse{} - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3291,7 +3161,7 @@ func (x *CleanStateResponse) String() string { func (*CleanStateResponse) ProtoMessage() {} func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3304,7 +3174,7 @@ func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateResponse.ProtoReflect.Descriptor instead. func (*CleanStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{43} + return file_daemon_proto_rawDescGZIP(), []int{41} } func (x *CleanStateResponse) GetCleanedStates() int32 { @@ -3325,7 +3195,7 @@ type DeleteStateRequest struct { func (x *DeleteStateRequest) Reset() { *x = DeleteStateRequest{} - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3337,7 +3207,7 @@ func (x *DeleteStateRequest) String() string { func (*DeleteStateRequest) ProtoMessage() {} func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3350,7 +3220,7 @@ func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateRequest.ProtoReflect.Descriptor instead. func (*DeleteStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{44} + return file_daemon_proto_rawDescGZIP(), []int{42} } func (x *DeleteStateRequest) GetStateName() string { @@ -3377,7 +3247,7 @@ type DeleteStateResponse struct { func (x *DeleteStateResponse) Reset() { *x = DeleteStateResponse{} - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3389,7 +3259,7 @@ func (x *DeleteStateResponse) String() string { func (*DeleteStateResponse) ProtoMessage() {} func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3402,7 +3272,7 @@ func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateResponse.ProtoReflect.Descriptor instead. func (*DeleteStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{45} + return file_daemon_proto_rawDescGZIP(), []int{43} } func (x *DeleteStateResponse) GetDeletedStates() int32 { @@ -3421,7 +3291,7 @@ type SetSyncResponsePersistenceRequest struct { func (x *SetSyncResponsePersistenceRequest) Reset() { *x = SetSyncResponsePersistenceRequest{} - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3433,7 +3303,7 @@ func (x *SetSyncResponsePersistenceRequest) String() string { func (*SetSyncResponsePersistenceRequest) ProtoMessage() {} func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3446,7 +3316,7 @@ func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceRequest.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{46} + return file_daemon_proto_rawDescGZIP(), []int{44} } func (x *SetSyncResponsePersistenceRequest) GetEnabled() bool { @@ -3464,7 +3334,7 @@ type SetSyncResponsePersistenceResponse struct { func (x *SetSyncResponsePersistenceResponse) Reset() { *x = SetSyncResponsePersistenceResponse{} - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3476,7 +3346,7 @@ func (x *SetSyncResponsePersistenceResponse) String() string { func (*SetSyncResponsePersistenceResponse) ProtoMessage() {} func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3489,7 +3359,7 @@ func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceResponse.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{47} + return file_daemon_proto_rawDescGZIP(), []int{45} } type TCPFlags struct { @@ -3506,7 +3376,7 @@ type TCPFlags struct { func (x *TCPFlags) Reset() { *x = TCPFlags{} - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3518,7 +3388,7 @@ func (x *TCPFlags) String() string { func (*TCPFlags) ProtoMessage() {} func (x *TCPFlags) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3531,7 +3401,7 @@ func (x *TCPFlags) ProtoReflect() protoreflect.Message { // Deprecated: Use TCPFlags.ProtoReflect.Descriptor instead. func (*TCPFlags) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{48} + return file_daemon_proto_rawDescGZIP(), []int{46} } func (x *TCPFlags) GetSyn() bool { @@ -3593,7 +3463,7 @@ type TracePacketRequest struct { func (x *TracePacketRequest) Reset() { *x = TracePacketRequest{} - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3605,7 +3475,7 @@ func (x *TracePacketRequest) String() string { func (*TracePacketRequest) ProtoMessage() {} func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3618,7 +3488,7 @@ func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketRequest.ProtoReflect.Descriptor instead. func (*TracePacketRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{49} + return file_daemon_proto_rawDescGZIP(), []int{47} } func (x *TracePacketRequest) GetSourceIp() string { @@ -3696,7 +3566,7 @@ type TraceStage struct { func (x *TraceStage) Reset() { *x = TraceStage{} - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3708,7 +3578,7 @@ func (x *TraceStage) String() string { func (*TraceStage) ProtoMessage() {} func (x *TraceStage) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3721,7 +3591,7 @@ func (x *TraceStage) ProtoReflect() protoreflect.Message { // Deprecated: Use TraceStage.ProtoReflect.Descriptor instead. func (*TraceStage) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{50} + return file_daemon_proto_rawDescGZIP(), []int{48} } func (x *TraceStage) GetName() string { @@ -3762,7 +3632,7 @@ type TracePacketResponse struct { func (x *TracePacketResponse) Reset() { *x = TracePacketResponse{} - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3774,7 +3644,7 @@ func (x *TracePacketResponse) String() string { func (*TracePacketResponse) ProtoMessage() {} func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3787,7 +3657,7 @@ func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketResponse.ProtoReflect.Descriptor instead. func (*TracePacketResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51} + return file_daemon_proto_rawDescGZIP(), []int{49} } func (x *TracePacketResponse) GetStages() []*TraceStage { @@ -3812,7 +3682,7 @@ type SubscribeRequest struct { func (x *SubscribeRequest) Reset() { *x = SubscribeRequest{} - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3824,7 +3694,7 @@ func (x *SubscribeRequest) String() string { func (*SubscribeRequest) ProtoMessage() {} func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3837,7 +3707,7 @@ func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead. func (*SubscribeRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{52} + return file_daemon_proto_rawDescGZIP(), []int{50} } type SystemEvent struct { @@ -3855,7 +3725,7 @@ type SystemEvent struct { func (x *SystemEvent) Reset() { *x = SystemEvent{} - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3867,7 +3737,7 @@ func (x *SystemEvent) String() string { func (*SystemEvent) ProtoMessage() {} func (x *SystemEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3880,7 +3750,7 @@ func (x *SystemEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemEvent.ProtoReflect.Descriptor instead. func (*SystemEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{53} + return file_daemon_proto_rawDescGZIP(), []int{51} } func (x *SystemEvent) GetId() string { @@ -3940,7 +3810,7 @@ type GetEventsRequest struct { func (x *GetEventsRequest) Reset() { *x = GetEventsRequest{} - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3952,7 +3822,7 @@ func (x *GetEventsRequest) String() string { func (*GetEventsRequest) ProtoMessage() {} func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3965,7 +3835,7 @@ func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsRequest.ProtoReflect.Descriptor instead. func (*GetEventsRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{54} + return file_daemon_proto_rawDescGZIP(), []int{52} } type GetEventsResponse struct { @@ -3977,7 +3847,7 @@ type GetEventsResponse struct { func (x *GetEventsResponse) Reset() { *x = GetEventsResponse{} - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3989,7 +3859,7 @@ func (x *GetEventsResponse) String() string { func (*GetEventsResponse) ProtoMessage() {} func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4002,7 +3872,7 @@ func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsResponse.ProtoReflect.Descriptor instead. func (*GetEventsResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{55} + return file_daemon_proto_rawDescGZIP(), []int{53} } func (x *GetEventsResponse) GetEvents() []*SystemEvent { @@ -4022,7 +3892,7 @@ type SwitchProfileRequest struct { func (x *SwitchProfileRequest) Reset() { *x = SwitchProfileRequest{} - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4034,7 +3904,7 @@ func (x *SwitchProfileRequest) String() string { func (*SwitchProfileRequest) ProtoMessage() {} func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4047,7 +3917,7 @@ func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileRequest.ProtoReflect.Descriptor instead. func (*SwitchProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{56} + return file_daemon_proto_rawDescGZIP(), []int{54} } func (x *SwitchProfileRequest) GetProfileName() string { @@ -4072,7 +3942,7 @@ type SwitchProfileResponse struct { func (x *SwitchProfileResponse) Reset() { *x = SwitchProfileResponse{} - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4084,7 +3954,7 @@ func (x *SwitchProfileResponse) String() string { func (*SwitchProfileResponse) ProtoMessage() {} func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4097,7 +3967,7 @@ func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileResponse.ProtoReflect.Descriptor instead. func (*SwitchProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{57} + return file_daemon_proto_rawDescGZIP(), []int{55} } type SetConfigRequest struct { @@ -4145,7 +4015,7 @@ type SetConfigRequest struct { func (x *SetConfigRequest) Reset() { *x = SetConfigRequest{} - mi := &file_daemon_proto_msgTypes[58] + mi := &file_daemon_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4157,7 +4027,7 @@ func (x *SetConfigRequest) String() string { func (*SetConfigRequest) ProtoMessage() {} func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[58] + mi := &file_daemon_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4170,7 +4040,7 @@ func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigRequest.ProtoReflect.Descriptor instead. func (*SetConfigRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{58} + return file_daemon_proto_rawDescGZIP(), []int{56} } func (x *SetConfigRequest) GetUsername() string { @@ -4419,7 +4289,7 @@ type SetConfigResponse struct { func (x *SetConfigResponse) Reset() { *x = SetConfigResponse{} - mi := &file_daemon_proto_msgTypes[59] + mi := &file_daemon_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4431,7 +4301,7 @@ func (x *SetConfigResponse) String() string { func (*SetConfigResponse) ProtoMessage() {} func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[59] + mi := &file_daemon_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4444,7 +4314,7 @@ func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigResponse.ProtoReflect.Descriptor instead. func (*SetConfigResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{59} + return file_daemon_proto_rawDescGZIP(), []int{57} } type AddProfileRequest struct { @@ -4457,7 +4327,7 @@ type AddProfileRequest struct { func (x *AddProfileRequest) Reset() { *x = AddProfileRequest{} - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4469,7 +4339,7 @@ func (x *AddProfileRequest) String() string { func (*AddProfileRequest) ProtoMessage() {} func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4482,7 +4352,7 @@ func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileRequest.ProtoReflect.Descriptor instead. func (*AddProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{60} + return file_daemon_proto_rawDescGZIP(), []int{58} } func (x *AddProfileRequest) GetUsername() string { @@ -4507,7 +4377,7 @@ type AddProfileResponse struct { func (x *AddProfileResponse) Reset() { *x = AddProfileResponse{} - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4519,7 +4389,7 @@ func (x *AddProfileResponse) String() string { func (*AddProfileResponse) ProtoMessage() {} func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4532,7 +4402,7 @@ func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileResponse.ProtoReflect.Descriptor instead. func (*AddProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{61} + return file_daemon_proto_rawDescGZIP(), []int{59} } type RemoveProfileRequest struct { @@ -4545,7 +4415,7 @@ type RemoveProfileRequest struct { func (x *RemoveProfileRequest) Reset() { *x = RemoveProfileRequest{} - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4557,7 +4427,7 @@ func (x *RemoveProfileRequest) String() string { func (*RemoveProfileRequest) ProtoMessage() {} func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4570,7 +4440,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead. func (*RemoveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{62} + return file_daemon_proto_rawDescGZIP(), []int{60} } func (x *RemoveProfileRequest) GetUsername() string { @@ -4595,7 +4465,7 @@ type RemoveProfileResponse struct { func (x *RemoveProfileResponse) Reset() { *x = RemoveProfileResponse{} - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4607,7 +4477,7 @@ func (x *RemoveProfileResponse) String() string { func (*RemoveProfileResponse) ProtoMessage() {} func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4620,7 +4490,7 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead. func (*RemoveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{63} + return file_daemon_proto_rawDescGZIP(), []int{61} } type ListProfilesRequest struct { @@ -4632,7 +4502,7 @@ type ListProfilesRequest struct { func (x *ListProfilesRequest) Reset() { *x = ListProfilesRequest{} - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4644,7 +4514,7 @@ func (x *ListProfilesRequest) String() string { func (*ListProfilesRequest) ProtoMessage() {} func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4657,7 +4527,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProfilesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{64} + return file_daemon_proto_rawDescGZIP(), []int{62} } func (x *ListProfilesRequest) GetUsername() string { @@ -4676,7 +4546,7 @@ type ListProfilesResponse struct { func (x *ListProfilesResponse) Reset() { *x = ListProfilesResponse{} - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4688,7 +4558,7 @@ func (x *ListProfilesResponse) String() string { func (*ListProfilesResponse) ProtoMessage() {} func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4701,7 +4571,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProfilesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{65} + return file_daemon_proto_rawDescGZIP(), []int{63} } func (x *ListProfilesResponse) GetProfiles() []*Profile { @@ -4721,7 +4591,7 @@ type Profile struct { func (x *Profile) Reset() { *x = Profile{} - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4733,7 +4603,7 @@ func (x *Profile) String() string { func (*Profile) ProtoMessage() {} func (x *Profile) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4746,7 +4616,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message { // Deprecated: Use Profile.ProtoReflect.Descriptor instead. func (*Profile) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{66} + return file_daemon_proto_rawDescGZIP(), []int{64} } func (x *Profile) GetName() string { @@ -4771,7 +4641,7 @@ type GetActiveProfileRequest struct { func (x *GetActiveProfileRequest) Reset() { *x = GetActiveProfileRequest{} - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4783,7 +4653,7 @@ func (x *GetActiveProfileRequest) String() string { func (*GetActiveProfileRequest) ProtoMessage() {} func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4796,7 +4666,7 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead. func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{67} + return file_daemon_proto_rawDescGZIP(), []int{65} } type GetActiveProfileResponse struct { @@ -4809,7 +4679,7 @@ type GetActiveProfileResponse struct { func (x *GetActiveProfileResponse) Reset() { *x = GetActiveProfileResponse{} - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4821,7 +4691,7 @@ func (x *GetActiveProfileResponse) String() string { func (*GetActiveProfileResponse) ProtoMessage() {} func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4834,7 +4704,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead. func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{68} + return file_daemon_proto_rawDescGZIP(), []int{66} } func (x *GetActiveProfileResponse) GetProfileName() string { @@ -4861,7 +4731,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4873,7 +4743,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4886,7 +4756,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{69} + return file_daemon_proto_rawDescGZIP(), []int{67} } func (x *LogoutRequest) GetProfileName() string { @@ -4911,7 +4781,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4923,7 +4793,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4936,7 +4806,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{70} + return file_daemon_proto_rawDescGZIP(), []int{68} } type GetFeaturesRequest struct { @@ -4947,7 +4817,7 @@ type GetFeaturesRequest struct { func (x *GetFeaturesRequest) Reset() { *x = GetFeaturesRequest{} - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4959,7 +4829,7 @@ func (x *GetFeaturesRequest) String() string { func (*GetFeaturesRequest) ProtoMessage() {} func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4972,7 +4842,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead. func (*GetFeaturesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{71} + return file_daemon_proto_rawDescGZIP(), []int{69} } type GetFeaturesResponse struct { @@ -4986,7 +4856,7 @@ type GetFeaturesResponse struct { func (x *GetFeaturesResponse) Reset() { *x = GetFeaturesResponse{} - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4998,7 +4868,7 @@ func (x *GetFeaturesResponse) String() string { func (*GetFeaturesResponse) ProtoMessage() {} func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5011,7 +4881,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead. func (*GetFeaturesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{72} + return file_daemon_proto_rawDescGZIP(), []int{70} } func (x *GetFeaturesResponse) GetDisableProfiles() bool { @@ -5043,7 +4913,7 @@ type TriggerUpdateRequest struct { func (x *TriggerUpdateRequest) Reset() { *x = TriggerUpdateRequest{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5055,7 +4925,7 @@ func (x *TriggerUpdateRequest) String() string { func (*TriggerUpdateRequest) ProtoMessage() {} func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5068,7 +4938,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{71} } type TriggerUpdateResponse struct { @@ -5081,7 +4951,7 @@ type TriggerUpdateResponse struct { func (x *TriggerUpdateResponse) Reset() { *x = TriggerUpdateResponse{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5093,7 +4963,7 @@ func (x *TriggerUpdateResponse) String() string { func (*TriggerUpdateResponse) ProtoMessage() {} func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5106,7 +4976,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{72} } func (x *TriggerUpdateResponse) GetSuccess() bool { @@ -5134,7 +5004,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5146,7 +5016,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5159,7 +5029,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{73} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5186,7 +5056,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5198,7 +5068,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5211,7 +5081,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{74} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5253,7 +5123,7 @@ type RequestJWTAuthRequest struct { func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5265,7 +5135,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5278,7 +5148,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{75} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5311,7 +5181,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5323,7 +5193,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5336,7 +5206,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5401,7 +5271,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5413,7 +5283,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5426,7 +5296,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{77} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5458,7 +5328,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5470,7 +5340,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5483,7 +5353,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{78} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5516,7 +5386,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5528,7 +5398,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5541,7 +5411,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{79} } // StartCPUProfileResponse confirms CPU profiling has started @@ -5553,7 +5423,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5565,7 +5435,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5578,7 +5448,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{80} } // StopCPUProfileRequest for stopping CPU profiling @@ -5590,7 +5460,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5602,7 +5472,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5615,7 +5485,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{81} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -5627,7 +5497,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5639,7 +5509,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5652,7 +5522,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{82} } type InstallerResultRequest struct { @@ -5663,7 +5533,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5675,7 +5545,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5688,7 +5558,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{85} + return file_daemon_proto_rawDescGZIP(), []int{83} } type InstallerResultResponse struct { @@ -5701,7 +5571,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5713,7 +5583,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5726,7 +5596,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{86} + return file_daemon_proto_rawDescGZIP(), []int{84} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -5759,7 +5629,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5771,7 +5641,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5784,7 +5654,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{87} + return file_daemon_proto_rawDescGZIP(), []int{85} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -5855,7 +5725,7 @@ type ExposeServiceEvent struct { func (x *ExposeServiceEvent) Reset() { *x = ExposeServiceEvent{} - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5867,7 +5737,7 @@ func (x *ExposeServiceEvent) String() string { func (*ExposeServiceEvent) ProtoMessage() {} func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5880,7 +5750,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{88} + return file_daemon_proto_rawDescGZIP(), []int{86} } func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { @@ -5921,7 +5791,7 @@ type ExposeServiceReady struct { func (x *ExposeServiceReady) Reset() { *x = ExposeServiceReady{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5933,7 +5803,7 @@ func (x *ExposeServiceReady) String() string { func (*ExposeServiceReady) ProtoMessage() {} func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5946,7 +5816,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. func (*ExposeServiceReady) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{89} + return file_daemon_proto_rawDescGZIP(), []int{87} } func (x *ExposeServiceReady) GetServiceName() string { @@ -5987,7 +5857,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5999,7 +5869,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6012,7 +5882,7 @@ func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo_Range.ProtoReflect.Descriptor instead. func (*PortInfo_Range) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{30, 0} + return file_daemon_proto_rawDescGZIP(), []int{28, 0} } func (x *PortInfo_Range) GetStart() uint32 { @@ -6034,15 +5904,7 @@ var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + "\n" + "\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" + - "\fEmptyRequest\"\x7f\n" + - "\x12OSLifecycleRequest\x128\n" + - "\x04type\x18\x01 \x01(\x0e2$.daemon.OSLifecycleRequest.CycleTypeR\x04type\"/\n" + - "\tCycleType\x12\v\n" + - "\aUNKNOWN\x10\x00\x12\t\n" + - "\x05SLEEP\x10\x01\x12\n" + - "\n" + - "\x06WAKEUP\x10\x02\"\x15\n" + - "\x13OSLifecycleResponse\"\xb6\x12\n" + + "\fEmptyRequest\"\xb6\x12\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -6566,7 +6428,7 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xfc\x15\n" + + "EXPOSE_TLS\x10\x042\xac\x15\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -6604,8 +6466,7 @@ const file_daemon_proto_rawDesc = "" + "\x0eRequestJWTAuth\x12\x1d.daemon.RequestJWTAuthRequest\x1a\x1e.daemon.RequestJWTAuthResponse\"\x00\x12K\n" + "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\x12T\n" + "\x0fStartCPUProfile\x12\x1e.daemon.StartCPUProfileRequest\x1a\x1f.daemon.StartCPUProfileResponse\"\x00\x12Q\n" + - "\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12N\n" + - "\x11NotifyOSLifecycle\x12\x1a.daemon.OSLifecycleRequest\x1a\x1b.daemon.OSLifecycleResponse\"\x00\x12W\n" + + "\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12W\n" + "\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" + "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01B\bZ\x06/protob\x06proto3" @@ -6621,226 +6482,220 @@ func file_daemon_proto_rawDescGZIP() []byte { return file_daemon_proto_rawDescData } -var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 5) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 93) +var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 91) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol - (OSLifecycleRequest_CycleType)(0), // 2: daemon.OSLifecycleRequest.CycleType - (SystemEvent_Severity)(0), // 3: daemon.SystemEvent.Severity - (SystemEvent_Category)(0), // 4: daemon.SystemEvent.Category - (*EmptyRequest)(nil), // 5: daemon.EmptyRequest - (*OSLifecycleRequest)(nil), // 6: daemon.OSLifecycleRequest - (*OSLifecycleResponse)(nil), // 7: daemon.OSLifecycleResponse - (*LoginRequest)(nil), // 8: daemon.LoginRequest - (*LoginResponse)(nil), // 9: daemon.LoginResponse - (*WaitSSOLoginRequest)(nil), // 10: daemon.WaitSSOLoginRequest - (*WaitSSOLoginResponse)(nil), // 11: daemon.WaitSSOLoginResponse - (*UpRequest)(nil), // 12: daemon.UpRequest - (*UpResponse)(nil), // 13: daemon.UpResponse - (*StatusRequest)(nil), // 14: daemon.StatusRequest - (*StatusResponse)(nil), // 15: daemon.StatusResponse - (*DownRequest)(nil), // 16: daemon.DownRequest - (*DownResponse)(nil), // 17: daemon.DownResponse - (*GetConfigRequest)(nil), // 18: daemon.GetConfigRequest - (*GetConfigResponse)(nil), // 19: daemon.GetConfigResponse - (*PeerState)(nil), // 20: daemon.PeerState - (*LocalPeerState)(nil), // 21: daemon.LocalPeerState - (*SignalState)(nil), // 22: daemon.SignalState - (*ManagementState)(nil), // 23: daemon.ManagementState - (*RelayState)(nil), // 24: daemon.RelayState - (*NSGroupState)(nil), // 25: daemon.NSGroupState - (*SSHSessionInfo)(nil), // 26: daemon.SSHSessionInfo - (*SSHServerState)(nil), // 27: daemon.SSHServerState - (*FullStatus)(nil), // 28: daemon.FullStatus - (*ListNetworksRequest)(nil), // 29: daemon.ListNetworksRequest - (*ListNetworksResponse)(nil), // 30: daemon.ListNetworksResponse - (*SelectNetworksRequest)(nil), // 31: daemon.SelectNetworksRequest - (*SelectNetworksResponse)(nil), // 32: daemon.SelectNetworksResponse - (*IPList)(nil), // 33: daemon.IPList - (*Network)(nil), // 34: daemon.Network - (*PortInfo)(nil), // 35: daemon.PortInfo - (*ForwardingRule)(nil), // 36: daemon.ForwardingRule - (*ForwardingRulesResponse)(nil), // 37: daemon.ForwardingRulesResponse - (*DebugBundleRequest)(nil), // 38: daemon.DebugBundleRequest - (*DebugBundleResponse)(nil), // 39: daemon.DebugBundleResponse - (*GetLogLevelRequest)(nil), // 40: daemon.GetLogLevelRequest - (*GetLogLevelResponse)(nil), // 41: daemon.GetLogLevelResponse - (*SetLogLevelRequest)(nil), // 42: daemon.SetLogLevelRequest - (*SetLogLevelResponse)(nil), // 43: daemon.SetLogLevelResponse - (*State)(nil), // 44: daemon.State - (*ListStatesRequest)(nil), // 45: daemon.ListStatesRequest - (*ListStatesResponse)(nil), // 46: daemon.ListStatesResponse - (*CleanStateRequest)(nil), // 47: daemon.CleanStateRequest - (*CleanStateResponse)(nil), // 48: daemon.CleanStateResponse - (*DeleteStateRequest)(nil), // 49: daemon.DeleteStateRequest - (*DeleteStateResponse)(nil), // 50: daemon.DeleteStateResponse - (*SetSyncResponsePersistenceRequest)(nil), // 51: daemon.SetSyncResponsePersistenceRequest - (*SetSyncResponsePersistenceResponse)(nil), // 52: daemon.SetSyncResponsePersistenceResponse - (*TCPFlags)(nil), // 53: daemon.TCPFlags - (*TracePacketRequest)(nil), // 54: daemon.TracePacketRequest - (*TraceStage)(nil), // 55: daemon.TraceStage - (*TracePacketResponse)(nil), // 56: daemon.TracePacketResponse - (*SubscribeRequest)(nil), // 57: daemon.SubscribeRequest - (*SystemEvent)(nil), // 58: daemon.SystemEvent - (*GetEventsRequest)(nil), // 59: daemon.GetEventsRequest - (*GetEventsResponse)(nil), // 60: daemon.GetEventsResponse - (*SwitchProfileRequest)(nil), // 61: daemon.SwitchProfileRequest - (*SwitchProfileResponse)(nil), // 62: daemon.SwitchProfileResponse - (*SetConfigRequest)(nil), // 63: daemon.SetConfigRequest - (*SetConfigResponse)(nil), // 64: daemon.SetConfigResponse - (*AddProfileRequest)(nil), // 65: daemon.AddProfileRequest - (*AddProfileResponse)(nil), // 66: daemon.AddProfileResponse - (*RemoveProfileRequest)(nil), // 67: daemon.RemoveProfileRequest - (*RemoveProfileResponse)(nil), // 68: daemon.RemoveProfileResponse - (*ListProfilesRequest)(nil), // 69: daemon.ListProfilesRequest - (*ListProfilesResponse)(nil), // 70: daemon.ListProfilesResponse - (*Profile)(nil), // 71: daemon.Profile - (*GetActiveProfileRequest)(nil), // 72: daemon.GetActiveProfileRequest - (*GetActiveProfileResponse)(nil), // 73: daemon.GetActiveProfileResponse - (*LogoutRequest)(nil), // 74: daemon.LogoutRequest - (*LogoutResponse)(nil), // 75: daemon.LogoutResponse - (*GetFeaturesRequest)(nil), // 76: daemon.GetFeaturesRequest - (*GetFeaturesResponse)(nil), // 77: daemon.GetFeaturesResponse - (*TriggerUpdateRequest)(nil), // 78: daemon.TriggerUpdateRequest - (*TriggerUpdateResponse)(nil), // 79: daemon.TriggerUpdateResponse - (*GetPeerSSHHostKeyRequest)(nil), // 80: daemon.GetPeerSSHHostKeyRequest - (*GetPeerSSHHostKeyResponse)(nil), // 81: daemon.GetPeerSSHHostKeyResponse - (*RequestJWTAuthRequest)(nil), // 82: daemon.RequestJWTAuthRequest - (*RequestJWTAuthResponse)(nil), // 83: daemon.RequestJWTAuthResponse - (*WaitJWTTokenRequest)(nil), // 84: daemon.WaitJWTTokenRequest - (*WaitJWTTokenResponse)(nil), // 85: daemon.WaitJWTTokenResponse - (*StartCPUProfileRequest)(nil), // 86: daemon.StartCPUProfileRequest - (*StartCPUProfileResponse)(nil), // 87: daemon.StartCPUProfileResponse - (*StopCPUProfileRequest)(nil), // 88: daemon.StopCPUProfileRequest - (*StopCPUProfileResponse)(nil), // 89: daemon.StopCPUProfileResponse - (*InstallerResultRequest)(nil), // 90: daemon.InstallerResultRequest - (*InstallerResultResponse)(nil), // 91: daemon.InstallerResultResponse - (*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest - (*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent - (*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady - nil, // 95: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 96: daemon.PortInfo.Range - nil, // 97: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 98: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 99: google.protobuf.Timestamp + (SystemEvent_Severity)(0), // 2: daemon.SystemEvent.Severity + (SystemEvent_Category)(0), // 3: daemon.SystemEvent.Category + (*EmptyRequest)(nil), // 4: daemon.EmptyRequest + (*LoginRequest)(nil), // 5: daemon.LoginRequest + (*LoginResponse)(nil), // 6: daemon.LoginResponse + (*WaitSSOLoginRequest)(nil), // 7: daemon.WaitSSOLoginRequest + (*WaitSSOLoginResponse)(nil), // 8: daemon.WaitSSOLoginResponse + (*UpRequest)(nil), // 9: daemon.UpRequest + (*UpResponse)(nil), // 10: daemon.UpResponse + (*StatusRequest)(nil), // 11: daemon.StatusRequest + (*StatusResponse)(nil), // 12: daemon.StatusResponse + (*DownRequest)(nil), // 13: daemon.DownRequest + (*DownResponse)(nil), // 14: daemon.DownResponse + (*GetConfigRequest)(nil), // 15: daemon.GetConfigRequest + (*GetConfigResponse)(nil), // 16: daemon.GetConfigResponse + (*PeerState)(nil), // 17: daemon.PeerState + (*LocalPeerState)(nil), // 18: daemon.LocalPeerState + (*SignalState)(nil), // 19: daemon.SignalState + (*ManagementState)(nil), // 20: daemon.ManagementState + (*RelayState)(nil), // 21: daemon.RelayState + (*NSGroupState)(nil), // 22: daemon.NSGroupState + (*SSHSessionInfo)(nil), // 23: daemon.SSHSessionInfo + (*SSHServerState)(nil), // 24: daemon.SSHServerState + (*FullStatus)(nil), // 25: daemon.FullStatus + (*ListNetworksRequest)(nil), // 26: daemon.ListNetworksRequest + (*ListNetworksResponse)(nil), // 27: daemon.ListNetworksResponse + (*SelectNetworksRequest)(nil), // 28: daemon.SelectNetworksRequest + (*SelectNetworksResponse)(nil), // 29: daemon.SelectNetworksResponse + (*IPList)(nil), // 30: daemon.IPList + (*Network)(nil), // 31: daemon.Network + (*PortInfo)(nil), // 32: daemon.PortInfo + (*ForwardingRule)(nil), // 33: daemon.ForwardingRule + (*ForwardingRulesResponse)(nil), // 34: daemon.ForwardingRulesResponse + (*DebugBundleRequest)(nil), // 35: daemon.DebugBundleRequest + (*DebugBundleResponse)(nil), // 36: daemon.DebugBundleResponse + (*GetLogLevelRequest)(nil), // 37: daemon.GetLogLevelRequest + (*GetLogLevelResponse)(nil), // 38: daemon.GetLogLevelResponse + (*SetLogLevelRequest)(nil), // 39: daemon.SetLogLevelRequest + (*SetLogLevelResponse)(nil), // 40: daemon.SetLogLevelResponse + (*State)(nil), // 41: daemon.State + (*ListStatesRequest)(nil), // 42: daemon.ListStatesRequest + (*ListStatesResponse)(nil), // 43: daemon.ListStatesResponse + (*CleanStateRequest)(nil), // 44: daemon.CleanStateRequest + (*CleanStateResponse)(nil), // 45: daemon.CleanStateResponse + (*DeleteStateRequest)(nil), // 46: daemon.DeleteStateRequest + (*DeleteStateResponse)(nil), // 47: daemon.DeleteStateResponse + (*SetSyncResponsePersistenceRequest)(nil), // 48: daemon.SetSyncResponsePersistenceRequest + (*SetSyncResponsePersistenceResponse)(nil), // 49: daemon.SetSyncResponsePersistenceResponse + (*TCPFlags)(nil), // 50: daemon.TCPFlags + (*TracePacketRequest)(nil), // 51: daemon.TracePacketRequest + (*TraceStage)(nil), // 52: daemon.TraceStage + (*TracePacketResponse)(nil), // 53: daemon.TracePacketResponse + (*SubscribeRequest)(nil), // 54: daemon.SubscribeRequest + (*SystemEvent)(nil), // 55: daemon.SystemEvent + (*GetEventsRequest)(nil), // 56: daemon.GetEventsRequest + (*GetEventsResponse)(nil), // 57: daemon.GetEventsResponse + (*SwitchProfileRequest)(nil), // 58: daemon.SwitchProfileRequest + (*SwitchProfileResponse)(nil), // 59: daemon.SwitchProfileResponse + (*SetConfigRequest)(nil), // 60: daemon.SetConfigRequest + (*SetConfigResponse)(nil), // 61: daemon.SetConfigResponse + (*AddProfileRequest)(nil), // 62: daemon.AddProfileRequest + (*AddProfileResponse)(nil), // 63: daemon.AddProfileResponse + (*RemoveProfileRequest)(nil), // 64: daemon.RemoveProfileRequest + (*RemoveProfileResponse)(nil), // 65: daemon.RemoveProfileResponse + (*ListProfilesRequest)(nil), // 66: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 67: daemon.ListProfilesResponse + (*Profile)(nil), // 68: daemon.Profile + (*GetActiveProfileRequest)(nil), // 69: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 70: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 71: daemon.LogoutRequest + (*LogoutResponse)(nil), // 72: daemon.LogoutResponse + (*GetFeaturesRequest)(nil), // 73: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 74: daemon.GetFeaturesResponse + (*TriggerUpdateRequest)(nil), // 75: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 76: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 77: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 78: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 79: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 80: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 81: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 82: daemon.WaitJWTTokenResponse + (*StartCPUProfileRequest)(nil), // 83: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 84: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 85: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 86: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 87: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 88: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 89: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 90: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 91: daemon.ExposeServiceReady + nil, // 92: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 93: daemon.PortInfo.Range + nil, // 94: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 95: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 96: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 2, // 0: daemon.OSLifecycleRequest.type:type_name -> daemon.OSLifecycleRequest.CycleType - 98, // 1: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 28, // 2: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 99, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 99, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 98, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration - 26, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo - 23, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState - 22, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState - 21, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState - 20, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState - 24, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState - 25, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState - 58, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent - 27, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState - 34, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 95, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 96, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range - 35, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo - 35, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo - 36, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule - 0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel - 0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel - 44, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State - 53, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags - 55, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage - 3, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity - 4, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 99, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 97, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry - 58, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 98, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 71, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile - 1, // 33: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 94, // 34: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 33, // 35: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList - 8, // 36: daemon.DaemonService.Login:input_type -> daemon.LoginRequest - 10, // 37: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest - 12, // 38: daemon.DaemonService.Up:input_type -> daemon.UpRequest - 14, // 39: daemon.DaemonService.Status:input_type -> daemon.StatusRequest - 16, // 40: daemon.DaemonService.Down:input_type -> daemon.DownRequest - 18, // 41: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest - 29, // 42: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest - 31, // 43: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest - 31, // 44: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest - 5, // 45: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest - 38, // 46: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest - 40, // 47: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest - 42, // 48: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest - 45, // 49: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest - 47, // 50: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest - 49, // 51: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest - 51, // 52: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest - 54, // 53: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 57, // 54: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest - 59, // 55: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest - 61, // 56: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest - 63, // 57: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest - 65, // 58: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 67, // 59: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 69, // 60: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 72, // 61: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 74, // 62: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 76, // 63: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 78, // 64: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 80, // 65: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 82, // 66: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 84, // 67: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 86, // 68: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 88, // 69: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 6, // 70: daemon.DaemonService.NotifyOSLifecycle:input_type -> daemon.OSLifecycleRequest - 90, // 71: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 92, // 72: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 9, // 73: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 11, // 74: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 13, // 75: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 15, // 76: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 17, // 77: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 19, // 78: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 30, // 79: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 32, // 80: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 32, // 81: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 37, // 82: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 39, // 83: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 41, // 84: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 43, // 85: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 46, // 86: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 48, // 87: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 50, // 88: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 52, // 89: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 56, // 90: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 58, // 91: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 60, // 92: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 62, // 93: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 64, // 94: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 66, // 95: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 68, // 96: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 70, // 97: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 73, // 98: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 75, // 99: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 77, // 100: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 79, // 101: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 81, // 102: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 83, // 103: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 85, // 104: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 87, // 105: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 89, // 106: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 7, // 107: daemon.DaemonService.NotifyOSLifecycle:output_type -> daemon.OSLifecycleResponse - 91, // 108: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 93, // 109: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 73, // [73:110] is the sub-list for method output_type - 36, // [36:73] is the sub-list for method input_type - 36, // [36:36] is the sub-list for extension type_name - 36, // [36:36] is the sub-list for extension extendee - 0, // [0:36] is the sub-list for field type_name + 95, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus + 96, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 96, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 95, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo + 20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState + 19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState + 18, // 8: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState + 17, // 9: daemon.FullStatus.peers:type_name -> daemon.PeerState + 21, // 10: daemon.FullStatus.relays:type_name -> daemon.RelayState + 22, // 11: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState + 55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent + 24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState + 31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network + 92, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 93, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo + 32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo + 33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule + 0, // 20: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel + 0, // 21: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel + 41, // 22: daemon.ListStatesResponse.states:type_name -> daemon.State + 50, // 23: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags + 52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage + 2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity + 3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category + 96, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 94, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent + 95, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol + 91, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 30, // 34: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList + 5, // 35: daemon.DaemonService.Login:input_type -> daemon.LoginRequest + 7, // 36: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest + 9, // 37: daemon.DaemonService.Up:input_type -> daemon.UpRequest + 11, // 38: daemon.DaemonService.Status:input_type -> daemon.StatusRequest + 13, // 39: daemon.DaemonService.Down:input_type -> daemon.DownRequest + 15, // 40: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest + 26, // 41: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest + 28, // 42: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest + 28, // 43: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest + 4, // 44: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest + 35, // 45: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest + 37, // 46: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest + 39, // 47: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest + 42, // 48: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest + 44, // 49: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest + 46, // 50: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest + 48, // 51: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest + 51, // 52: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest + 54, // 53: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest + 56, // 54: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest + 58, // 55: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest + 60, // 56: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest + 62, // 57: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest + 64, // 58: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 66, // 59: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 69, // 60: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 71, // 61: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 73, // 62: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 75, // 63: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 77, // 64: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 79, // 65: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 81, // 66: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 83, // 67: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 85, // 68: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 87, // 69: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 89, // 70: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 6, // 71: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 72: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 73: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 74: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 14, // 75: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 76: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 27, // 77: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 29, // 78: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 29, // 79: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 34, // 80: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 36, // 81: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 38, // 82: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 40, // 83: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 43, // 84: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 45, // 85: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 47, // 86: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 49, // 87: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 53, // 88: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 55, // 89: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 57, // 90: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 59, // 91: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 61, // 92: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 63, // 93: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 65, // 94: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 67, // 95: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 70, // 96: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 72, // 97: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 74, // 98: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 76, // 99: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 78, // 100: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 80, // 101: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 82, // 102: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 84, // 103: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 86, // 104: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 88, // 105: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 90, // 106: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 71, // [71:107] is the sub-list for method output_type + 35, // [35:71] is the sub-list for method input_type + 35, // [35:35] is the sub-list for extension type_name + 35, // [35:35] is the sub-list for extension extendee + 0, // [0:35] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -6848,20 +6703,20 @@ func file_daemon_proto_init() { if File_daemon_proto != nil { return } - file_daemon_proto_msgTypes[3].OneofWrappers = []any{} + file_daemon_proto_msgTypes[1].OneofWrappers = []any{} + file_daemon_proto_msgTypes[5].OneofWrappers = []any{} file_daemon_proto_msgTypes[7].OneofWrappers = []any{} - file_daemon_proto_msgTypes[9].OneofWrappers = []any{} - file_daemon_proto_msgTypes[30].OneofWrappers = []any{ + file_daemon_proto_msgTypes[28].OneofWrappers = []any{ (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } - file_daemon_proto_msgTypes[49].OneofWrappers = []any{} - file_daemon_proto_msgTypes[50].OneofWrappers = []any{} + file_daemon_proto_msgTypes[47].OneofWrappers = []any{} + file_daemon_proto_msgTypes[48].OneofWrappers = []any{} + file_daemon_proto_msgTypes[54].OneofWrappers = []any{} file_daemon_proto_msgTypes[56].OneofWrappers = []any{} - file_daemon_proto_msgTypes[58].OneofWrappers = []any{} - file_daemon_proto_msgTypes[69].OneofWrappers = []any{} - file_daemon_proto_msgTypes[77].OneofWrappers = []any{} - file_daemon_proto_msgTypes[88].OneofWrappers = []any{ + file_daemon_proto_msgTypes[67].OneofWrappers = []any{} + file_daemon_proto_msgTypes[75].OneofWrappers = []any{} + file_daemon_proto_msgTypes[86].OneofWrappers = []any{ (*ExposeServiceEvent_Ready)(nil), } type x struct{} @@ -6869,8 +6724,8 @@ func file_daemon_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), - NumEnums: 5, - NumMessages: 93, + NumEnums: 4, + NumMessages: 91, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 19976660c..f4e5b8e4d 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -104,8 +104,6 @@ service DaemonService { // StopCPUProfile stops CPU profiling in the daemon rpc StopCPUProfile(StopCPUProfileRequest) returns (StopCPUProfileResponse) {} - rpc NotifyOSLifecycle(OSLifecycleRequest) returns(OSLifecycleResponse) {} - rpc GetInstallerResult(InstallerResultRequest) returns (InstallerResultResponse) {} // ExposeService exposes a local port via the NetBird reverse proxy @@ -114,20 +112,6 @@ service DaemonService { -message OSLifecycleRequest { - // avoid collision with loglevel enum - enum CycleType { - UNKNOWN = 0; - SLEEP = 1; - WAKEUP = 2; - } - - CycleType type = 1; -} - -message OSLifecycleResponse {} - - message LoginRequest { // setupKey netbird setup key. string setupKey = 1; diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index e5bd89597..026ee2361 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -1,4 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v6.33.1 +// source: daemon.proto package proto @@ -11,8 +15,47 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + DaemonService_Login_FullMethodName = "/daemon.DaemonService/Login" + DaemonService_WaitSSOLogin_FullMethodName = "/daemon.DaemonService/WaitSSOLogin" + DaemonService_Up_FullMethodName = "/daemon.DaemonService/Up" + DaemonService_Status_FullMethodName = "/daemon.DaemonService/Status" + DaemonService_Down_FullMethodName = "/daemon.DaemonService/Down" + DaemonService_GetConfig_FullMethodName = "/daemon.DaemonService/GetConfig" + DaemonService_ListNetworks_FullMethodName = "/daemon.DaemonService/ListNetworks" + DaemonService_SelectNetworks_FullMethodName = "/daemon.DaemonService/SelectNetworks" + DaemonService_DeselectNetworks_FullMethodName = "/daemon.DaemonService/DeselectNetworks" + DaemonService_ForwardingRules_FullMethodName = "/daemon.DaemonService/ForwardingRules" + DaemonService_DebugBundle_FullMethodName = "/daemon.DaemonService/DebugBundle" + DaemonService_GetLogLevel_FullMethodName = "/daemon.DaemonService/GetLogLevel" + DaemonService_SetLogLevel_FullMethodName = "/daemon.DaemonService/SetLogLevel" + DaemonService_ListStates_FullMethodName = "/daemon.DaemonService/ListStates" + DaemonService_CleanState_FullMethodName = "/daemon.DaemonService/CleanState" + DaemonService_DeleteState_FullMethodName = "/daemon.DaemonService/DeleteState" + DaemonService_SetSyncResponsePersistence_FullMethodName = "/daemon.DaemonService/SetSyncResponsePersistence" + DaemonService_TracePacket_FullMethodName = "/daemon.DaemonService/TracePacket" + DaemonService_SubscribeEvents_FullMethodName = "/daemon.DaemonService/SubscribeEvents" + DaemonService_GetEvents_FullMethodName = "/daemon.DaemonService/GetEvents" + DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile" + DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig" + DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile" + DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile" + DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles" + DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile" + DaemonService_Logout_FullMethodName = "/daemon.DaemonService/Logout" + DaemonService_GetFeatures_FullMethodName = "/daemon.DaemonService/GetFeatures" + DaemonService_TriggerUpdate_FullMethodName = "/daemon.DaemonService/TriggerUpdate" + DaemonService_GetPeerSSHHostKey_FullMethodName = "/daemon.DaemonService/GetPeerSSHHostKey" + DaemonService_RequestJWTAuth_FullMethodName = "/daemon.DaemonService/RequestJWTAuth" + DaemonService_WaitJWTToken_FullMethodName = "/daemon.DaemonService/WaitJWTToken" + DaemonService_StartCPUProfile_FullMethodName = "/daemon.DaemonService/StartCPUProfile" + DaemonService_StopCPUProfile_FullMethodName = "/daemon.DaemonService/StopCPUProfile" + DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult" + DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService" +) // DaemonServiceClient is the client API for DaemonService service. // @@ -53,7 +96,7 @@ type DaemonServiceClient interface { // SetSyncResponsePersistence enables or disables sync response persistence SetSyncResponsePersistence(ctx context.Context, in *SetSyncResponsePersistenceRequest, opts ...grpc.CallOption) (*SetSyncResponsePersistenceResponse, error) TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error) - SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error) + SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error) @@ -77,10 +120,9 @@ type DaemonServiceClient interface { StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error) // StopCPUProfile stops CPU profiling in the daemon StopCPUProfile(ctx context.Context, in *StopCPUProfileRequest, opts ...grpc.CallOption) (*StopCPUProfileResponse, error) - NotifyOSLifecycle(ctx context.Context, in *OSLifecycleRequest, opts ...grpc.CallOption) (*OSLifecycleResponse, error) GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error) // ExposeService exposes a local port via the NetBird reverse proxy - ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (DaemonService_ExposeServiceClient, error) + ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) } type daemonServiceClient struct { @@ -92,8 +134,9 @@ func NewDaemonServiceClient(cc grpc.ClientConnInterface) DaemonServiceClient { } func (c *daemonServiceClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(LoginResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/Login", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_Login_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -101,8 +144,9 @@ func (c *daemonServiceClient) Login(ctx context.Context, in *LoginRequest, opts } func (c *daemonServiceClient) WaitSSOLogin(ctx context.Context, in *WaitSSOLoginRequest, opts ...grpc.CallOption) (*WaitSSOLoginResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(WaitSSOLoginResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/WaitSSOLogin", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_WaitSSOLogin_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -110,8 +154,9 @@ func (c *daemonServiceClient) WaitSSOLogin(ctx context.Context, in *WaitSSOLogin } func (c *daemonServiceClient) Up(ctx context.Context, in *UpRequest, opts ...grpc.CallOption) (*UpResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(UpResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/Up", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_Up_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -119,8 +164,9 @@ func (c *daemonServiceClient) Up(ctx context.Context, in *UpRequest, opts ...grp } func (c *daemonServiceClient) Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(StatusResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/Status", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_Status_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -128,8 +174,9 @@ func (c *daemonServiceClient) Status(ctx context.Context, in *StatusRequest, opt } func (c *daemonServiceClient) Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DownResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/Down", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_Down_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -137,8 +184,9 @@ func (c *daemonServiceClient) Down(ctx context.Context, in *DownRequest, opts .. } func (c *daemonServiceClient) GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetConfigResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetConfig", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetConfig_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -146,8 +194,9 @@ func (c *daemonServiceClient) GetConfig(ctx context.Context, in *GetConfigReques } func (c *daemonServiceClient) ListNetworks(ctx context.Context, in *ListNetworksRequest, opts ...grpc.CallOption) (*ListNetworksResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListNetworksResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/ListNetworks", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_ListNetworks_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -155,8 +204,9 @@ func (c *daemonServiceClient) ListNetworks(ctx context.Context, in *ListNetworks } func (c *daemonServiceClient) SelectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SelectNetworksResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SelectNetworks", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_SelectNetworks_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -164,8 +214,9 @@ func (c *daemonServiceClient) SelectNetworks(ctx context.Context, in *SelectNetw } func (c *daemonServiceClient) DeselectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SelectNetworksResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/DeselectNetworks", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_DeselectNetworks_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -173,8 +224,9 @@ func (c *daemonServiceClient) DeselectNetworks(ctx context.Context, in *SelectNe } func (c *daemonServiceClient) ForwardingRules(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*ForwardingRulesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ForwardingRulesResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/ForwardingRules", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_ForwardingRules_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -182,8 +234,9 @@ func (c *daemonServiceClient) ForwardingRules(ctx context.Context, in *EmptyRequ } func (c *daemonServiceClient) DebugBundle(ctx context.Context, in *DebugBundleRequest, opts ...grpc.CallOption) (*DebugBundleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DebugBundleResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/DebugBundle", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_DebugBundle_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -191,8 +244,9 @@ func (c *daemonServiceClient) DebugBundle(ctx context.Context, in *DebugBundleRe } func (c *daemonServiceClient) GetLogLevel(ctx context.Context, in *GetLogLevelRequest, opts ...grpc.CallOption) (*GetLogLevelResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetLogLevelResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetLogLevel", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetLogLevel_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -200,8 +254,9 @@ func (c *daemonServiceClient) GetLogLevel(ctx context.Context, in *GetLogLevelRe } func (c *daemonServiceClient) SetLogLevel(ctx context.Context, in *SetLogLevelRequest, opts ...grpc.CallOption) (*SetLogLevelResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SetLogLevelResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SetLogLevel", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_SetLogLevel_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -209,8 +264,9 @@ func (c *daemonServiceClient) SetLogLevel(ctx context.Context, in *SetLogLevelRe } func (c *daemonServiceClient) ListStates(ctx context.Context, in *ListStatesRequest, opts ...grpc.CallOption) (*ListStatesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListStatesResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/ListStates", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_ListStates_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -218,8 +274,9 @@ func (c *daemonServiceClient) ListStates(ctx context.Context, in *ListStatesRequ } func (c *daemonServiceClient) CleanState(ctx context.Context, in *CleanStateRequest, opts ...grpc.CallOption) (*CleanStateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(CleanStateResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/CleanState", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_CleanState_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -227,8 +284,9 @@ func (c *daemonServiceClient) CleanState(ctx context.Context, in *CleanStateRequ } func (c *daemonServiceClient) DeleteState(ctx context.Context, in *DeleteStateRequest, opts ...grpc.CallOption) (*DeleteStateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DeleteStateResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/DeleteState", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_DeleteState_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -236,8 +294,9 @@ func (c *daemonServiceClient) DeleteState(ctx context.Context, in *DeleteStateRe } func (c *daemonServiceClient) SetSyncResponsePersistence(ctx context.Context, in *SetSyncResponsePersistenceRequest, opts ...grpc.CallOption) (*SetSyncResponsePersistenceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SetSyncResponsePersistenceResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SetSyncResponsePersistence", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_SetSyncResponsePersistence_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -245,20 +304,22 @@ func (c *daemonServiceClient) SetSyncResponsePersistence(ctx context.Context, in } func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(TracePacketResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/TracePacket", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_TracePacket_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } -func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error) { - stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], "/daemon.DaemonService/SubscribeEvents", opts...) +func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_SubscribeEvents_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &daemonServiceSubscribeEventsClient{stream} + x := &grpc.GenericClientStream[SubscribeRequest, SystemEvent]{ClientStream: stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } @@ -268,26 +329,13 @@ func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *Subscribe return x, nil } -type DaemonService_SubscribeEventsClient interface { - Recv() (*SystemEvent, error) - grpc.ClientStream -} - -type daemonServiceSubscribeEventsClient struct { - grpc.ClientStream -} - -func (x *daemonServiceSubscribeEventsClient) Recv() (*SystemEvent, error) { - m := new(SystemEvent) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_SubscribeEventsClient = grpc.ServerStreamingClient[SystemEvent] func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetEventsResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetEvents", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetEvents_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -295,8 +343,9 @@ func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsReques } func (c *daemonServiceClient) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SwitchProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SwitchProfile", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_SwitchProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -304,8 +353,9 @@ func (c *daemonServiceClient) SwitchProfile(ctx context.Context, in *SwitchProfi } func (c *daemonServiceClient) SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SetConfigResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SetConfig", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_SetConfig_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -313,8 +363,9 @@ func (c *daemonServiceClient) SetConfig(ctx context.Context, in *SetConfigReques } func (c *daemonServiceClient) AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(AddProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/AddProfile", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_AddProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -322,8 +373,9 @@ func (c *daemonServiceClient) AddProfile(ctx context.Context, in *AddProfileRequ } func (c *daemonServiceClient) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RemoveProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/RemoveProfile", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_RemoveProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -331,8 +383,9 @@ func (c *daemonServiceClient) RemoveProfile(ctx context.Context, in *RemoveProfi } func (c *daemonServiceClient) ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListProfilesResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/ListProfiles", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_ListProfiles_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -340,8 +393,9 @@ func (c *daemonServiceClient) ListProfiles(ctx context.Context, in *ListProfiles } func (c *daemonServiceClient) GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetActiveProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetActiveProfile", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetActiveProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -349,8 +403,9 @@ func (c *daemonServiceClient) GetActiveProfile(ctx context.Context, in *GetActiv } func (c *daemonServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(LogoutResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/Logout", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_Logout_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -358,8 +413,9 @@ func (c *daemonServiceClient) Logout(ctx context.Context, in *LogoutRequest, opt } func (c *daemonServiceClient) GetFeatures(ctx context.Context, in *GetFeaturesRequest, opts ...grpc.CallOption) (*GetFeaturesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetFeaturesResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetFeatures", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetFeatures_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -367,8 +423,9 @@ func (c *daemonServiceClient) GetFeatures(ctx context.Context, in *GetFeaturesRe } func (c *daemonServiceClient) TriggerUpdate(ctx context.Context, in *TriggerUpdateRequest, opts ...grpc.CallOption) (*TriggerUpdateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(TriggerUpdateResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/TriggerUpdate", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_TriggerUpdate_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -376,8 +433,9 @@ func (c *daemonServiceClient) TriggerUpdate(ctx context.Context, in *TriggerUpda } func (c *daemonServiceClient) GetPeerSSHHostKey(ctx context.Context, in *GetPeerSSHHostKeyRequest, opts ...grpc.CallOption) (*GetPeerSSHHostKeyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetPeerSSHHostKeyResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetPeerSSHHostKey", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetPeerSSHHostKey_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -385,8 +443,9 @@ func (c *daemonServiceClient) GetPeerSSHHostKey(ctx context.Context, in *GetPeer } func (c *daemonServiceClient) RequestJWTAuth(ctx context.Context, in *RequestJWTAuthRequest, opts ...grpc.CallOption) (*RequestJWTAuthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RequestJWTAuthResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/RequestJWTAuth", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_RequestJWTAuth_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -394,8 +453,9 @@ func (c *daemonServiceClient) RequestJWTAuth(ctx context.Context, in *RequestJWT } func (c *daemonServiceClient) WaitJWTToken(ctx context.Context, in *WaitJWTTokenRequest, opts ...grpc.CallOption) (*WaitJWTTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(WaitJWTTokenResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/WaitJWTToken", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_WaitJWTToken_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -403,8 +463,9 @@ func (c *daemonServiceClient) WaitJWTToken(ctx context.Context, in *WaitJWTToken } func (c *daemonServiceClient) StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(StartCPUProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/StartCPUProfile", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_StartCPUProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -412,17 +473,9 @@ func (c *daemonServiceClient) StartCPUProfile(ctx context.Context, in *StartCPUP } func (c *daemonServiceClient) StopCPUProfile(ctx context.Context, in *StopCPUProfileRequest, opts ...grpc.CallOption) (*StopCPUProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(StopCPUProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/StopCPUProfile", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) NotifyOSLifecycle(ctx context.Context, in *OSLifecycleRequest, opts ...grpc.CallOption) (*OSLifecycleResponse, error) { - out := new(OSLifecycleResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/NotifyOSLifecycle", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_StopCPUProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -430,20 +483,22 @@ func (c *daemonServiceClient) NotifyOSLifecycle(ctx context.Context, in *OSLifec } func (c *daemonServiceClient) GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(InstallerResultResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetInstallerResult", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetInstallerResult_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } -func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (DaemonService_ExposeServiceClient, error) { - stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], "/daemon.DaemonService/ExposeService", opts...) +func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_ExposeService_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &daemonServiceExposeServiceClient{stream} + x := &grpc.GenericClientStream[ExposeServiceRequest, ExposeServiceEvent]{ClientStream: stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } @@ -453,26 +508,12 @@ func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServi return x, nil } -type DaemonService_ExposeServiceClient interface { - Recv() (*ExposeServiceEvent, error) - grpc.ClientStream -} - -type daemonServiceExposeServiceClient struct { - grpc.ClientStream -} - -func (x *daemonServiceExposeServiceClient) Recv() (*ExposeServiceEvent, error) { - m := new(ExposeServiceEvent) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_ExposeServiceClient = grpc.ServerStreamingClient[ExposeServiceEvent] // DaemonServiceServer is the server API for DaemonService service. // All implementations must embed UnimplementedDaemonServiceServer -// for forward compatibility +// for forward compatibility. type DaemonServiceServer interface { // Login uses setup key to prepare configuration for the daemon. Login(context.Context, *LoginRequest) (*LoginResponse, error) @@ -509,7 +550,7 @@ type DaemonServiceServer interface { // SetSyncResponsePersistence enables or disables sync response persistence SetSyncResponsePersistence(context.Context, *SetSyncResponsePersistenceRequest) (*SetSyncResponsePersistenceResponse, error) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) - SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error + SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error) @@ -533,129 +574,129 @@ type DaemonServiceServer interface { StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error) // StopCPUProfile stops CPU profiling in the daemon StopCPUProfile(context.Context, *StopCPUProfileRequest) (*StopCPUProfileResponse, error) - NotifyOSLifecycle(context.Context, *OSLifecycleRequest) (*OSLifecycleResponse, error) GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error) // ExposeService exposes a local port via the NetBird reverse proxy - ExposeService(*ExposeServiceRequest, DaemonService_ExposeServiceServer) error + ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error mustEmbedUnimplementedDaemonServiceServer() } -// UnimplementedDaemonServiceServer must be embedded to have forward compatible implementations. -type UnimplementedDaemonServiceServer struct { -} +// UnimplementedDaemonServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDaemonServiceServer struct{} func (UnimplementedDaemonServiceServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Login not implemented") + return nil, status.Error(codes.Unimplemented, "method Login not implemented") } func (UnimplementedDaemonServiceServer) WaitSSOLogin(context.Context, *WaitSSOLoginRequest) (*WaitSSOLoginResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method WaitSSOLogin not implemented") + return nil, status.Error(codes.Unimplemented, "method WaitSSOLogin not implemented") } func (UnimplementedDaemonServiceServer) Up(context.Context, *UpRequest) (*UpResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Up not implemented") + return nil, status.Error(codes.Unimplemented, "method Up not implemented") } func (UnimplementedDaemonServiceServer) Status(context.Context, *StatusRequest) (*StatusResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Status not implemented") + return nil, status.Error(codes.Unimplemented, "method Status not implemented") } func (UnimplementedDaemonServiceServer) Down(context.Context, *DownRequest) (*DownResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Down not implemented") + return nil, status.Error(codes.Unimplemented, "method Down not implemented") } func (UnimplementedDaemonServiceServer) GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetConfig not implemented") + return nil, status.Error(codes.Unimplemented, "method GetConfig not implemented") } func (UnimplementedDaemonServiceServer) ListNetworks(context.Context, *ListNetworksRequest) (*ListNetworksResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListNetworks not implemented") + return nil, status.Error(codes.Unimplemented, "method ListNetworks not implemented") } func (UnimplementedDaemonServiceServer) SelectNetworks(context.Context, *SelectNetworksRequest) (*SelectNetworksResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SelectNetworks not implemented") + return nil, status.Error(codes.Unimplemented, "method SelectNetworks not implemented") } func (UnimplementedDaemonServiceServer) DeselectNetworks(context.Context, *SelectNetworksRequest) (*SelectNetworksResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DeselectNetworks not implemented") + return nil, status.Error(codes.Unimplemented, "method DeselectNetworks not implemented") } func (UnimplementedDaemonServiceServer) ForwardingRules(context.Context, *EmptyRequest) (*ForwardingRulesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ForwardingRules not implemented") + return nil, status.Error(codes.Unimplemented, "method ForwardingRules not implemented") } func (UnimplementedDaemonServiceServer) DebugBundle(context.Context, *DebugBundleRequest) (*DebugBundleResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DebugBundle not implemented") + return nil, status.Error(codes.Unimplemented, "method DebugBundle not implemented") } func (UnimplementedDaemonServiceServer) GetLogLevel(context.Context, *GetLogLevelRequest) (*GetLogLevelResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetLogLevel not implemented") + return nil, status.Error(codes.Unimplemented, "method GetLogLevel not implemented") } func (UnimplementedDaemonServiceServer) SetLogLevel(context.Context, *SetLogLevelRequest) (*SetLogLevelResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SetLogLevel not implemented") + return nil, status.Error(codes.Unimplemented, "method SetLogLevel not implemented") } func (UnimplementedDaemonServiceServer) ListStates(context.Context, *ListStatesRequest) (*ListStatesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListStates not implemented") + return nil, status.Error(codes.Unimplemented, "method ListStates not implemented") } func (UnimplementedDaemonServiceServer) CleanState(context.Context, *CleanStateRequest) (*CleanStateResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method CleanState not implemented") + return nil, status.Error(codes.Unimplemented, "method CleanState not implemented") } func (UnimplementedDaemonServiceServer) DeleteState(context.Context, *DeleteStateRequest) (*DeleteStateResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DeleteState not implemented") + return nil, status.Error(codes.Unimplemented, "method DeleteState not implemented") } func (UnimplementedDaemonServiceServer) SetSyncResponsePersistence(context.Context, *SetSyncResponsePersistenceRequest) (*SetSyncResponsePersistenceResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SetSyncResponsePersistence not implemented") + return nil, status.Error(codes.Unimplemented, "method SetSyncResponsePersistence not implemented") } func (UnimplementedDaemonServiceServer) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method TracePacket not implemented") + return nil, status.Error(codes.Unimplemented, "method TracePacket not implemented") } -func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error { - return status.Errorf(codes.Unimplemented, "method SubscribeEvents not implemented") +func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error { + return status.Error(codes.Unimplemented, "method SubscribeEvents not implemented") } func (UnimplementedDaemonServiceServer) GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetEvents not implemented") + return nil, status.Error(codes.Unimplemented, "method GetEvents not implemented") } func (UnimplementedDaemonServiceServer) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SwitchProfile not implemented") + return nil, status.Error(codes.Unimplemented, "method SwitchProfile not implemented") } func (UnimplementedDaemonServiceServer) SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SetConfig not implemented") + return nil, status.Error(codes.Unimplemented, "method SetConfig not implemented") } func (UnimplementedDaemonServiceServer) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method AddProfile not implemented") + return nil, status.Error(codes.Unimplemented, "method AddProfile not implemented") } func (UnimplementedDaemonServiceServer) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method RemoveProfile not implemented") + return nil, status.Error(codes.Unimplemented, "method RemoveProfile not implemented") } func (UnimplementedDaemonServiceServer) ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListProfiles not implemented") + return nil, status.Error(codes.Unimplemented, "method ListProfiles not implemented") } func (UnimplementedDaemonServiceServer) GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetActiveProfile not implemented") + return nil, status.Error(codes.Unimplemented, "method GetActiveProfile not implemented") } func (UnimplementedDaemonServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented") + return nil, status.Error(codes.Unimplemented, "method Logout not implemented") } func (UnimplementedDaemonServiceServer) GetFeatures(context.Context, *GetFeaturesRequest) (*GetFeaturesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetFeatures not implemented") + return nil, status.Error(codes.Unimplemented, "method GetFeatures not implemented") } func (UnimplementedDaemonServiceServer) TriggerUpdate(context.Context, *TriggerUpdateRequest) (*TriggerUpdateResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method TriggerUpdate not implemented") + return nil, status.Error(codes.Unimplemented, "method TriggerUpdate not implemented") } func (UnimplementedDaemonServiceServer) GetPeerSSHHostKey(context.Context, *GetPeerSSHHostKeyRequest) (*GetPeerSSHHostKeyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetPeerSSHHostKey not implemented") + return nil, status.Error(codes.Unimplemented, "method GetPeerSSHHostKey not implemented") } func (UnimplementedDaemonServiceServer) RequestJWTAuth(context.Context, *RequestJWTAuthRequest) (*RequestJWTAuthResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method RequestJWTAuth not implemented") + return nil, status.Error(codes.Unimplemented, "method RequestJWTAuth not implemented") } func (UnimplementedDaemonServiceServer) WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method WaitJWTToken not implemented") + return nil, status.Error(codes.Unimplemented, "method WaitJWTToken not implemented") } func (UnimplementedDaemonServiceServer) StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method StartCPUProfile not implemented") + return nil, status.Error(codes.Unimplemented, "method StartCPUProfile not implemented") } func (UnimplementedDaemonServiceServer) StopCPUProfile(context.Context, *StopCPUProfileRequest) (*StopCPUProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method StopCPUProfile not implemented") -} -func (UnimplementedDaemonServiceServer) NotifyOSLifecycle(context.Context, *OSLifecycleRequest) (*OSLifecycleResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method NotifyOSLifecycle not implemented") + return nil, status.Error(codes.Unimplemented, "method StopCPUProfile not implemented") } func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetInstallerResult not implemented") + return nil, status.Error(codes.Unimplemented, "method GetInstallerResult not implemented") } -func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, DaemonService_ExposeServiceServer) error { - return status.Errorf(codes.Unimplemented, "method ExposeService not implemented") +func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error { + return status.Error(codes.Unimplemented, "method ExposeService not implemented") } func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {} +func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {} // UnsafeDaemonServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to DaemonServiceServer will @@ -665,6 +706,13 @@ type UnsafeDaemonServiceServer interface { } func RegisterDaemonServiceServer(s grpc.ServiceRegistrar, srv DaemonServiceServer) { + // If the following call panics, it indicates UnimplementedDaemonServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&DaemonService_ServiceDesc, srv) } @@ -678,7 +726,7 @@ func _DaemonService_Login_Handler(srv interface{}, ctx context.Context, dec func } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/Login", + FullMethod: DaemonService_Login_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).Login(ctx, req.(*LoginRequest)) @@ -696,7 +744,7 @@ func _DaemonService_WaitSSOLogin_Handler(srv interface{}, ctx context.Context, d } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/WaitSSOLogin", + FullMethod: DaemonService_WaitSSOLogin_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).WaitSSOLogin(ctx, req.(*WaitSSOLoginRequest)) @@ -714,7 +762,7 @@ func _DaemonService_Up_Handler(srv interface{}, ctx context.Context, dec func(in } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/Up", + FullMethod: DaemonService_Up_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).Up(ctx, req.(*UpRequest)) @@ -732,7 +780,7 @@ func _DaemonService_Status_Handler(srv interface{}, ctx context.Context, dec fun } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/Status", + FullMethod: DaemonService_Status_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).Status(ctx, req.(*StatusRequest)) @@ -750,7 +798,7 @@ func _DaemonService_Down_Handler(srv interface{}, ctx context.Context, dec func( } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/Down", + FullMethod: DaemonService_Down_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).Down(ctx, req.(*DownRequest)) @@ -768,7 +816,7 @@ func _DaemonService_GetConfig_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetConfig", + FullMethod: DaemonService_GetConfig_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetConfig(ctx, req.(*GetConfigRequest)) @@ -786,7 +834,7 @@ func _DaemonService_ListNetworks_Handler(srv interface{}, ctx context.Context, d } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/ListNetworks", + FullMethod: DaemonService_ListNetworks_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).ListNetworks(ctx, req.(*ListNetworksRequest)) @@ -804,7 +852,7 @@ func _DaemonService_SelectNetworks_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/SelectNetworks", + FullMethod: DaemonService_SelectNetworks_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).SelectNetworks(ctx, req.(*SelectNetworksRequest)) @@ -822,7 +870,7 @@ func _DaemonService_DeselectNetworks_Handler(srv interface{}, ctx context.Contex } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/DeselectNetworks", + FullMethod: DaemonService_DeselectNetworks_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).DeselectNetworks(ctx, req.(*SelectNetworksRequest)) @@ -840,7 +888,7 @@ func _DaemonService_ForwardingRules_Handler(srv interface{}, ctx context.Context } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/ForwardingRules", + FullMethod: DaemonService_ForwardingRules_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).ForwardingRules(ctx, req.(*EmptyRequest)) @@ -858,7 +906,7 @@ func _DaemonService_DebugBundle_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/DebugBundle", + FullMethod: DaemonService_DebugBundle_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).DebugBundle(ctx, req.(*DebugBundleRequest)) @@ -876,7 +924,7 @@ func _DaemonService_GetLogLevel_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetLogLevel", + FullMethod: DaemonService_GetLogLevel_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetLogLevel(ctx, req.(*GetLogLevelRequest)) @@ -894,7 +942,7 @@ func _DaemonService_SetLogLevel_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/SetLogLevel", + FullMethod: DaemonService_SetLogLevel_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).SetLogLevel(ctx, req.(*SetLogLevelRequest)) @@ -912,7 +960,7 @@ func _DaemonService_ListStates_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/ListStates", + FullMethod: DaemonService_ListStates_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).ListStates(ctx, req.(*ListStatesRequest)) @@ -930,7 +978,7 @@ func _DaemonService_CleanState_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/CleanState", + FullMethod: DaemonService_CleanState_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).CleanState(ctx, req.(*CleanStateRequest)) @@ -948,7 +996,7 @@ func _DaemonService_DeleteState_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/DeleteState", + FullMethod: DaemonService_DeleteState_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).DeleteState(ctx, req.(*DeleteStateRequest)) @@ -966,7 +1014,7 @@ func _DaemonService_SetSyncResponsePersistence_Handler(srv interface{}, ctx cont } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/SetSyncResponsePersistence", + FullMethod: DaemonService_SetSyncResponsePersistence_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).SetSyncResponsePersistence(ctx, req.(*SetSyncResponsePersistenceRequest)) @@ -984,7 +1032,7 @@ func _DaemonService_TracePacket_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/TracePacket", + FullMethod: DaemonService_TracePacket_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).TracePacket(ctx, req.(*TracePacketRequest)) @@ -997,21 +1045,11 @@ func _DaemonService_SubscribeEvents_Handler(srv interface{}, stream grpc.ServerS if err := stream.RecvMsg(m); err != nil { return err } - return srv.(DaemonServiceServer).SubscribeEvents(m, &daemonServiceSubscribeEventsServer{stream}) + return srv.(DaemonServiceServer).SubscribeEvents(m, &grpc.GenericServerStream[SubscribeRequest, SystemEvent]{ServerStream: stream}) } -type DaemonService_SubscribeEventsServer interface { - Send(*SystemEvent) error - grpc.ServerStream -} - -type daemonServiceSubscribeEventsServer struct { - grpc.ServerStream -} - -func (x *daemonServiceSubscribeEventsServer) Send(m *SystemEvent) error { - return x.ServerStream.SendMsg(m) -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_SubscribeEventsServer = grpc.ServerStreamingServer[SystemEvent] func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetEventsRequest) @@ -1023,7 +1061,7 @@ func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetEvents", + FullMethod: DaemonService_GetEvents_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetEvents(ctx, req.(*GetEventsRequest)) @@ -1041,7 +1079,7 @@ func _DaemonService_SwitchProfile_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/SwitchProfile", + FullMethod: DaemonService_SwitchProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).SwitchProfile(ctx, req.(*SwitchProfileRequest)) @@ -1059,7 +1097,7 @@ func _DaemonService_SetConfig_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/SetConfig", + FullMethod: DaemonService_SetConfig_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).SetConfig(ctx, req.(*SetConfigRequest)) @@ -1077,7 +1115,7 @@ func _DaemonService_AddProfile_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/AddProfile", + FullMethod: DaemonService_AddProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).AddProfile(ctx, req.(*AddProfileRequest)) @@ -1095,7 +1133,7 @@ func _DaemonService_RemoveProfile_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/RemoveProfile", + FullMethod: DaemonService_RemoveProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).RemoveProfile(ctx, req.(*RemoveProfileRequest)) @@ -1113,7 +1151,7 @@ func _DaemonService_ListProfiles_Handler(srv interface{}, ctx context.Context, d } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/ListProfiles", + FullMethod: DaemonService_ListProfiles_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).ListProfiles(ctx, req.(*ListProfilesRequest)) @@ -1131,7 +1169,7 @@ func _DaemonService_GetActiveProfile_Handler(srv interface{}, ctx context.Contex } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetActiveProfile", + FullMethod: DaemonService_GetActiveProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetActiveProfile(ctx, req.(*GetActiveProfileRequest)) @@ -1149,7 +1187,7 @@ func _DaemonService_Logout_Handler(srv interface{}, ctx context.Context, dec fun } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/Logout", + FullMethod: DaemonService_Logout_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).Logout(ctx, req.(*LogoutRequest)) @@ -1167,7 +1205,7 @@ func _DaemonService_GetFeatures_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetFeatures", + FullMethod: DaemonService_GetFeatures_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetFeatures(ctx, req.(*GetFeaturesRequest)) @@ -1185,7 +1223,7 @@ func _DaemonService_TriggerUpdate_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/TriggerUpdate", + FullMethod: DaemonService_TriggerUpdate_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).TriggerUpdate(ctx, req.(*TriggerUpdateRequest)) @@ -1203,7 +1241,7 @@ func _DaemonService_GetPeerSSHHostKey_Handler(srv interface{}, ctx context.Conte } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetPeerSSHHostKey", + FullMethod: DaemonService_GetPeerSSHHostKey_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetPeerSSHHostKey(ctx, req.(*GetPeerSSHHostKeyRequest)) @@ -1221,7 +1259,7 @@ func _DaemonService_RequestJWTAuth_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/RequestJWTAuth", + FullMethod: DaemonService_RequestJWTAuth_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).RequestJWTAuth(ctx, req.(*RequestJWTAuthRequest)) @@ -1239,7 +1277,7 @@ func _DaemonService_WaitJWTToken_Handler(srv interface{}, ctx context.Context, d } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/WaitJWTToken", + FullMethod: DaemonService_WaitJWTToken_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).WaitJWTToken(ctx, req.(*WaitJWTTokenRequest)) @@ -1257,7 +1295,7 @@ func _DaemonService_StartCPUProfile_Handler(srv interface{}, ctx context.Context } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/StartCPUProfile", + FullMethod: DaemonService_StartCPUProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).StartCPUProfile(ctx, req.(*StartCPUProfileRequest)) @@ -1275,7 +1313,7 @@ func _DaemonService_StopCPUProfile_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/StopCPUProfile", + FullMethod: DaemonService_StopCPUProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).StopCPUProfile(ctx, req.(*StopCPUProfileRequest)) @@ -1283,24 +1321,6 @@ func _DaemonService_StopCPUProfile_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } -func _DaemonService_NotifyOSLifecycle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(OSLifecycleRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(DaemonServiceServer).NotifyOSLifecycle(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/daemon.DaemonService/NotifyOSLifecycle", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(DaemonServiceServer).NotifyOSLifecycle(ctx, req.(*OSLifecycleRequest)) - } - return interceptor(ctx, in, info, handler) -} - func _DaemonService_GetInstallerResult_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(InstallerResultRequest) if err := dec(in); err != nil { @@ -1311,7 +1331,7 @@ func _DaemonService_GetInstallerResult_Handler(srv interface{}, ctx context.Cont } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetInstallerResult", + FullMethod: DaemonService_GetInstallerResult_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetInstallerResult(ctx, req.(*InstallerResultRequest)) @@ -1324,21 +1344,11 @@ func _DaemonService_ExposeService_Handler(srv interface{}, stream grpc.ServerStr if err := stream.RecvMsg(m); err != nil { return err } - return srv.(DaemonServiceServer).ExposeService(m, &daemonServiceExposeServiceServer{stream}) + return srv.(DaemonServiceServer).ExposeService(m, &grpc.GenericServerStream[ExposeServiceRequest, ExposeServiceEvent]{ServerStream: stream}) } -type DaemonService_ExposeServiceServer interface { - Send(*ExposeServiceEvent) error - grpc.ServerStream -} - -type daemonServiceExposeServiceServer struct { - grpc.ServerStream -} - -func (x *daemonServiceExposeServiceServer) Send(m *ExposeServiceEvent) error { - return x.ServerStream.SendMsg(m) -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_ExposeServiceServer = grpc.ServerStreamingServer[ExposeServiceEvent] // DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service. // It's only intended for direct use with grpc.RegisterService, @@ -1479,10 +1489,6 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "StopCPUProfile", Handler: _DaemonService_StopCPUProfile_Handler, }, - { - MethodName: "NotifyOSLifecycle", - Handler: _DaemonService_NotifyOSLifecycle_Handler, - }, { MethodName: "GetInstallerResult", Handler: _DaemonService_GetInstallerResult_Handler, diff --git a/client/server/server.go b/client/server/server.go index 70e4c342f..e70b83bf8 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -120,6 +120,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable } agent := &serverAgent{s} s.sleepHandler = sleephandler.New(agent) + s.startSleepDetector() return s } diff --git a/client/server/sleep.go b/client/server/sleep.go index 7a83c75a6..877ad9690 100644 --- a/client/server/sleep.go +++ b/client/server/sleep.go @@ -2,13 +2,18 @@ package server import ( "context" + "os" + "strconv" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/sleep" "github.com/netbirdio/netbird/client/proto" ) +const envDisableSleepDetector = "NB_DISABLE_SLEEP_DETECTOR" + // serverAgent adapts Server to the handler.Agent and handler.StatusChecker interfaces type serverAgent struct { s *Server @@ -28,19 +33,61 @@ func (a *serverAgent) Status() (internal.StatusType, error) { return internal.CtxGetState(a.s.rootCtx).Status() } -// NotifyOSLifecycle handles operating system lifecycle events by executing appropriate logic based on the request type. -func (s *Server) NotifyOSLifecycle(callerCtx context.Context, req *proto.OSLifecycleRequest) (*proto.OSLifecycleResponse, error) { - switch req.GetType() { - case proto.OSLifecycleRequest_WAKEUP: - if err := s.sleepHandler.HandleWakeUp(callerCtx); err != nil { - return &proto.OSLifecycleResponse{}, err - } - case proto.OSLifecycleRequest_SLEEP: - if err := s.sleepHandler.HandleSleep(callerCtx); err != nil { - return &proto.OSLifecycleResponse{}, err - } - default: - log.Errorf("unknown OSLifecycleRequest type: %v", req.GetType()) +// startSleepDetector starts the OS sleep/wake detector and forwards events to +// the sleep handler. On platforms without a supported detector the attempt +// logs a warning and returns. Setting NB_DISABLE_SLEEP_DETECTOR=true skips +// registration entirely. +func (s *Server) startSleepDetector() { + if sleepDetectorDisabled() { + log.Info("sleep detection disabled via " + envDisableSleepDetector) + return } - return &proto.OSLifecycleResponse{}, nil + + svc, err := sleep.New() + if err != nil { + log.Warnf("failed to initialize sleep detection: %v", err) + return + } + + err = svc.Register(func(event sleep.EventType) { + switch event { + case sleep.EventTypeSleep: + log.Info("handling sleep event") + if err := s.sleepHandler.HandleSleep(s.rootCtx); err != nil { + log.Errorf("failed to handle sleep event: %v", err) + } + case sleep.EventTypeWakeUp: + log.Info("handling wakeup event") + if err := s.sleepHandler.HandleWakeUp(s.rootCtx); err != nil { + log.Errorf("failed to handle wakeup event: %v", err) + } + } + }) + if err != nil { + log.Errorf("failed to register sleep detector: %v", err) + return + } + + log.Info("sleep detection service initialized") + + go func() { + <-s.rootCtx.Done() + log.Info("stopping sleep event listener") + if err := svc.Deregister(); err != nil { + log.Errorf("failed to deregister sleep detector: %v", err) + } + }() +} + +func sleepDetectorDisabled() bool { + val := os.Getenv(envDisableSleepDetector) + if val == "" { + return false + } + disabled, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s=%q: %v", envDisableSleepDetector, val, err) + return false + } + return disabled } diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 0a4687eda..28f98ae59 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -38,7 +38,6 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/internal/sleep" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ui/desktop" "github.com/netbirdio/netbird/client/ui/event" @@ -1149,9 +1148,6 @@ func (s *serviceClient) onTrayReady() { go s.eventManager.Start(s.ctx) go s.eventHandler.listen(s.ctx) - - // Start sleep detection listener - go s.startSleepListener() } func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File { @@ -1212,62 +1208,6 @@ func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonService return s.conn, nil } -// startSleepListener initializes the sleep detection service and listens for sleep events -func (s *serviceClient) startSleepListener() { - sleepService, err := sleep.New() - if err != nil { - log.Warnf("%v", err) - return - } - - if err := sleepService.Register(s.handleSleepEvents); err != nil { - log.Errorf("failed to start sleep detection: %v", err) - return - } - - log.Info("sleep detection service initialized") - - // Cleanup on context cancellation - go func() { - <-s.ctx.Done() - log.Info("stopping sleep event listener") - if err := sleepService.Deregister(); err != nil { - log.Errorf("failed to deregister sleep detection: %v", err) - } - }() -} - -// handleSleepEvents sends a sleep notification to the daemon via gRPC -func (s *serviceClient) handleSleepEvents(event sleep.EventType) { - conn, err := s.getSrvClient(0) - if err != nil { - log.Errorf("failed to get daemon client for sleep notification: %v", err) - return - } - - req := &proto.OSLifecycleRequest{} - - switch event { - case sleep.EventTypeWakeUp: - log.Infof("handle wakeup event: %v", event) - req.Type = proto.OSLifecycleRequest_WAKEUP - case sleep.EventTypeSleep: - log.Infof("handle sleep event: %v", event) - req.Type = proto.OSLifecycleRequest_SLEEP - default: - log.Infof("unknown event: %v", event) - return - } - - _, err = conn.NotifyOSLifecycle(s.ctx, req) - if err != nil { - log.Errorf("failed to notify daemon about os lifecycle notification: %v", err) - return - } - - log.Info("successfully notified daemon about os lifecycle") -} - // setSettingsEnabled enables or disables the settings menu based on the provided state func (s *serviceClient) setSettingsEnabled(enabled bool) { if s.mSettings != nil { diff --git a/go.mod b/go.mod index 1958a3278..8e6a481d2 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( github.com/crowdsecurity/go-cs-bouncer v0.0.21 github.com/dexidp/dex v0.0.0-00010101000000-000000000000 github.com/dexidp/dex/api/v2 v2.4.0 + github.com/ebitengine/purego v0.8.4 github.com/eko/gocache/lib/v4 v4.2.0 github.com/eko/gocache/store/go_cache/v4 v4.2.2 github.com/eko/gocache/store/redis/v4 v4.2.2 @@ -179,7 +180,6 @@ require ( github.com/docker/docker v28.0.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/ebitengine/purego v0.8.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fredbi/uri v1.1.1 // indirect github.com/fyne-io/gl-js v0.2.0 // indirect From 28fe26637b3d94b45444cbe5fa9cab921257cf09 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:01:07 +0900 Subject: [PATCH 02/80] [client] Fix Windows installer upgrade detection for pre-0.70.1 installs (#6025) --- client/installer.nsis | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/installer.nsis b/client/installer.nsis index 8b2b8ea39..6b8d3258e 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -200,9 +200,17 @@ Pop $0 !macroend Function .onInit -SetRegView 64 StrCpy $INSTDIR "${INSTALL_DIR}" + +; Pre-0.70.1 installers ran without SetRegView, so their uninstall keys live +; in the 32-bit view. Fall back to it so upgrades still find them. +SetRegView 64 ReadRegStr $R0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\$(^NAME)" "UninstallString" +${If} $R0 == "" + SetRegView 32 + ReadRegStr $R0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\$(^NAME)" "UninstallString" + SetRegView 64 +${EndIf} ${If} $R0 != "" # if silent install jump to uninstall step IfSilent uninstall From 7eba5dafd8bbba9e5a0c4e8bd34d14dfda565db9 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Wed, 29 Apr 2026 11:28:55 +0200 Subject: [PATCH 03/80] [misc] Add comment automation on release workflow for PRs (#6016) * feat: add comment automation on release workflow for PRs * update action permissions --- .github/workflows/release.yml | 156 ++++++++++++++++++++++++++++++++-- 1 file changed, 150 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 826c05ff3..081bcafc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,6 +115,12 @@ jobs: release: runs-on: ubuntu-latest-m + outputs: + release_artifact_url: ${{ steps.upload_release.outputs.artifact-url }} + linux_packages_artifact_url: ${{ steps.upload_linux_packages.outputs.artifact-url }} + windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }} + macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }} + ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }} env: flags: "" steps: @@ -213,10 +219,13 @@ jobs: if: always() run: rm -f /tmp/gpg-rpm-signing-key.asc - name: Tag and push images (amd64 only) + id: tag_and_push_images if: | (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'push' && github.ref == 'refs/heads/main') run: | + set -euo pipefail + resolve_tags() { if [[ "${{ github.event_name }}" == "pull_request" ]]; then echo "pr-${{ github.event.pull_request.number }}" @@ -225,6 +234,17 @@ jobs: fi } + ghcr_package_url() { + local image="$1" package encoded_package + package="${image#ghcr.io/}" + package="${package#*/}" + package="${package%%:*}" + encoded_package="${package//\//%2F}" + echo "https://github.com/orgs/netbirdio/packages/container/package/${encoded_package}" + } + + image_refs=() + tag_and_push() { local src="$1" img_name tag dst img_name="${src%%:*}" @@ -233,35 +253,56 @@ jobs: echo "Tagging ${src} -> ${dst}" docker tag "$src" "$dst" docker push "$dst" + image_refs+=("$dst") done } - export -f tag_and_push resolve_tags + cat > /tmp/goreleaser-artifacts.json <<'JSON' + ${{ steps.goreleaser.outputs.artifacts }} + JSON - echo '${{ steps.goreleaser.outputs.artifacts }}' | \ - jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name' | \ - grep '^ghcr.io/' | while read -r SRC; do - tag_and_push "$SRC" - done + mapfile -t src_images < <( + jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name | select(startswith("ghcr.io/"))' /tmp/goreleaser-artifacts.json + ) + + for src in "${src_images[@]}"; do + tag_and_push "$src" + done + + { + echo "images_markdown<> "$GITHUB_OUTPUT" - name: upload non tags for debug purposes + id: upload_release uses: actions/upload-artifact@v4 with: name: release path: dist/ retention-days: 7 - name: upload linux packages + id: upload_linux_packages uses: actions/upload-artifact@v4 with: name: linux-packages path: dist/netbird_linux** retention-days: 7 - name: upload windows packages + id: upload_windows_packages uses: actions/upload-artifact@v4 with: name: windows-packages path: dist/netbird_windows** retention-days: 7 - name: upload macos packages + id: upload_macos_packages uses: actions/upload-artifact@v4 with: name: macos-packages @@ -270,6 +311,8 @@ jobs: release_ui: runs-on: ubuntu-latest + outputs: + release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }} steps: - name: Parse semver string id: semver_parser @@ -360,6 +403,7 @@ jobs: if: always() run: rm -f /tmp/gpg-rpm-signing-key.asc - name: upload non tags for debug purposes + id: upload_release_ui uses: actions/upload-artifact@v4 with: name: release-ui @@ -368,6 +412,8 @@ jobs: release_ui_darwin: runs-on: macos-latest + outputs: + release_ui_darwin_artifact_url: ${{ steps.upload_release_ui_darwin.outputs.artifact-url }} steps: - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} run: echo "flags=--snapshot" >> $GITHUB_ENV @@ -402,12 +448,110 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: upload non tags for debug purposes + id: upload_release_ui_darwin uses: actions/upload-artifact@v4 with: name: release-ui-darwin path: dist/ retention-days: 3 + comment_release_artifacts: + name: Comment release artifacts + runs-on: ubuntu-latest + needs: [release, release_ui, release_ui_darwin] + if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Create or update PR comment + uses: actions/github-script@v7 + env: + RELEASE_RESULT: ${{ needs.release.result }} + RELEASE_UI_RESULT: ${{ needs.release_ui.result }} + RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }} + RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }} + LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }} + WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_packages_artifact_url }} + MACOS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.macos_packages_artifact_url }} + RELEASE_UI_ARTIFACT_URL: ${{ needs.release_ui.outputs.release_ui_artifact_url }} + RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }} + GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const marker = ''; + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + const shortSha = context.payload.pull_request.head.sha.slice(0, 7); + + const artifactCell = (url, result) => { + if (url) return `[Download](${url})`; + return result && result !== 'success' ? `_Not available (${result})_` : '_Not available_'; + }; + + const artifacts = [ + ['All release artifacts', process.env.RELEASE_ARTIFACT_URL, process.env.RELEASE_RESULT], + ['Linux packages', process.env.LINUX_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT], + ['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT], + ['macOS packages', process.env.MACOS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT], + ['UI artifacts', process.env.RELEASE_UI_ARTIFACT_URL, process.env.RELEASE_UI_RESULT], + ['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT], + ]; + + const artifactRows = artifacts + .map(([name, url, result]) => `| ${name} | ${artifactCell(url, result)} |`) + .join('\n'); + + const ghcrImages = (process.env.GHCR_IMAGES_MARKDOWN || '').trim() || '_No GHCR images were pushed._'; + + const body = [ + marker, + '## Release artifacts', + '', + `Built for PR head \`${shortSha}\` in [workflow run #${process.env.GITHUB_RUN_NUMBER}](${runUrl}).`, + '', + '| Artifact | Link |', + '| --- | --- |', + artifactRows, + '', + '### GHCR images (amd64)', + ghcrImages, + '', + '_This comment is updated by the Release workflow. Artifact links expire according to the workflow retention policy._', + ].join('\n'); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + + const previous = comments.find(comment => + comment.user?.type === 'Bot' && comment.body?.includes(marker) + ); + + if (previous) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: previous.id, + body, + }); + core.info(`Updated release artifacts comment ${previous.id}`); + } else { + const { data } = await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + core.info(`Created release artifacts comment ${data.id}`); + } + trigger_signer: runs-on: ubuntu-latest needs: [release, release_ui, release_ui_darwin] From ad93dcf9807e46ac648ff67b0ab994696b8cb6fc Mon Sep 17 00:00:00 2001 From: shuuri-labs <61762328+shuuri-labs@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:14:46 +0200 Subject: [PATCH 04/80] [client] Enable UI autostart for silent and MSI installs (#6026) * fix(client): enable UI autostart for silent and MSI installs The MSI installer had no autostart logic and the EXE silent installer skipped the autostart page, leaving the registry entry unwritten. This caused the NetBird UI tray to not start at login after RMM deployments. Add an AUTOSTART property (default: 1) to the MSI that writes the HKLM Run key, and initialize AutostartEnabled in the NSIS .onInit so silent installs match the interactive default. * add real guid for NetBirdAutoStart component --- client/installer.nsis | 2 ++ client/netbird.wxs | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/client/installer.nsis b/client/installer.nsis index 6b8d3258e..63bff1c5b 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -201,6 +201,8 @@ Pop $0 Function .onInit StrCpy $INSTDIR "${INSTALL_DIR}" +; Default autostart to enabled so silent installs (/S) match the interactive default +StrCpy $AutostartEnabled "1" ; Pre-0.70.1 installers ran without SetRegView, so their uninstall keys live ; in the 32-bit view. Fall back to it so upgrades still find them. diff --git a/client/netbird.wxs b/client/netbird.wxs index 23aa250f4..2849bc6b9 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -13,6 +13,9 @@ + + + @@ -63,9 +66,21 @@ + + + + AUTOSTART = "1" + + + + + + From df197d5001c19dbeedb6e4bb44f51a6d298b3422 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Wed, 29 Apr 2026 15:04:27 +0300 Subject: [PATCH 05/80] [management] Prevent JWT reuse during peer login (#6002) --- client/cmd/testutil_test.go | 2 +- client/internal/engine_test.go | 2 +- client/server/server_test.go | 2 +- management/internals/server/boot.go | 2 +- management/internals/server/controllers.go | 7 ++ management/internals/shared/grpc/server.go | 37 +++++++++- management/server/auth/session.go | 61 ++++++++++++++++ management/server/auth/session_test.go | 82 ++++++++++++++++++++++ management/server/management_proto_test.go | 2 +- management/server/management_test.go | 1 + shared/management/client/client_test.go | 2 +- 11 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 management/server/auth/session.go create mode 100644 management/server/auth/session_test.go diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index d7564c353..fd1007bb4 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -135,7 +135,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp if err != nil { t.Fatal(err) } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil) + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil, nil) if err != nil { t.Fatal(err) } diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 9fa4e51b2..f4c5be70a 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -1671,7 +1671,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri if err != nil { return nil, "", err } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil) + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) if err != nil { return nil, "", err } diff --git a/client/server/server_test.go b/client/server/server_test.go index 772997575..54ad47e55 100644 --- a/client/server/server_test.go +++ b/client/server/server_test.go @@ -335,7 +335,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve if err != nil { return nil, "", err } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil) + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) if err != nil { return nil, "", err } diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 2b40c0aad..f2ab0a2c4 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -173,7 +173,7 @@ func (s *BaseServer) GRPCServer() *grpc.Server { } gRPCAPIHandler := grpc.NewServer(gRPCOpts...) - srv, err := nbgrpc.NewServer(s.Config, s.AccountManager(), s.SettingsManager(), s.JobManager(), s.SecretsManager(), s.Metrics(), s.AuthManager(), s.IntegratedValidator(), s.NetworkMapController(), s.OAuthConfigProvider()) + srv, err := nbgrpc.NewServer(s.Config, s.AccountManager(), s.SettingsManager(), s.JobManager(), s.SecretsManager(), s.Metrics(), s.AuthManager(), s.IntegratedValidator(), s.NetworkMapController(), s.OAuthConfigProvider(), s.SessionStore()) if err != nil { log.Fatalf("failed to create management server: %v", err) } diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go index 9a8e45d33..89bdf0abe 100644 --- a/management/internals/server/controllers.go +++ b/management/internals/server/controllers.go @@ -6,6 +6,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/management-integrations/integrations" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" @@ -66,6 +67,12 @@ func (s *BaseServer) SecretsManager() grpc.SecretsManager { }) } +func (s *BaseServer) SessionStore() *auth.SessionStore { + return Create(s, func() *auth.SessionStore { + return auth.NewSessionStore(s.CacheStore()) + }) +} + func (s *BaseServer) AuthManager() auth.Manager { audiences := s.Config.GetAuthAudiences() audience := s.Config.HttpConfig.AuthAudience diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 6e8358f02..0c1611e7f 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -14,6 +14,7 @@ import ( "sync/atomic" "time" + jwtv5 "github.com/golang-jwt/jwt/v5" pb "github.com/golang/protobuf/proto" // nolint "github.com/golang/protobuf/ptypes/timestamp" "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip" @@ -67,6 +68,7 @@ type Server struct { appMetrics telemetry.AppMetrics peerLocks sync.Map authManager auth.Manager + sessionStore *auth.SessionStore logBlockedPeers bool blockPeersWithSameConfig bool @@ -98,6 +100,7 @@ func NewServer( integratedPeerValidator integrated_validator.IntegratedValidator, networkMapController network_map.Controller, oAuthConfigProvider idp.OAuthConfigProvider, + sessionStore *auth.SessionStore, ) (*Server, error) { if appMetrics != nil { // update gauge based on number of connected peers which is equal to open gRPC streams @@ -140,6 +143,7 @@ func NewServer( integratedPeerValidator: integratedPeerValidator, networkMapController: networkMapController, oAuthConfigProvider: oAuthConfigProvider, + sessionStore: sessionStore, loginFilter: newLoginFilter(), @@ -535,7 +539,7 @@ func (s *Server) cancelPeerRoutinesWithoutLock(ctx context.Context, accountID st log.WithContext(ctx).Debugf("peer %s has been disconnected", peer.Key) } -func (s *Server) validateToken(ctx context.Context, jwtToken string) (string, error) { +func (s *Server) validateToken(ctx context.Context, peerKey, jwtToken string) (string, error) { if s.authManager == nil { return "", status.Errorf(codes.Internal, "missing auth manager") } @@ -545,6 +549,10 @@ func (s *Server) validateToken(ctx context.Context, jwtToken string) (string, er return "", status.Errorf(codes.InvalidArgument, "invalid jwt token, err: %v", err) } + if err := s.claimLoginToken(ctx, peerKey, jwtToken, token); err != nil { + return "", err + } + // we need to call this method because if user is new, we will automatically add it to existing or create a new account accountId, _, err := s.accountManager.GetAccountIDFromUserAuth(ctx, userAuth) if err != nil { @@ -828,6 +836,31 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne return loginResp, nil } +func (s *Server) claimLoginToken(ctx context.Context, peerKey, jwtToken string, token *jwtv5.Token) error { + if s.sessionStore == nil || token == nil { + return nil + } + + exp, err := token.Claims.GetExpirationTime() + if err != nil || exp == nil { + log.WithContext(ctx).Warnf("JWT has no usable exp claim for peer %s", peerKey) + return status.Error(codes.Unauthenticated, "jwt token has no expiration") + } + + err = s.sessionStore.RegisterToken(ctx, jwtToken, exp.Time) + if err == nil { + return nil + } + + if errors.Is(err, auth.ErrTokenAlreadyUsed) || errors.Is(err, auth.ErrTokenExpired) { + log.WithContext(ctx).Warnf("%v for peer %s", err, peerKey) + return status.Error(codes.Unauthenticated, err.Error()) + } + + log.WithContext(ctx).Warnf("failed to claim JWT for peer %s: %v", peerKey, err) + return status.Error(codes.Unavailable, "failed to claim jwt token") +} + // processJwtToken validates the existence of a JWT token in the login request, and returns the corresponding user ID if // the token is valid. // @@ -838,7 +871,7 @@ func (s *Server) processJwtToken(ctx context.Context, loginReq *proto.LoginReque if loginReq.GetJwtToken() != "" { var err error for i := 0; i < 3; i++ { - userID, err = s.validateToken(ctx, loginReq.GetJwtToken()) + userID, err = s.validateToken(ctx, peerKey.String(), loginReq.GetJwtToken()) if err == nil { break } diff --git a/management/server/auth/session.go b/management/server/auth/session.go new file mode 100644 index 000000000..7621a1c10 --- /dev/null +++ b/management/server/auth/session.go @@ -0,0 +1,61 @@ +package auth + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/eko/gocache/lib/v4/cache" + "github.com/eko/gocache/lib/v4/store" +) + +const ( + usedTokenKeyPrefix = "jwt-used:" + usedTokenMarker = "1" +) + +var ( + ErrTokenAlreadyUsed = errors.New("JWT already used") + ErrTokenExpired = errors.New("JWT expired") +) + +type SessionStore struct { + cache *cache.Cache[string] +} + +func NewSessionStore(cacheStore store.StoreInterface) *SessionStore { + return &SessionStore{cache: cache.New[string](cacheStore)} +} + +// RegisterToken records a JWT until its exp time and rejects reuse. +func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresAt time.Time) error { + ttl := time.Until(expiresAt) + if ttl <= 0 { + return ErrTokenExpired + } + + key := usedTokenKeyPrefix + hashToken(token) + _, err := s.cache.Get(ctx, key) + if err == nil { + return ErrTokenAlreadyUsed + } + + var notFound *store.NotFound + if !errors.As(err, ¬Found) { + return fmt.Errorf("failed to lookup used token entry: %w", err) + } + + if err := s.cache.Set(ctx, key, usedTokenMarker, store.WithExpiration(ttl)); err != nil { + return fmt.Errorf("failed to store used token entry: %w", err) + } + + return nil +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/management/server/auth/session_test.go b/management/server/auth/session_test.go new file mode 100644 index 000000000..3a7d85f4c --- /dev/null +++ b/management/server/auth/session_test.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbcache "github.com/netbirdio/netbird/management/server/cache" +) + +func newTestSessionStore(t *testing.T) *SessionStore { + t.Helper() + cacheStore, err := nbcache.NewStore(context.Background(), time.Hour, time.Hour, 100) + require.NoError(t, err) + return NewSessionStore(cacheStore) +} + +func TestSessionStore_FirstRegisterSucceeds(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + + require.NoError(t, s.RegisterToken(ctx, "token", time.Now().Add(time.Hour))) +} + +func TestSessionStore_RegisterSameTokenTwiceIsRejected(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + token := "token" + exp := time.Now().Add(time.Hour) + + require.NoError(t, s.RegisterToken(ctx, token, exp)) + + err := s.RegisterToken(ctx, token, exp) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenAlreadyUsed) +} + +func TestSessionStore_RegisterDifferentTokensAreIndependent(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + exp := time.Now().Add(time.Hour) + + require.NoError(t, s.RegisterToken(ctx, "tokenA", exp)) + require.NoError(t, s.RegisterToken(ctx, "tokenB", exp)) +} + +func TestSessionStore_RegisterWithPastExpiryIsRejected(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + token := "token" + + err := s.RegisterToken(ctx, token, time.Now().Add(-time.Second)) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenExpired) +} + +func TestSessionStore_EntryEvictsAtTTLAndAllowsReRegistration(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + token := "token" + + require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(50*time.Millisecond))) + + err := s.RegisterToken(ctx, token, time.Now().Add(50*time.Millisecond)) + assert.ErrorIs(t, err, ErrTokenAlreadyUsed) + + time.Sleep(120 * time.Millisecond) + + require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(time.Hour))) +} + +func TestHashToken_StableAndDoesNotLeak(t *testing.T) { + a := hashToken("tokenA") + b := hashToken("tokenB") + assert.Equal(t, a, hashToken("tokenA"), "hash must be deterministic") + assert.NotEqual(t, a, b, "different tokens must hash differently") + assert.Len(t, a, 64, "sha256 hex must be 64 chars") + assert.NotContains(t, a, "tokenA", "raw token must not appear in hash") +} diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index 18d85315d..1b77ea335 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -391,7 +391,7 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config return nil, nil, "", cleanup, err } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, MockIntegratedValidator{}, networkMapController, nil) + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, MockIntegratedValidator{}, networkMapController, nil, nil) if err != nil { return nil, nil, "", cleanup, err } diff --git a/management/server/management_test.go b/management/server/management_test.go index 3ac28cd4a..f1d49193c 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -256,6 +256,7 @@ func startServer( server.MockIntegratedValidator{}, networkMapController, nil, + nil, ) if err != nil { t.Fatalf("failed creating management server: %v", err) diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go index d9a1a7d65..a8e8172dc 100644 --- a/shared/management/client/client_test.go +++ b/shared/management/client/client_test.go @@ -138,7 +138,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) { if err != nil { t.Fatal(err) } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, mgmt.MockIntegratedValidator{}, networkMapController, nil) + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, mgmt.MockIntegratedValidator{}, networkMapController, nil, nil) if err != nil { t.Fatal(err) } From 11ac2af2f5130899633b31ac575a683afea7e308 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:07:33 +0900 Subject: [PATCH 06/80] Use BindListener for all userspace bind in lazyconn activity (#6028) --- .../lazyconn/activity/listener_bind_test.go | 13 ------------- client/internal/lazyconn/activity/manager.go | 12 ------------ 2 files changed, 25 deletions(-) diff --git a/client/internal/lazyconn/activity/listener_bind_test.go b/client/internal/lazyconn/activity/listener_bind_test.go index f86dd3877..1baaae6be 100644 --- a/client/internal/lazyconn/activity/listener_bind_test.go +++ b/client/internal/lazyconn/activity/listener_bind_test.go @@ -3,7 +3,6 @@ package activity import ( "net" "net/netip" - "runtime" "testing" "time" @@ -18,10 +17,6 @@ import ( peerid "github.com/netbirdio/netbird/client/internal/peer/id" ) -func isBindListenerPlatform() bool { - return runtime.GOOS == "windows" || runtime.GOOS == "js" -} - // mockEndpointManager implements device.EndpointManager for testing type mockEndpointManager struct { endpoints map[netip.Addr]net.Conn @@ -181,10 +176,6 @@ func TestBindListener_Close(t *testing.T) { } func TestManager_BindMode(t *testing.T) { - if !isBindListenerPlatform() { - t.Skip("BindListener only used on Windows/JS platforms") - } - mockEndpointMgr := newMockEndpointManager() mockIface := &MockWGIfaceBind{endpointMgr: mockEndpointMgr} @@ -226,10 +217,6 @@ func TestManager_BindMode(t *testing.T) { } func TestManager_BindMode_MultiplePeers(t *testing.T) { - if !isBindListenerPlatform() { - t.Skip("BindListener only used on Windows/JS platforms") - } - mockEndpointMgr := newMockEndpointManager() mockIface := &MockWGIfaceBind{endpointMgr: mockEndpointMgr} diff --git a/client/internal/lazyconn/activity/manager.go b/client/internal/lazyconn/activity/manager.go index 1c11378c8..cccc0669f 100644 --- a/client/internal/lazyconn/activity/manager.go +++ b/client/internal/lazyconn/activity/manager.go @@ -4,14 +4,12 @@ import ( "errors" "net" "net/netip" - "runtime" "sync" "time" log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/lazyconn" peerid "github.com/netbirdio/netbird/client/internal/peer/id" @@ -75,16 +73,6 @@ func (m *Manager) createListener(peerCfg lazyconn.PeerConfig) (listener, error) return NewUDPListener(m.wgIface, peerCfg) } - // BindListener is used on Windows, JS, and netstack platforms: - // - JS: Cannot listen to UDP sockets - // - Windows: IP_UNICAST_IF socket option forces packets out the interface the default - // gateway points to, preventing them from reaching the loopback interface. - // - Netstack: Allows multiple instances on the same host without port conflicts. - // BindListener bypasses these issues by passing data directly through the bind. - if runtime.GOOS != "windows" && runtime.GOOS != "js" && !netstack.IsEnabled() { - return NewUDPListener(m.wgIface, peerCfg) - } - provider, ok := m.wgIface.(bindProvider) if !ok { return nil, errors.New("interface claims userspace bind but doesn't implement bindProvider") From ed828b7af4e25e64d5f2fdccaaa1285964e27bd8 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:08:47 +0900 Subject: [PATCH 07/80] Tolerate EEXIST when adding macOS scoped default routes (#6027) --- .../routemanager/systemops/systemops_darwin.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/client/internal/routemanager/systemops/systemops_darwin.go b/client/internal/routemanager/systemops/systemops_darwin.go index d6875ff95..3fcac4c6a 100644 --- a/client/internal/routemanager/systemops/systemops_darwin.go +++ b/client/internal/routemanager/systemops/systemops_darwin.go @@ -89,8 +89,16 @@ func (r *SysOps) installScopedDefaultFor(unspec netip.Addr) (bool, error) { return false, fmt.Errorf("unusable default nexthop for %s (no interface)", unspec) } + reused := false if err := r.addScopedDefault(unspec, nexthop); err != nil { - return false, fmt.Errorf("add scoped default on %s: %w", nexthop.Intf.Name, err) + if !errors.Is(err, unix.EEXIST) { + return false, fmt.Errorf("add scoped default on %s: %w", nexthop.Intf.Name, err) + } + // macOS installs its own RTF_IFSCOPE defaults for primary service + // selection on multi-NIC setups, so a route on this ifindex can + // already exist before we try. Binding to it via IP[V6]_BOUND_IF + // still produces the scoped lookup we need. + reused = true } af := unix.AF_INET @@ -102,7 +110,11 @@ func (r *SysOps) installScopedDefaultFor(unspec netip.Addr) (bool, error) { if nexthop.IP.IsValid() { via = nexthop.IP.String() } - log.Infof("installed scoped default route via %s on %s for %s", via, nexthop.Intf.Name, afOf(unspec)) + verb := "installed" + if reused { + verb = "reused existing" + } + log.Infof("%s scoped default route via %s on %s for %s", verb, via, nexthop.Intf.Name, afOf(unspec)) return true, nil } From 57945fc3286a4a7c7f06c688fb251e90e38bfbce Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 29 Apr 2026 17:19:22 +0200 Subject: [PATCH 08/80] [client] Trigger mobile submodule bump PRs on release tags (#6029) Trigger mobile submodule bump PRs on release tags --- .github/workflows/sync-tag.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/sync-tag.yml b/.github/workflows/sync-tag.yml index 1cc553b12..a75d9a9d5 100644 --- a/.github/workflows/sync-tag.yml +++ b/.github/workflows/sync-tag.yml @@ -9,6 +9,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} cancel-in-progress: true +# Receiving workflows (cloud sync-tag, mobile bump-netbird) expect the short +# tag form (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref. jobs: trigger_sync_tag: runs-on: ubuntu-latest @@ -20,4 +22,30 @@ jobs: ref: main repo: ${{ secrets.UPSTREAM_REPO }} token: ${{ secrets.NC_GITHUB_TOKEN }} + inputs: '{ "tag": "${{ github.ref_name }}" }' + + trigger_android_bump: + runs-on: ubuntu-latest + if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') + steps: + - name: Trigger android-client submodule bump + uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1 + with: + workflow: bump-netbird.yml + ref: main + repo: netbirdio/android-client + token: ${{ secrets.NC_GITHUB_TOKEN }} + inputs: '{ "tag": "${{ github.ref_name }}" }' + + trigger_ios_bump: + runs-on: ubuntu-latest + if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') + steps: + - name: Trigger ios-client submodule bump + uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1 + with: + workflow: bump-netbird.yml + ref: main + repo: netbirdio/ios-client + token: ${{ secrets.NC_GITHUB_TOKEN }} inputs: '{ "tag": "${{ github.ref_name }}" }' \ No newline at end of file From 3fc5a8d4a1fe308ff1068764a09b90b0859ab8fe Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 29 Apr 2026 23:44:38 +0200 Subject: [PATCH 09/80] [misc] fix MSI generation add installer tests (#6031) Add Windows installer build test workflow --- .github/workflows/release.yml | 149 +++++++++++++++++++++++++++++++++- client/netbird.wxs | 3 +- 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 081bcafc4..c1ae01a98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,7 +114,7 @@ jobs: retention-days: 30 release: - runs-on: ubuntu-latest-m + runs-on: ubuntu-24.04-8-core outputs: release_artifact_url: ${{ steps.upload_release.outputs.artifact-url }} linux_packages_artifact_url: ${{ steps.upload_linux_packages.outputs.artifact-url }} @@ -455,6 +455,151 @@ jobs: path: dist/ retention-days: 3 + test_windows_installer: + name: "Windows Installer / Build Test" + runs-on: windows-2022 + needs: [release, release_ui] + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + wintun_arch: amd64 + - arch: arm64 + wintun_arch: arm64 + defaults: + run: + shell: powershell + env: + PackageWorkdir: netbird_windows_${{ matrix.arch }} + downloadPath: '${{ github.workspace }}\temp' + steps: + - name: Parse semver string + id: semver_parser + uses: booxmedialtd/ws-action-parse-semver@v1 + with: + input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }} + version_extractor_regex: '\/v(.*)$' + + - name: Checkout + uses: actions/checkout@v4 + + - name: Add 7-Zip to PATH + run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + name: release + path: release + + - name: Download UI release artifacts + uses: actions/download-artifact@v4 + with: + name: release-ui + path: release-ui + + - name: Stage binaries into dist + run: | + $workdir = "dist\${{ env.PackageWorkdir }}" + New-Item -ItemType Directory -Force -Path $workdir | Out-Null + $client = Get-ChildItem -Recurse -Path release -Filter "netbird_*_windows_${{ matrix.arch }}.tar.gz" | Select-Object -First 1 + $ui = Get-ChildItem -Recurse -Path release-ui -Filter "netbird-ui-windows_*_windows_${{ matrix.arch }}.tar.gz" | Select-Object -First 1 + if (-not $client) { Write-Host "::error::client tarball not found for ${{ matrix.arch }}"; exit 1 } + if (-not $ui) { Write-Host "::error::ui tarball not found for ${{ matrix.arch }}"; exit 1 } + Write-Host "Client: $($client.FullName)" + Write-Host "UI: $($ui.FullName)" + tar -zvxf $client.FullName -C $workdir + tar -zvxf $ui.FullName -C $workdir + Get-ChildItem $workdir + + - name: Download wintun + uses: carlosperate/download-file-action@v2 + id: download-wintun + with: + file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip + file-name: wintun.zip + location: ${{ env.downloadPath }} + sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51' + + - name: Decompress wintun files + run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }} + + - name: Move wintun.dll into dist + run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\ + + - name: Download Mesa3D (amd64 only) + uses: carlosperate/download-file-action@v2 + id: download-mesa3d + if: matrix.arch == 'amd64' + with: + file-url: https://downloads.fdossena.com/Projects/Mesa3D/Builds/MesaForWindows-x64-20.1.8.7z + file-name: mesa3d.7z + location: ${{ env.downloadPath }} + sha256: '71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9' + + - name: Extract Mesa3D driver (amd64 only) + if: matrix.arch == 'amd64' + run: 7z x -o"${{ env.downloadPath }}" "${{ env.downloadPath }}/mesa3d.7z" + + - name: Move opengl32.dll into dist (amd64 only) + if: matrix.arch == 'amd64' + run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\ + + - name: Download EnVar plugin for NSIS + uses: carlosperate/download-file-action@v2 + with: + file-url: https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip + file-name: envar_plugin.zip + location: ${{ github.workspace }} + + - name: Extract EnVar plugin + run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip" + + - name: Download ShellExecAsUser plugin for NSIS (amd64 only) + uses: carlosperate/download-file-action@v2 + if: matrix.arch == 'amd64' + with: + file-url: https://nsis.sourceforge.io/mediawiki/images/6/68/ShellExecAsUser_amd64-Unicode.7z + file-name: ShellExecAsUser_amd64-Unicode.7z + location: ${{ github.workspace }} + + - name: Extract ShellExecAsUser plugin (amd64 only) + if: matrix.arch == 'amd64' + run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z" + + - name: Build NSIS installer + uses: joncloud/makensis-action@v3.3 + with: + additional-plugin-paths: ${{ github.workspace }}/NSIS_Plugins/Plugins + script-file: client/installer.nsis + arguments: "/V4 /DARCH=${{ matrix.arch }}" + env: + APPVER: ${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}.${{ steps.semver_parser.outputs.patch }}.${{ github.run_id }} + + - name: Rename NSIS installer + run: mv netbird-installer.exe netbird_installer_test_windows_${{ matrix.arch }}.exe + + - name: Install WiX + run: | + dotnet tool install --global wix --version 6.0.2 + wix extension add WixToolset.Util.wixext/6.0.2 + + - name: Build MSI installer + env: + NETBIRD_VERSION: "${{ steps.semver_parser.outputs.fullversion }}" + run: wix build -arch ${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }} -ext WixToolset.Util.wixext -o netbird_installer_test_windows_${{ matrix.arch }}.msi .\client\netbird.wxs -d ProcessorArchitecture=${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }} -d ArchSuffix=${{ matrix.arch }} + + - name: Upload installer artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-installer-test-${{ matrix.arch }} + path: | + netbird_installer_test_windows_${{ matrix.arch }}.exe + netbird_installer_test_windows_${{ matrix.arch }}.msi + retention-days: 3 + comment_release_artifacts: name: Comment release artifacts runs-on: ubuntu-latest @@ -554,7 +699,7 @@ jobs: trigger_signer: runs-on: ubuntu-latest - needs: [release, release_ui, release_ui_darwin] + needs: [release, release_ui, release_ui_darwin, test_windows_installer] if: startsWith(github.ref, 'refs/tags/') steps: - name: Trigger binaries sign pipelines diff --git a/client/netbird.wxs b/client/netbird.wxs index 2849bc6b9..6f18b63b5 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -68,8 +68,7 @@ - - AUTOSTART = "1" + From f29f5a09784380a3003ef3de5a2c7de4b5733657 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:52:54 +0200 Subject: [PATCH 10/80] [management] add monitoring for nmap update source (#6036) --- .../network_map/controller/controller.go | 15 ++++++-- .../controllers/network_map/interface.go | 4 +- .../controllers/network_map/interface_mock.go | 16 ++++---- .../peers/ephemeral/manager/ephemeral_test.go | 4 +- management/internals/modules/peers/manager.go | 3 +- .../service/manager/l4_port_test.go | 2 +- .../reverseproxy/service/manager/manager.go | 17 +++++---- .../service/manager/manager_test.go | 10 ++--- .../modules/zones/manager/manager.go | 5 ++- .../modules/zones/records/manager/manager.go | 7 ++-- management/server/account.go | 4 +- management/server/account/manager.go | 4 +- management/server/account/manager_mock.go | 16 ++++---- management/server/dns.go | 2 +- management/server/group.go | 16 ++++---- management/server/mock_server/account_mock.go | 12 +++--- management/server/nameserver.go | 6 +-- management/server/networks/manager.go | 3 +- .../server/networks/resources/manager.go | 6 +-- management/server/networks/routers/manager.go | 7 ++-- management/server/peer.go | 8 ++-- management/server/peer_test.go | 4 +- management/server/policy.go | 8 +++- management/server/posture_checks.go | 7 +++- management/server/route.go | 6 +-- .../telemetry/accountmanager_metrics.go | 20 ++++++++++ management/server/types/update_reason.go | 37 +++++++++++++++++++ management/server/user.go | 2 +- 28 files changed, 165 insertions(+), 86 deletions(-) create mode 100644 management/server/types/update_reason.go diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 4b47ecaa0..36de950e9 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -257,7 +257,10 @@ func (c *Controller) bufferSendUpdateAccountPeers(ctx context.Context, accountID // UpdatePeers updates all peers that belong to an account. // Should be called when changes have to be synced to peers. -func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string) error { +func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error { + if c.accountManagerMetrics != nil { + c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation)) + } return c.sendUpdateAccountPeers(ctx, accountID) } @@ -331,9 +334,13 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return nil } -func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID string) error { +func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error { log.WithContext(ctx).Tracef("buffer updating peers for account %s from %s", accountID, util.GetCallerName()) + if c.accountManagerMetrics != nil { + c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation)) + } + bufUpd, _ := c.accountUpdateLocks.LoadOrStore(accountID, &bufferUpdate{}) b := bufUpd.(*bufferUpdate) @@ -348,14 +355,14 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str go func() { defer b.mu.Unlock() - _ = c.UpdateAccountPeers(ctx, accountID) + _ = c.sendUpdateAccountPeers(ctx, accountID) if !b.update.Load() { return } b.update.Store(false) if b.next == nil { b.next = time.AfterFunc(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() { - _ = c.UpdateAccountPeers(ctx, accountID) + _ = c.sendUpdateAccountPeers(ctx, accountID) }) return } diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go index cfea2d3de..44d8f7d72 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -18,9 +18,9 @@ const ( ) type Controller interface { - UpdateAccountPeers(ctx context.Context, accountID string) error + UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error - BufferUpdateAccountPeers(ctx context.Context, accountID string) error + BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) GetDNSDomain(settings *types.Settings) string StartWarmup(context.Context) diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go index 4e86d2973..073a75d3b 100644 --- a/management/internals/controllers/network_map/interface_mock.go +++ b/management/internals/controllers/network_map/interface_mock.go @@ -44,17 +44,17 @@ func (m *MockController) EXPECT() *MockControllerMockRecorder { } // BufferUpdateAccountPeers mocks base method. -func (m *MockController) BufferUpdateAccountPeers(ctx context.Context, accountID string) error { +func (m *MockController) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BufferUpdateAccountPeers", ctx, accountID) + ret := m.ctrl.Call(m, "BufferUpdateAccountPeers", ctx, accountID, reason) ret0, _ := ret[0].(error) return ret0 } // BufferUpdateAccountPeers indicates an expected call of BufferUpdateAccountPeers. -func (mr *MockControllerMockRecorder) BufferUpdateAccountPeers(ctx, accountID any) *gomock.Call { +func (mr *MockControllerMockRecorder) BufferUpdateAccountPeers(ctx, accountID, reason any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAccountPeers), ctx, accountID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAccountPeers), ctx, accountID, reason) } // CountStreams mocks base method. @@ -238,15 +238,15 @@ func (mr *MockControllerMockRecorder) UpdateAccountPeer(ctx, accountId, peerId a } // UpdateAccountPeers mocks base method. -func (m *MockController) UpdateAccountPeers(ctx context.Context, accountID string) error { +func (m *MockController) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateAccountPeers", ctx, accountID) + ret := m.ctrl.Call(m, "UpdateAccountPeers", ctx, accountID, reason) ret0, _ := ret[0].(error) return ret0 } // UpdateAccountPeers indicates an expected call of UpdateAccountPeers. -func (mr *MockControllerMockRecorder) UpdateAccountPeers(ctx, accountID any) *gomock.Call { +func (mr *MockControllerMockRecorder) UpdateAccountPeers(ctx, accountID, reason any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockController)(nil).UpdateAccountPeers), ctx, accountID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockController)(nil).UpdateAccountPeers), ctx, accountID, reason) } diff --git a/management/internals/modules/peers/ephemeral/manager/ephemeral_test.go b/management/internals/modules/peers/ephemeral/manager/ephemeral_test.go index fc3010dd1..314e84501 100644 --- a/management/internals/modules/peers/ephemeral/manager/ephemeral_test.go +++ b/management/internals/modules/peers/ephemeral/manager/ephemeral_test.go @@ -62,7 +62,7 @@ func (a *MockAccountManager) GetDeletePeerCalls() int { return a.deletePeerCalls } -func (a *MockAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string) { +func (a *MockAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { a.mu.Lock() defer a.mu.Unlock() if a.bufferUpdateCalls == nil { @@ -248,7 +248,7 @@ func TestCleanupSchedulingBehaviorIsBatched(t *testing.T) { return err } } - mockAM.BufferUpdateAccountPeers(ctx, accountID) + mockAM.BufferUpdateAccountPeers(ctx, accountID, types.UpdateReason{}) return nil }). Times(1) diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index d3f8f44ff..c913efb92 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -20,6 +20,7 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -178,7 +179,7 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs } } - m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete}) return nil } diff --git a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go index 28461641d..fc91b8616 100644 --- a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go +++ b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go @@ -85,7 +85,7 @@ func setupL4Test(t *testing.T, customPortsSupported *bool) (*Manager, store.Stor accountMgr := &mock_server.MockAccountManager{ StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, _ map[string]any) {}, - UpdateAccountPeersFunc: func(_ context.Context, _ string) {}, + UpdateAccountPeersFunc: func(_ context.Context, _ string, _ types.UpdateReason) {}, GetGroupByNameFunc: func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error) { return testStore.GetGroupByName(ctx, store.LockingStrengthNone, accountID, groupName) }, diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index ed9d4201b..0fb5f46ff 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -25,6 +25,7 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -231,7 +232,7 @@ func (m *Manager) CreateService(ctx context.Context, accountID, userID string, s m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster) - m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationCreate}) return s, nil } @@ -515,7 +516,7 @@ func (m *Manager) UpdateService(ctx context.Context, accountID, userID string, s } m.sendServiceUpdateNotifications(ctx, accountID, service, updateInfo) - m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationUpdate}) return service, nil } @@ -819,7 +820,7 @@ func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceI m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster) - m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete}) return nil } @@ -860,7 +861,7 @@ func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID strin m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", oidcCfg), svc.ProxyCluster) } - m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete}) return nil } @@ -916,7 +917,7 @@ func (m *Manager) ReloadService(ctx context.Context, accountID, serviceID string m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster) - m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationUpdate}) return nil } @@ -1098,7 +1099,7 @@ func (m *Manager) CreateServiceFromPeer(ctx context.Context, accountID, peerID s } m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster) - m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationCreate}) serviceURL := "https://" + svc.Domain if service.IsL4Protocol(svc.Mode) { @@ -1210,7 +1211,7 @@ func (m *Manager) deletePeerService(ctx context.Context, accountID, peerID, serv m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster) - m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete}) return nil } @@ -1261,7 +1262,7 @@ func (m *Manager) deleteExpiredPeerService(ctx context.Context, accountID, peerI meta := addPeerInfoToEventMeta(svc.EventMeta(), peer) m.accountManager.StoreEvent(ctx, peerID, serviceID, accountID, activity.PeerServiceExposeExpired, meta) m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster) - m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete}) return nil } diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index 54ac8ab18..e9403849c 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -447,7 +447,7 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { StoreEventFunc: func(_ context.Context, _, _, _ string, activityID activity.ActivityDescriber, _ map[string]any) { storedActivity = activityID.(activity.Activity) }, - UpdateAccountPeersFunc: func(_ context.Context, _ string) {}, + UpdateAccountPeersFunc: func(_ context.Context, _ string, _ types.UpdateReason) {}, } mockStore.EXPECT(). @@ -549,7 +549,7 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { StoreEventFunc: func(_ context.Context, _, _, _ string, activityID activity.ActivityDescriber, _ map[string]any) { storedActivity = activityID.(activity.Activity) }, - UpdateAccountPeersFunc: func(_ context.Context, _ string) {}, + UpdateAccountPeersFunc: func(_ context.Context, _ string, _ types.UpdateReason) {}, } mockStore.EXPECT(). @@ -593,7 +593,7 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, meta map[string]any) { storedMeta = meta }, - UpdateAccountPeersFunc: func(_ context.Context, _ string) {}, + UpdateAccountPeersFunc: func(_ context.Context, _ string, _ types.UpdateReason) {}, } mockStore.EXPECT(). @@ -704,7 +704,7 @@ func setupIntegrationTest(t *testing.T) (*Manager, store.Store) { accountMgr := &mock_server.MockAccountManager{ StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, _ map[string]any) {}, - UpdateAccountPeersFunc: func(_ context.Context, _ string) {}, + UpdateAccountPeersFunc: func(_ context.Context, _ string, _ types.UpdateReason) {}, GetGroupByNameFunc: func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error) { return testStore.GetGroupByName(ctx, store.LockingStrengthNone, accountID, groupName) }, @@ -1173,7 +1173,7 @@ func TestDeleteService_DeletesTargets(t *testing.T) { mockAcct.EXPECT(). StoreEvent(ctx, userID, service.ID, accountID, activity.ServiceDeleted, gomock.Any()) mockAcct.EXPECT(). - UpdateAccountPeers(ctx, accountID) + UpdateAccountPeers(ctx, accountID, gomock.Any()) err = mgr.DeleteService(ctx, accountID, userID, service.ID) require.NoError(t, err) diff --git a/management/internals/modules/zones/manager/manager.go b/management/internals/modules/zones/manager/manager.go index 8548dd48c..439671e65 100644 --- a/management/internals/modules/zones/manager/manager.go +++ b/management/internals/modules/zones/manager/manager.go @@ -11,6 +11,7 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -144,7 +145,7 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string, m.accountManager.StoreEvent(ctx, userID, zone.ID, accountID, activity.DNSZoneUpdated, zone.EventMeta()) - go m.accountManager.UpdateAccountPeers(ctx, accountID) + go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZone, Operation: types.UpdateOperationUpdate}) return zone, nil } @@ -206,7 +207,7 @@ func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID event() } - go m.accountManager.UpdateAccountPeers(ctx, accountID) + go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZone, Operation: types.UpdateOperationDelete}) return nil } diff --git a/management/internals/modules/zones/records/manager/manager.go b/management/internals/modules/zones/records/manager/manager.go index 5374a2ef2..7458b41db 100644 --- a/management/internals/modules/zones/records/manager/manager.go +++ b/management/internals/modules/zones/records/manager/manager.go @@ -13,6 +13,7 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -95,7 +96,7 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI meta := record.EventMeta(zone.ID, zone.Name) m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordCreated, meta) - go m.accountManager.UpdateAccountPeers(ctx, accountID) + go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationCreate}) return record, nil } @@ -154,7 +155,7 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI meta := record.EventMeta(zone.ID, zone.Name) m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordUpdated, meta) - go m.accountManager.UpdateAccountPeers(ctx, accountID) + go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationUpdate}) return record, nil } @@ -201,7 +202,7 @@ func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneI meta := record.EventMeta(zone.ID, zone.Name) m.accountManager.StoreEvent(ctx, userID, recordID, accountID, activity.DNSRecordDeleted, meta) - go m.accountManager.UpdateAccountPeers(ctx, accountID) + go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationDelete}) return nil } diff --git a/management/server/account.go b/management/server/account.go index 7d53cef03..4b71ab486 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -400,7 +400,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco } if updateAccountPeers || extraSettingsChanged || groupChangesAffectPeers { - go am.UpdateAccountPeers(ctx, accountID) + go am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceAccountSettings, Operation: types.UpdateOperationUpdate}) } return newSettings, nil @@ -1581,7 +1581,7 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth if removedGroupAffectsPeers || newGroupsAffectsPeers { log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating account peers", userAuth.UserId) - am.BufferUpdateAccountPeers(ctx, userAuth.AccountId) + am.BufferUpdateAccountPeers(ctx, userAuth.AccountId, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate}) } return nil diff --git a/management/server/account/manager.go b/management/server/account/manager.go index b4516d512..626ed222d 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -124,8 +124,8 @@ type Manager interface { GetAccountIDForPeerKey(ctx context.Context, peerKey string) (string, error) GetAccountSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) DeleteSetupKey(ctx context.Context, accountID, userID, keyID string) error - UpdateAccountPeers(ctx context.Context, accountID string) - BufferUpdateAccountPeers(ctx context.Context, accountID string) + UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) + BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) BuildUserInfosForAccount(ctx context.Context, accountID, initiatorUserID string, accountUsers []*types.User) (map[string]*types.UserInfo, error) SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error GetStore() store.Store diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index 36e5fe39f..8f3b22ecc 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -111,15 +111,15 @@ func (mr *MockManagerMockRecorder) ApproveUser(ctx, accountID, initiatorUserID, } // BufferUpdateAccountPeers mocks base method. -func (m *MockManager) BufferUpdateAccountPeers(ctx context.Context, accountID string) { +func (m *MockManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { m.ctrl.T.Helper() - m.ctrl.Call(m, "BufferUpdateAccountPeers", ctx, accountID) + m.ctrl.Call(m, "BufferUpdateAccountPeers", ctx, accountID, reason) } // BufferUpdateAccountPeers indicates an expected call of BufferUpdateAccountPeers. -func (mr *MockManagerMockRecorder) BufferUpdateAccountPeers(ctx, accountID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) BufferUpdateAccountPeers(ctx, accountID, reason interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).BufferUpdateAccountPeers), ctx, accountID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).BufferUpdateAccountPeers), ctx, accountID, reason) } // BuildUserInfosForAccount mocks base method. @@ -1597,15 +1597,15 @@ func (mr *MockManagerMockRecorder) UpdateAccountOnboarding(ctx, accountID, userI } // UpdateAccountPeers mocks base method. -func (m *MockManager) UpdateAccountPeers(ctx context.Context, accountID string) { +func (m *MockManager) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { m.ctrl.T.Helper() - m.ctrl.Call(m, "UpdateAccountPeers", ctx, accountID) + m.ctrl.Call(m, "UpdateAccountPeers", ctx, accountID, reason) } // UpdateAccountPeers indicates an expected call of UpdateAccountPeers. -func (mr *MockManagerMockRecorder) UpdateAccountPeers(ctx, accountID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) UpdateAccountPeers(ctx, accountID, reason interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).UpdateAccountPeers), ctx, accountID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).UpdateAccountPeers), ctx, accountID, reason) } // UpdateAccountSettings mocks base method. diff --git a/management/server/dns.go b/management/server/dns.go index baf6debc3..c62fa5185 100644 --- a/management/server/dns.go +++ b/management/server/dns.go @@ -86,7 +86,7 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID } if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceDNSSettings, Operation: types.UpdateOperationUpdate}) } return nil diff --git a/management/server/group.go b/management/server/group.go index 7b5b9b86c..e1d05171e 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -117,7 +117,7 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use } if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationCreate}) } return nil @@ -185,7 +185,7 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use } if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) } return nil @@ -253,7 +253,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us } if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationCreate}) } return globalErr @@ -321,7 +321,7 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us } if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) } return globalErr @@ -493,7 +493,7 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr } if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) } return nil @@ -531,7 +531,7 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID } if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) } return nil @@ -559,7 +559,7 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, } if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) } return nil @@ -597,7 +597,7 @@ func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accoun } if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) } return nil diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index ff369355e..ac4d0c6d6 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -128,8 +128,8 @@ type MockAccountManager struct { GetOrCreateAccountByPrivateDomainFunc func(ctx context.Context, initiatorId, domain string) (*types.Account, bool, error) AllowSyncFunc func(string, uint64) bool - UpdateAccountPeersFunc func(ctx context.Context, accountID string) - BufferUpdateAccountPeersFunc func(ctx context.Context, accountID string) + UpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason) + BufferUpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason) RecalculateNetworkMapCacheFunc func(ctx context.Context, accountId string) error GetIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) @@ -200,15 +200,15 @@ func (am *MockAccountManager) UpdateGroups(ctx context.Context, accountID, userI return status.Errorf(codes.Unimplemented, "method UpdateGroups is not implemented") } -func (am *MockAccountManager) UpdateAccountPeers(ctx context.Context, accountID string) { +func (am *MockAccountManager) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { if am.UpdateAccountPeersFunc != nil { - am.UpdateAccountPeersFunc(ctx, accountID) + am.UpdateAccountPeersFunc(ctx, accountID, reason) } } -func (am *MockAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string) { +func (am *MockAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { if am.BufferUpdateAccountPeersFunc != nil { - am.BufferUpdateAccountPeersFunc(ctx, accountID) + am.BufferUpdateAccountPeersFunc(ctx, accountID, reason) } } diff --git a/management/server/nameserver.go b/management/server/nameserver.go index 3d8c78912..5859bfb0d 100644 --- a/management/server/nameserver.go +++ b/management/server/nameserver.go @@ -82,7 +82,7 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco am.StoreEvent(ctx, userID, newNSGroup.ID, accountID, activity.NameserverGroupCreated, newNSGroup.EventMeta()) if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationCreate}) } return newNSGroup.Copy(), nil @@ -133,7 +133,7 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun am.StoreEvent(ctx, userID, nsGroupToSave.ID, accountID, activity.NameserverGroupUpdated, nsGroupToSave.EventMeta()) if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationUpdate}) } return nil @@ -176,7 +176,7 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco am.StoreEvent(ctx, userID, nsGroup.ID, accountID, activity.NameserverGroupDeleted, nsGroup.EventMeta()) if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationDelete}) } return nil diff --git a/management/server/networks/manager.go b/management/server/networks/manager.go index b6706ca45..c96b60bb2 100644 --- a/management/server/networks/manager.go +++ b/management/server/networks/manager.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" + serverTypes "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -177,7 +178,7 @@ func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, netw event() } - go m.accountManager.UpdateAccountPeers(ctx, accountID) + go m.accountManager.UpdateAccountPeers(ctx, accountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetwork, Operation: serverTypes.UpdateOperationDelete}) return nil } diff --git a/management/server/networks/resources/manager.go b/management/server/networks/resources/manager.go index 86f9b6579..5a0e26533 100644 --- a/management/server/networks/resources/manager.go +++ b/management/server/networks/resources/manager.go @@ -162,7 +162,7 @@ func (m *managerImpl) CreateResource(ctx context.Context, userID string, resourc event() } - go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID) + go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationCreate}) return resource, nil } @@ -270,7 +270,7 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc } }() - go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID) + go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationUpdate}) return resource, nil } @@ -352,7 +352,7 @@ func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, net event() } - go m.accountManager.UpdateAccountPeers(ctx, accountID) + go m.accountManager.UpdateAccountPeers(ctx, accountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationDelete}) return nil } diff --git a/management/server/networks/routers/manager.go b/management/server/networks/routers/manager.go index 82cac424a..c7c3f2ff4 100644 --- a/management/server/networks/routers/manager.go +++ b/management/server/networks/routers/manager.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" + serverTypes "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -119,7 +120,7 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t m.accountManager.StoreEvent(ctx, userID, router.ID, router.AccountID, activity.NetworkRouterCreated, router.EventMeta(network)) - go m.accountManager.UpdateAccountPeers(ctx, router.AccountID) + go m.accountManager.UpdateAccountPeers(ctx, router.AccountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationCreate}) return router, nil } @@ -183,7 +184,7 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t m.accountManager.StoreEvent(ctx, userID, router.ID, router.AccountID, activity.NetworkRouterUpdated, router.EventMeta(network)) - go m.accountManager.UpdateAccountPeers(ctx, router.AccountID) + go m.accountManager.UpdateAccountPeers(ctx, router.AccountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationUpdate}) return router, nil } @@ -217,7 +218,7 @@ func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, netwo event() - go m.accountManager.UpdateAccountPeers(ctx, accountID) + go m.accountManager.UpdateAccountPeers(ctx, accountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationDelete}) return nil } diff --git a/management/server/peer.go b/management/server/peer.go index 07428539b..d1c52002e 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1221,12 +1221,12 @@ func (am *DefaultAccountManager) GetPeer(ctx context.Context, accountID, peerID, // UpdateAccountPeers updates all peers that belong to an account. // Should be called when changes have to be synced to peers. -func (am *DefaultAccountManager) UpdateAccountPeers(ctx context.Context, accountID string) { - _ = am.networkMapController.UpdateAccountPeers(ctx, accountID) +func (am *DefaultAccountManager) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { + _ = am.networkMapController.UpdateAccountPeers(ctx, accountID, reason) } -func (am *DefaultAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string) { - _ = am.networkMapController.BufferUpdateAccountPeers(ctx, accountID) +func (am *DefaultAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { + _ = am.networkMapController.BufferUpdateAccountPeers(ctx, accountID, reason) } // UpdateAccountPeer updates a single peer that belongs to an account. diff --git a/management/server/peer_test.go b/management/server/peer_test.go index dae676e77..36809d354 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -975,7 +975,7 @@ func BenchmarkUpdateAccountPeers(b *testing.B) { start := time.Now() for i := 0; i < b.N; i++ { - manager.UpdateAccountPeers(ctx, account.Id) + manager.UpdateAccountPeers(ctx, account.Id, types.UpdateReason{}) } duration := time.Since(start) @@ -1033,7 +1033,7 @@ func testUpdateAccountPeers(t *testing.T) { peerChannels[peerID] = updateManager.CreateChannel(ctx, peerID) } - manager.UpdateAccountPeers(ctx, account.Id) + manager.UpdateAccountPeers(ctx, account.Id, types.UpdateReason{}) for _, channel := range peerChannels { update := <-channel diff --git a/management/server/policy.go b/management/server/policy.go index 48297ca11..40f3908e3 100644 --- a/management/server/policy.go +++ b/management/server/policy.go @@ -96,7 +96,11 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user am.StoreEvent(ctx, userID, policy.ID, accountID, action, policy.EventMeta()) if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + policyOp := types.UpdateOperationCreate + if isUpdate { + policyOp = types.UpdateOperationUpdate + } + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePolicy, Operation: policyOp}) } return policy, nil @@ -139,7 +143,7 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po am.StoreEvent(ctx, userID, policyID, accountID, activity.PolicyRemoved, policy.EventMeta()) if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePolicy, Operation: types.UpdateOperationDelete}) } return nil diff --git a/management/server/posture_checks.go b/management/server/posture_checks.go index 9562487c0..1e3ce4b8a 100644 --- a/management/server/posture_checks.go +++ b/management/server/posture_checks.go @@ -11,6 +11,7 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -76,7 +77,11 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI am.StoreEvent(ctx, userID, postureChecks.ID, accountID, action, postureChecks.EventMeta()) if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + postureOp := types.UpdateOperationCreate + if isUpdate { + postureOp = types.UpdateOperationUpdate + } + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePostureCheck, Operation: postureOp}) } return postureChecks, nil diff --git a/management/server/route.go b/management/server/route.go index 2b4f11d05..a9561faf0 100644 --- a/management/server/route.go +++ b/management/server/route.go @@ -191,7 +191,7 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri am.StoreEvent(ctx, userID, string(newRoute.ID), accountID, activity.RouteCreated, newRoute.EventMeta()) if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationCreate}) } return newRoute, nil @@ -245,7 +245,7 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI am.StoreEvent(ctx, userID, string(routeToSave.ID), accountID, activity.RouteUpdated, routeToSave.EventMeta()) if oldRouteAffectsPeers || newRouteAffectsPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationUpdate}) } return nil @@ -288,7 +288,7 @@ func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID stri am.StoreEvent(ctx, userID, string(route.ID), accountID, activity.RouteRemoved, route.EventMeta()) if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationDelete}) } return nil diff --git a/management/server/telemetry/accountmanager_metrics.go b/management/server/telemetry/accountmanager_metrics.go index 3b1e078eb..518aae7eb 100644 --- a/management/server/telemetry/accountmanager_metrics.go +++ b/management/server/telemetry/accountmanager_metrics.go @@ -4,6 +4,7 @@ import ( "context" "time" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" ) @@ -11,6 +12,7 @@ import ( type AccountManagerMetrics struct { ctx context.Context updateAccountPeersDurationMs metric.Float64Histogram + updateAccountPeersCounter metric.Int64Counter getPeerNetworkMapDurationMs metric.Float64Histogram networkMapObjectCount metric.Int64Histogram peerMetaUpdateCount metric.Int64Counter @@ -48,6 +50,13 @@ func NewAccountManagerMetrics(ctx context.Context, meter metric.Meter) (*Account return nil, err } + updateAccountPeersCounter, err := meter.Int64Counter("management.account.update.account.peers.counter", + metric.WithUnit("1"), + metric.WithDescription("Number of account peers updates triggered, labeled by resource and operation")) + if err != nil { + return nil, err + } + peerMetaUpdateCount, err := meter.Int64Counter("management.account.peer.meta.update.counter", metric.WithUnit("1"), metric.WithDescription("Number of updates with new meta data from the peers")) @@ -59,6 +68,7 @@ func NewAccountManagerMetrics(ctx context.Context, meter metric.Meter) (*Account ctx: ctx, getPeerNetworkMapDurationMs: getPeerNetworkMapDurationMs, updateAccountPeersDurationMs: updateAccountPeersDurationMs, + updateAccountPeersCounter: updateAccountPeersCounter, networkMapObjectCount: networkMapObjectCount, peerMetaUpdateCount: peerMetaUpdateCount, }, nil @@ -80,6 +90,16 @@ func (metrics *AccountManagerMetrics) CountNetworkMapObjects(count int64) { metrics.networkMapObjectCount.Record(metrics.ctx, count) } +// CountUpdateAccountPeersTriggered increments the counter for account peers updates with resource and operation labels. +func (metrics *AccountManagerMetrics) CountUpdateAccountPeersTriggered(resource, operation string) { + metrics.updateAccountPeersCounter.Add(metrics.ctx, 1, + metric.WithAttributes( + attribute.String("resource", resource), + attribute.String("operation", operation), + ), + ) +} + // CountPeerMetUpdate counts the number of peer meta updates func (metrics *AccountManagerMetrics) CountPeerMetUpdate() { metrics.peerMetaUpdateCount.Add(metrics.ctx, 1) diff --git a/management/server/types/update_reason.go b/management/server/types/update_reason.go new file mode 100644 index 000000000..9d752da9a --- /dev/null +++ b/management/server/types/update_reason.go @@ -0,0 +1,37 @@ +package types + +// UpdateReason describes why an account peers update was triggered. +type UpdateReason struct { + Resource UpdateResource + Operation UpdateOperation +} + +// UpdateResource represents the kind of resource that triggered an account peers update. +type UpdateResource string + +const ( + UpdateResourceAccountSettings UpdateResource = "account_settings" + UpdateResourceDNSSettings UpdateResource = "dns_settings" + UpdateResourceGroup UpdateResource = "group" + UpdateResourceNameServerGroup UpdateResource = "nameserver_group" + UpdateResourcePolicy UpdateResource = "policy" + UpdateResourcePostureCheck UpdateResource = "posture_check" + UpdateResourceRoute UpdateResource = "route" + UpdateResourceUser UpdateResource = "user" + UpdateResourcePeer UpdateResource = "peer" + UpdateResourceNetwork UpdateResource = "network" + UpdateResourceNetworkResource UpdateResource = "network_resource" + UpdateResourceNetworkRouter UpdateResource = "network_router" + UpdateResourceService UpdateResource = "service" + UpdateResourceZone UpdateResource = "zone" + UpdateResourceZoneRecord UpdateResource = "zone_record" +) + +// UpdateOperation represents the kind of change that triggered the update. +type UpdateOperation string + +const ( + UpdateOperationCreate UpdateOperation = "create" + UpdateOperationUpdate UpdateOperation = "update" + UpdateOperationDelete UpdateOperation = "delete" +) diff --git a/management/server/user.go b/management/server/user.go index c1f984f2f..b1fb51195 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -675,7 +675,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, if err = am.Store.IncrementNetworkSerial(ctx, accountID); err != nil { return nil, fmt.Errorf("failed to increment network serial: %w", err) } - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate}) } return updatedUsersInfo, globalErr From dcd1db42ef212d3c8e14a0be451681460fce0a7c Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Thu, 30 Apr 2026 17:21:35 +0200 Subject: [PATCH 11/80] [management] Enable PAT creation during setup (#6003) * enable pat creation on setup * remove logic from handler towards setup service * fix lint issue * fix rollback on account id returning empty * fix coderabbit comments * fix setup PAT rollback behavior --- management/server/account/pat.go | 8 + management/server/http/handler.go | 6 +- .../handlers/instance/instance_handler.go | 31 +- .../instance/instance_handler_test.go | 254 +++++++++++++- management/server/instance/manager.go | 54 +++ management/server/instance/manager_test.go | 87 ++++- management/server/instance/setup_service.go | 216 ++++++++++++ .../server/instance/setup_service_test.go | 318 ++++++++++++++++++ management/server/user.go | 5 +- shared/management/http/api/openapi.yml | 28 +- shared/management/http/api/types.gen.go | 9 + 11 files changed, 997 insertions(+), 19 deletions(-) create mode 100644 management/server/account/pat.go create mode 100644 management/server/instance/setup_service.go create mode 100644 management/server/instance/setup_service_test.go diff --git a/management/server/account/pat.go b/management/server/account/pat.go new file mode 100644 index 000000000..8e5e3e3f9 --- /dev/null +++ b/management/server/account/pat.go @@ -0,0 +1,8 @@ +package account + +const ( + // PATMinExpireDays is the minimum allowed Personal Access Token expiration in days. + PATMinExpireDays = 1 + // PATMaxExpireDays is the maximum allowed Personal Access Token expiration in days. + PATMaxExpireDays = 365 +) diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 56b2d8203..b9ea605d3 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -62,9 +62,7 @@ import ( "github.com/netbirdio/netbird/management/server/telemetry" ) -const ( - apiPrefix = "/api" -) +const apiPrefix = "/api" // NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter) (http.Handler, error) { @@ -141,7 +139,7 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks zonesManager.RegisterEndpoints(router, zManager) recordsManager.RegisterEndpoints(router, rManager) idp.AddEndpoints(accountManager, router) - instance.AddEndpoints(instanceManager, router) + instance.AddEndpoints(instanceManager, accountManager, router) instance.AddVersionEndpoint(instanceManager, router) if serviceManager != nil && reverseProxyDomainManager != nil { reverseproxymanager.RegisterEndpoints(serviceManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, permissionsManager, router) diff --git a/management/server/http/handlers/instance/instance_handler.go b/management/server/http/handlers/instance/instance_handler.go index cd9fae6b8..e98ce4d7c 100644 --- a/management/server/http/handlers/instance/instance_handler.go +++ b/management/server/http/handlers/instance/instance_handler.go @@ -7,6 +7,7 @@ import ( "github.com/gorilla/mux" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/server/account" nbinstance "github.com/netbirdio/netbird/management/server/instance" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" @@ -15,13 +16,15 @@ import ( // handler handles the instance setup HTTP endpoints type handler struct { instanceManager nbinstance.Manager + setupManager *nbinstance.SetupService } // AddEndpoints registers the instance setup endpoints. // These endpoints bypass authentication for initial setup. -func AddEndpoints(instanceManager nbinstance.Manager, router *mux.Router) { +func AddEndpoints(instanceManager nbinstance.Manager, accountManager account.Manager, router *mux.Router) { h := &handler{ instanceManager: instanceManager, + setupManager: nbinstance.NewSetupService(instanceManager, accountManager), } router.HandleFunc("/instance", h.getInstanceStatus).Methods("GET", "OPTIONS") @@ -55,24 +58,36 @@ func (h *handler) getInstanceStatus(w http.ResponseWriter, r *http.Request) { // setup creates the initial admin user for the instance. // This endpoint is unauthenticated but only works when setup is required. func (h *handler) setup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var req api.SetupRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { util.WriteErrorResponse("invalid request body", http.StatusBadRequest, w) return } - userData, err := h.instanceManager.CreateOwnerUser(r.Context(), req.Email, req.Password, req.Name) + result, err := h.setupManager.SetupOwner(ctx, req.Email, req.Password, req.Name, nbinstance.SetupOptions{ + CreatePAT: req.CreatePat != nil && *req.CreatePat, + PATExpireInDays: req.PatExpireIn, + }) if err != nil { - util.WriteError(r.Context(), err, w) + util.WriteError(ctx, err, w) return } - log.WithContext(r.Context()).Infof("instance setup completed: created user %s", req.Email) + log.WithContext(ctx).Infof("instance setup completed: created user %s", req.Email) - util.WriteJSONObject(r.Context(), w, api.SetupResponse{ - UserId: userData.ID, - Email: userData.Email, - }) + resp := api.SetupResponse{ + UserId: result.User.ID, + Email: result.User.Email, + } + + if result.PATPlainToken != "" { + resp.PersonalAccessToken = &result.PATPlainToken + } + + w.Header().Set("Cache-Control", "no-store") + util.WriteJSONObject(ctx, w, resp) } // getVersionInfo returns version information for NetBird components. diff --git a/management/server/http/handlers/instance/instance_handler_test.go b/management/server/http/handlers/instance/instance_handler_test.go index 470079c85..711e01964 100644 --- a/management/server/http/handlers/instance/instance_handler_test.go +++ b/management/server/http/handlers/instance/instance_handler_test.go @@ -10,12 +10,18 @@ import ( "net/mail" "testing" + "github.com/golang/mock/gomock" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/idp" nbinstance "github.com/netbirdio/netbird/management/server/instance" + "github.com/netbirdio/netbird/management/server/mock_server" + nbstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" ) @@ -25,6 +31,7 @@ type mockInstanceManager struct { isSetupRequired bool isSetupRequiredFn func(ctx context.Context) (bool, error) createOwnerUserFn func(ctx context.Context, email, password, name string) (*idp.UserData, error) + rollbackSetupFn func(ctx context.Context, userID string) error getVersionInfoFn func(ctx context.Context) (*nbinstance.VersionInfo, error) } @@ -67,6 +74,13 @@ func (m *mockInstanceManager) CreateOwnerUser(ctx context.Context, email, passwo }, nil } +func (m *mockInstanceManager) RollbackSetup(ctx context.Context, userID string) error { + if m.rollbackSetupFn != nil { + return m.rollbackSetupFn(ctx, userID) + } + return nil +} + func (m *mockInstanceManager) GetVersionInfo(ctx context.Context) (*nbinstance.VersionInfo, error) { if m.getVersionInfoFn != nil { return m.getVersionInfoFn(ctx) @@ -82,8 +96,12 @@ func (m *mockInstanceManager) GetVersionInfo(ctx context.Context) (*nbinstance.V var _ nbinstance.Manager = (*mockInstanceManager)(nil) func setupTestRouter(manager nbinstance.Manager) *mux.Router { + return setupTestRouterWithPAT(manager, nil) +} + +func setupTestRouterWithPAT(manager nbinstance.Manager, accountManager account.Manager) *mux.Router { router := mux.NewRouter() - AddEndpoints(manager, router) + AddEndpoints(manager, accountManager, router) return router } @@ -161,6 +179,7 @@ func TestSetup_Success(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) var response api.SetupResponse err := json.NewDecoder(rec.Body).Decode(&response) @@ -293,6 +312,239 @@ func TestSetup_ManagerError(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, rec.Code) } +func TestSetup_PAT_FeatureDisabled_IgnoresCreatePAT(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "false") + + manager := &mockInstanceManager{isSetupRequired: true} + // NB_SETUP_PAT_ENABLED=false: request fields must be silently ignored + router := setupTestRouterWithPAT(manager, &mock_server.MockAccountManager{}) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var response api.SetupResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) + assert.Nil(t, response.PersonalAccessToken) +} + +func TestSetup_PAT_FlagOmitted_NoPAT(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + manager := &mockInstanceManager{isSetupRequired: true} + router := setupTestRouterWithPAT(manager, &mock_server.MockAccountManager{}) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin"}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var response api.SetupResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) + assert.Nil(t, response.PersonalAccessToken) +} + +func TestSetup_PAT_MissingExpireIn_DefaultsToOneDay(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + createCalled := false + manager := &mockInstanceManager{ + isSetupRequired: true, + createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) { + createCalled = true + return &idp.UserData{ID: "u1", Email: email, Name: name}, nil + }, + } + accountMgr := &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + assert.Equal(t, "u1", userAuth.UserId) + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, accountID, initiator, target, name string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { + assert.Equal(t, "acc-1", accountID) + assert.Equal(t, "u1", initiator) + assert.Equal(t, "u1", target) + assert.Equal(t, "setup-token", name) + assert.Equal(t, 1, expiresIn) + return &types.PersonalAccessTokenGenerated{PlainToken: "nbp_plain"}, nil + }, + } + router := setupTestRouterWithPAT(manager, accountMgr) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + assert.True(t, createCalled) + var response api.SetupResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) + require.NotNil(t, response.PersonalAccessToken) + assert.Equal(t, "nbp_plain", *response.PersonalAccessToken) +} + +func TestSetup_PAT_ExpireOutOfRange(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + manager := &mockInstanceManager{isSetupRequired: true} + router := setupTestRouterWithPAT(manager, &mock_server.MockAccountManager{}) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 0}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) +} + +func TestSetup_PAT_Success(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + manager := &mockInstanceManager{ + isSetupRequired: true, + createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) { + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + } + + gotAccountArgs := struct { + userID string + email string + }{} + accountMgr := &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + gotAccountArgs.userID = userAuth.UserId + gotAccountArgs.email = userAuth.Email + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, accountID, initiator, target, name string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { + assert.Equal(t, "acc-1", accountID) + assert.Equal(t, "owner-id", initiator) + assert.Equal(t, "owner-id", target) + assert.Equal(t, "setup-token", name) + assert.Equal(t, 30, expiresIn) + return &types.PersonalAccessTokenGenerated{PlainToken: "nbp_plain"}, nil + }, + } + + router := setupTestRouterWithPAT(manager, accountMgr) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 30}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + var response api.SetupResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) + assert.Equal(t, "owner-id", response.UserId) + require.NotNil(t, response.PersonalAccessToken) + assert.Equal(t, "nbp_plain", *response.PersonalAccessToken) + assert.Equal(t, "owner-id", gotAccountArgs.userID) + assert.Equal(t, "admin@example.com", gotAccountArgs.email) +} + +func TestSetup_PAT_AccountCreationFails_Rollback(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + accountStore.EXPECT().GetAccountIDByUserID(gomock.Any(), nbstore.LockingStrengthNone, "owner-id").Return("", status.NewAccountNotFoundError("owner-id")) + + rolledBackFor := "" + manager := &mockInstanceManager{ + isSetupRequired: true, + createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) { + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + rollbackSetupFn: func(_ context.Context, userID string) error { + rolledBackFor = userID + return nil + }, + } + accountMgr := &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { + return "", errors.New("db down") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + } + + router := setupTestRouterWithPAT(manager, accountMgr) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 30}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Equal(t, "owner-id", rolledBackFor, "RollbackSetup must be called with the created user id") +} + +func TestSetup_PAT_CreatePATFails_Rollback(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + account := &types.Account{Id: "acc-1"} + accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) + accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(nil) + + rolledBackFor := "" + manager := &mockInstanceManager{ + isSetupRequired: true, + createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) { + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + rollbackSetupFn: func(_ context.Context, userID string) error { + rolledBackFor = userID + return nil + }, + } + accountMgr := &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) { + return nil, status.Errorf(status.Internal, "token store unavailable") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + } + + router := setupTestRouterWithPAT(manager, accountMgr) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 30}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Equal(t, "owner-id", rolledBackFor, "RollbackSetup must be called when CreatePAT fails") +} + func TestGetVersionInfo_Success(t *testing.T) { manager := &mockInstanceManager{} router := mux.NewRouter() diff --git a/management/server/instance/manager.go b/management/server/instance/manager.go index 9579d7a35..2c355bb3b 100644 --- a/management/server/instance/manager.go +++ b/management/server/instance/manager.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/dexidp/dex/storage" goversion "github.com/hashicorp/go-version" log "github.com/sirupsen/logrus" @@ -60,6 +61,13 @@ type Manager interface { // This should only be called when IsSetupRequired returns true. CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error) + // RollbackSetup reverses a successful CreateOwnerUser by deleting the user + // from the embedded IDP and reloading setupRequired from persistent state, so + // /api/setup can be retried only when no accounts or local users remain. Used + // when post-user steps (account or PAT creation) fail and the caller wants a + // clean slate. + RollbackSetup(ctx context.Context, userID string) error + // GetVersionInfo returns version information for NetBird components. GetVersionInfo(ctx context.Context) (*VersionInfo, error) } @@ -70,6 +78,7 @@ type instanceStore interface { type embeddedIdP interface { CreateUserWithPassword(ctx context.Context, email, password, name string) (*idp.UserData, error) + DeleteUser(ctx context.Context, userID string) error GetAllAccounts(ctx context.Context) (map[string][]*idp.UserData, error) } @@ -187,6 +196,51 @@ func (m *DefaultManager) CreateOwnerUser(ctx context.Context, email, password, n return userData, nil } +// RollbackSetup undoes a successful CreateOwnerUser: deletes the user from the +// embedded IDP and reloads setupRequired from persistent state. +func (m *DefaultManager) RollbackSetup(ctx context.Context, userID string) error { + if m.embeddedIdpManager == nil { + return errors.New("embedded IDP is not enabled") + } + + var deleteErr error + if err := m.embeddedIdpManager.DeleteUser(ctx, userID); err != nil { + if isNotFoundError(err) { + log.WithContext(ctx).Debugf("setup rollback user %s already deleted", userID) + } else { + deleteErr = fmt.Errorf("failed to delete user from embedded IdP: %w", err) + } + } + + if err := m.loadSetupRequired(ctx); err != nil { + reloadErr := fmt.Errorf("failed to reload setup state after rollback: %w", err) + if deleteErr != nil { + return errors.Join(deleteErr, reloadErr) + } + return reloadErr + } + + if deleteErr != nil { + return deleteErr + } + + log.WithContext(ctx).Infof("rolled back setup for user %s", userID) + return nil +} + +func isNotFoundError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, storage.ErrNotFound) { + return true + } + if s, ok := status.FromError(err); ok { + return s.Type() == status.NotFound + } + return false +} + func (m *DefaultManager) checkSetupRequiredFromDB(ctx context.Context) error { numAccounts, err := m.store.GetAccountsCounter(ctx) if err != nil { diff --git a/management/server/instance/manager_test.go b/management/server/instance/manager_test.go index e3be9cfea..5ffb016de 100644 --- a/management/server/instance/manager_test.go +++ b/management/server/instance/manager_test.go @@ -10,16 +10,19 @@ import ( "testing" "time" + "github.com/dexidp/dex/storage" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/shared/management/status" ) type mockIdP struct { - mu sync.Mutex - createUserFunc func(ctx context.Context, email, password, name string) (*idp.UserData, error) - users map[string][]*idp.UserData + mu sync.Mutex + createUserFunc func(ctx context.Context, email, password, name string) (*idp.UserData, error) + deleteUserFunc func(ctx context.Context, userID string) error + users map[string][]*idp.UserData getAllAccountsErr error } @@ -30,6 +33,13 @@ func (m *mockIdP) CreateUserWithPassword(ctx context.Context, email, password, n return &idp.UserData{ID: "test-user-id", Email: email, Name: name}, nil } +func (m *mockIdP) DeleteUser(ctx context.Context, userID string) error { + if m.deleteUserFunc != nil { + return m.deleteUserFunc(ctx, userID) + } + return nil +} + func (m *mockIdP) GetAllAccounts(_ context.Context) (map[string][]*idp.UserData, error) { m.mu.Lock() defer m.mu.Unlock() @@ -223,6 +233,77 @@ func TestIsSetupRequired_ReturnsFlag(t *testing.T) { assert.False(t, required) } +func TestRollbackSetup_UserAlreadyDeletedIsSuccess(t *testing.T) { + tests := []struct { + name string + err error + }{ + { + name: "management status not found", + err: status.NewUserNotFoundError("owner-id"), + }, + { + name: "dex storage not found", + err: fmt.Errorf("failed to get user for deletion: %w", storage.ErrNotFound), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + idpMock := &mockIdP{ + deleteUserFunc: func(_ context.Context, userID string) error { + assert.Equal(t, "owner-id", userID) + return tt.err + }, + } + mgr := newTestManager(idpMock, &mockStore{}) + mgr.setupRequired = false + + err := mgr.RollbackSetup(context.Background(), "owner-id") + require.NoError(t, err) + + required, err := mgr.IsSetupRequired(context.Background()) + require.NoError(t, err) + assert.True(t, required, "setup should be required when no accounts or local users remain") + }) + } +} + +func TestRollbackSetup_RecomputesSetupStateWhenAccountStillExists(t *testing.T) { + idpMock := &mockIdP{ + deleteUserFunc: func(_ context.Context, _ string) error { + return status.NewUserNotFoundError("owner-id") + }, + } + mgr := newTestManager(idpMock, &mockStore{accountsCount: 1}) + mgr.setupRequired = true + + err := mgr.RollbackSetup(context.Background(), "owner-id") + require.NoError(t, err) + + required, err := mgr.IsSetupRequired(context.Background()) + require.NoError(t, err) + assert.False(t, required, "setup should not be required while an account still exists") +} + +func TestRollbackSetup_ReturnsDeleteErrorButReloadsSetupState(t *testing.T) { + idpMock := &mockIdP{ + deleteUserFunc: func(_ context.Context, _ string) error { + return errors.New("idp unavailable") + }, + } + mgr := newTestManager(idpMock, &mockStore{}) + mgr.setupRequired = false + + err := mgr.RollbackSetup(context.Background(), "owner-id") + require.Error(t, err) + assert.Contains(t, err.Error(), "idp unavailable") + + required, err := mgr.IsSetupRequired(context.Background()) + require.NoError(t, err) + assert.True(t, required, "setup state should be reloaded even when user deletion fails") +} + func TestDefaultManager_ValidateSetupRequest(t *testing.T) { manager := &DefaultManager{setupRequired: true} diff --git a/management/server/instance/setup_service.go b/management/server/instance/setup_service.go new file mode 100644 index 000000000..92a4923be --- /dev/null +++ b/management/server/instance/setup_service.go @@ -0,0 +1,216 @@ +package instance + +import ( + "context" + "fmt" + "os" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + setupPATTokenName = "setup-token" + + // SetupPATEnabledEnvKey enables setup-time Personal Access Token creation. + SetupPATEnabledEnvKey = "NB_SETUP_PAT_ENABLED" + + setupPATDefaultExpireDays = 1 +) + +// SetupOptions controls optional work performed during initial instance setup. +type SetupOptions struct { + // CreatePAT requests creation of a setup Personal Access Token. It is honored + // only when SetupPATEnabledEnvKey is set to "true". + CreatePAT bool + // PATExpireInDays defaults to 1 day when CreatePAT is requested and setup PAT + // creation is enabled. + PATExpireInDays *int +} + +// SetupResult contains resources created during initial instance setup. +type SetupResult struct { + User *idp.UserData + PATPlainToken string +} + +// SetupService orchestrates the initial setup use case across the instance and +// account bounded contexts and owns the compensation logic when a later step +// fails. +type SetupService struct { + instanceManager Manager + accountManager account.Manager + setupPATEnabled bool +} + +// NewSetupService creates a setup use-case service. +func NewSetupService(instanceManager Manager, accountManager account.Manager) *SetupService { + return &SetupService{ + instanceManager: instanceManager, + accountManager: accountManager, + setupPATEnabled: os.Getenv(SetupPATEnabledEnvKey) == "true", + } +} + +func normalizeSetupOptions(opts SetupOptions, setupPATEnabled bool) (SetupOptions, error) { + if !opts.CreatePAT { + return opts, nil + } + + if !setupPATEnabled { + opts.CreatePAT = false + opts.PATExpireInDays = nil + return opts, nil + } + + if opts.PATExpireInDays == nil { + defaultExpireInDays := setupPATDefaultExpireDays + opts.PATExpireInDays = &defaultExpireInDays + } + + if *opts.PATExpireInDays < account.PATMinExpireDays || *opts.PATExpireInDays > account.PATMaxExpireDays { + return opts, status.Errorf(status.InvalidArgument, "pat_expire_in must be between %d and %d", account.PATMinExpireDays, account.PATMaxExpireDays) + } + + return opts, nil +} + +// SetupOwner creates the initial owner user and, when requested and enabled by +// SetupPATEnabledEnvKey, provisions the account and a setup Personal Access +// Token. If account or PAT provisioning fails, created resources are rolled +// back so setup can be retried. If account rollback fails, user rollback is +// skipped to avoid leaving an account without its owner user. +func (m *SetupService) SetupOwner(ctx context.Context, email, password, name string, opts SetupOptions) (*SetupResult, error) { + opts, err := normalizeSetupOptions(opts, m.setupPATEnabled) + if err != nil { + return nil, err + } + + if opts.CreatePAT && m.accountManager == nil { + return nil, fmt.Errorf("account manager is required to create setup PAT") + } + + userData, err := m.instanceManager.CreateOwnerUser(ctx, email, password, name) + if err != nil { + return nil, err + } + + result := &SetupResult{User: userData} + if !opts.CreatePAT { + return result, nil + } + + userAuth := auth.UserAuth{ + UserId: userData.ID, + Email: userData.Email, + Name: userData.Name, + } + + accountID, err := m.accountManager.GetAccountIDByUserID(ctx, userAuth) + if err != nil { + err = fmt.Errorf("create account for setup user: %w", err) + if rollbackErr := m.rollbackSetup(ctx, userData.ID, "account provisioning failed", err, ""); rollbackErr != nil { + return nil, fmt.Errorf("%w; failed to roll back setup resources: %v", err, rollbackErr) + } + return nil, err + } + + pat, err := m.accountManager.CreatePAT(ctx, accountID, userData.ID, userData.ID, setupPATTokenName, *opts.PATExpireInDays) + if err != nil { + err = fmt.Errorf("create setup PAT: %w", err) + if rollbackErr := m.rollbackSetup(ctx, userData.ID, "setup PAT provisioning failed", err, accountID); rollbackErr != nil { + return nil, fmt.Errorf("%w; failed to roll back setup resources: %v", err, rollbackErr) + } + return nil, err + } + + result.PATPlainToken = pat.PlainToken + return result, nil +} + +func (m *SetupService) rollbackSetup(ctx context.Context, userID, reason string, origErr error, accountID string) error { + if accountID == "" { + resolvedAccountID, err := m.lookupSetupAccountIDForRollback(ctx, userID) + if err != nil { + rollbackErr := fmt.Errorf("resolve setup account for rollback: %w", err) + log.WithContext(ctx).Errorf("failed to resolve setup account for user %s after %s: original error: %v, rollback error: %v", userID, reason, origErr, rollbackErr) + return rollbackErr + } + accountID = resolvedAccountID + } + + if accountID != "" { + if err := m.rollbackSetupAccount(ctx, accountID); err != nil { + rollbackErr := fmt.Errorf("roll back setup account %s: %w", accountID, err) + log.WithContext(ctx).Errorf("failed to roll back setup account %s for user %s after %s: original error: %v, rollback error: %v", accountID, userID, reason, origErr, rollbackErr) + return rollbackErr + } + log.WithContext(ctx).Warnf("rolled back setup account %s for user %s after %s: %v", accountID, userID, reason, origErr) + } + + if err := m.instanceManager.RollbackSetup(ctx, userID); err != nil { + rollbackErr := fmt.Errorf("roll back setup user %s: %w", userID, err) + log.WithContext(ctx).Errorf("failed to roll back setup user %s after %s: original error: %v, rollback error: %v", userID, reason, origErr, rollbackErr) + return rollbackErr + } + log.WithContext(ctx).Warnf("rolled back setup user %s after %s: %v", userID, reason, origErr) + return nil +} + +func (m *SetupService) lookupSetupAccountIDForRollback(ctx context.Context, userID string) (string, error) { + if m.accountManager == nil { + return "", fmt.Errorf("account manager is required to resolve setup account") + } + + accountStore := m.accountManager.GetStore() + if accountStore == nil { + return "", fmt.Errorf("account store is unavailable") + } + + accountID, err := accountStore.GetAccountIDByUserID(ctx, store.LockingStrengthNone, userID) + if err != nil { + if isNotFoundError(err) { + return "", nil + } + return "", fmt.Errorf("get setup account ID for rollback: %w", err) + } + + return accountID, nil +} + +// rollbackSetupAccount removes only the setup-created account data from the +// store. It intentionally avoids accountManager.DeleteAccount because the normal +// account deletion path also deletes users from the IdP; embedded IdP cleanup is +// owned by instanceManager.RollbackSetup. +func (m *SetupService) rollbackSetupAccount(ctx context.Context, accountID string) error { + if m.accountManager == nil { + return fmt.Errorf("account manager is required to roll back setup account") + } + + accountStore := m.accountManager.GetStore() + if accountStore == nil { + return fmt.Errorf("account store is unavailable") + } + + account, err := accountStore.GetAccount(ctx, accountID) + if err != nil { + if isNotFoundError(err) { + return nil + } + return fmt.Errorf("get setup account for rollback: %w", err) + } + + if err := accountStore.DeleteAccount(ctx, account); err != nil { + if isNotFoundError(err) { + return nil + } + return fmt.Errorf("delete setup account for rollback: %w", err) + } + + return nil +} diff --git a/management/server/instance/setup_service_test.go b/management/server/instance/setup_service_test.go new file mode 100644 index 000000000..12ec7d0fa --- /dev/null +++ b/management/server/instance/setup_service_test.go @@ -0,0 +1,318 @@ +package instance + +import ( + "context" + "errors" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/mock_server" + nbstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/status" +) + +type setupInstanceManagerMock struct { + createOwnerUserFn func(ctx context.Context, email, password, name string) (*idp.UserData, error) + rollbackSetupFn func(ctx context.Context, userID string) error +} + +func (m *setupInstanceManagerMock) IsSetupRequired(context.Context) (bool, error) { + return true, nil +} + +func (m *setupInstanceManagerMock) CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error) { + if m.createOwnerUserFn != nil { + return m.createOwnerUserFn(ctx, email, password, name) + } + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil +} + +func (m *setupInstanceManagerMock) RollbackSetup(ctx context.Context, userID string) error { + if m.rollbackSetupFn != nil { + return m.rollbackSetupFn(ctx, userID) + } + return nil +} + +func (m *setupInstanceManagerMock) GetVersionInfo(context.Context) (*VersionInfo, error) { + return &VersionInfo{}, nil +} + +var _ Manager = (*setupInstanceManagerMock)(nil) + +func intPtr(v int) *int { + return &v +} + +func TestSetupOwner_PATFeatureDisabled_IgnoresCreatePAT(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "false") + + createCalls := 0 + setupManager := NewSetupService( + &setupInstanceManagerMock{ + createOwnerUserFn: func(_ context.Context, email, _, name string) (*idp.UserData, error) { + createCalls++ + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + }, + &mock_server.MockAccountManager{}, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "owner-id", result.User.ID) + assert.Empty(t, result.PATPlainToken) + assert.Equal(t, 1, createCalls) +} + +func TestSetupOwner_PATFeatureEnabled_MissingExpireDefaultsToOneDay(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + createCalled := false + setupManager := NewSetupService( + &setupInstanceManagerMock{ + createOwnerUserFn: func(_ context.Context, email, _, name string) (*idp.UserData, error) { + createCalled = true + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + }, + &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + assert.Equal(t, "owner-id", userAuth.UserId) + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, accountID, initiatorUserID, targetUserID, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { + assert.Equal(t, "acc-1", accountID) + assert.Equal(t, "owner-id", initiatorUserID) + assert.Equal(t, "owner-id", targetUserID) + assert.Equal(t, setupPATTokenName, tokenName) + assert.Equal(t, setupPATDefaultExpireDays, expiresIn) + return &types.PersonalAccessTokenGenerated{PlainToken: "nbp_plain"}, nil + }, + }, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, createCalled) + assert.Equal(t, "nbp_plain", result.PATPlainToken) +} + +func TestSetupOwner_PATFeatureEnabled_MissingAccountManagerFailsBeforeCreateUser(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + createCalled := false + rollbackCalled := false + setupManager := NewSetupService( + &setupInstanceManagerMock{ + createOwnerUserFn: func(_ context.Context, email, _, name string) (*idp.UserData, error) { + createCalled = true + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + rollbackSetupFn: func(_ context.Context, _ string) error { + rollbackCalled = true + return nil + }, + }, + nil, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "account manager is required") + assert.False(t, createCalled) + assert.False(t, rollbackCalled) +} + +func TestSetupOwner_AccountProvisioningFails_RollsBackSideEffectAccountAndUser(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + account := &types.Account{Id: "acc-1"} + accountStore.EXPECT().GetAccountIDByUserID(gomock.Any(), nbstore.LockingStrengthNone, "owner-id").Return("acc-1", nil) + accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) + accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(nil) + + rolledBackFor := "" + rollbackCalls := 0 + setupManager := NewSetupService( + &setupInstanceManagerMock{ + rollbackSetupFn: func(_ context.Context, userID string) error { + rollbackCalls++ + rolledBackFor = userID + return nil + }, + }, + &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + assert.Equal(t, "owner-id", userAuth.UserId) + return "", errors.New("metadata update failed") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + }, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + PATExpireInDays: intPtr(30), + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "create account for setup user") + assert.Equal(t, "owner-id", rolledBackFor) + assert.Equal(t, 1, rollbackCalls) +} + +func TestSetupOwner_CreatePATFails_RollsBackSetupAccountAndUser(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + account := &types.Account{Id: "acc-1"} + accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) + accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(nil) + + rollbackCalls := 0 + setupManager := NewSetupService( + &setupInstanceManagerMock{ + rollbackSetupFn: func(_ context.Context, userID string) error { + rollbackCalls++ + assert.Equal(t, "owner-id", userID) + return nil + }, + }, + &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + assert.Equal(t, "owner-id", userAuth.UserId) + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, accountID, initiatorUserID, targetUserID, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { + assert.Equal(t, "acc-1", accountID) + assert.Equal(t, "owner-id", initiatorUserID) + assert.Equal(t, "owner-id", targetUserID) + assert.Equal(t, setupPATTokenName, tokenName) + assert.Equal(t, 30, expiresIn) + return nil, status.Errorf(status.Internal, "token store unavailable") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + }, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + PATExpireInDays: intPtr(30), + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "create setup PAT") + assert.Equal(t, 1, rollbackCalls) +} + +func TestSetupOwner_CreatePATFails_AccountAlreadyGoneStillRollsBackUser(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(nil, status.NewAccountNotFoundError("acc-1")) + + rolledBackFor := "" + rollbackCalls := 0 + setupManager := NewSetupService( + &setupInstanceManagerMock{ + rollbackSetupFn: func(_ context.Context, userID string) error { + rollbackCalls++ + rolledBackFor = userID + return nil + }, + }, + &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) { + return nil, errors.New("token failure") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + }, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + PATExpireInDays: intPtr(30), + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "create setup PAT") + assert.Equal(t, "owner-id", rolledBackFor) + assert.Equal(t, 1, rollbackCalls) +} + +func TestSetupOwner_CreatePATFails_AccountRollbackFailureStopsBeforeUserRollback(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + account := &types.Account{Id: "acc-1"} + accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) + accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(errors.New("delete failed")) + + rollbackCalls := 0 + setupManager := NewSetupService( + &setupInstanceManagerMock{ + rollbackSetupFn: func(_ context.Context, userID string) error { + rollbackCalls++ + return nil + }, + }, + &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) { + return nil, errors.New("token failure") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + }, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + PATExpireInDays: intPtr(30), + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "create setup PAT") + assert.Contains(t, err.Error(), "failed to roll back setup resources") + assert.Equal(t, 0, rollbackCalls) +} diff --git a/management/server/user.go b/management/server/user.go index b1fb51195..43e0a9821 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -15,6 +15,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/idp" nbpeer "github.com/netbirdio/netbird/management/server/peer" @@ -395,8 +396,8 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string return nil, status.Errorf(status.InvalidArgument, "token name can't be empty") } - if expiresIn < 1 || expiresIn > 365 { - return nil, status.Errorf(status.InvalidArgument, "expiration has to be between 1 and 365") + if expiresIn < account.PATMinExpireDays || expiresIn > account.PATMaxExpireDays { + return nil, status.Errorf(status.InvalidArgument, "expiration has to be between %d and %d", account.PATMinExpireDays, account.PATMaxExpireDays) } allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index b70f89499..c5fdbfbe0 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -3426,6 +3426,17 @@ components: description: Display name for the admin user (defaults to email if not provided) type: string example: Admin User + create_pat: + description: If true and the server has setup-time PAT issuance enabled (NB_SETUP_PAT_ENABLED=true), create a Personal Access Token for the new owner user and return it in the response. Ignored when the server feature is disabled. + type: boolean + example: true + pat_expire_in: + description: Expiration of the Personal Access Token in days. Applies only when create_pat is true and the server feature is enabled. Defaults to 1 day when omitted. + type: integer + minimum: 1 + maximum: 365 + default: 1 + example: 30 required: - email - password @@ -3442,6 +3453,12 @@ components: description: Email address of the created user type: string example: admin@example.com + personal_access_token: + description: Plain text Personal Access Token created during setup. Present only when create_pat was requested and the NB_SETUP_PAT_ENABLED feature was enabled on the server. + type: string + format: password + readOnly: true + example: nbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx required: - user_id - email @@ -4980,7 +4997,10 @@ paths: /api/setup: post: summary: Setup Instance - description: Creates the initial admin user for the instance. This endpoint does not require authentication but only works when setup is required (no accounts exist and embedded IDP is enabled). + description: | + Creates the initial admin user for the instance. This endpoint does not require authentication but only works when setup is required (no accounts exist and embedded IDP is enabled). + + When the management server is started with `NB_SETUP_PAT_ENABLED=true` and the request includes `create_pat: true`, the endpoint also provisions the NetBird account for the new owner user and returns the plain text Personal Access Token in `personal_access_token`. The optional `pat_expire_in` value applies only when `create_pat` is true and defaults to 1 day when omitted. If a post-user step fails, setup-created resources are rolled back when safe; if account cleanup fails, the owner user is left in place to avoid leaving an account without its admin user. tags: [ Instance ] security: [ ] requestBody: @@ -4993,6 +5013,12 @@ paths: responses: '200': description: Setup completed successfully + headers: + Cache-Control: + description: Always set to no-store because the response may contain a one-time plain text Personal Access Token. + schema: + type: string + example: no-store content: application/json: schema: diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index d56cb9b74..11cb8e46a 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -4297,6 +4297,9 @@ type SetupKeyRequest struct { // SetupRequest Request to set up the initial admin user type SetupRequest struct { + // CreatePat If true and the server has setup-time PAT issuance enabled (NB_SETUP_PAT_ENABLED=true), create a Personal Access Token for the new owner user and return it in the response. Ignored when the server feature is disabled. + CreatePat *bool `json:"create_pat,omitempty"` + // Email Email address for the admin user Email string `json:"email"` @@ -4305,6 +4308,9 @@ type SetupRequest struct { // Password Password for the admin user (minimum 8 characters) Password string `json:"password"` + + // PatExpireIn Expiration of the Personal Access Token in days. Applies only when create_pat is true and the server feature is enabled. Defaults to 1 day when omitted. + PatExpireIn *int `json:"pat_expire_in,omitempty"` } // SetupResponse Response after successful instance setup @@ -4312,6 +4318,9 @@ type SetupResponse struct { // Email Email address of the created user Email string `json:"email"` + // PersonalAccessToken Plain text Personal Access Token created during setup. Present only when create_pat was requested and the NB_SETUP_PAT_ENABLED feature was enabled on the server. + PersonalAccessToken *string `json:"personal_access_token,omitempty"` + // UserId The ID of the created user UserId string `json:"user_id"` } From c4b2da4c92520d006af448d90c6f533352b10769 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Thu, 30 Apr 2026 18:36:50 +0200 Subject: [PATCH 12/80] [management] Add public connection ipv4 and ipv6 posture check (#6038) This change enables admins to configure posture checks for connecting public IPs of their peers. It changes the behavior of the check as well and now the evaluation is if the received network is part of the configured network. --- management/server/posture/network.go | 47 ++++- management/server/posture/network_test.go | 200 ++++++++++++++++++++++ shared/management/http/api/openapi.yml | 9 +- shared/management/http/api/types.gen.go | 6 +- 4 files changed, 247 insertions(+), 15 deletions(-) diff --git a/management/server/posture/network.go b/management/server/posture/network.go index f78744143..4b4b3ccaa 100644 --- a/management/server/posture/network.go +++ b/management/server/posture/network.go @@ -17,19 +17,48 @@ type PeerNetworkRangeCheck struct { var _ Check = (*PeerNetworkRangeCheck)(nil) +// prefixContains reports whether outer fully contains inner (equal counts as contained). +// Requires the same address family, that outer is no more specific than inner (its +// netmask is shorter or equal), and that inner's network address falls inside outer. +// This is stricter than netip.Prefix.Contains(Addr) — a peer's /24 NIC will not match a +// configured /32 rule, since the rule covers a single host but the NIC describes a whole +// subnet whose host bits are unknown. +func prefixContains(outer, inner netip.Prefix) bool { + outer = outer.Masked() + inner = inner.Masked() + return outer.Bits() <= inner.Bits() && + outer.Addr().BitLen() == inner.Addr().BitLen() && // same family + outer.Contains(inner.Addr()) +} + +// Check evaluates configured ranges against the peer's local network interface prefixes +// and its public connection IP (as a /32 or /128). A configured range matches when it +// fully contains one of those prefixes, so operators can target both private subnets +// and public CIDRs (e.g. 1.0.0.0/24, 2.2.2.2/32). Including the connection IP is what +// lets a public-range posture check work — peer.Meta.NetworkAddresses only carries +// local NIC addresses. func (p *PeerNetworkRangeCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, error) { - if len(peer.Meta.NetworkAddresses) == 0 { + peerPrefixes := make([]netip.Prefix, 0, len(peer.Meta.NetworkAddresses)+1) + for _, peerNetAddr := range peer.Meta.NetworkAddresses { + peerPrefixes = append(peerPrefixes, peerNetAddr.NetIP) + } + // Unmap collapses 4-in-6 forms (::ffff:a.b.c.d) so an IPv4 range matches. + if connIP := peer.Location.ConnectionIP; len(connIP) > 0 { + if addr, ok := netip.AddrFromSlice(connIP); ok { + addr = addr.Unmap() + peerPrefixes = append(peerPrefixes, netip.PrefixFrom(addr, addr.BitLen())) + } + } + + if len(peerPrefixes) == 0 { return false, fmt.Errorf("peer's does not contain peer network range addresses") } - maskedPrefixes := make([]netip.Prefix, 0, len(p.Ranges)) - for _, prefix := range p.Ranges { - maskedPrefixes = append(maskedPrefixes, prefix.Masked()) - } - - for _, peerNetAddr := range peer.Meta.NetworkAddresses { - peerMaskedPrefix := peerNetAddr.NetIP.Masked() - if slices.Contains(maskedPrefixes, peerMaskedPrefix) { + for _, peerPrefix := range peerPrefixes { + for _, rangePrefix := range p.Ranges { + if !prefixContains(rangePrefix, peerPrefix) { + continue + } switch p.Action { case CheckActionDeny: return false, nil diff --git a/management/server/posture/network_test.go b/management/server/posture/network_test.go index a841bbe08..4af394c62 100644 --- a/management/server/posture/network_test.go +++ b/management/server/posture/network_test.go @@ -2,6 +2,7 @@ package posture import ( "context" + "net" "net/netip" "testing" @@ -134,6 +135,205 @@ func TestPeerNetworkRangeCheck_Check(t *testing.T) { wantErr: true, isValid: false, }, + { + name: "Peer connection IP matches the denied /32", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("109.41.115.194/32"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.123/24")}, + }, + }, + Location: nbpeer.Location{ConnectionIP: net.ParseIP("109.41.115.194")}, + }, + wantErr: false, + isValid: false, + }, + { + name: "Peer connection IP does not match the denied /32", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("109.41.115.194/32"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.123/24")}, + }, + }, + Location: nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")}, + }, + wantErr: false, + isValid: true, + }, + { + name: "Peer connection IP matches the allowed /32 with no NetworkAddresses", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("109.41.115.194/32"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("109.41.115.194")}, + }, + wantErr: false, + isValid: true, + }, + { + name: "IPv6 connection IP matches the denied /128", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("2001:db8::1/128"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("2001:db8::1")}, + }, + wantErr: false, + isValid: false, + }, + { + name: "IPv6 connection IP does not match the denied /128", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("2001:db8::1/128"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("2001:db8::2")}, + }, + wantErr: false, + isValid: true, + }, + { + name: "IPv4-mapped IPv6 connection IP matches IPv4 /32", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("109.41.115.194/32"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("::ffff:109.41.115.194")}, + }, + wantErr: false, + isValid: false, + }, + { + name: "Connection IP falls inside an allowed /24 range", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("1.0.0.0/24"), + netip.MustParsePrefix("2.2.2.2/32"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("1.0.0.55")}, + }, + wantErr: false, + isValid: true, + }, + { + name: "Connection IP falls inside an allowed /23 range", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("3.0.0.0/23"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("3.0.1.200")}, + }, + wantErr: false, + isValid: true, + }, + { + name: "Connection IP outside the allowed /24 range", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("1.0.0.0/24"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("1.0.1.5")}, + }, + wantErr: false, + isValid: false, + }, + { + name: "Connection IP inside a denied /24 range", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("1.0.0.0/24"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("1.0.0.7")}, + }, + wantErr: false, + isValid: false, + }, + { + name: "Local NIC /24 does not match a /32 rule even if host bit lines up", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.5/32"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.5/24")}, + }, + }, + }, + wantErr: false, + isValid: false, + }, + { + name: "Local NIC address inside an allowed /16 range", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.0/16"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.5.7/24")}, + }, + }, + }, + wantErr: false, + isValid: true, + }, + { + name: "Empty NetworkAddresses and empty ConnectionIP still errors", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("109.41.115.194/32"), + }, + }, + peer: nbpeer.Peer{}, + wantErr: true, + isValid: false, + }, } for _, tt := range tests { diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index c5fdbfbe0..327e20614 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -1687,15 +1687,18 @@ components: - locations - action PeerNetworkRangeCheck: - description: Posture check for allow or deny access based on peer local network addresses + description: | + Posture check for allow or deny access based on the peer's IP addresses. A range matches when it + contains any of the peer's local network interface IPs or its public connection (NAT egress) IP, + so ranges may target private subnets, public CIDRs, or single hosts via a /32 or /128. type: object properties: ranges: - description: List of peer network ranges in CIDR notation + description: List of network ranges in CIDR notation, matched against the peer's local interface IPs and its public connection IP type: array items: type: string - example: [ "192.168.1.0/24", "10.0.0.0/8", "2001:db8:1234:1a00::/56" ] + example: [ "192.168.1.0/24", "10.0.0.0/8", "1.0.0.0/24", "2.2.2.2/32", "2001:db8:1234:1a00::/56" ] action: description: Action to take upon policy match type: string diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 11cb8e46a..dc916f81a 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1626,7 +1626,7 @@ type Checks struct { // OsVersionCheck Posture check for the version of operating system OsVersionCheck *OSVersionCheck `json:"os_version_check,omitempty"` - // PeerNetworkRangeCheck Posture check for allow or deny access based on peer local network addresses + // PeerNetworkRangeCheck Posture check for allow or deny access based on the peer's IP addresses. A range matches when it contains any of the peer's local network interface IPs or its public connection (NAT egress) IP, so ranges may target private subnets, public CIDRs, or single hosts via a /32 or /128. PeerNetworkRangeCheck *PeerNetworkRangeCheck `json:"peer_network_range_check,omitempty"` // ProcessCheck Posture Check for binaries exist and are running in the peer’s system @@ -3312,12 +3312,12 @@ type PeerMinimum struct { Name string `json:"name"` } -// PeerNetworkRangeCheck Posture check for allow or deny access based on peer local network addresses +// PeerNetworkRangeCheck Posture check for allow or deny access based on the peer's IP addresses. A range matches when it contains any of the peer's local network interface IPs or its public connection (NAT egress) IP, so ranges may target private subnets, public CIDRs, or single hosts via a /32 or /128. type PeerNetworkRangeCheck struct { // Action Action to take upon policy match Action PeerNetworkRangeCheckAction `json:"action"` - // Ranges List of peer network ranges in CIDR notation + // Ranges List of network ranges in CIDR notation, matched against the peer's local interface IPs and its public connection IP Ranges []string `json:"ranges"` } From 057d651d2e1f27c539a16c010c34e1ba88a117de Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 4 May 2026 18:28:56 +0900 Subject: [PATCH 13/80] [client, proxy] Add packet capture to debug bundle and CLI (#5891) --- client/Dockerfile | 1 + client/Dockerfile-rootless | 1 + client/cmd/capture.go | 196 ++++++ client/cmd/debug.go | 41 ++ client/cmd/root.go | 1 + client/cmd/service.go | 1 + client/cmd/service_controller.go | 2 +- client/cmd/service_installer.go | 4 + client/cmd/service_params.go | 6 + client/cmd/service_params_test.go | 1 + client/cmd/testutil_test.go | 2 +- client/embed/capture.go | 65 ++ client/embed/embed.go | 53 +- client/firewall/uspfilter/filter.go | 32 +- .../firewall/uspfilter/forwarder/endpoint.go | 17 +- .../firewall/uspfilter/forwarder/forwarder.go | 10 + client/firewall/uspfilter/forwarder/icmp.go | 4 + client/iface/device/device_filter.go | 54 +- client/iface/device/device_filter_test.go | 2 +- client/internal/debug/debug.go | 33 +- client/internal/engine.go | 65 ++ client/internal/lazyconn/manager/manager.go | 5 +- client/internal/netflow/store/memory.go | 4 +- .../internal/routeselector/routeselector.go | 13 +- client/proto/daemon.pb.go | 563 ++++++++++++---- client/proto/daemon.proto | 34 + client/proto/daemon_grpc.pb.go | 131 +++- client/server/capture.go | 365 ++++++++++ client/server/debug.go | 5 +- client/server/server.go | 10 +- client/server/server_test.go | 6 +- client/server/setconfig_test.go | 2 +- client/ui/debug.go | 187 ++--- client/wasm/cmd/main.go | 96 +++ client/wasm/internal/capture/capture.go | 176 +++++ management/server/account_test.go | 2 +- proxy/cmd/proxy/cmd/debug.go | 114 ++++ proxy/internal/debug/client.go | 70 ++ proxy/internal/debug/handler.go | 77 +++ util/capture/afpacket_linux.go | 199 ++++++ util/capture/afpacket_stub.go | 26 + util/capture/capture.go | 59 ++ util/capture/filter.go | 528 +++++++++++++++ util/capture/filter_test.go | 263 ++++++++ util/capture/pcap.go | 85 +++ util/capture/pcap_test.go | 68 ++ util/capture/session.go | 213 ++++++ util/capture/session_test.go | 144 ++++ util/capture/text.go | 638 ++++++++++++++++++ 49 files changed, 4421 insertions(+), 253 deletions(-) create mode 100644 client/cmd/capture.go create mode 100644 client/embed/capture.go create mode 100644 client/server/capture.go create mode 100644 client/wasm/internal/capture/capture.go create mode 100644 util/capture/afpacket_linux.go create mode 100644 util/capture/afpacket_stub.go create mode 100644 util/capture/capture.go create mode 100644 util/capture/filter.go create mode 100644 util/capture/filter_test.go create mode 100644 util/capture/pcap.go create mode 100644 util/capture/pcap_test.go create mode 100644 util/capture/session.go create mode 100644 util/capture/session_test.go create mode 100644 util/capture/text.go diff --git a/client/Dockerfile b/client/Dockerfile index 64d5ba04f..53e4555ef 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -17,6 +17,7 @@ ENV \ NETBIRD_BIN="/usr/local/bin/netbird" \ NB_LOG_FILE="console,/var/log/netbird/client.log" \ NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \ + NB_ENABLE_CAPTURE="false" \ NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless index 69d00aaf2..706bf40de 100644 --- a/client/Dockerfile-rootless +++ b/client/Dockerfile-rootless @@ -23,6 +23,7 @@ ENV \ NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \ NB_LOG_FILE="console,/var/lib/netbird/client.log" \ NB_DISABLE_DNS="true" \ + NB_ENABLE_CAPTURE="false" \ NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] diff --git a/client/cmd/capture.go b/client/cmd/capture.go new file mode 100644 index 000000000..95caaa5cd --- /dev/null +++ b/client/cmd/capture.go @@ -0,0 +1,196 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/hashicorp/go-multierror" + "github.com/spf13/cobra" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/durationpb" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util/capture" +) + +var captureCmd = &cobra.Command{ + Use: "capture", + Short: "Capture packets on the WireGuard interface", + Long: `Captures decrypted packets flowing through the WireGuard interface. + +Default output is human-readable text. Use --pcap or --output for pcap binary. +Requires --enable-capture to be set at service install or reconfigure time. + +Examples: + netbird debug capture + netbird debug capture host 100.64.0.1 and port 443 + netbird debug capture tcp + netbird debug capture icmp + netbird debug capture src host 10.0.0.1 and dst port 80 + netbird debug capture -o capture.pcap + netbird debug capture --pcap | tshark -r - + netbird debug capture --pcap | tcpdump -r - -n`, + Args: cobra.ArbitraryArgs, + RunE: runCapture, +} + +func init() { + debugCmd.AddCommand(captureCmd) + + captureCmd.Flags().Bool("pcap", false, "Force pcap binary output (default when --output is set)") + captureCmd.Flags().BoolP("verbose", "v", false, "Show seq/ack, TTL, window, total length") + captureCmd.Flags().Bool("ascii", false, "Print payload as ASCII after each packet (useful for HTTP)") + captureCmd.Flags().Uint32("snap-len", 0, "Max bytes per packet (0 = full)") + captureCmd.Flags().DurationP("duration", "d", 0, "Capture duration (0 = until interrupted)") + captureCmd.Flags().StringP("output", "o", "", "Write pcap to file instead of stdout") +} + +func runCapture(cmd *cobra.Command, args []string) error { + conn, err := getClient(cmd) + if err != nil { + return err + } + defer func() { + if err := conn.Close(); err != nil { + cmd.PrintErrf(errCloseConnection, err) + } + }() + + client := proto.NewDaemonServiceClient(conn) + + req, err := buildCaptureRequest(cmd, args) + if err != nil { + return err + } + + ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + stream, err := client.StartCapture(ctx, req) + if err != nil { + return handleCaptureError(err) + } + + // First Recv is the empty acceptance message from the server. If the + // device is unavailable (kernel WG, not connected, capture disabled), + // the server returns an error instead. + if _, err := stream.Recv(); err != nil { + return handleCaptureError(err) + } + + out, cleanup, err := captureOutput(cmd) + if err != nil { + return err + } + + if req.TextOutput { + cmd.PrintErrf("Capturing packets... Press Ctrl+C to stop.\n") + } else { + cmd.PrintErrf("Capturing packets (pcap)... Press Ctrl+C to stop.\n") + } + + streamErr := streamCapture(ctx, cmd, stream, out) + cleanupErr := cleanup() + if streamErr != nil { + return streamErr + } + return cleanupErr +} + +func buildCaptureRequest(cmd *cobra.Command, args []string) (*proto.StartCaptureRequest, error) { + req := &proto.StartCaptureRequest{} + + if len(args) > 0 { + expr := strings.Join(args, " ") + if _, err := capture.ParseFilter(expr); err != nil { + return nil, fmt.Errorf("invalid filter: %w", err) + } + req.FilterExpr = expr + } + + if snap, _ := cmd.Flags().GetUint32("snap-len"); snap > 0 { + req.SnapLen = snap + } + if d, _ := cmd.Flags().GetDuration("duration"); d != 0 { + if d < 0 { + return nil, fmt.Errorf("duration must not be negative") + } + req.Duration = durationpb.New(d) + } + req.Verbose, _ = cmd.Flags().GetBool("verbose") + req.Ascii, _ = cmd.Flags().GetBool("ascii") + + outPath, _ := cmd.Flags().GetString("output") + forcePcap, _ := cmd.Flags().GetBool("pcap") + req.TextOutput = !forcePcap && outPath == "" + + return req, nil +} + +func streamCapture(ctx context.Context, cmd *cobra.Command, stream proto.DaemonService_StartCaptureClient, out io.Writer) error { + for { + pkt, err := stream.Recv() + if err != nil { + if ctx.Err() != nil { + cmd.PrintErrf("\nCapture stopped.\n") + return nil //nolint:nilerr // user interrupted + } + if err == io.EOF { + cmd.PrintErrf("\nCapture finished.\n") + return nil + } + return handleCaptureError(err) + } + if _, err := out.Write(pkt.GetData()); err != nil { + return fmt.Errorf("write output: %w", err) + } + } +} + +// captureOutput returns the writer for capture data and a cleanup function +// that finalizes the file. Errors from the cleanup must be propagated. +func captureOutput(cmd *cobra.Command) (io.Writer, func() error, error) { + outPath, _ := cmd.Flags().GetString("output") + if outPath == "" { + return os.Stdout, func() error { return nil }, nil + } + + f, err := os.CreateTemp(filepath.Dir(outPath), filepath.Base(outPath)+".*.tmp") + if err != nil { + return nil, nil, fmt.Errorf("create output file: %w", err) + } + tmpPath := f.Name() + return f, func() error { + var merr *multierror.Error + if err := f.Close(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("close output file: %w", err)) + } + fi, statErr := os.Stat(tmpPath) + if statErr != nil || fi.Size() == 0 { + if rmErr := os.Remove(tmpPath); rmErr != nil && !os.IsNotExist(rmErr) { + merr = multierror.Append(merr, fmt.Errorf("remove empty output file: %w", rmErr)) + } + return nberrors.FormatErrorOrNil(merr) + } + if err := os.Rename(tmpPath, outPath); err != nil { + merr = multierror.Append(merr, fmt.Errorf("rename output file: %w", err)) + return nberrors.FormatErrorOrNil(merr) + } + cmd.PrintErrf("Wrote %s\n", outPath) + return nberrors.FormatErrorOrNil(merr) + }, nil +} + +func handleCaptureError(err error) error { + if s, ok := status.FromError(err); ok { + return fmt.Errorf("%s", s.Message()) + } + return err +} diff --git a/client/cmd/debug.go b/client/cmd/debug.go index e3d3afe5f..2a8cdc887 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -9,6 +9,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/durationpb" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/debug" @@ -239,11 +240,50 @@ func runForDuration(cmd *cobra.Command, args []string) error { }() } + captureStarted := false + if wantCapture, _ := cmd.Flags().GetBool("capture"); wantCapture { + captureTimeout := duration + 30*time.Second + const maxBundleCapture = 10 * time.Minute + if captureTimeout > maxBundleCapture { + captureTimeout = maxBundleCapture + } + _, err := client.StartBundleCapture(cmd.Context(), &proto.StartBundleCaptureRequest{ + Timeout: durationpb.New(captureTimeout), + }) + if err != nil { + cmd.PrintErrf("Failed to start packet capture: %v\n", status.Convert(err).Message()) + } else { + captureStarted = true + cmd.Println("Packet capture started.") + // Safety: always stop on exit, even if the normal stop below runs too. + defer func() { + if captureStarted { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := client.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { + cmd.PrintErrf("Failed to stop packet capture: %v\n", err) + } + } + }() + } + } + if waitErr := waitForDurationOrCancel(cmd.Context(), duration, cmd); waitErr != nil { return waitErr } cmd.Println("\nDuration completed") + if captureStarted { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := client.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { + cmd.PrintErrf("Failed to stop packet capture: %v\n", err) + } else { + captureStarted = false + cmd.Println("Packet capture stopped.") + } + } + if cpuProfilingStarted { if _, err := client.StopCPUProfile(cmd.Context(), &proto.StopCPUProfileRequest{}); err != nil { cmd.PrintErrf("Failed to stop CPU profiling: %v\n", err) @@ -416,4 +456,5 @@ func init() { forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle") forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server") forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle") + forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle") } diff --git a/client/cmd/root.go b/client/cmd/root.go index c872fe9f6..29d4328a1 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -75,6 +75,7 @@ var ( mtu uint16 profilesDisabled bool updateSettingsDisabled bool + captureEnabled bool networksDisabled bool rootCmd = &cobra.Command{ diff --git a/client/cmd/service.go b/client/cmd/service.go index f1123ce8c..56d8a8726 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -44,6 +44,7 @@ func init() { serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd, resetParamsCmd) serviceCmd.PersistentFlags().BoolVar(&profilesDisabled, "disable-profiles", false, "Disables profiles feature. If enabled, the client will not be able to change or edit any profile. To persist this setting, use: netbird service install --disable-profiles") serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings") + serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture") serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks") rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name") diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 0943b6184..88121c067 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -61,7 +61,7 @@ func (p *program) Start(svc service.Service) error { } } - serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, networksDisabled) + serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled) if err := serverInstance.Start(); err != nil { log.Fatalf("failed to start daemon: %v", err) } diff --git a/client/cmd/service_installer.go b/client/cmd/service_installer.go index 5ada6f633..2d45fa063 100644 --- a/client/cmd/service_installer.go +++ b/client/cmd/service_installer.go @@ -59,6 +59,10 @@ func buildServiceArguments() []string { args = append(args, "--disable-update-settings") } + if captureEnabled { + args = append(args, "--enable-capture") + } + if networksDisabled { args = append(args, "--disable-networks") } diff --git a/client/cmd/service_params.go b/client/cmd/service_params.go index 5a86aebc6..192e0ac60 100644 --- a/client/cmd/service_params.go +++ b/client/cmd/service_params.go @@ -28,6 +28,7 @@ type serviceParams struct { LogFiles []string `json:"log_files,omitempty"` DisableProfiles bool `json:"disable_profiles,omitempty"` DisableUpdateSettings bool `json:"disable_update_settings,omitempty"` + EnableCapture bool `json:"enable_capture,omitempty"` DisableNetworks bool `json:"disable_networks,omitempty"` ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"` } @@ -79,6 +80,7 @@ func currentServiceParams() *serviceParams { LogFiles: logFiles, DisableProfiles: profilesDisabled, DisableUpdateSettings: updateSettingsDisabled, + EnableCapture: captureEnabled, DisableNetworks: networksDisabled, } @@ -144,6 +146,10 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) { updateSettingsDisabled = params.DisableUpdateSettings } + if !serviceCmd.PersistentFlags().Changed("enable-capture") { + captureEnabled = params.EnableCapture + } + if !serviceCmd.PersistentFlags().Changed("disable-networks") { networksDisabled = params.DisableNetworks } diff --git a/client/cmd/service_params_test.go b/client/cmd/service_params_test.go index 7e04e5abe..f338c12f4 100644 --- a/client/cmd/service_params_test.go +++ b/client/cmd/service_params_test.go @@ -535,6 +535,7 @@ func fieldToGlobalVar(field string) string { "LogFiles": "logFiles", "DisableProfiles": "profilesDisabled", "DisableUpdateSettings": "updateSettingsDisabled", + "EnableCapture": "captureEnabled", "DisableNetworks": "networksDisabled", "ServiceEnvVars": "serviceEnvVars", } diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index fd1007bb4..c24965e8d 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -160,7 +160,7 @@ func startClientDaemon( s := grpc.NewServer() server := client.New(ctx, - "", "", false, false, false) + "", "", false, false, false, false) if err := server.Start(); err != nil { t.Fatal(err) } diff --git a/client/embed/capture.go b/client/embed/capture.go new file mode 100644 index 000000000..30f9b496f --- /dev/null +++ b/client/embed/capture.go @@ -0,0 +1,65 @@ +package embed + +import ( + "io" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/util/capture" +) + +// CaptureOptions configures a packet capture session. +type CaptureOptions struct { + // Output receives pcap-formatted data. Nil disables pcap output. + Output io.Writer + // TextOutput receives human-readable packet summaries. Nil disables text output. + TextOutput io.Writer + // Filter is a BPF-like filter expression (e.g. "host 10.0.0.1 and tcp port 443"). + // Empty captures all packets. + Filter string + // Verbose adds seq/ack, TTL, window, and total length to text output. + Verbose bool + // ASCII dumps transport payload as printable ASCII after each packet line. + ASCII bool +} + +// CaptureStats reports capture session counters. +type CaptureStats struct { + Packets int64 + Bytes int64 + Dropped int64 +} + +// CaptureSession represents an active packet capture. Call Stop to end the +// capture and flush buffered packets. +type CaptureSession struct { + sess *capture.Session + engine *internal.Engine +} + +// Stop ends the capture, flushes remaining packets, and detaches from the device. +// Safe to call multiple times. +func (cs *CaptureSession) Stop() { + if cs.engine != nil { + _ = cs.engine.SetCapture(nil) + cs.engine = nil + } + if cs.sess != nil { + cs.sess.Stop() + } +} + +// Stats returns current capture counters. +func (cs *CaptureSession) Stats() CaptureStats { + s := cs.sess.Stats() + return CaptureStats{ + Packets: s.Packets, + Bytes: s.Bytes, + Dropped: s.Dropped, + } +} + +// Done returns a channel that is closed when the capture's writer goroutine +// has fully exited and all buffered packets have been flushed. +func (cs *CaptureSession) Done() <-chan struct{} { + return cs.sess.Done() +} diff --git a/client/embed/embed.go b/client/embed/embed.go index 88f7e541c..baa1d94d6 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -24,6 +24,7 @@ import ( "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/util/capture" ) var ( @@ -65,7 +66,7 @@ type Options struct { PrivateKey string // ManagementURL overrides the default management server URL ManagementURL string - // PreSharedKey is the pre-shared key for the WireGuard interface + // PreSharedKey is the pre-shared key for the tunnel interface PreSharedKey string // LogOutput is the output destination for logs (defaults to os.Stderr if nil) LogOutput io.Writer @@ -81,9 +82,9 @@ type Options struct { DisableClientRoutes bool // BlockInbound blocks all inbound connections from peers BlockInbound bool - // WireguardPort is the port for the WireGuard interface. Use 0 for a random port. + // WireguardPort is the port for the tunnel interface. Use 0 for a random port. WireguardPort *int - // MTU is the MTU for the WireGuard interface. + // MTU is the MTU for the tunnel interface. // Valid values are in the range 576..8192 bytes. // If non-nil, this value overrides any value stored in the config file. // If nil, the existing config MTU (if non-zero) is preserved; otherwise it defaults to 1280. @@ -469,6 +470,52 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error { return sshcommon.VerifyHostKey(storedKey, key, peerAddress) } +// StartCapture begins capturing packets on this client's tunnel device. +// Only one capture can be active at a time; starting a new one stops the previous. +// Call StopCapture (or CaptureSession.Stop) to end it. +func (c *Client) StartCapture(opts CaptureOptions) (*CaptureSession, error) { + engine, err := c.getEngine() + if err != nil { + return nil, err + } + + var matcher capture.Matcher + if opts.Filter != "" { + m, err := capture.ParseFilter(opts.Filter) + if err != nil { + return nil, fmt.Errorf("parse filter: %w", err) + } + matcher = m + } + + sess, err := capture.NewSession(capture.Options{ + Output: opts.Output, + TextOutput: opts.TextOutput, + Matcher: matcher, + Verbose: opts.Verbose, + ASCII: opts.ASCII, + }) + if err != nil { + return nil, fmt.Errorf("create capture session: %w", err) + } + + if err := engine.SetCapture(sess); err != nil { + sess.Stop() + return nil, fmt.Errorf("set capture: %w", err) + } + + return &CaptureSession{sess: sess, engine: engine}, nil +} + +// StopCapture stops the active capture session if one is running. +func (c *Client) StopCapture() error { + engine, err := c.getEngine() + if err != nil { + return err + } + return engine.SetCapture(nil) +} + // getEngine safely retrieves the engine from the client with proper locking. // Returns ErrClientNotStarted if the client is not started. // Returns ErrEngineNotStarted if the engine is not available. diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index 24b3d0167..3787e63a8 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -115,12 +115,13 @@ type Manager struct { localipmanager *localIPManager - udpTracker *conntrack.UDPTracker - icmpTracker *conntrack.ICMPTracker - tcpTracker *conntrack.TCPTracker - forwarder atomic.Pointer[forwarder.Forwarder] - logger *nblog.Logger - flowLogger nftypes.FlowLogger + udpTracker *conntrack.UDPTracker + icmpTracker *conntrack.ICMPTracker + tcpTracker *conntrack.TCPTracker + forwarder atomic.Pointer[forwarder.Forwarder] + pendingCapture atomic.Pointer[forwarder.PacketCapture] + logger *nblog.Logger + flowLogger nftypes.FlowLogger blockRule firewall.Rule @@ -351,6 +352,19 @@ func (m *Manager) determineRouting() error { return nil } +// SetPacketCapture sets or clears packet capture on the forwarder endpoint. +// This captures outbound response packets that bypass the FilteredDevice in netstack mode. +func (m *Manager) SetPacketCapture(pc forwarder.PacketCapture) { + if pc == nil { + m.pendingCapture.Store(nil) + } else { + m.pendingCapture.Store(&pc) + } + if fwder := m.forwarder.Load(); fwder != nil { + fwder.SetCapture(pc) + } +} + // initForwarder initializes the forwarder, it disables routing on errors func (m *Manager) initForwarder() error { if m.forwarder.Load() != nil { @@ -372,6 +386,11 @@ func (m *Manager) initForwarder() error { m.forwarder.Store(forwarder) + // Re-load after store: a concurrent SetPacketCapture may have seen forwarder as nil and only updated pendingCapture. + if pc := m.pendingCapture.Load(); pc != nil { + forwarder.SetCapture(*pc) + } + log.Debug("forwarder initialized") return nil @@ -614,6 +633,7 @@ func (m *Manager) resetState() { } if fwder := m.forwarder.Load(); fwder != nil { + fwder.SetCapture(nil) fwder.Stop() } diff --git a/client/firewall/uspfilter/forwarder/endpoint.go b/client/firewall/uspfilter/forwarder/endpoint.go index 692a24140..96ab89af8 100644 --- a/client/firewall/uspfilter/forwarder/endpoint.go +++ b/client/firewall/uspfilter/forwarder/endpoint.go @@ -12,12 +12,19 @@ import ( nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" ) +// PacketCapture captures raw packets for debugging. Implementations must be +// safe for concurrent use and must not block. +type PacketCapture interface { + Offer(data []byte, outbound bool) +} + // endpoint implements stack.LinkEndpoint and handles integration with the wireguard device type endpoint struct { logger *nblog.Logger dispatcher stack.NetworkDispatcher device *wgdevice.Device mtu atomic.Uint32 + capture atomic.Pointer[PacketCapture] } func (e *endpoint) Attach(dispatcher stack.NetworkDispatcher) { @@ -54,13 +61,17 @@ func (e *endpoint) WritePackets(pkts stack.PacketBufferList) (int, tcpip.Error) continue } - // Send the packet through WireGuard + pktBytes := data.AsSlice() + address := netHeader.DestinationAddress() - err := e.device.CreateOutboundPacket(data.AsSlice(), address.AsSlice()) - if err != nil { + if err := e.device.CreateOutboundPacket(pktBytes, address.AsSlice()); err != nil { e.logger.Error1("CreateOutboundPacket: %v", err) continue } + + if pc := e.capture.Load(); pc != nil { + (*pc).Offer(pktBytes, true) + } written++ } diff --git a/client/firewall/uspfilter/forwarder/forwarder.go b/client/firewall/uspfilter/forwarder/forwarder.go index d17c3cd5c..925273f24 100644 --- a/client/firewall/uspfilter/forwarder/forwarder.go +++ b/client/firewall/uspfilter/forwarder/forwarder.go @@ -139,6 +139,16 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow return f, nil } +// SetCapture sets or clears the packet capture on the forwarder endpoint. +// This captures outbound packets that bypass the FilteredDevice (netstack forwarding). +func (f *Forwarder) SetCapture(pc PacketCapture) { + if pc == nil { + f.endpoint.capture.Store(nil) + return + } + f.endpoint.capture.Store(&pc) +} + func (f *Forwarder) InjectIncomingPacket(payload []byte) error { if len(payload) < header.IPv4MinimumSize { return fmt.Errorf("packet too small: %d bytes", len(payload)) diff --git a/client/firewall/uspfilter/forwarder/icmp.go b/client/firewall/uspfilter/forwarder/icmp.go index cb3db325d..217423901 100644 --- a/client/firewall/uspfilter/forwarder/icmp.go +++ b/client/firewall/uspfilter/forwarder/icmp.go @@ -270,5 +270,9 @@ func (f *Forwarder) injectICMPReply(id stack.TransportEndpointID, icmpPayload [] return 0 } + if pc := f.endpoint.capture.Load(); pc != nil { + (*pc).Offer(fullPacket, true) + } + return len(fullPacket) } diff --git a/client/iface/device/device_filter.go b/client/iface/device/device_filter.go index 4357d1916..fc1c65efa 100644 --- a/client/iface/device/device_filter.go +++ b/client/iface/device/device_filter.go @@ -3,6 +3,7 @@ package device import ( "net/netip" "sync" + "sync/atomic" "golang.zx2c4.com/wireguard/tun" ) @@ -28,11 +29,20 @@ type PacketFilter interface { SetTCPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool) } +// PacketCapture captures raw packets for debugging. Implementations must be +// safe for concurrent use and must not block. +type PacketCapture interface { + // Offer submits a packet for capture. outbound is true for packets + // leaving the host (Read path), false for packets arriving (Write path). + Offer(data []byte, outbound bool) +} + // FilteredDevice to override Read or Write of packets type FilteredDevice struct { tun.Device filter PacketFilter + capture atomic.Pointer[PacketCapture] mutex sync.RWMutex closeOnce sync.Once } @@ -63,20 +73,25 @@ func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, er if n, err = d.Device.Read(bufs, sizes, offset); err != nil { return 0, err } + d.mutex.RLock() filter := d.filter d.mutex.RUnlock() - if filter == nil { - return + if filter != nil { + for i := 0; i < n; i++ { + if filter.FilterOutbound(bufs[i][offset:offset+sizes[i]], sizes[i]) { + bufs = append(bufs[:i], bufs[i+1:]...) + sizes = append(sizes[:i], sizes[i+1:]...) + n-- + i-- + } + } } - for i := 0; i < n; i++ { - if filter.FilterOutbound(bufs[i][offset:offset+sizes[i]], sizes[i]) { - bufs = append(bufs[:i], bufs[i+1:]...) - sizes = append(sizes[:i], sizes[i+1:]...) - n-- - i-- + if pc := d.capture.Load(); pc != nil { + for i := 0; i < n; i++ { + (*pc).Offer(bufs[i][offset:offset+sizes[i]], true) } } @@ -85,6 +100,13 @@ func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, er // Write wraps write method with filtering feature func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) { + // Capture before filtering so dropped packets are still visible in captures. + if pc := d.capture.Load(); pc != nil { + for _, buf := range bufs { + (*pc).Offer(buf[offset:], false) + } + } + d.mutex.RLock() filter := d.filter d.mutex.RUnlock() @@ -96,9 +118,10 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) { filteredBufs := make([][]byte, 0, len(bufs)) dropped := 0 for _, buf := range bufs { - if !filter.FilterInbound(buf[offset:], len(buf)) { - filteredBufs = append(filteredBufs, buf) + if filter.FilterInbound(buf[offset:], len(buf)) { dropped++ + } else { + filteredBufs = append(filteredBufs, buf) } } @@ -113,3 +136,14 @@ func (d *FilteredDevice) SetFilter(filter PacketFilter) { d.filter = filter d.mutex.Unlock() } + +// SetCapture sets or clears the packet capture sink. Pass nil to disable. +// Uses atomic store so the hot path (Read/Write) is a single pointer load +// with no locking overhead when capture is off. +func (d *FilteredDevice) SetCapture(pc PacketCapture) { + if pc == nil { + d.capture.Store(nil) + return + } + d.capture.Store(&pc) +} diff --git a/client/iface/device/device_filter_test.go b/client/iface/device/device_filter_test.go index eef783542..8fb16ca8d 100644 --- a/client/iface/device/device_filter_test.go +++ b/client/iface/device/device_filter_test.go @@ -158,7 +158,7 @@ func TestDeviceWrapperRead(t *testing.T) { t.Errorf("unexpected error: %v", err) return } - if n != 0 { + if n != 1 { t.Errorf("expected n=1, got %d", n) return } diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index bddb9a69e..90560d028 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -61,6 +61,7 @@ allocs.prof: Allocations profiling information. threadcreate.prof: Thread creation profiling information. cpu.prof: CPU profiling information. stack_trace.txt: Complete stack traces of all goroutines at the time of bundle creation. +capture.pcap: Packet capture in pcap format. Only present when capture was running during bundle collection. Omitted from anonymized bundles because it contains raw decrypted packet data. Anonymization Process @@ -234,6 +235,7 @@ type BundleGenerator struct { logPath string tempDir string cpuProfile []byte + capturePath string refreshStatus func() // Optional callback to refresh status before bundle generation clientMetrics MetricsExporter @@ -257,7 +259,8 @@ type GeneratorDependencies struct { LogPath string TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. CPUProfile []byte - RefreshStatus func() // Optional callback to refresh status before bundle generation + CapturePath string + RefreshStatus func() ClientMetrics MetricsExporter } @@ -277,6 +280,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen logPath: deps.LogPath, tempDir: deps.TempDir, cpuProfile: deps.CPUProfile, + capturePath: deps.CapturePath, refreshStatus: deps.RefreshStatus, clientMetrics: deps.ClientMetrics, @@ -346,6 +350,10 @@ func (g *BundleGenerator) createArchive() error { log.Errorf("failed to add CPU profile to debug bundle: %v", err) } + if err := g.addCaptureFile(); err != nil { + log.Errorf("failed to add capture file to debug bundle: %v", err) + } + if err := g.addStackTrace(); err != nil { log.Errorf("failed to add stack trace to debug bundle: %v", err) } @@ -669,6 +677,29 @@ func (g *BundleGenerator) addCPUProfile() error { return nil } +func (g *BundleGenerator) addCaptureFile() error { + if g.capturePath == "" { + return nil + } + + if g.anonymize { + log.Info("skipping capture file in anonymized bundle (contains raw packet data)") + return nil + } + + f, err := os.Open(g.capturePath) + if err != nil { + return fmt.Errorf("open capture file: %w", err) + } + defer f.Close() + + if err := g.addFileToZip(f, "capture.pcap"); err != nil { + return fmt.Errorf("add capture file to zip: %w", err) + } + + return nil +} + func (g *BundleGenerator) addStackTrace() error { buf := make([]byte, 5242880) // 5 MB buffer n := runtime.Stack(buf, true) diff --git a/client/internal/engine.go b/client/internal/engine.go index 351e4bfe9..8c9553e52 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -28,6 +28,7 @@ import ( "github.com/netbirdio/netbird/client/firewall" "github.com/netbirdio/netbird/client/firewall/firewalld" firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/firewall/uspfilter/forwarder" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/device" nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" @@ -68,6 +69,7 @@ import ( signal "github.com/netbirdio/netbird/shared/signal/client" sProto "github.com/netbirdio/netbird/shared/signal/proto" "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/capture" ) // PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer. @@ -218,6 +220,8 @@ type Engine struct { portForwardManager *portforward.Manager srWatcher *guard.SRWatcher + afpacketCapture *capture.AFPacketCapture + // Sync response persistence (protected by syncRespMux) syncRespMux sync.RWMutex persistSyncResponse bool @@ -1703,6 +1707,11 @@ func (e *Engine) parseNATExternalIPMappings() []string { } func (e *Engine) close() { + if e.afpacketCapture != nil { + e.afpacketCapture.Stop() + e.afpacketCapture = nil + } + log.Debugf("removing Netbird interface %s", e.config.WgIfaceName) if e.wgInterface != nil { @@ -2168,6 +2177,62 @@ func (e *Engine) Address() (netip.Addr, error) { return e.wgInterface.Address().IP, nil } +// SetCapture sets or clears packet capture on the WireGuard device. +// On userspace WireGuard, it taps the FilteredDevice directly. +// On kernel WireGuard (Linux), it falls back to AF_PACKET raw socket capture. +// Pass nil to disable capture. +func (e *Engine) SetCapture(pc device.PacketCapture) error { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + + intf := e.wgInterface + if intf == nil { + return errors.New("wireguard interface not initialized") + } + + if e.afpacketCapture != nil { + e.afpacketCapture.Stop() + e.afpacketCapture = nil + } + + dev := intf.GetDevice() + if dev != nil { + dev.SetCapture(pc) + e.setForwarderCapture(pc) + return nil + } + + // Kernel mode: no FilteredDevice. Use AF_PACKET on Linux. + if pc == nil { + return nil + } + sess, ok := pc.(*capture.Session) + if !ok { + return errors.New("filtered device not available and AF_PACKET requires *capture.Session") + } + + afc := capture.NewAFPacketCapture(intf.Name(), sess) + if err := afc.Start(); err != nil { + return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err) + } + e.afpacketCapture = afc + return nil +} + +// setForwarderCapture propagates capture to the USP filter's forwarder endpoint. +// This captures outbound response packets that bypass the FilteredDevice in netstack mode. +func (e *Engine) setForwarderCapture(pc device.PacketCapture) { + if e.firewall == nil { + return + } + type forwarderCapturer interface { + SetPacketCapture(pc forwarder.PacketCapture) + } + if fc, ok := e.firewall.(forwarderCapturer); ok { + fc.SetPacketCapture(pc) + } +} + func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewallManager.ForwardRule, error) { if e.firewall == nil { log.Warn("firewall is disabled, not updating forwarding rules") diff --git a/client/internal/lazyconn/manager/manager.go b/client/internal/lazyconn/manager/manager.go index b6b3c6091..fc47bda39 100644 --- a/client/internal/lazyconn/manager/manager.go +++ b/client/internal/lazyconn/manager/manager.go @@ -6,7 +6,6 @@ import ( "time" log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" "github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/lazyconn/activity" @@ -91,8 +90,8 @@ func (m *Manager) UpdateRouteHAMap(haMap route.HAMap) { m.routesMu.Lock() defer m.routesMu.Unlock() - maps.Clear(m.peerToHAGroups) - maps.Clear(m.haGroupToPeers) + clear(m.peerToHAGroups) + clear(m.haGroupToPeers) for haUniqueID, routes := range haMap { var peers []string diff --git a/client/internal/netflow/store/memory.go b/client/internal/netflow/store/memory.go index b695a0a12..a44505e96 100644 --- a/client/internal/netflow/store/memory.go +++ b/client/internal/netflow/store/memory.go @@ -3,8 +3,6 @@ package store import ( "sync" - "golang.org/x/exp/maps" - "github.com/google/uuid" "github.com/netbirdio/netbird/client/internal/netflow/types" @@ -30,7 +28,7 @@ func (m *Memory) StoreEvent(event *types.Event) { func (m *Memory) Close() { m.mux.Lock() defer m.mux.Unlock() - maps.Clear(m.events) + clear(m.events) } func (m *Memory) GetEvents() []*types.Event { diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go index 61c8bbc79..30afc013b 100644 --- a/client/internal/routeselector/routeselector.go +++ b/client/internal/routeselector/routeselector.go @@ -7,7 +7,6 @@ import ( "sync" "github.com/hashicorp/go-multierror" - "golang.org/x/exp/maps" "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/route" @@ -44,8 +43,8 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al if rs.selectedRoutes == nil { rs.selectedRoutes = map[route.NetID]struct{}{} } - maps.Clear(rs.deselectedRoutes) - maps.Clear(rs.selectedRoutes) + clear(rs.deselectedRoutes) + clear(rs.selectedRoutes) for _, r := range allRoutes { rs.deselectedRoutes[r] = struct{}{} } @@ -78,8 +77,8 @@ func (rs *RouteSelector) SelectAllRoutes() { if rs.selectedRoutes == nil { rs.selectedRoutes = map[route.NetID]struct{}{} } - maps.Clear(rs.deselectedRoutes) - maps.Clear(rs.selectedRoutes) + clear(rs.deselectedRoutes) + clear(rs.selectedRoutes) } // DeselectRoutes removes specific routes from the selection. @@ -116,8 +115,8 @@ func (rs *RouteSelector) DeselectAllRoutes() { if rs.selectedRoutes == nil { rs.selectedRoutes = map[route.NetID]struct{}{} } - maps.Clear(rs.deselectedRoutes) - maps.Clear(rs.selectedRoutes) + clear(rs.deselectedRoutes) + clear(rs.selectedRoutes) } // IsSelected checks if a specific route is selected. diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 31658d5a1..11e7877f2 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -5847,6 +5847,288 @@ func (x *ExposeServiceReady) GetPortAutoAssigned() bool { return false } +type StartCaptureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TextOutput bool `protobuf:"varint,1,opt,name=text_output,json=textOutput,proto3" json:"text_output,omitempty"` + SnapLen uint32 `protobuf:"varint,2,opt,name=snap_len,json=snapLen,proto3" json:"snap_len,omitempty"` + Duration *durationpb.Duration `protobuf:"bytes,3,opt,name=duration,proto3" json:"duration,omitempty"` + FilterExpr string `protobuf:"bytes,4,opt,name=filter_expr,json=filterExpr,proto3" json:"filter_expr,omitempty"` + Verbose bool `protobuf:"varint,5,opt,name=verbose,proto3" json:"verbose,omitempty"` + Ascii bool `protobuf:"varint,6,opt,name=ascii,proto3" json:"ascii,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartCaptureRequest) Reset() { + *x = StartCaptureRequest{} + mi := &file_daemon_proto_msgTypes[88] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartCaptureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartCaptureRequest) ProtoMessage() {} + +func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[88] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead. +func (*StartCaptureRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{88} +} + +func (x *StartCaptureRequest) GetTextOutput() bool { + if x != nil { + return x.TextOutput + } + return false +} + +func (x *StartCaptureRequest) GetSnapLen() uint32 { + if x != nil { + return x.SnapLen + } + return 0 +} + +func (x *StartCaptureRequest) GetDuration() *durationpb.Duration { + if x != nil { + return x.Duration + } + return nil +} + +func (x *StartCaptureRequest) GetFilterExpr() string { + if x != nil { + return x.FilterExpr + } + return "" +} + +func (x *StartCaptureRequest) GetVerbose() bool { + if x != nil { + return x.Verbose + } + return false +} + +func (x *StartCaptureRequest) GetAscii() bool { + if x != nil { + return x.Ascii + } + return false +} + +type CapturePacket struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CapturePacket) Reset() { + *x = CapturePacket{} + mi := &file_daemon_proto_msgTypes[89] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CapturePacket) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CapturePacket) ProtoMessage() {} + +func (x *CapturePacket) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[89] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead. +func (*CapturePacket) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{89} +} + +func (x *CapturePacket) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type StartBundleCaptureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // timeout auto-stops the capture after this duration. + // Clamped to a server-side maximum (10 minutes). Zero or unset defaults to the maximum. + Timeout *durationpb.Duration `protobuf:"bytes,1,opt,name=timeout,proto3" json:"timeout,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartBundleCaptureRequest) Reset() { + *x = StartBundleCaptureRequest{} + mi := &file_daemon_proto_msgTypes[90] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartBundleCaptureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartBundleCaptureRequest) ProtoMessage() {} + +func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[90] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead. +func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{90} +} + +func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration { + if x != nil { + return x.Timeout + } + return nil +} + +type StartBundleCaptureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartBundleCaptureResponse) Reset() { + *x = StartBundleCaptureResponse{} + mi := &file_daemon_proto_msgTypes[91] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartBundleCaptureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartBundleCaptureResponse) ProtoMessage() {} + +func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[91] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead. +func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{91} +} + +type StopBundleCaptureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StopBundleCaptureRequest) Reset() { + *x = StopBundleCaptureRequest{} + mi := &file_daemon_proto_msgTypes[92] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StopBundleCaptureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StopBundleCaptureRequest) ProtoMessage() {} + +func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[92] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead. +func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{92} +} + +type StopBundleCaptureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StopBundleCaptureResponse) Reset() { + *x = StopBundleCaptureResponse{} + mi := &file_daemon_proto_msgTypes[93] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StopBundleCaptureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StopBundleCaptureResponse) ProtoMessage() {} + +func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[93] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead. +func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{93} +} + type PortInfo_Range struct { state protoimpl.MessageState `protogen:"open.v1"` Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"` @@ -5857,7 +6139,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5869,7 +6151,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6410,7 +6692,23 @@ const file_daemon_proto_rawDesc = "" + "\vservice_url\x18\x02 \x01(\tR\n" + "serviceUrl\x12\x16\n" + "\x06domain\x18\x03 \x01(\tR\x06domain\x12,\n" + - "\x12port_auto_assigned\x18\x04 \x01(\bR\x10portAutoAssigned*b\n" + + "\x12port_auto_assigned\x18\x04 \x01(\bR\x10portAutoAssigned\"\xd9\x01\n" + + "\x13StartCaptureRequest\x12\x1f\n" + + "\vtext_output\x18\x01 \x01(\bR\n" + + "textOutput\x12\x19\n" + + "\bsnap_len\x18\x02 \x01(\rR\asnapLen\x125\n" + + "\bduration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\bduration\x12\x1f\n" + + "\vfilter_expr\x18\x04 \x01(\tR\n" + + "filterExpr\x12\x18\n" + + "\averbose\x18\x05 \x01(\bR\averbose\x12\x14\n" + + "\x05ascii\x18\x06 \x01(\bR\x05ascii\"#\n" + + "\rCapturePacket\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\"P\n" + + "\x19StartBundleCaptureRequest\x123\n" + + "\atimeout\x18\x01 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\x1c\n" + + "\x1aStartBundleCaptureResponse\"\x1a\n" + + "\x18StopBundleCaptureRequest\"\x1b\n" + + "\x19StopBundleCaptureResponse*b\n" + "\bLogLevel\x12\v\n" + "\aUNKNOWN\x10\x00\x12\t\n" + "\x05PANIC\x10\x01\x12\t\n" + @@ -6428,7 +6726,7 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xac\x15\n" + + "EXPOSE_TLS\x10\x042\xaf\x17\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -6449,7 +6747,10 @@ const file_daemon_proto_rawDesc = "" + "CleanState\x12\x19.daemon.CleanStateRequest\x1a\x1a.daemon.CleanStateResponse\"\x00\x12H\n" + "\vDeleteState\x12\x1a.daemon.DeleteStateRequest\x1a\x1b.daemon.DeleteStateResponse\"\x00\x12u\n" + "\x1aSetSyncResponsePersistence\x12).daemon.SetSyncResponsePersistenceRequest\x1a*.daemon.SetSyncResponsePersistenceResponse\"\x00\x12H\n" + - "\vTracePacket\x12\x1a.daemon.TracePacketRequest\x1a\x1b.daemon.TracePacketResponse\"\x00\x12D\n" + + "\vTracePacket\x12\x1a.daemon.TracePacketRequest\x1a\x1b.daemon.TracePacketResponse\"\x00\x12F\n" + + "\fStartCapture\x12\x1b.daemon.StartCaptureRequest\x1a\x15.daemon.CapturePacket\"\x000\x01\x12]\n" + + "\x12StartBundleCapture\x12!.daemon.StartBundleCaptureRequest\x1a\".daemon.StartBundleCaptureResponse\"\x00\x12Z\n" + + "\x11StopBundleCapture\x12 .daemon.StopBundleCaptureRequest\x1a!.daemon.StopBundleCaptureResponse\"\x00\x12D\n" + "\x0fSubscribeEvents\x12\x18.daemon.SubscribeRequest\x1a\x13.daemon.SystemEvent\"\x000\x01\x12B\n" + "\tGetEvents\x12\x18.daemon.GetEventsRequest\x1a\x19.daemon.GetEventsResponse\"\x00\x12N\n" + "\rSwitchProfile\x12\x1c.daemon.SwitchProfileRequest\x1a\x1d.daemon.SwitchProfileResponse\"\x00\x12B\n" + @@ -6483,7 +6784,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 91) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 97) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -6577,125 +6878,139 @@ var file_daemon_proto_goTypes = []any{ (*ExposeServiceRequest)(nil), // 89: daemon.ExposeServiceRequest (*ExposeServiceEvent)(nil), // 90: daemon.ExposeServiceEvent (*ExposeServiceReady)(nil), // 91: daemon.ExposeServiceReady - nil, // 92: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 93: daemon.PortInfo.Range - nil, // 94: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 95: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 96: google.protobuf.Timestamp + (*StartCaptureRequest)(nil), // 92: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 93: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 94: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 95: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 96: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 97: daemon.StopBundleCaptureResponse + nil, // 98: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 99: daemon.PortInfo.Range + nil, // 100: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 101: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 102: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 95, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 96, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 96, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 95, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration - 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo - 20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState - 19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState - 18, // 8: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState - 17, // 9: daemon.FullStatus.peers:type_name -> daemon.PeerState - 21, // 10: daemon.FullStatus.relays:type_name -> daemon.RelayState - 22, // 11: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState - 55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent - 24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState - 31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 92, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 93, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range - 32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo - 32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo - 33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule - 0, // 20: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel - 0, // 21: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel - 41, // 22: daemon.ListStatesResponse.states:type_name -> daemon.State - 50, // 23: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags - 52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage - 2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity - 3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 96, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 94, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry - 55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 95, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile - 1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 91, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 30, // 34: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList - 5, // 35: daemon.DaemonService.Login:input_type -> daemon.LoginRequest - 7, // 36: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest - 9, // 37: daemon.DaemonService.Up:input_type -> daemon.UpRequest - 11, // 38: daemon.DaemonService.Status:input_type -> daemon.StatusRequest - 13, // 39: daemon.DaemonService.Down:input_type -> daemon.DownRequest - 15, // 40: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest - 26, // 41: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest - 28, // 42: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest - 28, // 43: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest - 4, // 44: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest - 35, // 45: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest - 37, // 46: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest - 39, // 47: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest - 42, // 48: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest - 44, // 49: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest - 46, // 50: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest - 48, // 51: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest - 51, // 52: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 54, // 53: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest - 56, // 54: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest - 58, // 55: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest - 60, // 56: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest - 62, // 57: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 64, // 58: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 66, // 59: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 69, // 60: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 71, // 61: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 73, // 62: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 75, // 63: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 77, // 64: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 79, // 65: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 81, // 66: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 83, // 67: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 85, // 68: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 87, // 69: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 89, // 70: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 6, // 71: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 8, // 72: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 10, // 73: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 12, // 74: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 14, // 75: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 16, // 76: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 27, // 77: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 29, // 78: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 29, // 79: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 34, // 80: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 36, // 81: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 38, // 82: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 40, // 83: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 43, // 84: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 45, // 85: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 47, // 86: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 49, // 87: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 53, // 88: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 55, // 89: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 57, // 90: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 59, // 91: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 61, // 92: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 63, // 93: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 65, // 94: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 67, // 95: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 70, // 96: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 72, // 97: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 74, // 98: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 76, // 99: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 78, // 100: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 80, // 101: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 82, // 102: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 84, // 103: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 86, // 104: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 88, // 105: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 90, // 106: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 71, // [71:107] is the sub-list for method output_type - 35, // [35:71] is the sub-list for method input_type - 35, // [35:35] is the sub-list for extension type_name - 35, // [35:35] is the sub-list for extension extendee - 0, // [0:35] is the sub-list for field type_name + 101, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus + 102, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 102, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 101, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo + 20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState + 19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState + 18, // 8: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState + 17, // 9: daemon.FullStatus.peers:type_name -> daemon.PeerState + 21, // 10: daemon.FullStatus.relays:type_name -> daemon.RelayState + 22, // 11: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState + 55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent + 24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState + 31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network + 98, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 99, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo + 32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo + 33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule + 0, // 20: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel + 0, // 21: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel + 41, // 22: daemon.ListStatesResponse.states:type_name -> daemon.State + 50, // 23: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags + 52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage + 2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity + 3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category + 102, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 100, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent + 101, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol + 91, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 101, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 101, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList + 5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest + 7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest + 9, // 39: daemon.DaemonService.Up:input_type -> daemon.UpRequest + 11, // 40: daemon.DaemonService.Status:input_type -> daemon.StatusRequest + 13, // 41: daemon.DaemonService.Down:input_type -> daemon.DownRequest + 15, // 42: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest + 26, // 43: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest + 28, // 44: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest + 28, // 45: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest + 4, // 46: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest + 35, // 47: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest + 37, // 48: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest + 39, // 49: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest + 42, // 50: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest + 44, // 51: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest + 46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest + 48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest + 51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest + 92, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 94, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 96, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest + 56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest + 58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest + 60, // 61: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest + 62, // 62: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest + 64, // 63: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 66, // 64: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 69, // 65: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 71, // 66: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 73, // 67: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 75, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 77, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 79, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 81, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 83, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 85, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 87, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 89, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 79: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 14, // 80: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 81: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 27, // 82: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 29, // 83: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 29, // 84: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 34, // 85: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 36, // 86: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 38, // 87: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 40, // 88: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 43, // 89: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 45, // 90: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 47, // 91: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 49, // 92: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 53, // 93: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 93, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 95, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 97, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 61, // 100: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 63, // 101: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 65, // 102: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 67, // 103: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 70, // 104: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 72, // 105: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 74, // 106: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 76, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 78, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 80, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 82, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 84, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 86, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 88, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 90, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 76, // [76:115] is the sub-list for method output_type + 37, // [37:76] is the sub-list for method input_type + 37, // [37:37] is the sub-list for extension type_name + 37, // [37:37] is the sub-list for extension extendee + 0, // [0:37] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -6725,7 +7040,7 @@ func file_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 4, - NumMessages: 91, + NumMessages: 97, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index f4e5b8e4d..3fee9eca8 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -64,6 +64,17 @@ service DaemonService { rpc TracePacket(TracePacketRequest) returns (TracePacketResponse) {} + // StartCapture begins streaming packet capture on the WireGuard interface. + // Requires --enable-capture set at service install/reconfigure time. + rpc StartCapture(StartCaptureRequest) returns (stream CapturePacket) {} + + // StartBundleCapture begins capturing packets to a server-side temp file + // for inclusion in the next debug bundle. Auto-stops after the given timeout. + rpc StartBundleCapture(StartBundleCaptureRequest) returns (StartBundleCaptureResponse) {} + + // StopBundleCapture stops the running bundle capture. Idempotent. + rpc StopBundleCapture(StopBundleCaptureRequest) returns (StopBundleCaptureResponse) {} + rpc SubscribeEvents(SubscribeRequest) returns (stream SystemEvent) {} rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {} @@ -832,3 +843,26 @@ message ExposeServiceReady { string domain = 3; bool port_auto_assigned = 4; } + +message StartCaptureRequest { + bool text_output = 1; + uint32 snap_len = 2; + google.protobuf.Duration duration = 3; + string filter_expr = 4; + bool verbose = 5; + bool ascii = 6; +} + +message CapturePacket { + bytes data = 1; +} + +message StartBundleCaptureRequest { + // timeout auto-stops the capture after this duration. + // Clamped to a server-side maximum (10 minutes). Zero or unset defaults to the maximum. + google.protobuf.Duration timeout = 1; +} + +message StartBundleCaptureResponse {} +message StopBundleCaptureRequest {} +message StopBundleCaptureResponse {} diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 026ee2361..66a8efcc3 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -37,6 +37,9 @@ const ( DaemonService_DeleteState_FullMethodName = "/daemon.DaemonService/DeleteState" DaemonService_SetSyncResponsePersistence_FullMethodName = "/daemon.DaemonService/SetSyncResponsePersistence" DaemonService_TracePacket_FullMethodName = "/daemon.DaemonService/TracePacket" + DaemonService_StartCapture_FullMethodName = "/daemon.DaemonService/StartCapture" + DaemonService_StartBundleCapture_FullMethodName = "/daemon.DaemonService/StartBundleCapture" + DaemonService_StopBundleCapture_FullMethodName = "/daemon.DaemonService/StopBundleCapture" DaemonService_SubscribeEvents_FullMethodName = "/daemon.DaemonService/SubscribeEvents" DaemonService_GetEvents_FullMethodName = "/daemon.DaemonService/GetEvents" DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile" @@ -96,6 +99,14 @@ type DaemonServiceClient interface { // SetSyncResponsePersistence enables or disables sync response persistence SetSyncResponsePersistence(ctx context.Context, in *SetSyncResponsePersistenceRequest, opts ...grpc.CallOption) (*SetSyncResponsePersistenceResponse, error) TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error) + // StartCapture begins streaming packet capture on the WireGuard interface. + // Requires --enable-capture set at service install/reconfigure time. + StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CapturePacket], error) + // StartBundleCapture begins capturing packets to a server-side temp file + // for inclusion in the next debug bundle. Auto-stops after the given timeout. + StartBundleCapture(ctx context.Context, in *StartBundleCaptureRequest, opts ...grpc.CallOption) (*StartBundleCaptureResponse, error) + // StopBundleCapture stops the running bundle capture. Idempotent. + StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) @@ -313,9 +324,48 @@ func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRe return out, nil } +func (c *daemonServiceClient) StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CapturePacket], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_StartCapture_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StartCaptureRequest, CapturePacket]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_StartCaptureClient = grpc.ServerStreamingClient[CapturePacket] + +func (c *daemonServiceClient) StartBundleCapture(ctx context.Context, in *StartBundleCaptureRequest, opts ...grpc.CallOption) (*StartBundleCaptureResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StartBundleCaptureResponse) + err := c.cc.Invoke(ctx, DaemonService_StartBundleCapture_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StopBundleCaptureResponse) + err := c.cc.Invoke(ctx, DaemonService_StopBundleCapture_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_SubscribeEvents_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_SubscribeEvents_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -494,7 +544,7 @@ func (c *daemonServiceClient) GetInstallerResult(ctx context.Context, in *Instal func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_ExposeService_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], DaemonService_ExposeService_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -550,6 +600,14 @@ type DaemonServiceServer interface { // SetSyncResponsePersistence enables or disables sync response persistence SetSyncResponsePersistence(context.Context, *SetSyncResponsePersistenceRequest) (*SetSyncResponsePersistenceResponse, error) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) + // StartCapture begins streaming packet capture on the WireGuard interface. + // Requires --enable-capture set at service install/reconfigure time. + StartCapture(*StartCaptureRequest, grpc.ServerStreamingServer[CapturePacket]) error + // StartBundleCapture begins capturing packets to a server-side temp file + // for inclusion in the next debug bundle. Auto-stops after the given timeout. + StartBundleCapture(context.Context, *StartBundleCaptureRequest) (*StartBundleCaptureResponse, error) + // StopBundleCapture stops the running bundle capture. Idempotent. + StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error) SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) @@ -641,6 +699,15 @@ func (UnimplementedDaemonServiceServer) SetSyncResponsePersistence(context.Conte func (UnimplementedDaemonServiceServer) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) { return nil, status.Error(codes.Unimplemented, "method TracePacket not implemented") } +func (UnimplementedDaemonServiceServer) StartCapture(*StartCaptureRequest, grpc.ServerStreamingServer[CapturePacket]) error { + return status.Error(codes.Unimplemented, "method StartCapture not implemented") +} +func (UnimplementedDaemonServiceServer) StartBundleCapture(context.Context, *StartBundleCaptureRequest) (*StartBundleCaptureResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StartBundleCapture not implemented") +} +func (UnimplementedDaemonServiceServer) StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StopBundleCapture not implemented") +} func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error { return status.Error(codes.Unimplemented, "method SubscribeEvents not implemented") } @@ -1040,6 +1107,53 @@ func _DaemonService_TracePacket_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } +func _DaemonService_StartCapture_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StartCaptureRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(DaemonServiceServer).StartCapture(m, &grpc.GenericServerStream[StartCaptureRequest, CapturePacket]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_StartCaptureServer = grpc.ServerStreamingServer[CapturePacket] + +func _DaemonService_StartBundleCapture_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StartBundleCaptureRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).StartBundleCapture(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_StartBundleCapture_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).StartBundleCapture(ctx, req.(*StartBundleCaptureRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_StopBundleCapture_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StopBundleCaptureRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).StopBundleCapture(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_StopBundleCapture_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).StopBundleCapture(ctx, req.(*StopBundleCaptureRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_SubscribeEvents_Handler(srv interface{}, stream grpc.ServerStream) error { m := new(SubscribeRequest) if err := stream.RecvMsg(m); err != nil { @@ -1429,6 +1543,14 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "TracePacket", Handler: _DaemonService_TracePacket_Handler, }, + { + MethodName: "StartBundleCapture", + Handler: _DaemonService_StartBundleCapture_Handler, + }, + { + MethodName: "StopBundleCapture", + Handler: _DaemonService_StopBundleCapture_Handler, + }, { MethodName: "GetEvents", Handler: _DaemonService_GetEvents_Handler, @@ -1495,6 +1617,11 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ }, }, Streams: []grpc.StreamDesc{ + { + StreamName: "StartCapture", + Handler: _DaemonService_StartCapture_Handler, + ServerStreams: true, + }, { StreamName: "SubscribeEvents", Handler: _DaemonService_SubscribeEvents_Handler, diff --git a/client/server/capture.go b/client/server/capture.go new file mode 100644 index 000000000..308c00338 --- /dev/null +++ b/client/server/capture.go @@ -0,0 +1,365 @@ +package server + +import ( + "context" + "io" + "os" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util/capture" +) + +const maxBundleCaptureDuration = 10 * time.Minute + +// bundleCapture holds the state of an in-progress capture destined for the +// debug bundle. The lifecycle is: +// +// StartBundleCapture → capture running, writing to temp file +// StopBundleCapture → capture stopped, temp file available +// DebugBundle → temp file included in zip, then cleaned up +type bundleCapture struct { + mu sync.Mutex + sess *capture.Session + file *os.File + engine *internal.Engine + cancel context.CancelFunc + stopped bool +} + +// stop halts the capture session and closes the pcap writer. Idempotent. +func (bc *bundleCapture) stop() { + bc.mu.Lock() + defer bc.mu.Unlock() + + if bc.stopped { + return + } + bc.stopped = true + + if bc.cancel != nil { + bc.cancel() + } + if bc.sess != nil { + bc.sess.Stop() + } +} + +// path returns the temp file path, or "" if no file exists. +func (bc *bundleCapture) path() string { + if bc.file == nil { + return "" + } + return bc.file.Name() +} + +// cleanup removes the temp file. +func (bc *bundleCapture) cleanup() { + if bc.file == nil { + return + } + name := bc.file.Name() + if err := bc.file.Close(); err != nil { + log.Debugf("close bundle capture file: %v", err) + } + if err := os.Remove(name); err != nil && !os.IsNotExist(err) { + log.Debugf("remove bundle capture file: %v", err) + } + bc.file = nil +} + +// StartCapture streams a pcap or text packet capture over gRPC. +// Gated by the --enable-capture service flag. +func (s *Server) StartCapture(req *proto.StartCaptureRequest, stream proto.DaemonService_StartCaptureServer) error { + if !s.captureEnabled { + return status.Error(codes.PermissionDenied, + "packet capture is disabled; reinstall or reconfigure the service with --enable-capture") + } + + if d := req.GetDuration(); d != nil && d.AsDuration() < 0 { + return status.Error(codes.InvalidArgument, "duration must not be negative") + } + + matcher, err := parseCaptureFilter(req) + if err != nil { + return status.Errorf(codes.InvalidArgument, "invalid filter: %v", err) + } + + pr, pw := io.Pipe() + + opts := capture.Options{ + Matcher: matcher, + SnapLen: req.GetSnapLen(), + Verbose: req.GetVerbose(), + ASCII: req.GetAscii(), + } + if req.GetTextOutput() { + opts.TextOutput = pw + } else { + opts.Output = pw + } + + sess, err := capture.NewSession(opts) + if err != nil { + pw.Close() + return status.Errorf(codes.Internal, "create capture session: %v", err) + } + + engine, err := s.claimCapture(sess) + if err != nil { + sess.Stop() + pw.Close() + return err + } + + if err := engine.SetCapture(sess); err != nil { + s.releaseCapture(sess) + sess.Stop() + pw.Close() + return status.Errorf(codes.Internal, "set capture: %v", err) + } + + // Send an empty initial message to signal that the capture was accepted. + // The client waits for this before printing the banner, so it must arrive + // before any packet data. + if err := stream.Send(&proto.CapturePacket{}); err != nil { + s.clearCaptureIfOwner(sess, engine) + sess.Stop() + pw.Close() + return status.Errorf(codes.Internal, "send initial message: %v", err) + } + + ctx := stream.Context() + if d := req.GetDuration(); d != nil { + if dur := d.AsDuration(); dur > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, dur) + defer cancel() + } + } + + go func() { + <-ctx.Done() + s.clearCaptureIfOwner(sess, engine) + sess.Stop() + pw.Close() + }() + defer pr.Close() + + log.Infof("packet capture started (text=%v, expr=%q)", req.GetTextOutput(), req.GetFilterExpr()) + defer func() { + stats := sess.Stats() + log.Infof("packet capture stopped: %d packets, %d bytes, %d dropped", + stats.Packets, stats.Bytes, stats.Dropped) + }() + + return streamToGRPC(pr, stream) +} + +func streamToGRPC(r io.Reader, stream proto.DaemonService_StartCaptureServer) error { + buf := make([]byte, 32*1024) + for { + n, readErr := r.Read(buf) + if n > 0 { + if err := stream.Send(&proto.CapturePacket{Data: buf[:n]}); err != nil { + log.Debugf("capture stream send: %v", err) + return nil //nolint:nilerr // client disconnected + } + } + if readErr != nil { + return nil //nolint:nilerr // pipe closed, capture stopped normally + } + } +} + +// StartBundleCapture begins capturing packets to a server-side temp file for +// inclusion in the next debug bundle. Not gated by --enable-capture since the +// output stays on the server (same trust level as CPU profiling). +// +// A timeout auto-stops the capture as a safety net if StopBundleCapture is +// never called (e.g. CLI crash). +func (s *Server) StartBundleCapture(_ context.Context, req *proto.StartBundleCaptureRequest) (*proto.StartBundleCaptureResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.stopBundleCaptureLocked() + s.cleanupBundleCapture() + + if s.activeCapture != nil { + return nil, status.Error(codes.FailedPrecondition, "another capture is already running") + } + + engine, err := s.getCaptureEngineLocked() + if err != nil { + // Not fatal: kernel mode or not connected. Log and return success + // so the debug bundle still generates without capture data. + log.Warnf("packet capture unavailable, skipping: %v", err) + return &proto.StartBundleCaptureResponse{}, nil + } + + timeout := req.GetTimeout().AsDuration() + if timeout <= 0 || timeout > maxBundleCaptureDuration { + timeout = maxBundleCaptureDuration + } + + f, err := os.CreateTemp("", "netbird.capture.*.pcap") + if err != nil { + return nil, status.Errorf(codes.Internal, "create temp file: %v", err) + } + + sess, err := capture.NewSession(capture.Options{Output: f}) + if err != nil { + f.Close() + os.Remove(f.Name()) + return nil, status.Errorf(codes.Internal, "create capture session: %v", err) + } + + if err := engine.SetCapture(sess); err != nil { + sess.Stop() + f.Close() + os.Remove(f.Name()) + log.Warnf("packet capture unavailable (no filtered device), skipping: %v", err) + return &proto.StartBundleCaptureResponse{}, nil + } + s.activeCapture = sess + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + bc := &bundleCapture{ + sess: sess, + file: f, + engine: engine, + cancel: cancel, + } + + s.bundleCapture = bc + + go func() { + <-ctx.Done() + s.mutex.Lock() + if s.bundleCapture == bc { + s.stopBundleCaptureLocked() + } else { + bc.stop() + } + s.mutex.Unlock() + log.Infof("bundle capture auto-stopped after timeout") + }() + log.Infof("bundle capture started (timeout=%s, file=%s)", timeout, f.Name()) + + return &proto.StartBundleCaptureResponse{}, nil +} + +// StopBundleCapture stops the running bundle capture. Idempotent. +func (s *Server) StopBundleCapture(_ context.Context, _ *proto.StopBundleCaptureRequest) (*proto.StopBundleCaptureResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.stopBundleCaptureLocked() + return &proto.StopBundleCaptureResponse{}, nil +} + +// stopBundleCaptureLocked stops the bundle capture if running. Must hold s.mutex. +func (s *Server) stopBundleCaptureLocked() { + if s.bundleCapture == nil { + return + } + bc := s.bundleCapture + if bc.engine != nil && s.activeCapture == bc.sess { + if err := bc.engine.SetCapture(nil); err != nil { + log.Debugf("clear bundle capture: %v", err) + } + s.activeCapture = nil + } + bc.stop() + + stats := bc.sess.Stats() + log.Infof("bundle capture stopped: %d packets, %d bytes, %d dropped", + stats.Packets, stats.Bytes, stats.Dropped) +} + +// bundleCapturePath returns the temp file path if a capture has been taken, +// stops any running capture, and returns "". Called from DebugBundle. +// Must hold s.mutex. +func (s *Server) bundleCapturePath() string { + if s.bundleCapture == nil { + return "" + } + + s.bundleCapture.stop() + return s.bundleCapture.path() +} + +// cleanupBundleCapture removes the temp file and clears state. Must hold s.mutex. +func (s *Server) cleanupBundleCapture() { + if s.bundleCapture == nil { + return + } + s.bundleCapture.cleanup() + s.bundleCapture = nil +} + +// claimCapture reserves the engine's capture slot for sess. Returns +// FailedPrecondition if another capture is already active. +func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.activeCapture != nil { + return nil, status.Error(codes.FailedPrecondition, "another capture is already running") + } + engine, err := s.getCaptureEngineLocked() + if err != nil { + return nil, err + } + s.activeCapture = sess + return engine, nil +} + +// releaseCapture clears the active-capture owner if it still matches sess. +func (s *Server) releaseCapture(sess *capture.Session) { + s.mutex.Lock() + defer s.mutex.Unlock() + if s.activeCapture == sess { + s.activeCapture = nil + } +} + +// clearCaptureIfOwner clears engine's capture slot only if sess still owns it. +func (s *Server) clearCaptureIfOwner(sess *capture.Session, engine *internal.Engine) { + s.mutex.Lock() + defer s.mutex.Unlock() + if s.activeCapture != sess { + return + } + if err := engine.SetCapture(nil); err != nil { + log.Debugf("clear capture: %v", err) + } + s.activeCapture = nil +} + +func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) { + if s.connectClient == nil { + return nil, status.Error(codes.FailedPrecondition, "client not connected") + } + engine := s.connectClient.Engine() + if engine == nil { + return nil, status.Error(codes.FailedPrecondition, "engine not initialized") + } + return engine, nil +} + +// parseCaptureFilter returns a Matcher from the request. +// Returns nil (match all) when no filter expression is set. +func parseCaptureFilter(req *proto.StartCaptureRequest) (capture.Matcher, error) { + expr := req.GetFilterExpr() + if expr == "" { + return nil, nil //nolint:nilnil // nil Matcher means "match all" + } + return capture.ParseFilter(expr) +} diff --git a/client/server/debug.go b/client/server/debug.go index 81708e576..33247db5f 100644 --- a/client/server/debug.go +++ b/client/server/debug.go @@ -43,7 +43,9 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( }() } - // Prepare refresh callback for health probes + capturePath := s.bundleCapturePath() + defer s.cleanupBundleCapture() + var refreshStatus func() if s.connectClient != nil { engine := s.connectClient.Engine() @@ -62,6 +64,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( SyncResponse: syncResponse, LogPath: s.logFile, CPUProfile: cpuProfileData, + CapturePath: capturePath, RefreshStatus: refreshStatus, ClientMetrics: clientMetrics, }, diff --git a/client/server/server.go b/client/server/server.go index e70b83bf8..648ffa8ce 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -33,6 +33,7 @@ import ( "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util/capture" "github.com/netbirdio/netbird/version" ) @@ -89,7 +90,11 @@ type Server struct { profileManager *profilemanager.ServiceManager profilesDisabled bool updateSettingsDisabled bool - networksDisabled bool + captureEnabled bool + bundleCapture *bundleCapture + // activeCapture is the session currently installed on the engine; guarded by s.mutex. + activeCapture *capture.Session + networksDisabled bool sleepHandler *sleephandler.SleepHandler @@ -106,7 +111,7 @@ type oauthAuthFlow struct { } // New server instance constructor. -func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool, networksDisabled bool) *Server { +func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool, captureEnabled bool, networksDisabled bool) *Server { s := &Server{ rootCtx: ctx, logFile: logFile, @@ -115,6 +120,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable profileManager: profilemanager.NewServiceManager(configFile), profilesDisabled: profilesDisabled, updateSettingsDisabled: updateSettingsDisabled, + captureEnabled: captureEnabled, networksDisabled: networksDisabled, jwtCache: newJWTCache(), } diff --git a/client/server/server_test.go b/client/server/server_test.go index 54ad47e55..641cd85fe 100644 --- a/client/server/server_test.go +++ b/client/server/server_test.go @@ -104,7 +104,7 @@ func TestConnectWithRetryRuns(t *testing.T) { t.Fatalf("failed to set active profile state: %v", err) } - s := New(ctx, "debug", "", false, false, false) + s := New(ctx, "debug", "", false, false, false, false) s.config = config @@ -165,7 +165,7 @@ func TestServer_Up(t *testing.T) { t.Fatalf("failed to set active profile state: %v", err) } - s := New(ctx, "console", "", false, false, false) + s := New(ctx, "console", "", false, false, false, false) err = s.Start() require.NoError(t, err) @@ -235,7 +235,7 @@ func TestServer_SubcribeEvents(t *testing.T) { t.Fatalf("failed to set active profile state: %v", err) } - s := New(ctx, "console", "", false, false, false) + s := New(ctx, "console", "", false, false, false, false) err = s.Start() require.NoError(t, err) diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 7f6847c43..b90b5653d 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -53,7 +53,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.NoError(t, err) ctx := context.Background() - s := New(ctx, "console", "", false, false, false) + s := New(ctx, "console", "", false, false, false, false) rosenpassEnabled := true rosenpassPermissive := true diff --git a/client/ui/debug.go b/client/ui/debug.go index 4ebe4d675..cf5ac1a75 100644 --- a/client/ui/debug.go +++ b/client/ui/debug.go @@ -16,6 +16,7 @@ import ( "fyne.io/fyne/v2/widget" log "github.com/sirupsen/logrus" "github.com/skratchdot/open-golang/open" + "google.golang.org/protobuf/types/known/durationpb" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/proto" @@ -38,6 +39,7 @@ type debugCollectionParams struct { upload bool uploadURL string enablePersistence bool + capture bool } // UI components for progress tracking @@ -51,25 +53,58 @@ type progressUI struct { func (s *serviceClient) showDebugUI() { w := s.app.NewWindow("NetBird Debug") w.SetOnClosed(s.cancel) - w.Resize(fyne.NewSize(600, 500)) w.SetFixedSize(true) anonymizeCheck := widget.NewCheck("Anonymize sensitive information (public IPs, domains, ...)", nil) systemInfoCheck := widget.NewCheck("Include system information (routes, interfaces, ...)", nil) systemInfoCheck.SetChecked(true) + captureCheck := widget.NewCheck("Include packet capture", nil) uploadCheck := widget.NewCheck("Upload bundle automatically after creation", nil) uploadCheck.SetChecked(true) - uploadURLLabel := widget.NewLabel("Debug upload URL:") + uploadURLContainer, uploadURL := s.buildUploadSection(uploadCheck) + + debugModeContainer, runForDurationCheck, durationInput, noteLabel := s.buildDurationSection() + + statusLabel := widget.NewLabel("") + statusLabel.Hide() + progressBar := widget.NewProgressBar() + progressBar.Hide() + createButton := widget.NewButton("Create Debug Bundle", nil) + + uiControls := []fyne.Disableable{ + anonymizeCheck, systemInfoCheck, captureCheck, + uploadCheck, uploadURL, runForDurationCheck, durationInput, createButton, + } + + createButton.OnTapped = s.getCreateHandler( + statusLabel, progressBar, uploadCheck, uploadURL, + anonymizeCheck, systemInfoCheck, captureCheck, + runForDurationCheck, durationInput, uiControls, w, + ) + + content := container.NewVBox( + widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"), + widget.NewLabel(""), + anonymizeCheck, systemInfoCheck, captureCheck, + uploadCheck, uploadURLContainer, + widget.NewLabel(""), + debugModeContainer, noteLabel, + widget.NewLabel(""), + statusLabel, progressBar, createButton, + ) + + w.SetContent(container.NewPadded(content)) + w.Show() +} + +func (s *serviceClient) buildUploadSection(uploadCheck *widget.Check) (*fyne.Container, *widget.Entry) { uploadURL := widget.NewEntry() uploadURL.SetText(uptypes.DefaultBundleURL) uploadURL.SetPlaceHolder("Enter upload URL") - uploadURLContainer := container.NewVBox( - uploadURLLabel, - uploadURL, - ) + uploadURLContainer := container.NewVBox(widget.NewLabel("Debug upload URL:"), uploadURL) uploadCheck.OnChanged = func(checked bool) { if checked { @@ -78,13 +113,14 @@ func (s *serviceClient) showDebugUI() { uploadURLContainer.Hide() } } + return uploadURLContainer, uploadURL +} - debugModeContainer := container.NewHBox() +func (s *serviceClient) buildDurationSection() (*fyne.Container, *widget.Check, *widget.Entry, *widget.Label) { runForDurationCheck := widget.NewCheck("Run with trace logs before creating bundle", nil) runForDurationCheck.SetChecked(true) forLabel := widget.NewLabel("for") - durationInput := widget.NewEntry() durationInput.SetText("1") minutesLabel := widget.NewLabel("minute") @@ -108,63 +144,8 @@ func (s *serviceClient) showDebugUI() { } } - debugModeContainer.Add(runForDurationCheck) - debugModeContainer.Add(forLabel) - debugModeContainer.Add(durationInput) - debugModeContainer.Add(minutesLabel) - - statusLabel := widget.NewLabel("") - statusLabel.Hide() - - progressBar := widget.NewProgressBar() - progressBar.Hide() - - createButton := widget.NewButton("Create Debug Bundle", nil) - - // UI controls that should be disabled during debug collection - uiControls := []fyne.Disableable{ - anonymizeCheck, - systemInfoCheck, - uploadCheck, - uploadURL, - runForDurationCheck, - durationInput, - createButton, - } - - createButton.OnTapped = s.getCreateHandler( - statusLabel, - progressBar, - uploadCheck, - uploadURL, - anonymizeCheck, - systemInfoCheck, - runForDurationCheck, - durationInput, - uiControls, - w, - ) - - content := container.NewVBox( - widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"), - widget.NewLabel(""), - anonymizeCheck, - systemInfoCheck, - uploadCheck, - uploadURLContainer, - widget.NewLabel(""), - debugModeContainer, - noteLabel, - widget.NewLabel(""), - statusLabel, - progressBar, - createButton, - ) - - paddedContent := container.NewPadded(content) - w.SetContent(paddedContent) - - w.Show() + modeContainer := container.NewHBox(runForDurationCheck, forLabel, durationInput, minutesLabel) + return modeContainer, runForDurationCheck, durationInput, noteLabel } func validateMinute(s string, minutesLabel *widget.Label) error { @@ -200,6 +181,7 @@ func (s *serviceClient) getCreateHandler( uploadURL *widget.Entry, anonymizeCheck *widget.Check, systemInfoCheck *widget.Check, + captureCheck *widget.Check, runForDurationCheck *widget.Check, duration *widget.Entry, uiControls []fyne.Disableable, @@ -222,6 +204,7 @@ func (s *serviceClient) getCreateHandler( params := &debugCollectionParams{ anonymize: anonymizeCheck.Checked, systemInfo: systemInfoCheck.Checked, + capture: captureCheck.Checked, upload: uploadCheck.Checked, uploadURL: url, enablePersistence: true, @@ -253,10 +236,7 @@ func (s *serviceClient) getCreateHandler( statusLabel.SetText("Creating debug bundle...") go s.handleDebugCreation( - anonymizeCheck.Checked, - systemInfoCheck.Checked, - uploadCheck.Checked, - url, + params, statusLabel, uiControls, w, @@ -371,7 +351,7 @@ func startProgressTracker(ctx context.Context, wg *sync.WaitGroup, duration time func (s *serviceClient) configureServiceForDebug( conn proto.DaemonServiceClient, state *debugInitialState, - enablePersistence bool, + params *debugCollectionParams, ) { if state.wasDown { if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { @@ -397,7 +377,7 @@ func (s *serviceClient) configureServiceForDebug( time.Sleep(time.Second) } - if enablePersistence { + if params.enablePersistence { if _, err := conn.SetSyncResponsePersistence(s.ctx, &proto.SetSyncResponsePersistenceRequest{ Enabled: true, }); err != nil { @@ -417,6 +397,26 @@ func (s *serviceClient) configureServiceForDebug( if _, err := conn.StartCPUProfile(s.ctx, &proto.StartCPUProfileRequest{}); err != nil { log.Warnf("failed to start CPU profiling: %v", err) } + + s.startBundleCaptureIfEnabled(conn, params) +} + +func (s *serviceClient) startBundleCaptureIfEnabled(conn proto.DaemonServiceClient, params *debugCollectionParams) { + if !params.capture { + return + } + + const maxCapture = 10 * time.Minute + timeout := params.duration + 30*time.Second + if timeout > maxCapture { + timeout = maxCapture + log.Warnf("packet capture clamped to %s (server maximum)", maxCapture) + } + if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{ + Timeout: durationpb.New(timeout), + }); err != nil { + log.Warnf("failed to start bundle capture: %v", err) + } } func (s *serviceClient) collectDebugData( @@ -430,7 +430,7 @@ func (s *serviceClient) collectDebugData( var wg sync.WaitGroup startProgressTracker(ctx, &wg, params.duration, progress) - s.configureServiceForDebug(conn, state, params.enablePersistence) + s.configureServiceForDebug(conn, state, params) wg.Wait() progress.progressBar.Hide() @@ -440,6 +440,14 @@ func (s *serviceClient) collectDebugData( log.Warnf("failed to stop CPU profiling: %v", err) } + if params.capture { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { + log.Warnf("failed to stop bundle capture: %v", err) + } + } + return nil } @@ -520,18 +528,37 @@ func handleError(progress *progressUI, errMsg string) { } func (s *serviceClient) handleDebugCreation( - anonymize bool, - systemInfo bool, - upload bool, - uploadURL string, + params *debugCollectionParams, statusLabel *widget.Label, uiControls []fyne.Disableable, w fyne.Window, ) { - log.Infof("Creating debug bundle (Anonymized: %v, System Info: %v, Upload Attempt: %v)...", - anonymize, systemInfo, upload) + conn, err := s.getSrvClient(failFastTimeout) + if err != nil { + log.Errorf("Failed to get client for debug: %v", err) + statusLabel.SetText(fmt.Sprintf("Error: %v", err)) + enableUIControls(uiControls) + return + } - resp, err := s.createDebugBundle(anonymize, systemInfo, uploadURL) + if params.capture { + if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{ + Timeout: durationpb.New(30 * time.Second), + }); err != nil { + log.Warnf("failed to start bundle capture: %v", err) + } else { + defer func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { + log.Warnf("failed to stop bundle capture: %v", err) + } + }() + time.Sleep(2 * time.Second) + } + } + + resp, err := s.createDebugBundle(params.anonymize, params.systemInfo, params.uploadURL) if err != nil { log.Errorf("Failed to create debug bundle: %v", err) statusLabel.SetText(fmt.Sprintf("Error creating bundle: %v", err)) @@ -543,7 +570,7 @@ func (s *serviceClient) handleDebugCreation( uploadFailureReason := resp.GetUploadFailureReason() uploadedKey := resp.GetUploadedKey() - if upload { + if params.upload { if uploadFailureReason != "" { showUploadFailedDialog(w, localPath, uploadFailureReason) } else { diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index d8e50ab6d..cb512f132 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -5,6 +5,7 @@ package main import ( "context" "fmt" + "sync" "syscall/js" "time" @@ -14,6 +15,7 @@ import ( netbird "github.com/netbirdio/netbird/client/embed" sshdetection "github.com/netbirdio/netbird/client/ssh/detection" nbstatus "github.com/netbirdio/netbird/client/status" + wasmcapture "github.com/netbirdio/netbird/client/wasm/internal/capture" "github.com/netbirdio/netbird/client/wasm/internal/http" "github.com/netbirdio/netbird/client/wasm/internal/rdp" "github.com/netbirdio/netbird/client/wasm/internal/ssh" @@ -459,6 +461,95 @@ func createSetLogLevelMethod(client *netbird.Client) js.Func { }) } +// createStartCaptureMethod creates the programmable packet capture method. +// Returns a JS interface with onpacket callback and stop() method. +// +// Usage from JavaScript: +// +// const cap = await client.startCapture({ filter: "tcp port 443", verbose: true }) +// cap.onpacket = (line) => console.log(line) +// const stats = cap.stop() +func createStartCaptureMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(_ js.Value, args []js.Value) any { + var opts js.Value + if len(args) > 0 { + opts = args[0] + } + + return createPromise(func(resolve, reject js.Value) { + iface, err := wasmcapture.Start(client, opts) + if err != nil { + reject.Invoke(js.ValueOf(fmt.Sprintf("start capture: %v", err))) + return + } + resolve.Invoke(iface) + }) + }) +} + +// captureMethods returns capture() and stopCapture() that share state for +// the console-log shortcut. capture() logs packets to the browser console +// and stopCapture() ends it, like Ctrl+C on the CLI. +// +// Usage from browser devtools console: +// +// await client.capture() // capture all packets +// await client.capture("tcp") // capture with filter +// await client.capture({filter: "host 10.0.0.1", verbose: true}) +// client.stopCapture() // stop and print stats +func captureMethods(client *netbird.Client) (startFn, stopFn js.Func) { + var mu sync.Mutex + var active *wasmcapture.Handle + + startFn = js.FuncOf(func(_ js.Value, args []js.Value) any { + var opts js.Value + if len(args) > 0 { + opts = args[0] + } + + return createPromise(func(resolve, reject js.Value) { + mu.Lock() + defer mu.Unlock() + + if active != nil { + active.Stop() + active = nil + } + + h, err := wasmcapture.StartConsole(client, opts) + if err != nil { + reject.Invoke(js.ValueOf(fmt.Sprintf("start capture: %v", err))) + return + } + active = h + + console := js.Global().Get("console") + console.Call("log", "[capture] started, call client.stopCapture() to stop") + resolve.Invoke(js.Undefined()) + }) + }) + + stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any { + mu.Lock() + defer mu.Unlock() + + if active == nil { + js.Global().Get("console").Call("log", "[capture] no active capture") + return js.Undefined() + } + + stats := active.Stop() + active = nil + + console := js.Global().Get("console") + console.Call("log", fmt.Sprintf("[capture] stopped: %d packets, %d bytes, %d dropped", + stats.Packets, stats.Bytes, stats.Dropped)) + return js.Undefined() + }) + + return startFn, stopFn +} + // createPromise is a helper to create JavaScript promises func createPromise(handler func(resolve, reject js.Value)) js.Value { return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any { @@ -521,6 +612,11 @@ func createClientObject(client *netbird.Client) js.Value { obj["statusDetail"] = createStatusDetailMethod(client) obj["getSyncResponse"] = createGetSyncResponseMethod(client) obj["setLogLevel"] = createSetLogLevelMethod(client) + obj["startCapture"] = createStartCaptureMethod(client) + + capStart, capStop := captureMethods(client) + obj["capture"] = capStart + obj["stopCapture"] = capStop return js.ValueOf(obj) } diff --git a/client/wasm/internal/capture/capture.go b/client/wasm/internal/capture/capture.go new file mode 100644 index 000000000..53e43c45e --- /dev/null +++ b/client/wasm/internal/capture/capture.go @@ -0,0 +1,176 @@ +//go:build js + +// Package capture bridges the util/capture package to JavaScript via syscall/js. +package capture + +import ( + "strings" + "sync" + "syscall/js" + + netbird "github.com/netbirdio/netbird/client/embed" +) + +// Handle holds a running capture session so it can be stopped later. +type Handle struct { + cs *netbird.CaptureSession + stopFn js.Func + stopped bool +} + +// Stop ends the capture and returns stats. +func (h *Handle) Stop() netbird.CaptureStats { + if h.stopped { + return h.cs.Stats() + } + h.stopped = true + h.stopFn.Release() + + h.cs.Stop() + return h.cs.Stats() +} + +func statsToJS(s netbird.CaptureStats) js.Value { + obj := js.Global().Get("Object").Call("create", js.Null()) + obj.Set("packets", js.ValueOf(s.Packets)) + obj.Set("bytes", js.ValueOf(s.Bytes)) + obj.Set("dropped", js.ValueOf(s.Dropped)) + return obj +} + +// parseOpts extracts filter/verbose/ascii from a JS options value. +func parseOpts(jsOpts js.Value) (filter string, verbose, ascii bool) { + if jsOpts.IsNull() || jsOpts.IsUndefined() { + return + } + if jsOpts.Type() == js.TypeString { + filter = jsOpts.String() + return + } + if jsOpts.Type() != js.TypeObject { + return + } + if f := jsOpts.Get("filter"); !f.IsUndefined() && !f.IsNull() { + filter = f.String() + } + if v := jsOpts.Get("verbose"); !v.IsUndefined() { + verbose = v.Truthy() + } + if a := jsOpts.Get("ascii"); !a.IsUndefined() { + ascii = a.Truthy() + } + return +} + +// Start creates a capture session and returns a JS interface for streaming text +// output. The returned object exposes: +// +// onpacket(callback) - set callback(string) for each text line +// stop() - stop capture and return stats { packets, bytes, dropped } +// +// Options: { filter: string, verbose: bool, ascii: bool } or just a filter string. +func Start(client *netbird.Client, jsOpts js.Value) (js.Value, error) { + filter, verbose, ascii := parseOpts(jsOpts) + + cb := &jsCallbackWriter{} + + cs, err := client.StartCapture(netbird.CaptureOptions{ + TextOutput: cb, + Filter: filter, + Verbose: verbose, + ASCII: ascii, + }) + if err != nil { + return js.Undefined(), err + } + + handle := &Handle{cs: cs} + + iface := js.Global().Get("Object").Call("create", js.Null()) + handle.stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any { + return statsToJS(handle.Stop()) + }) + iface.Set("stop", handle.stopFn) + iface.Set("onpacket", js.Undefined()) + cb.setInterface(iface) + + return iface, nil +} + +// StartConsole starts a capture that logs every packet line to console.log. +// Returns a Handle so the caller can stop it later. +func StartConsole(client *netbird.Client, jsOpts js.Value) (*Handle, error) { + filter, verbose, ascii := parseOpts(jsOpts) + + cb := &jsCallbackWriter{} + + cs, err := client.StartCapture(netbird.CaptureOptions{ + TextOutput: cb, + Filter: filter, + Verbose: verbose, + ASCII: ascii, + }) + if err != nil { + return nil, err + } + + handle := &Handle{cs: cs} + handle.stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any { + return statsToJS(handle.Stop()) + }) + + iface := js.Global().Get("Object").Call("create", js.Null()) + console := js.Global().Get("console") + iface.Set("onpacket", console.Get("log").Call("bind", console, js.ValueOf("[capture]"))) + cb.setInterface(iface) + + return handle, nil +} + +// jsCallbackWriter is an io.Writer that buffers text until a newline, then +// invokes the JS onpacket callback with each complete line. +type jsCallbackWriter struct { + mu sync.Mutex + iface js.Value + buf strings.Builder +} + +func (w *jsCallbackWriter) setInterface(iface js.Value) { + w.mu.Lock() + defer w.mu.Unlock() + w.iface = iface +} + +func (w *jsCallbackWriter) Write(p []byte) (int, error) { + w.mu.Lock() + w.buf.Write(p) + + var lines []string + for { + str := w.buf.String() + idx := strings.IndexByte(str, '\n') + if idx < 0 { + break + } + lines = append(lines, str[:idx]) + w.buf.Reset() + if idx+1 < len(str) { + w.buf.WriteString(str[idx+1:]) + } + } + + iface := w.iface + w.mu.Unlock() + + if iface.IsUndefined() { + return len(p), nil + } + cb := iface.Get("onpacket") + if cb.IsUndefined() || cb.IsNull() { + return len(p), nil + } + for _, line := range lines { + cb.Invoke(js.ValueOf(line)) + } + return len(p), nil +} diff --git a/management/server/account_test.go b/management/server/account_test.go index 756c42421..e259856e3 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -1761,7 +1761,7 @@ func hasNilField(x interface{}) error { if f := rv.Field(i); f.IsValid() { k := f.Kind() switch k { - case reflect.Ptr: + case reflect.Pointer: if f.IsNil() { return fmt.Errorf("field %s is nil", f.String()) } diff --git a/proxy/cmd/proxy/cmd/debug.go b/proxy/cmd/proxy/cmd/debug.go index 59f7a6b65..1b1664490 100644 --- a/proxy/cmd/proxy/cmd/debug.go +++ b/proxy/cmd/proxy/cmd/debug.go @@ -2,7 +2,12 @@ package cmd import ( "fmt" + "os" + "os/signal" + "path/filepath" "strconv" + "strings" + "syscall" "github.com/spf13/cobra" @@ -99,6 +104,27 @@ var debugStopCmd = &cobra.Command{ SilenceUsage: true, } +var debugCaptureCmd = &cobra.Command{ + Use: "capture [filter expression]", + Short: "Capture packets on a client's WireGuard interface", + Long: `Captures decrypted packets flowing through a client's WireGuard interface. + +Default output is human-readable text. Use --pcap or --output for pcap binary. +Filter arguments after the account ID use BPF-like syntax. + +Examples: + netbird-proxy debug capture + netbird-proxy debug capture --duration 1m host 10.0.0.1 + netbird-proxy debug capture host 10.0.0.1 and tcp port 443 + netbird-proxy debug capture not port 22 + netbird-proxy debug capture -o capture.pcap + netbird-proxy debug capture --pcap | tcpdump -r - -n + netbird-proxy debug capture --pcap | tshark -r -`, + Args: cobra.MinimumNArgs(1), + RunE: runDebugCapture, + SilenceUsage: true, +} + func init() { debugCmd.PersistentFlags().StringVar(&debugAddr, "addr", envStringOrDefault("NB_PROXY_DEBUG_ADDRESS", "localhost:8444"), "Debug endpoint address") debugCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output JSON instead of pretty format") @@ -110,6 +136,12 @@ func init() { debugPingCmd.Flags().StringVar(&pingTimeout, "timeout", "", "Ping timeout (e.g., 10s)") + debugCaptureCmd.Flags().DurationP("duration", "d", 0, "Capture duration (0 = server default)") + debugCaptureCmd.Flags().Bool("pcap", false, "Force pcap binary output (default when --output is set)") + debugCaptureCmd.Flags().BoolP("verbose", "v", false, "Show seq/ack, TTL, window, total length (text mode)") + debugCaptureCmd.Flags().Bool("ascii", false, "Print payload as ASCII after each packet (text mode)") + debugCaptureCmd.Flags().StringP("output", "o", "", "Write pcap to file instead of stdout") + debugCmd.AddCommand(debugHealthCmd) debugCmd.AddCommand(debugClientsCmd) debugCmd.AddCommand(debugStatusCmd) @@ -119,6 +151,7 @@ func init() { debugCmd.AddCommand(debugLogCmd) debugCmd.AddCommand(debugStartCmd) debugCmd.AddCommand(debugStopCmd) + debugCmd.AddCommand(debugCaptureCmd) rootCmd.AddCommand(debugCmd) } @@ -171,3 +204,84 @@ func runDebugStart(cmd *cobra.Command, args []string) error { func runDebugStop(cmd *cobra.Command, args []string) error { return getDebugClient(cmd).StopClient(cmd.Context(), args[0]) } + +func runDebugCapture(cmd *cobra.Command, args []string) error { + duration, _ := cmd.Flags().GetDuration("duration") + forcePcap, _ := cmd.Flags().GetBool("pcap") + verbose, _ := cmd.Flags().GetBool("verbose") + ascii, _ := cmd.Flags().GetBool("ascii") + outPath, _ := cmd.Flags().GetString("output") + + // Default to text. Use pcap when --pcap is set or --output is given. + wantText := !forcePcap && outPath == "" + + var filterExpr string + if len(args) > 1 { + filterExpr = strings.Join(args[1:], " ") + } + + ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + out, cleanup, err := captureOutputWriter(cmd, outPath) + if err != nil { + return err + } + defer cleanup() + + if wantText { + cmd.PrintErrln("Capturing packets... Press Ctrl+C to stop.") + } else { + cmd.PrintErrln("Capturing packets (pcap)... Press Ctrl+C to stop.") + } + + var durationStr string + if duration > 0 { + durationStr = duration.String() + } + + err = getDebugClient(cmd).Capture(ctx, debug.CaptureOptions{ + AccountID: args[0], + Duration: durationStr, + FilterExpr: filterExpr, + Text: wantText, + Verbose: verbose, + ASCII: ascii, + Output: out, + }) + if err != nil { + return err + } + + cmd.PrintErrln("\nCapture finished.") + return nil +} + +// captureOutputWriter returns the writer and cleanup function for capture output. +func captureOutputWriter(cmd *cobra.Command, outPath string) (out *os.File, cleanup func(), err error) { + if outPath != "" { + f, err := os.CreateTemp(filepath.Dir(outPath), filepath.Base(outPath)+".*.tmp") + if err != nil { + return nil, nil, fmt.Errorf("create output file: %w", err) + } + tmpPath := f.Name() + return f, func() { + if err := f.Close(); err != nil { + cmd.PrintErrf("close output file: %v\n", err) + } + if fi, err := os.Stat(tmpPath); err == nil && fi.Size() > 0 { + if err := os.Rename(tmpPath, outPath); err != nil { + cmd.PrintErrf("rename output file: %v\n", err) + } else { + cmd.PrintErrf("Wrote %s\n", outPath) + } + } else { + os.Remove(tmpPath) + } + }, nil + } + + return os.Stdout, func() { + // no cleanup needed for stdout + }, nil +} diff --git a/proxy/internal/debug/client.go b/proxy/internal/debug/client.go index 01b0bc8e6..e01149522 100644 --- a/proxy/internal/debug/client.go +++ b/proxy/internal/debug/client.go @@ -310,6 +310,76 @@ func (c *Client) printError(data map[string]any) { } } +// CaptureOptions configures a capture request. +type CaptureOptions struct { + AccountID string + Duration string + FilterExpr string + Text bool + Verbose bool + ASCII bool + Output io.Writer +} + +// Capture streams a packet capture from the debug endpoint. The response body +// (pcap or text) is written directly to opts.Output until the server closes the +// connection or the context is cancelled. +func (c *Client) Capture(ctx context.Context, opts CaptureOptions) error { + if opts.AccountID == "" { + return fmt.Errorf("account ID is required") + } + if opts.Output == nil { + return fmt.Errorf("output writer is required") + } + + params := url.Values{} + if opts.Duration != "" { + params.Set("duration", opts.Duration) + } + if opts.FilterExpr != "" { + params.Set("filter", opts.FilterExpr) + } + if opts.Text { + params.Set("format", "text") + } + if opts.Verbose { + params.Set("verbose", "true") + } + if opts.ASCII { + params.Set("ascii", "true") + } + + path := fmt.Sprintf("/debug/clients/%s/capture", url.PathEscape(opts.AccountID)) + if len(params) > 0 { + path += "?" + params.Encode() + } + + fullURL := c.baseURL + path + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + // Use a separate client without timeout since captures stream for their full duration. + httpClient := &http.Client{} + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server error (%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + _, err = io.Copy(opts.Output, resp.Body) + if err != nil && ctx.Err() != nil { + return nil + } + return err +} + func (c *Client) fetchAndPrint(ctx context.Context, path string, printer func(map[string]any)) error { data, raw, err := c.fetch(ctx, path) if err != nil { diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go index c507cfad9..6cd124554 100644 --- a/proxy/internal/debug/handler.go +++ b/proxy/internal/debug/handler.go @@ -174,6 +174,8 @@ func (h *Handler) handleClientRoutes(w http.ResponseWriter, r *http.Request, pat h.handleClientStart(w, r, accountID) case "stop": h.handleClientStop(w, r, accountID) + case "capture": + h.handleCapture(w, r, accountID) default: return false } @@ -632,6 +634,81 @@ func (h *Handler) handleClientStop(w http.ResponseWriter, r *http.Request, accou }) } +const maxCaptureDuration = 30 * time.Minute + +// handleCapture streams a pcap or text packet capture for the given client. +// +// Query params: +// +// duration: capture duration (0 or absent = max, capped at 30m) +// format: "text" for human-readable output (default: pcap) +// filter: BPF-like filter expression (e.g. "host 10.0.0.1 and tcp port 443") +func (h *Handler) handleCapture(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + http.Error(w, "client not found", http.StatusNotFound) + return + } + + duration := maxCaptureDuration + if durationStr := r.URL.Query().Get("duration"); durationStr != "" { + d, err := time.ParseDuration(durationStr) + if err != nil { + http.Error(w, "invalid duration: "+err.Error(), http.StatusBadRequest) + return + } + if d < 0 { + http.Error(w, "duration must not be negative", http.StatusBadRequest) + return + } + if d > 0 { + duration = min(d, maxCaptureDuration) + } + } + + filter := r.URL.Query().Get("filter") + wantText := r.URL.Query().Get("format") == "text" + verbose := r.URL.Query().Get("verbose") == "true" + ascii := r.URL.Query().Get("ascii") == "true" + + opts := nbembed.CaptureOptions{Filter: filter, Verbose: verbose, ASCII: ascii} + if wantText { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + opts.TextOutput = w + } else { + w.Header().Set("Content-Type", "application/vnd.tcpdump.pcap") + w.Header().Set("Content-Disposition", + fmt.Sprintf("attachment; filename=capture-%s.pcap", accountID)) + opts.Output = w + } + + cs, err := client.StartCapture(opts) + if err != nil { + http.Error(w, "start capture: "+err.Error(), http.StatusServiceUnavailable) + return + } + defer cs.Stop() + + // Flush headers after setup succeeds so errors above can still set status codes. + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + timer := time.NewTimer(duration) + defer timer.Stop() + + select { + case <-r.Context().Done(): + case <-timer.C: + } + + cs.Stop() + + stats := cs.Stats() + h.logger.Infof("capture for %s finished: %d packets, %d bytes, %d dropped", + accountID, stats.Packets, stats.Bytes, stats.Dropped) +} + func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request, wantJSON bool) { if !wantJSON { http.Redirect(w, r, "/debug", http.StatusSeeOther) diff --git a/util/capture/afpacket_linux.go b/util/capture/afpacket_linux.go new file mode 100644 index 000000000..bf59e806a --- /dev/null +++ b/util/capture/afpacket_linux.go @@ -0,0 +1,199 @@ +package capture + +import ( + "encoding/binary" + "errors" + "fmt" + "net" + "sync" + "sync/atomic" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" +) + +// htons converts a uint16 from host to network (big-endian) byte order. +func htons(v uint16) uint16 { + var buf [2]byte + binary.BigEndian.PutUint16(buf[:], v) + return binary.NativeEndian.Uint16(buf[:]) +} + +// AFPacketCapture reads raw packets from a network interface using an +// AF_PACKET socket. This is the kernel-mode fallback when FilteredDevice is +// not available (kernel WireGuard). Linux only. +// +// It implements device.PacketCapture so it can be set on a Session, but it +// drives its own read loop rather than being called from FilteredDevice. +// Call Start to begin and Stop to end. +type AFPacketCapture struct { + ifaceName string + sess *Session + fd int + mu sync.Mutex + stopped chan struct{} + started atomic.Bool + closed atomic.Bool +} + +// NewAFPacketCapture creates a capture bound to the given interface. +// The session receives packets via Offer. +func NewAFPacketCapture(ifaceName string, sess *Session) *AFPacketCapture { + return &AFPacketCapture{ + ifaceName: ifaceName, + sess: sess, + fd: -1, + stopped: make(chan struct{}), + } +} + +// Start opens the AF_PACKET socket and begins reading packets. +// Packets are fed to the session via Offer. Returns immediately; +// the read loop runs in a goroutine. +func (c *AFPacketCapture) Start() error { + if c.sess == nil { + return errors.New("nil capture session") + } + if !c.started.CompareAndSwap(false, true) { + return errors.New("capture already started") + } + if c.closed.Load() { + c.started.Store(false) + return errors.New("cannot restart stopped capture") + } + + iface, err := net.InterfaceByName(c.ifaceName) + if err != nil { + c.started.Store(false) + return fmt.Errorf("interface %s: %w", c.ifaceName, err) + } + + fd, err := unix.Socket(unix.AF_PACKET, unix.SOCK_DGRAM|unix.SOCK_NONBLOCK|unix.SOCK_CLOEXEC, int(htons(unix.ETH_P_ALL))) + if err != nil { + c.started.Store(false) + return fmt.Errorf("create AF_PACKET socket: %w", err) + } + + addr := &unix.SockaddrLinklayer{ + Protocol: htons(unix.ETH_P_ALL), + Ifindex: iface.Index, + } + if err := unix.Bind(fd, addr); err != nil { + unix.Close(fd) + c.started.Store(false) + return fmt.Errorf("bind to %s: %w", c.ifaceName, err) + } + + c.mu.Lock() + c.fd = fd + c.mu.Unlock() + + go c.readLoop(fd) + return nil +} + +// Stop closes the socket and waits for the read loop to exit. Idempotent. +func (c *AFPacketCapture) Stop() { + if !c.closed.CompareAndSwap(false, true) { + if c.started.Load() { + <-c.stopped + } + return + } + + c.mu.Lock() + fd := c.fd + c.fd = -1 + c.mu.Unlock() + + if fd >= 0 { + unix.Close(fd) + } + + if c.started.Load() { + <-c.stopped + } +} + +func (c *AFPacketCapture) readLoop(fd int) { + defer close(c.stopped) + + buf := make([]byte, 65536) + pollFds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} + + for { + if c.closed.Load() { + return + } + + ok, err := c.pollOnce(pollFds) + if err != nil { + return + } + if !ok { + continue + } + + c.recvAndOffer(fd, buf) + } +} + +// pollOnce waits for data on the fd. Returns true if data is ready, false for timeout/retry. +// Returns an error to signal the loop should exit. +func (c *AFPacketCapture) pollOnce(pollFds []unix.PollFd) (bool, error) { + n, err := unix.Poll(pollFds, 200) + if err != nil { + if errors.Is(err, unix.EINTR) { + return false, nil + } + if c.closed.Load() { + return false, errors.New("closed") + } + log.Debugf("af_packet poll: %v", err) + return false, err + } + if n == 0 { + return false, nil + } + if pollFds[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 { + return false, errors.New("fd error") + } + return true, nil +} + +func (c *AFPacketCapture) recvAndOffer(fd int, buf []byte) { + nr, from, err := unix.Recvfrom(fd, buf, 0) + if err != nil { + if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) { + return + } + if !c.closed.Load() { + log.Debugf("af_packet recvfrom: %v", err) + } + return + } + if nr < 1 { + return + } + + ver := buf[0] >> 4 + if ver != 4 && ver != 6 { + return + } + + // The kernel sets Pkttype on AF_PACKET sockets: + // PACKET_HOST(0) = addressed to us (inbound) + // PACKET_OUTGOING(4) = sent by us (outbound) + outbound := false + if sa, ok := from.(*unix.SockaddrLinklayer); ok { + outbound = sa.Pkttype == unix.PACKET_OUTGOING + } + c.sess.Offer(buf[:nr], outbound) +} + +// Offer satisfies device.PacketCapture but is unused: the AFPacketCapture +// drives its own read loop. This exists only so the type signature is +// compatible if someone tries to set it as a PacketCapture. +func (c *AFPacketCapture) Offer([]byte, bool) { + // unused: AFPacketCapture drives its own read loop +} diff --git a/util/capture/afpacket_stub.go b/util/capture/afpacket_stub.go new file mode 100644 index 000000000..bde368e88 --- /dev/null +++ b/util/capture/afpacket_stub.go @@ -0,0 +1,26 @@ +//go:build !linux + +package capture + +import "errors" + +// AFPacketCapture is not available on this platform. +type AFPacketCapture struct{} + +// NewAFPacketCapture returns nil on non-Linux platforms. +func NewAFPacketCapture(string, *Session) *AFPacketCapture { return nil } + +// Start returns an error on non-Linux platforms. +func (c *AFPacketCapture) Start() error { + return errors.New("AF_PACKET capture is only supported on Linux") +} + +// Stop is a no-op on non-Linux platforms. +func (c *AFPacketCapture) Stop() { + // no-op on non-Linux platforms +} + +// Offer is a no-op on non-Linux platforms. +func (c *AFPacketCapture) Offer([]byte, bool) { + // no-op on non-Linux platforms +} diff --git a/util/capture/capture.go b/util/capture/capture.go new file mode 100644 index 000000000..0d92a4311 --- /dev/null +++ b/util/capture/capture.go @@ -0,0 +1,59 @@ +// Package capture provides userspace packet capture in pcap format. +// +// It taps decrypted WireGuard packets flowing through the FilteredDevice and +// writes them as pcap (readable by tcpdump, tshark, Wireshark) or as +// human-readable one-line-per-packet text. +package capture + +import "io" + +// Direction indicates whether a packet is entering or leaving the host. +type Direction uint8 + +const ( + // Inbound is a packet arriving from the network (FilteredDevice.Write path). + Inbound Direction = iota + // Outbound is a packet leaving the host (FilteredDevice.Read path). + Outbound +) + +// String returns "IN" or "OUT". +func (d Direction) String() string { + if d == Outbound { + return "OUT" + } + return "IN" +} + +const ( + protoICMP = 1 + protoTCP = 6 + protoUDP = 17 + protoICMPv6 = 58 +) + +// Options configures a capture session. +type Options struct { + // Output receives pcap-formatted data. Nil disables pcap output. + Output io.Writer + // TextOutput receives human-readable packet summaries. Nil disables text output. + TextOutput io.Writer + // Matcher selects which packets to capture. Nil captures all. + // Use ParseFilter("host 10.0.0.1 and tcp") or &Filter{...}. + Matcher Matcher + // Verbose adds seq/ack, TTL, window, total length to text output. + Verbose bool + // ASCII dumps transport payload as printable ASCII after each packet line. + ASCII bool + // SnapLen is the maximum bytes captured per packet. 0 means 65535. + SnapLen uint32 + // BufSize is the internal channel buffer size. 0 means 256. + BufSize int +} + +// Stats reports capture session counters. +type Stats struct { + Packets int64 + Bytes int64 + Dropped int64 +} diff --git a/util/capture/filter.go b/util/capture/filter.go new file mode 100644 index 000000000..d463450b8 --- /dev/null +++ b/util/capture/filter.go @@ -0,0 +1,528 @@ +package capture + +import ( + "encoding/binary" + "fmt" + "net/netip" + "strconv" + "strings" +) + +// Matcher tests whether a raw packet should be captured. +type Matcher interface { + Match(data []byte) bool +} + +// Filter selects packets by flat AND'd criteria. Useful for structured APIs +// (query params, proto fields). Implements Matcher. +type Filter struct { + SrcIP netip.Addr + DstIP netip.Addr + Host netip.Addr + SrcPort uint16 + DstPort uint16 + Port uint16 + Proto uint8 +} + +// IsEmpty returns true if the filter has no criteria set. +func (f *Filter) IsEmpty() bool { + return !f.SrcIP.IsValid() && !f.DstIP.IsValid() && !f.Host.IsValid() && + f.SrcPort == 0 && f.DstPort == 0 && f.Port == 0 && f.Proto == 0 +} + +// Match implements Matcher. All non-zero fields must match (AND). +func (f *Filter) Match(data []byte) bool { + if f.IsEmpty() { + return true + } + info, ok := parsePacketInfo(data) + if !ok { + return false + } + if f.Host.IsValid() && info.srcIP != f.Host && info.dstIP != f.Host { + return false + } + if f.SrcIP.IsValid() && info.srcIP != f.SrcIP { + return false + } + if f.DstIP.IsValid() && info.dstIP != f.DstIP { + return false + } + if f.Proto != 0 && info.proto != f.Proto { + return false + } + if f.Port != 0 && info.srcPort != f.Port && info.dstPort != f.Port { + return false + } + if f.SrcPort != 0 && info.srcPort != f.SrcPort { + return false + } + if f.DstPort != 0 && info.dstPort != f.DstPort { + return false + } + return true +} + +// exprNode evaluates a filter condition against pre-parsed packet info. +type exprNode func(info *packetInfo) bool + +// exprMatcher wraps an expression tree. Parses the packet once, then walks the tree. +type exprMatcher struct { + root exprNode +} + +func (m *exprMatcher) Match(data []byte) bool { + info, ok := parsePacketInfo(data) + if !ok { + return false + } + return m.root(&info) +} + +func nodeAnd(a, b exprNode) exprNode { + return func(info *packetInfo) bool { return a(info) && b(info) } +} + +func nodeOr(a, b exprNode) exprNode { + return func(info *packetInfo) bool { return a(info) || b(info) } +} + +func nodeNot(n exprNode) exprNode { + return func(info *packetInfo) bool { return !n(info) } +} + +func nodeHost(addr netip.Addr) exprNode { + return func(info *packetInfo) bool { return info.srcIP == addr || info.dstIP == addr } +} + +func nodeSrcHost(addr netip.Addr) exprNode { + return func(info *packetInfo) bool { return info.srcIP == addr } +} + +func nodeDstHost(addr netip.Addr) exprNode { + return func(info *packetInfo) bool { return info.dstIP == addr } +} + +func nodePort(port uint16) exprNode { + return func(info *packetInfo) bool { return info.srcPort == port || info.dstPort == port } +} + +func nodeSrcPort(port uint16) exprNode { + return func(info *packetInfo) bool { return info.srcPort == port } +} + +func nodeDstPort(port uint16) exprNode { + return func(info *packetInfo) bool { return info.dstPort == port } +} + +func nodeProto(proto uint8) exprNode { + return func(info *packetInfo) bool { return info.proto == proto } +} + +func nodeFamily(family uint8) exprNode { + return func(info *packetInfo) bool { return info.family == family } +} + +func nodeNet(prefix netip.Prefix) exprNode { + return func(info *packetInfo) bool { return prefix.Contains(info.srcIP) || prefix.Contains(info.dstIP) } +} + +func nodeSrcNet(prefix netip.Prefix) exprNode { + return func(info *packetInfo) bool { return prefix.Contains(info.srcIP) } +} + +func nodeDstNet(prefix netip.Prefix) exprNode { + return func(info *packetInfo) bool { return prefix.Contains(info.dstIP) } +} + +// packetInfo holds parsed header fields for filtering and display. +type packetInfo struct { + family uint8 + srcIP netip.Addr + dstIP netip.Addr + proto uint8 + srcPort uint16 + dstPort uint16 + hdrLen int +} + +func parsePacketInfo(data []byte) (packetInfo, bool) { + if len(data) < 1 { + return packetInfo{}, false + } + switch data[0] >> 4 { + case 4: + return parseIPv4Info(data) + case 6: + return parseIPv6Info(data) + default: + return packetInfo{}, false + } +} + +func parseIPv4Info(data []byte) (packetInfo, bool) { + if len(data) < 20 { + return packetInfo{}, false + } + ihl := int(data[0]&0x0f) * 4 + if ihl < 20 || len(data) < ihl { + return packetInfo{}, false + } + info := packetInfo{ + family: 4, + srcIP: netip.AddrFrom4([4]byte{data[12], data[13], data[14], data[15]}), + dstIP: netip.AddrFrom4([4]byte{data[16], data[17], data[18], data[19]}), + proto: data[9], + hdrLen: ihl, + } + if (info.proto == protoTCP || info.proto == protoUDP) && len(data) >= ihl+4 { + info.srcPort = binary.BigEndian.Uint16(data[ihl:]) + info.dstPort = binary.BigEndian.Uint16(data[ihl+2:]) + } + return info, true +} + +// parseIPv6Info parses the fixed IPv6 header. It reads the Next Header field +// directly, so packets with extension headers (hop-by-hop, routing, fragment, +// etc.) will report the extension type as the protocol rather than the final +// transport protocol. This is acceptable for a debug capture tool. +func parseIPv6Info(data []byte) (packetInfo, bool) { + if len(data) < 40 { + return packetInfo{}, false + } + var src, dst [16]byte + copy(src[:], data[8:24]) + copy(dst[:], data[24:40]) + info := packetInfo{ + family: 6, + srcIP: netip.AddrFrom16(src), + dstIP: netip.AddrFrom16(dst), + proto: data[6], + hdrLen: 40, + } + if (info.proto == protoTCP || info.proto == protoUDP) && len(data) >= 44 { + info.srcPort = binary.BigEndian.Uint16(data[40:]) + info.dstPort = binary.BigEndian.Uint16(data[42:]) + } + return info, true +} + +// ParseFilter parses a BPF-like filter expression and returns a Matcher. +// Returns nil Matcher for an empty expression (match all). +// +// Grammar (mirrors common tcpdump BPF syntax): +// +// orExpr = andExpr ("or" andExpr)* +// andExpr = unary ("and" unary)* +// unary = "not" unary | "(" orExpr ")" | term +// +// term = "host" IP | "src" target | "dst" target +// | "port" NUM | "net" PREFIX +// | "tcp" | "udp" | "icmp" | "icmp6" +// | "ip" | "ip6" | "proto" NUM +// target = "host" IP | "port" NUM | "net" PREFIX | IP +// +// Examples: +// +// host 10.0.0.1 and tcp port 443 +// not port 22 +// (host 10.0.0.1 or host 10.0.0.2) and tcp +// ip6 and icmp6 +// net 10.0.0.0/24 +// src host 10.0.0.1 or dst port 80 +func ParseFilter(expr string) (Matcher, error) { + tokens := tokenize(expr) + if len(tokens) == 0 { + return nil, nil //nolint:nilnil // nil Matcher means "match all" + } + + p := &parser{tokens: tokens} + node, err := p.parseOr() + if err != nil { + return nil, err + } + if p.pos < len(p.tokens) { + return nil, fmt.Errorf("unexpected token %q at position %d", p.tokens[p.pos], p.pos) + } + return &exprMatcher{root: node}, nil +} + +func tokenize(expr string) []string { + expr = strings.TrimSpace(expr) + if expr == "" { + return nil + } + // Split on whitespace but keep parens as separate tokens. + var tokens []string + for _, field := range strings.Fields(expr) { + tokens = append(tokens, splitParens(field)...) + } + return tokens +} + +// splitParens splits "(foo)" into "(", "foo", ")". +func splitParens(s string) []string { + var out []string + for strings.HasPrefix(s, "(") { + out = append(out, "(") + s = s[1:] + } + var trail []string + for strings.HasSuffix(s, ")") { + trail = append(trail, ")") + s = s[:len(s)-1] + } + if s != "" { + out = append(out, s) + } + out = append(out, trail...) + return out +} + +type parser struct { + tokens []string + pos int +} + +func (p *parser) peek() string { + if p.pos >= len(p.tokens) { + return "" + } + return strings.ToLower(p.tokens[p.pos]) +} + +func (p *parser) next() string { + tok := p.peek() + if tok != "" { + p.pos++ + } + return tok +} + +func (p *parser) expect(tok string) error { + got := p.next() + if got != tok { + return fmt.Errorf("expected %q, got %q", tok, got) + } + return nil +} + +func (p *parser) parseOr() (exprNode, error) { + left, err := p.parseAnd() + if err != nil { + return nil, err + } + for p.peek() == "or" { + p.next() + right, err := p.parseAnd() + if err != nil { + return nil, err + } + left = nodeOr(left, right) + } + return left, nil +} + +func (p *parser) parseAnd() (exprNode, error) { + left, err := p.parseUnary() + if err != nil { + return nil, err + } + for { + tok := p.peek() + if tok == "and" { + p.next() + right, err := p.parseUnary() + if err != nil { + return nil, err + } + left = nodeAnd(left, right) + continue + } + // Implicit AND: two atoms without "and" between them. + // Only if the next token starts an atom (not "or", ")", or EOF). + if tok != "" && tok != "or" && tok != ")" { + right, err := p.parseUnary() + if err != nil { + return nil, err + } + left = nodeAnd(left, right) + continue + } + break + } + return left, nil +} + +func (p *parser) parseUnary() (exprNode, error) { + switch p.peek() { + case "not": + p.next() + inner, err := p.parseUnary() + if err != nil { + return nil, err + } + return nodeNot(inner), nil + case "(": + p.next() + inner, err := p.parseOr() + if err != nil { + return nil, err + } + if err := p.expect(")"); err != nil { + return nil, fmt.Errorf("unclosed parenthesis") + } + return inner, nil + default: + return p.parseAtom() + } +} + +func (p *parser) parseAtom() (exprNode, error) { + tok := p.next() + if tok == "" { + return nil, fmt.Errorf("unexpected end of expression") + } + + switch tok { + case "host": + addr, err := p.parseAddr() + if err != nil { + return nil, fmt.Errorf("host: %w", err) + } + return nodeHost(addr), nil + + case "port": + port, err := p.parsePort() + if err != nil { + return nil, fmt.Errorf("port: %w", err) + } + return nodePort(port), nil + + case "net": + prefix, err := p.parsePrefix() + if err != nil { + return nil, fmt.Errorf("net: %w", err) + } + return nodeNet(prefix), nil + + case "src": + return p.parseDirTarget(true) + + case "dst": + return p.parseDirTarget(false) + + case "tcp": + return nodeProto(protoTCP), nil + case "udp": + return nodeProto(protoUDP), nil + case "icmp": + return nodeProto(protoICMP), nil + case "icmp6": + return nodeProto(protoICMPv6), nil + case "ip": + return nodeFamily(4), nil + case "ip6": + return nodeFamily(6), nil + + case "proto": + raw := p.next() + if raw == "" { + return nil, fmt.Errorf("proto: missing number") + } + n, err := strconv.Atoi(raw) + if err != nil || n < 0 || n > 255 { + return nil, fmt.Errorf("proto: invalid number %q", raw) + } + return nodeProto(uint8(n)), nil + + default: + return nil, fmt.Errorf("unknown filter keyword %q", tok) + } +} + +func (p *parser) parseDirTarget(isSrc bool) (exprNode, error) { + tok := p.peek() + switch tok { + case "host": + p.next() + addr, err := p.parseAddr() + if err != nil { + return nil, err + } + if isSrc { + return nodeSrcHost(addr), nil + } + return nodeDstHost(addr), nil + + case "port": + p.next() + port, err := p.parsePort() + if err != nil { + return nil, err + } + if isSrc { + return nodeSrcPort(port), nil + } + return nodeDstPort(port), nil + + case "net": + p.next() + prefix, err := p.parsePrefix() + if err != nil { + return nil, err + } + if isSrc { + return nodeSrcNet(prefix), nil + } + return nodeDstNet(prefix), nil + + default: + // Try as bare IP: "src 10.0.0.1" + addr, err := p.parseAddr() + if err != nil { + return nil, fmt.Errorf("expected host, port, net, or IP after src/dst, got %q", tok) + } + if isSrc { + return nodeSrcHost(addr), nil + } + return nodeDstHost(addr), nil + } +} + +func (p *parser) parseAddr() (netip.Addr, error) { + raw := p.next() + if raw == "" { + return netip.Addr{}, fmt.Errorf("missing IP address") + } + addr, err := netip.ParseAddr(raw) + if err != nil { + return netip.Addr{}, fmt.Errorf("invalid IP %q", raw) + } + return addr.Unmap(), nil +} + +func (p *parser) parsePort() (uint16, error) { + raw := p.next() + if raw == "" { + return 0, fmt.Errorf("missing port number") + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > 65535 { + return 0, fmt.Errorf("invalid port %q", raw) + } + return uint16(n), nil +} + +func (p *parser) parsePrefix() (netip.Prefix, error) { + raw := p.next() + if raw == "" { + return netip.Prefix{}, fmt.Errorf("missing network prefix") + } + prefix, err := netip.ParsePrefix(raw) + if err != nil { + return netip.Prefix{}, fmt.Errorf("invalid prefix %q", raw) + } + return prefix, nil +} diff --git a/util/capture/filter_test.go b/util/capture/filter_test.go new file mode 100644 index 000000000..d5fd17566 --- /dev/null +++ b/util/capture/filter_test.go @@ -0,0 +1,263 @@ +package capture + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildIPv4Packet creates a minimal IPv4+TCP/UDP packet for filter testing. +func buildIPv4Packet(t *testing.T, srcIP, dstIP netip.Addr, proto uint8, srcPort, dstPort uint16) []byte { + t.Helper() + + hdrLen := 20 + pkt := make([]byte, hdrLen+20) + pkt[0] = 0x45 + pkt[9] = proto + + src := srcIP.As4() + dst := dstIP.As4() + copy(pkt[12:16], src[:]) + copy(pkt[16:20], dst[:]) + + pkt[20] = byte(srcPort >> 8) + pkt[21] = byte(srcPort) + pkt[22] = byte(dstPort >> 8) + pkt[23] = byte(dstPort) + + return pkt +} + +// buildIPv6Packet creates a minimal IPv6+TCP/UDP packet for filter testing. +func buildIPv6Packet(t *testing.T, srcIP, dstIP netip.Addr, proto uint8, srcPort, dstPort uint16) []byte { + t.Helper() + + pkt := make([]byte, 44) // 40 header + 4 ports + pkt[0] = 0x60 // version 6 + pkt[6] = proto // next header + + src := srcIP.As16() + dst := dstIP.As16() + copy(pkt[8:24], src[:]) + copy(pkt[24:40], dst[:]) + + pkt[40] = byte(srcPort >> 8) + pkt[41] = byte(srcPort) + pkt[42] = byte(dstPort >> 8) + pkt[43] = byte(dstPort) + + return pkt +} + +// ---- Filter struct tests ---- + +func TestFilter_Empty(t *testing.T) { + f := Filter{} + assert.True(t, f.IsEmpty()) + assert.True(t, f.Match(buildIPv4Packet(t, + netip.MustParseAddr("10.0.0.1"), + netip.MustParseAddr("10.0.0.2"), + protoTCP, 12345, 443))) +} + +func TestFilter_Host(t *testing.T) { + f := Filter{Host: netip.MustParseAddr("10.0.0.1")} + assert.True(t, f.Match(buildIPv4Packet(t, netip.MustParseAddr("10.0.0.1"), netip.MustParseAddr("10.0.0.2"), protoTCP, 1234, 80))) + assert.True(t, f.Match(buildIPv4Packet(t, netip.MustParseAddr("10.0.0.2"), netip.MustParseAddr("10.0.0.1"), protoTCP, 1234, 80))) + assert.False(t, f.Match(buildIPv4Packet(t, netip.MustParseAddr("10.0.0.2"), netip.MustParseAddr("10.0.0.3"), protoTCP, 1234, 80))) +} + +func TestFilter_InvalidPacket(t *testing.T) { + f := Filter{Host: netip.MustParseAddr("10.0.0.1")} + assert.False(t, f.Match(nil)) + assert.False(t, f.Match([]byte{})) + assert.False(t, f.Match([]byte{0x00})) +} + +func TestParsePacketInfo_IPv4(t *testing.T) { + pkt := buildIPv4Packet(t, netip.MustParseAddr("192.168.1.1"), netip.MustParseAddr("10.0.0.1"), protoTCP, 54321, 80) + info, ok := parsePacketInfo(pkt) + require.True(t, ok) + assert.Equal(t, uint8(4), info.family) + assert.Equal(t, netip.MustParseAddr("192.168.1.1"), info.srcIP) + assert.Equal(t, netip.MustParseAddr("10.0.0.1"), info.dstIP) + assert.Equal(t, uint8(protoTCP), info.proto) + assert.Equal(t, uint16(54321), info.srcPort) + assert.Equal(t, uint16(80), info.dstPort) +} + +func TestParsePacketInfo_IPv6(t *testing.T) { + pkt := buildIPv6Packet(t, netip.MustParseAddr("fd00::1"), netip.MustParseAddr("fd00::2"), protoUDP, 1234, 53) + info, ok := parsePacketInfo(pkt) + require.True(t, ok) + assert.Equal(t, uint8(6), info.family) + assert.Equal(t, netip.MustParseAddr("fd00::1"), info.srcIP) + assert.Equal(t, netip.MustParseAddr("fd00::2"), info.dstIP) + assert.Equal(t, uint8(protoUDP), info.proto) + assert.Equal(t, uint16(1234), info.srcPort) + assert.Equal(t, uint16(53), info.dstPort) +} + +// ---- ParseFilter expression tests ---- + +func matchV4(t *testing.T, m Matcher, srcIP, dstIP string, proto uint8, srcPort, dstPort uint16) bool { + t.Helper() + return m.Match(buildIPv4Packet(t, netip.MustParseAddr(srcIP), netip.MustParseAddr(dstIP), proto, srcPort, dstPort)) +} + +func matchV6(t *testing.T, m Matcher, srcIP, dstIP string, proto uint8, srcPort, dstPort uint16) bool { + t.Helper() + return m.Match(buildIPv6Packet(t, netip.MustParseAddr(srcIP), netip.MustParseAddr(dstIP), proto, srcPort, dstPort)) +} + +func TestParseFilter_Empty(t *testing.T) { + m, err := ParseFilter("") + require.NoError(t, err) + assert.Nil(t, m, "empty expression should return nil matcher") +} + +func TestParseFilter_Atoms(t *testing.T) { + tests := []struct { + expr string + match bool + }{ + {"tcp", true}, + {"udp", false}, + {"host 10.0.0.1", true}, + {"host 10.0.0.99", false}, + {"port 443", true}, + {"port 80", false}, + {"src host 10.0.0.1", true}, + {"dst host 10.0.0.2", true}, + {"dst host 10.0.0.1", false}, + {"src port 12345", true}, + {"dst port 443", true}, + {"dst port 80", false}, + {"proto 6", true}, + {"proto 17", false}, + } + + pkt := buildIPv4Packet(t, netip.MustParseAddr("10.0.0.1"), netip.MustParseAddr("10.0.0.2"), protoTCP, 12345, 443) + + for _, tt := range tests { + t.Run(tt.expr, func(t *testing.T) { + m, err := ParseFilter(tt.expr) + require.NoError(t, err) + assert.Equal(t, tt.match, m.Match(pkt)) + }) + } +} + +func TestParseFilter_And(t *testing.T) { + m, err := ParseFilter("host 10.0.0.1 and tcp port 443") + require.NoError(t, err) + assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 55555, 443)) + assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoUDP, 55555, 443), "wrong proto") + assert.False(t, matchV4(t, m, "10.0.0.3", "10.0.0.2", protoTCP, 55555, 443), "wrong host") + assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 55555, 80), "wrong port") +} + +func TestParseFilter_ImplicitAnd(t *testing.T) { + // "tcp port 443" = implicit AND between tcp and port 443 + m, err := ParseFilter("tcp port 443") + require.NoError(t, err) + assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 443)) + assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoUDP, 1, 443)) +} + +func TestParseFilter_Or(t *testing.T) { + m, err := ParseFilter("port 80 or port 443") + require.NoError(t, err) + assert.True(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 80)) + assert.True(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 443)) + assert.False(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 8080)) +} + +func TestParseFilter_Not(t *testing.T) { + m, err := ParseFilter("not port 22") + require.NoError(t, err) + assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 443)) + assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 22)) + assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 22, 80)) +} + +func TestParseFilter_Parens(t *testing.T) { + m, err := ParseFilter("(port 80 or port 443) and tcp") + require.NoError(t, err) + assert.True(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 443)) + assert.False(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoUDP, 1, 443), "wrong proto") + assert.False(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 8080), "wrong port") +} + +func TestParseFilter_Family(t *testing.T) { + mV4, err := ParseFilter("ip") + require.NoError(t, err) + assert.True(t, matchV4(t, mV4, "10.0.0.1", "10.0.0.2", protoTCP, 1, 80)) + assert.False(t, matchV6(t, mV4, "fd00::1", "fd00::2", protoTCP, 1, 80)) + + mV6, err := ParseFilter("ip6") + require.NoError(t, err) + assert.False(t, matchV4(t, mV6, "10.0.0.1", "10.0.0.2", protoTCP, 1, 80)) + assert.True(t, matchV6(t, mV6, "fd00::1", "fd00::2", protoTCP, 1, 80)) +} + +func TestParseFilter_Net(t *testing.T) { + m, err := ParseFilter("net 10.0.0.0/24") + require.NoError(t, err) + assert.True(t, matchV4(t, m, "10.0.0.1", "192.168.1.1", protoTCP, 1, 80), "src in net") + assert.True(t, matchV4(t, m, "192.168.1.1", "10.0.0.200", protoTCP, 1, 80), "dst in net") + assert.False(t, matchV4(t, m, "10.0.1.1", "192.168.1.1", protoTCP, 1, 80), "neither in net") +} + +func TestParseFilter_SrcDstNet(t *testing.T) { + m, err := ParseFilter("src net 10.0.0.0/8 and dst net 192.168.0.0/16") + require.NoError(t, err) + assert.True(t, matchV4(t, m, "10.1.2.3", "192.168.1.1", protoTCP, 1, 80)) + assert.False(t, matchV4(t, m, "192.168.1.1", "10.1.2.3", protoTCP, 1, 80), "reversed") +} + +func TestParseFilter_Complex(t *testing.T) { + // Real-world: capture HTTP(S) traffic to/from specific host, excluding SSH + m, err := ParseFilter("host 10.0.0.1 and (port 80 or port 443) and not port 22") + require.NoError(t, err) + assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 55555, 443)) + assert.True(t, matchV4(t, m, "10.0.0.2", "10.0.0.1", protoTCP, 55555, 80)) + assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 22, 443), "port 22 excluded") + assert.False(t, matchV4(t, m, "10.0.0.3", "10.0.0.2", protoTCP, 55555, 443), "wrong host") +} + +func TestParseFilter_IPv6Combined(t *testing.T) { + m, err := ParseFilter("ip6 and icmp6") + require.NoError(t, err) + assert.True(t, matchV6(t, m, "fd00::1", "fd00::2", protoICMPv6, 0, 0)) + assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoICMP, 0, 0), "wrong family") + assert.False(t, matchV6(t, m, "fd00::1", "fd00::2", protoTCP, 1, 80), "wrong proto") +} + +func TestParseFilter_CaseInsensitive(t *testing.T) { + m, err := ParseFilter("HOST 10.0.0.1 AND TCP PORT 443") + require.NoError(t, err) + assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 443)) +} + +func TestParseFilter_Errors(t *testing.T) { + bad := []string{ + "badkeyword", + "host", + "port abc", + "port 99999", + "net invalid", + "(", + "(port 80", + "not", + "src", + } + for _, expr := range bad { + t.Run(expr, func(t *testing.T) { + _, err := ParseFilter(expr) + assert.Error(t, err, "should fail for %q", expr) + }) + } +} diff --git a/util/capture/pcap.go b/util/capture/pcap.go new file mode 100644 index 000000000..0a9057045 --- /dev/null +++ b/util/capture/pcap.go @@ -0,0 +1,85 @@ +package capture + +import ( + "encoding/binary" + "io" + "time" +) + +const ( + pcapMagic = 0xa1b2c3d4 + pcapVersionMaj = 2 + pcapVersionMin = 4 + // linkTypeRaw is LINKTYPE_RAW: raw IPv4/IPv6 packets without link-layer header. + linkTypeRaw = 101 + defaultSnapLen = 65535 +) + +// PcapWriter writes packets in pcap format to an underlying writer. +// The global header is written lazily on the first WritePacket call so that +// the writer can be used with unbuffered io.Pipes without deadlocking. +// It is not safe for concurrent use; callers must serialize access. +type PcapWriter struct { + w io.Writer + snapLen uint32 + headerWritten bool +} + +// NewPcapWriter creates a pcap writer. The global header is deferred until the +// first WritePacket call. +func NewPcapWriter(w io.Writer, snapLen uint32) *PcapWriter { + if snapLen == 0 { + snapLen = defaultSnapLen + } + return &PcapWriter{w: w, snapLen: snapLen} +} + +// writeGlobalHeader writes the 24-byte pcap file header. +func (pw *PcapWriter) writeGlobalHeader() error { + var hdr [24]byte + binary.LittleEndian.PutUint32(hdr[0:4], pcapMagic) + binary.LittleEndian.PutUint16(hdr[4:6], pcapVersionMaj) + binary.LittleEndian.PutUint16(hdr[6:8], pcapVersionMin) + binary.LittleEndian.PutUint32(hdr[16:20], pw.snapLen) + binary.LittleEndian.PutUint32(hdr[20:24], linkTypeRaw) + + _, err := pw.w.Write(hdr[:]) + return err +} + +// WriteHeader writes the pcap global header. Safe to call multiple times. +func (pw *PcapWriter) WriteHeader() error { + if pw.headerWritten { + return nil + } + if err := pw.writeGlobalHeader(); err != nil { + return err + } + pw.headerWritten = true + return nil +} + +// WritePacket writes a single packet record, preceded by the global header +// on the first call. +func (pw *PcapWriter) WritePacket(ts time.Time, data []byte) error { + if err := pw.WriteHeader(); err != nil { + return err + } + + origLen := uint32(len(data)) + if origLen > pw.snapLen { + data = data[:pw.snapLen] + } + + var hdr [16]byte + binary.LittleEndian.PutUint32(hdr[0:4], uint32(ts.Unix())) + binary.LittleEndian.PutUint32(hdr[4:8], uint32(ts.Nanosecond()/1000)) + binary.LittleEndian.PutUint32(hdr[8:12], uint32(len(data))) + binary.LittleEndian.PutUint32(hdr[12:16], origLen) + + if _, err := pw.w.Write(hdr[:]); err != nil { + return err + } + _, err := pw.w.Write(data) + return err +} diff --git a/util/capture/pcap_test.go b/util/capture/pcap_test.go new file mode 100644 index 000000000..c3d21ef4a --- /dev/null +++ b/util/capture/pcap_test.go @@ -0,0 +1,68 @@ +package capture + +import ( + "bytes" + "encoding/binary" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPcapWriter_GlobalHeader(t *testing.T) { + var buf bytes.Buffer + pw := NewPcapWriter(&buf, 0) + + // Header is lazy, so write a dummy packet to trigger it. + err := pw.WritePacket(time.Now(), []byte{0x45, 0, 0, 20, 0, 0, 0, 0, 64, 1, 0, 0, 10, 0, 0, 1, 10, 0, 0, 2}) + require.NoError(t, err) + + data := buf.Bytes() + require.GreaterOrEqual(t, len(data), 24, "should contain global header") + + assert.Equal(t, uint32(pcapMagic), binary.LittleEndian.Uint32(data[0:4]), "magic number") + assert.Equal(t, uint16(pcapVersionMaj), binary.LittleEndian.Uint16(data[4:6]), "version major") + assert.Equal(t, uint16(pcapVersionMin), binary.LittleEndian.Uint16(data[6:8]), "version minor") + assert.Equal(t, uint32(defaultSnapLen), binary.LittleEndian.Uint32(data[16:20]), "snap length") + assert.Equal(t, uint32(linkTypeRaw), binary.LittleEndian.Uint32(data[20:24]), "link type") +} + +func TestPcapWriter_WritePacket(t *testing.T) { + var buf bytes.Buffer + pw := NewPcapWriter(&buf, 100) + + ts := time.Date(2025, 6, 15, 12, 30, 45, 123456000, time.UTC) + payload := make([]byte, 50) + for i := range payload { + payload[i] = byte(i) + } + + err := pw.WritePacket(ts, payload) + require.NoError(t, err) + + data := buf.Bytes()[24:] // skip global header + require.Len(t, data, 16+50, "packet header + payload") + + assert.Equal(t, uint32(ts.Unix()), binary.LittleEndian.Uint32(data[0:4]), "timestamp seconds") + assert.Equal(t, uint32(123456), binary.LittleEndian.Uint32(data[4:8]), "timestamp microseconds") + assert.Equal(t, uint32(50), binary.LittleEndian.Uint32(data[8:12]), "included length") + assert.Equal(t, uint32(50), binary.LittleEndian.Uint32(data[12:16]), "original length") + assert.Equal(t, payload, data[16:], "packet data") +} + +func TestPcapWriter_SnapLen(t *testing.T) { + var buf bytes.Buffer + pw := NewPcapWriter(&buf, 10) + + ts := time.Now() + payload := make([]byte, 50) + + err := pw.WritePacket(ts, payload) + require.NoError(t, err) + + data := buf.Bytes()[24:] + assert.Equal(t, uint32(10), binary.LittleEndian.Uint32(data[8:12]), "included length should be truncated") + assert.Equal(t, uint32(50), binary.LittleEndian.Uint32(data[12:16]), "original length preserved") + assert.Len(t, data[16:], 10, "only snap_len bytes written") +} diff --git a/util/capture/session.go b/util/capture/session.go new file mode 100644 index 000000000..09806e10c --- /dev/null +++ b/util/capture/session.go @@ -0,0 +1,213 @@ +package capture + +import ( + "fmt" + "sync" + "sync/atomic" + "time" +) + +const defaultBufSize = 256 + +type packetEntry struct { + ts time.Time + data []byte + dir Direction +} + +// Session manages an active packet capture. Packets are offered via Offer, +// buffered in a channel, and written to configured sinks by a background +// goroutine. This keeps the hot path (FilteredDevice.Read/Write) non-blocking. +// +// The caller must call Stop when done to flush remaining packets and release +// resources. +type Session struct { + pcapW *PcapWriter + textW *TextWriter + matcher Matcher + snapLen uint32 + flushFn func() + + ch chan packetEntry + done chan struct{} + stopped chan struct{} + + closeOnce sync.Once + closed atomic.Bool + packets atomic.Int64 + bytes atomic.Int64 + dropped atomic.Int64 + started time.Time +} + +// NewSession creates and starts a capture session. At least one of +// Options.Output or Options.TextOutput must be non-nil. +func NewSession(opts Options) (*Session, error) { + if opts.Output == nil && opts.TextOutput == nil { + return nil, fmt.Errorf("at least one output sink required") + } + + snapLen := opts.SnapLen + if snapLen == 0 { + snapLen = defaultSnapLen + } + + bufSize := opts.BufSize + if bufSize <= 0 { + bufSize = defaultBufSize + } + + s := &Session{ + matcher: opts.Matcher, + snapLen: snapLen, + ch: make(chan packetEntry, bufSize), + done: make(chan struct{}), + stopped: make(chan struct{}), + started: time.Now(), + } + + if opts.Output != nil { + s.pcapW = NewPcapWriter(opts.Output, snapLen) + } + if opts.TextOutput != nil { + s.textW = NewTextWriter(opts.TextOutput, opts.Verbose, opts.ASCII) + } + + s.flushFn = buildFlushFn(opts.Output, opts.TextOutput) + + go s.run() + return s, nil +} + +// Offer submits a packet for capture. It returns immediately and never blocks +// the caller. If the internal buffer is full the packet is dropped silently. +// +// outbound should be true for packets leaving the host (FilteredDevice.Read +// path) and false for packets arriving (FilteredDevice.Write path). +// +// Offer satisfies the device.PacketCapture interface. +func (s *Session) Offer(data []byte, outbound bool) { + if s.closed.Load() { + return + } + + if s.matcher != nil && !s.matcher.Match(data) { + return + } + + captureLen := len(data) + if s.snapLen > 0 && uint32(captureLen) > s.snapLen { + captureLen = int(s.snapLen) + } + + copied := make([]byte, captureLen) + copy(copied, data) + + dir := Inbound + if outbound { + dir = Outbound + } + + select { + case s.ch <- packetEntry{ts: time.Now(), data: copied, dir: dir}: + s.packets.Add(1) + s.bytes.Add(int64(len(data))) + default: + s.dropped.Add(1) + } +} + +// Stop signals the session to stop accepting packets, drains any buffered +// packets to the sinks, and waits for the writer goroutine to exit. +// It is safe to call multiple times. +func (s *Session) Stop() { + s.closeOnce.Do(func() { + s.closed.Store(true) + close(s.done) + }) + <-s.stopped +} + +// Done returns a channel that is closed when the session's writer goroutine +// has fully exited and all buffered packets have been flushed. +func (s *Session) Done() <-chan struct{} { + return s.stopped +} + +// Stats returns current capture counters. +func (s *Session) Stats() Stats { + return Stats{ + Packets: s.packets.Load(), + Bytes: s.bytes.Load(), + Dropped: s.dropped.Load(), + } +} + +func (s *Session) run() { + defer close(s.stopped) + + for { + select { + case pkt := <-s.ch: + s.write(pkt) + case <-s.done: + s.drain() + return + } + } +} + +func (s *Session) drain() { + for { + select { + case pkt := <-s.ch: + s.write(pkt) + default: + return + } + } +} + +func (s *Session) write(pkt packetEntry) { + if s.pcapW != nil { + // Best-effort: if the writer fails (broken pipe etc.), discard silently. + _ = s.pcapW.WritePacket(pkt.ts, pkt.data) + } + if s.textW != nil { + _ = s.textW.WritePacket(pkt.ts, pkt.data, pkt.dir) + } + s.flushFn() +} + +// buildFlushFn returns a function that flushes all writers that support it. +// This covers http.Flusher and similar streaming writers. +func buildFlushFn(writers ...any) func() { + type flusher interface { + Flush() + } + + var fns []func() + for _, w := range writers { + if w == nil { + continue + } + if f, ok := w.(flusher); ok { + fns = append(fns, f.Flush) + } + } + + switch len(fns) { + case 0: + return func() { + // no writers to flush + } + case 1: + return fns[0] + default: + return func() { + for _, fn := range fns { + fn() + } + } + } +} diff --git a/util/capture/session_test.go b/util/capture/session_test.go new file mode 100644 index 000000000..ab27686c6 --- /dev/null +++ b/util/capture/session_test.go @@ -0,0 +1,144 @@ +package capture + +import ( + "bytes" + "encoding/binary" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSession_PcapOutput(t *testing.T) { + var buf bytes.Buffer + sess, err := NewSession(Options{ + Output: &buf, + BufSize: 16, + }) + require.NoError(t, err) + + pkt := buildIPv4Packet(t, + netip.MustParseAddr("10.0.0.1"), + netip.MustParseAddr("10.0.0.2"), + protoTCP, 12345, 443) + + sess.Offer(pkt, true) + sess.Stop() + + data := buf.Bytes() + require.Greater(t, len(data), 24, "should have global header + at least one packet") + + // Verify global header + assert.Equal(t, uint32(pcapMagic), binary.LittleEndian.Uint32(data[0:4])) + assert.Equal(t, uint32(linkTypeRaw), binary.LittleEndian.Uint32(data[20:24])) + + // Verify packet record + pktData := data[24:] + inclLen := binary.LittleEndian.Uint32(pktData[8:12]) + assert.Equal(t, uint32(len(pkt)), inclLen) + + stats := sess.Stats() + assert.Equal(t, int64(1), stats.Packets) + assert.Equal(t, int64(len(pkt)), stats.Bytes) + assert.Equal(t, int64(0), stats.Dropped) +} + +func TestSession_TextOutput(t *testing.T) { + var buf bytes.Buffer + sess, err := NewSession(Options{ + TextOutput: &buf, + BufSize: 16, + }) + require.NoError(t, err) + + pkt := buildIPv4Packet(t, + netip.MustParseAddr("10.0.0.1"), + netip.MustParseAddr("10.0.0.2"), + protoTCP, 12345, 443) + + sess.Offer(pkt, false) + sess.Stop() + + output := buf.String() + assert.Contains(t, output, "TCP") + assert.Contains(t, output, "10.0.0.1") + assert.Contains(t, output, "10.0.0.2") + assert.Contains(t, output, "443") + assert.Contains(t, output, "[IN TCP]") +} + +func TestSession_Filter(t *testing.T) { + var buf bytes.Buffer + sess, err := NewSession(Options{ + Output: &buf, + Matcher: &Filter{Port: 443}, + }) + require.NoError(t, err) + + pktMatch := buildIPv4Packet(t, + netip.MustParseAddr("10.0.0.1"), + netip.MustParseAddr("10.0.0.2"), + protoTCP, 12345, 443) + pktNoMatch := buildIPv4Packet(t, + netip.MustParseAddr("10.0.0.1"), + netip.MustParseAddr("10.0.0.2"), + protoTCP, 12345, 80) + + sess.Offer(pktMatch, true) + sess.Offer(pktNoMatch, true) + sess.Stop() + + stats := sess.Stats() + assert.Equal(t, int64(1), stats.Packets, "only matching packet should be captured") +} + +func TestSession_StopIdempotent(t *testing.T) { + var buf bytes.Buffer + sess, err := NewSession(Options{Output: &buf}) + require.NoError(t, err) + + sess.Stop() + sess.Stop() // should not panic or deadlock +} + +func TestSession_OfferAfterStop(t *testing.T) { + var buf bytes.Buffer + sess, err := NewSession(Options{Output: &buf}) + require.NoError(t, err) + sess.Stop() + + pkt := buildIPv4Packet(t, + netip.MustParseAddr("10.0.0.1"), + netip.MustParseAddr("10.0.0.2"), + protoTCP, 12345, 443) + sess.Offer(pkt, true) // should not panic + + assert.Equal(t, int64(0), sess.Stats().Packets) +} + +func TestSession_Done(t *testing.T) { + var buf bytes.Buffer + sess, err := NewSession(Options{Output: &buf}) + require.NoError(t, err) + + select { + case <-sess.Done(): + t.Fatal("Done should not be closed before Stop") + default: + } + + sess.Stop() + + select { + case <-sess.Done(): + case <-time.After(time.Second): + t.Fatal("Done should be closed after Stop") + } +} + +func TestSession_RequiresOutput(t *testing.T) { + _, err := NewSession(Options{}) + assert.Error(t, err) +} diff --git a/util/capture/text.go b/util/capture/text.go new file mode 100644 index 000000000..b44bd0cad --- /dev/null +++ b/util/capture/text.go @@ -0,0 +1,638 @@ +package capture + +import ( + "encoding/binary" + "fmt" + "io" + "net/netip" + "strings" + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" +) + +// TextWriter writes human-readable one-line-per-packet summaries. +// It is not safe for concurrent use; callers must serialize access. +type TextWriter struct { + w io.Writer + verbose bool + ascii bool + flows map[dirKey]uint32 +} + +type dirKey struct { + src netip.AddrPort + dst netip.AddrPort +} + +// NewTextWriter creates a text formatter that writes to w. +func NewTextWriter(w io.Writer, verbose, ascii bool) *TextWriter { + return &TextWriter{ + w: w, + verbose: verbose, + ascii: ascii, + flows: make(map[dirKey]uint32), + } +} + +// tag formats the fixed-width "[DIR PROTO]" prefix with right-aligned protocol. +func tag(dir Direction, proto string) string { + return fmt.Sprintf("[%-3s %4s]", dir, proto) +} + +// WritePacket formats and writes a single packet line. +func (tw *TextWriter) WritePacket(ts time.Time, data []byte, dir Direction) error { + ts = ts.Local() + info, ok := parsePacketInfo(data) + if !ok { + _, err := fmt.Fprintf(tw.w, "%s [%-3s ?] ??? len=%d\n", + ts.Format("15:04:05.000000"), dir, len(data)) + return err + } + + timeStr := ts.Format("15:04:05.000000") + + var err error + switch info.proto { + case protoTCP: + err = tw.writeTCP(timeStr, dir, &info, data) + case protoUDP: + err = tw.writeUDP(timeStr, dir, &info, data) + case protoICMP: + err = tw.writeICMPv4(timeStr, dir, &info, data) + case protoICMPv6: + err = tw.writeICMPv6(timeStr, dir, &info, data) + default: + var verbose string + if tw.verbose { + verbose = tw.verboseIP(data, info.family) + } + _, err = fmt.Fprintf(tw.w, "%s %s %s > %s length %d%s\n", + timeStr, tag(dir, fmt.Sprintf("P%d", info.proto)), + info.srcIP, info.dstIP, len(data)-info.hdrLen, verbose) + } + return err +} + +func (tw *TextWriter) writeTCP(timeStr string, dir Direction, info *packetInfo, data []byte) error { + tcp := &layers.TCP{} + if err := tcp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil { + return tw.writeFallback(timeStr, dir, "TCP", info, data) + } + + flags := tcpFlagsStr(tcp) + plen := len(tcp.Payload) + + // Protocol annotation + var annotation string + if plen > 0 { + annotation = annotatePayload(tcp.Payload) + } + + if !tw.verbose { + _, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s] length %d%s\n", + timeStr, tag(dir, "TCP"), + info.srcIP, info.srcPort, info.dstIP, info.dstPort, + flags, plen, annotation) + if err != nil { + return err + } + if tw.ascii && plen > 0 { + return tw.writeASCII(tcp.Payload) + } + return nil + } + + relSeq, relAck := tw.relativeSeqAck(info, tcp.Seq, tcp.Ack) + + var seqStr string + if plen > 0 { + seqStr = fmt.Sprintf(", seq %d:%d", relSeq, relSeq+uint32(plen)) + } else { + seqStr = fmt.Sprintf(", seq %d", relSeq) + } + + var ackStr string + if tcp.ACK { + ackStr = fmt.Sprintf(", ack %d", relAck) + } + + var opts string + if s := formatTCPOptions(tcp.Options); s != "" { + opts = ", options [" + s + "]" + } + + verbose := tw.verboseIP(data, info.family) + + _, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s]%s%s, win %d%s, length %d%s%s\n", + timeStr, tag(dir, "TCP"), + info.srcIP, info.srcPort, info.dstIP, info.dstPort, + flags, seqStr, ackStr, tcp.Window, opts, plen, annotation, verbose) + if err != nil { + return err + } + if tw.ascii && plen > 0 { + return tw.writeASCII(tcp.Payload) + } + return nil +} + +func (tw *TextWriter) writeUDP(timeStr string, dir Direction, info *packetInfo, data []byte) error { + udp := &layers.UDP{} + if err := udp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil { + return tw.writeFallback(timeStr, dir, "UDP", info, data) + } + + plen := len(udp.Payload) + + // DNS replaces the entire line format + if plen > 0 && isDNSPort(info.srcPort, info.dstPort) { + if s := formatDNSPayload(udp.Payload); s != "" { + var verbose string + if tw.verbose { + verbose = tw.verboseIP(data, info.family) + } + _, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d %s%s\n", + timeStr, tag(dir, "UDP"), + info.srcIP, info.srcPort, info.dstIP, info.dstPort, + s, verbose) + return err + } + } + + var verbose string + if tw.verbose { + verbose = tw.verboseIP(data, info.family) + } + _, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d%s\n", + timeStr, tag(dir, "UDP"), + info.srcIP, info.srcPort, info.dstIP, info.dstPort, + plen, verbose) + if err != nil { + return err + } + if tw.ascii && plen > 0 { + return tw.writeASCII(udp.Payload) + } + return nil +} + +func (tw *TextWriter) writeICMPv4(timeStr string, dir Direction, info *packetInfo, data []byte) error { + icmp := &layers.ICMPv4{} + if err := icmp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil { + return tw.writeFallback(timeStr, dir, "ICMP", info, data) + } + + var detail string + if icmp.TypeCode.Type() == layers.ICMPv4TypeEchoRequest || icmp.TypeCode.Type() == layers.ICMPv4TypeEchoReply { + detail = fmt.Sprintf("%s, id %d, seq %d", icmp.TypeCode.String(), icmp.Id, icmp.Seq) + } else { + detail = icmp.TypeCode.String() + } + + var verbose string + if tw.verbose { + verbose = tw.verboseIP(data, info.family) + } + _, err := fmt.Fprintf(tw.w, "%s %s %s > %s %s, length %d%s\n", + timeStr, tag(dir, "ICMP"), info.srcIP, info.dstIP, detail, len(data)-info.hdrLen, verbose) + return err +} + +func (tw *TextWriter) writeICMPv6(timeStr string, dir Direction, info *packetInfo, data []byte) error { + icmp := &layers.ICMPv6{} + if err := icmp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil { + return tw.writeFallback(timeStr, dir, "ICMP", info, data) + } + + var verbose string + if tw.verbose { + verbose = tw.verboseIP(data, info.family) + } + _, err := fmt.Fprintf(tw.w, "%s %s %s > %s %s, length %d%s\n", + timeStr, tag(dir, "ICMP"), info.srcIP, info.dstIP, icmp.TypeCode.String(), len(data)-info.hdrLen, verbose) + return err +} + +func (tw *TextWriter) writeFallback(timeStr string, dir Direction, proto string, info *packetInfo, data []byte) error { + _, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d\n", + timeStr, tag(dir, proto), + info.srcIP, info.srcPort, info.dstIP, info.dstPort, + len(data)-info.hdrLen) + return err +} + +func (tw *TextWriter) verboseIP(data []byte, family uint8) string { + return fmt.Sprintf(", ttl %d, id %d, iplen %d", + ipTTL(data, family), ipID(data, family), len(data)) +} + +// relativeSeqAck returns seq/ack relative to the first seen value per direction. +func (tw *TextWriter) relativeSeqAck(info *packetInfo, seq, ack uint32) (relSeq, relAck uint32) { + fwd := dirKey{ + src: netip.AddrPortFrom(info.srcIP, info.srcPort), + dst: netip.AddrPortFrom(info.dstIP, info.dstPort), + } + rev := dirKey{ + src: netip.AddrPortFrom(info.dstIP, info.dstPort), + dst: netip.AddrPortFrom(info.srcIP, info.srcPort), + } + + if isn, ok := tw.flows[fwd]; ok { + relSeq = seq - isn + } else { + tw.flows[fwd] = seq + } + + if isn, ok := tw.flows[rev]; ok { + relAck = ack - isn + } else { + relAck = ack + } + + return relSeq, relAck +} + +// writeASCII prints payload bytes as printable ASCII. +func (tw *TextWriter) writeASCII(payload []byte) error { + if len(payload) == 0 { + return nil + } + buf := make([]byte, len(payload)) + for i, b := range payload { + switch { + case b >= 0x20 && b < 0x7f: + buf[i] = b + case b == '\n' || b == '\r' || b == '\t': + buf[i] = b + default: + buf[i] = '.' + } + } + _, err := fmt.Fprintf(tw.w, "%s\n", buf) + return err +} + +// --- TCP helpers --- + +func ipTTL(data []byte, family uint8) uint8 { + if family == 4 && len(data) > 8 { + return data[8] + } + if family == 6 && len(data) > 7 { + return data[7] + } + return 0 +} + +func ipID(data []byte, family uint8) uint16 { + if family == 4 && len(data) >= 6 { + return binary.BigEndian.Uint16(data[4:6]) + } + return 0 +} + +func tcpFlagsStr(tcp *layers.TCP) string { + var buf [6]byte + n := 0 + if tcp.SYN { + buf[n] = 'S' + n++ + } + if tcp.FIN { + buf[n] = 'F' + n++ + } + if tcp.RST { + buf[n] = 'R' + n++ + } + if tcp.PSH { + buf[n] = 'P' + n++ + } + if tcp.ACK { + buf[n] = '.' + n++ + } + if tcp.URG { + buf[n] = 'U' + n++ + } + if n == 0 { + return "none" + } + return string(buf[:n]) +} + +func formatTCPOptions(opts []layers.TCPOption) string { + var parts []string + for _, opt := range opts { + switch opt.OptionType { + case layers.TCPOptionKindEndList: + return strings.Join(parts, ",") + case layers.TCPOptionKindNop: + parts = append(parts, "nop") + case layers.TCPOptionKindMSS: + if len(opt.OptionData) == 2 { + parts = append(parts, fmt.Sprintf("mss %d", binary.BigEndian.Uint16(opt.OptionData))) + } + case layers.TCPOptionKindWindowScale: + if len(opt.OptionData) == 1 { + parts = append(parts, fmt.Sprintf("wscale %d", opt.OptionData[0])) + } + case layers.TCPOptionKindSACKPermitted: + parts = append(parts, "sackOK") + case layers.TCPOptionKindSACK: + blocks := len(opt.OptionData) / 8 + parts = append(parts, fmt.Sprintf("sack %d", blocks)) + case layers.TCPOptionKindTimestamps: + if len(opt.OptionData) == 8 { + tsval := binary.BigEndian.Uint32(opt.OptionData[0:4]) + tsecr := binary.BigEndian.Uint32(opt.OptionData[4:8]) + parts = append(parts, fmt.Sprintf("TS val %d ecr %d", tsval, tsecr)) + } + } + } + return strings.Join(parts, ",") +} + +// --- Protocol annotation --- + +// annotatePayload returns a protocol annotation string for known application protocols. +func annotatePayload(payload []byte) string { + if len(payload) < 4 { + return "" + } + + s := string(payload) + + // SSH banner: "SSH-2.0-OpenSSH_9.6\r\n" + if strings.HasPrefix(s, "SSH-") { + if end := strings.IndexByte(s, '\r'); end > 0 && end < 256 { + return ": " + s[:end] + } + } + + // TLS records + if ann := annotateTLS(payload); ann != "" { + return ": " + ann + } + + // HTTP request or response + for _, method := range [...]string{"GET ", "POST ", "PUT ", "DELETE ", "HEAD ", "PATCH ", "OPTIONS ", "CONNECT "} { + if strings.HasPrefix(s, method) { + if end := strings.IndexByte(s, '\r'); end > 0 && end < 200 { + return ": " + s[:end] + } + } + } + if strings.HasPrefix(s, "HTTP/") { + if end := strings.IndexByte(s, '\r'); end > 0 && end < 200 { + return ": " + s[:end] + } + } + + return "" +} + +// annotateTLS returns a description for TLS handshake and alert records. +func annotateTLS(data []byte) string { + if len(data) < 6 { + return "" + } + + switch data[0] { + case 0x16: + return annotateTLSHandshake(data) + case 0x15: + return annotateTLSAlert(data) + } + return "" +} + +func annotateTLSHandshake(data []byte) string { + if len(data) < 10 { + return "" + } + switch data[5] { + case 0x01: + if sni := extractSNI(data); sni != "" { + return "TLS ClientHello SNI=" + sni + } + return "TLS ClientHello" + case 0x02: + return "TLS ServerHello" + } + return "" +} + +func annotateTLSAlert(data []byte) string { + if len(data) < 7 { + return "" + } + severity := "warning" + if data[5] == 2 { + severity = "fatal" + } + return fmt.Sprintf("TLS Alert %s %s", severity, tlsAlertDesc(data[6])) +} + +func tlsAlertDesc(code byte) string { + switch code { + case 0: + return "close_notify" + case 10: + return "unexpected_message" + case 40: + return "handshake_failure" + case 42: + return "bad_certificate" + case 43: + return "unsupported_certificate" + case 44: + return "certificate_revoked" + case 45: + return "certificate_expired" + case 48: + return "unknown_ca" + case 49: + return "access_denied" + case 50: + return "decode_error" + case 70: + return "protocol_version" + case 80: + return "internal_error" + case 86: + return "inappropriate_fallback" + case 90: + return "user_canceled" + case 112: + return "unrecognized_name" + default: + return fmt.Sprintf("alert(%d)", code) + } +} + +// extractSNI parses a TLS ClientHello and returns the SNI server name. +func extractSNI(data []byte) string { + if len(data) < 6 || data[0] != 0x16 { + return "" + } + recordLen := int(binary.BigEndian.Uint16(data[3:5])) + handshake := data[5:] + if len(handshake) > recordLen { + handshake = handshake[:recordLen] + } + + if len(handshake) < 4 || handshake[0] != 0x01 { + return "" + } + hsLen := int(handshake[1])<<16 | int(handshake[2])<<8 | int(handshake[3]) + body := handshake[4:] + if len(body) > hsLen { + body = body[:hsLen] + } + + extPos := clientHelloExtensionsOffset(body) + if extPos < 0 { + return "" + } + return findSNIExtension(body, extPos) +} + +// clientHelloExtensionsOffset returns the byte offset where extensions begin +// within the ClientHello body, or -1 if the body is too short. +func clientHelloExtensionsOffset(body []byte) int { + if len(body) < 38 { + return -1 + } + pos := 34 + + if pos >= len(body) { + return -1 + } + pos += 1 + int(body[pos]) // session ID + + if pos+2 > len(body) { + return -1 + } + pos += 2 + int(binary.BigEndian.Uint16(body[pos:pos+2])) // cipher suites + + if pos >= len(body) { + return -1 + } + pos += 1 + int(body[pos]) // compression methods + + if pos+2 > len(body) { + return -1 + } + return pos +} + +func findSNIExtension(body []byte, pos int) string { + extLen := int(binary.BigEndian.Uint16(body[pos : pos+2])) + pos += 2 + extEnd := pos + extLen + if extEnd > len(body) { + extEnd = len(body) + } + + for pos+4 <= extEnd { + extType := binary.BigEndian.Uint16(body[pos : pos+2]) + eLen := int(binary.BigEndian.Uint16(body[pos+2 : pos+4])) + pos += 4 + if pos+eLen > extEnd { + break + } + if extType == 0 && eLen >= 5 { + nameLen := int(binary.BigEndian.Uint16(body[pos+3 : pos+5])) + if pos+5+nameLen <= extEnd { + return string(body[pos+5 : pos+5+nameLen]) + } + } + pos += eLen + } + return "" +} + +func isDNSPort(src, dst uint16) bool { + return src == 53 || dst == 53 || src == 5353 || dst == 5353 +} + +// formatDNSPayload parses DNS and returns a tcpdump-style summary. +func formatDNSPayload(payload []byte) string { + d := &layers.DNS{} + if err := d.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil { + return "" + } + + rd := "" + if d.RD { + rd = "+" + } + + if !d.QR { + return formatDNSQuery(d, rd, len(payload)) + } + return formatDNSResponse(d, rd, len(payload)) +} + +func formatDNSQuery(d *layers.DNS, rd string, plen int) string { + if len(d.Questions) == 0 { + return fmt.Sprintf("%04x%s (%d)", d.ID, rd, plen) + } + q := d.Questions[0] + return fmt.Sprintf("%04x%s %s? %s. (%d)", d.ID, rd, q.Type, q.Name, plen) +} + +func formatDNSResponse(d *layers.DNS, rd string, plen int) string { + anCount := d.ANCount + nsCount := d.NSCount + arCount := d.ARCount + + if d.ResponseCode != layers.DNSResponseCodeNoErr { + return fmt.Sprintf("%04x %d/%d/%d %s (%d)", d.ID, anCount, nsCount, arCount, d.ResponseCode, plen) + } + + if anCount > 0 && len(d.Answers) > 0 { + rr := d.Answers[0] + if rdata := shortRData(&rr); rdata != "" { + return fmt.Sprintf("%04x %d/%d/%d %s %s (%d)", d.ID, anCount, nsCount, arCount, rr.Type, rdata, plen) + } + } + + return fmt.Sprintf("%04x %d/%d/%d (%d)", d.ID, anCount, nsCount, arCount, plen) +} + +func shortRData(rr *layers.DNSResourceRecord) string { + switch rr.Type { + case layers.DNSTypeA, layers.DNSTypeAAAA: + if rr.IP != nil { + return rr.IP.String() + } + case layers.DNSTypeCNAME: + if len(rr.CNAME) > 0 { + return string(rr.CNAME) + "." + } + case layers.DNSTypePTR: + if len(rr.PTR) > 0 { + return string(rr.PTR) + "." + } + case layers.DNSTypeNS: + if len(rr.NS) > 0 { + return string(rr.NS) + "." + } + case layers.DNSTypeMX: + return fmt.Sprintf("%d %s.", rr.MX.Preference, rr.MX.Name) + case layers.DNSTypeTXT: + if len(rr.TXTs) > 0 { + return fmt.Sprintf("%q", string(rr.TXTs[0])) + } + case layers.DNSTypeSRV: + return fmt.Sprintf("%d %d %d %s.", rr.SRV.Priority, rr.SRV.Weight, rr.SRV.Port, rr.SRV.Name) + } + return "" +} From 50b58a682868851a3666a99662aeb00d7fbb3846 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 4 May 2026 18:40:25 +0900 Subject: [PATCH 14/80] [client, relay] Advertise relay server IP via signal for foreign-relay fallback dial (#6004) --- client/internal/engine.go | 18 ++ client/internal/peer/handshaker.go | 8 +- client/internal/peer/signaler.go | 20 +- client/internal/peer/status.go | 2 +- client/internal/peer/worker_relay.go | 11 +- shared/relay/client/client.go | 116 +++++++- shared/relay/client/client_serverip_test.go | 280 ++++++++++++++++++ shared/relay/client/dialer/quic/quic.go | 15 +- shared/relay/client/dialer/race_dialer.go | 17 +- .../relay/client/dialer/race_dialer_test.go | 2 +- shared/relay/client/dialer/ws/conn.go | 16 +- .../client/dialer/ws/dialopts_generic.go | 10 +- shared/relay/client/dialer/ws/dialopts_js.go | 10 +- shared/relay/client/dialer/ws/ws.go | 21 +- shared/relay/client/manager.go | 37 ++- shared/relay/client/manager_serverip_test.go | 144 +++++++++ shared/relay/client/manager_test.go | 19 +- shared/signal/client/client.go | 69 +++-- shared/signal/proto/signalexchange.pb.go | 88 +++--- shared/signal/proto/signalexchange.proto | 10 +- 20 files changed, 789 insertions(+), 124 deletions(-) create mode 100644 shared/relay/client/client_serverip_test.go create mode 100644 shared/relay/client/manager_serverip_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index 8c9553e52..7f19e2d28 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -2454,6 +2454,8 @@ func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) { } } + relayIP := decodeRelayIP(msg.GetBody().GetRelayServerIP()) + offerAnswer := peer.OfferAnswer{ IceCredentials: peer.IceCredentials{ UFrag: remoteCred.UFrag, @@ -2464,7 +2466,23 @@ func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) { RosenpassPubKey: rosenpassPubKey, RosenpassAddr: rosenpassAddr, RelaySrvAddress: msg.GetBody().GetRelayServerAddress(), + RelaySrvIP: relayIP, SessionID: sessionID, } return &offerAnswer, nil } + +// decodeRelayIP decodes the proto relayServerIP bytes (4 or 16) into a +// netip.Addr. Returns the zero value for empty input and logs a warning +// for malformed payloads. +func decodeRelayIP(b []byte) netip.Addr { + if len(b) == 0 { + return netip.Addr{} + } + ip, ok := netip.AddrFromSlice(b) + if !ok { + log.Warnf("invalid relayServerIP in signal message (%d bytes), ignoring", len(b)) + return netip.Addr{} + } + return ip.Unmap() +} diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index 741dfce60..1d44096b6 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -3,6 +3,7 @@ package peer import ( "context" "errors" + "net/netip" "sync" "sync/atomic" @@ -40,6 +41,10 @@ type OfferAnswer struct { // relay server address RelaySrvAddress string + // RelaySrvIP is the IP the remote peer is connected to on its + // relay server. Used as a dial target if DNS for RelaySrvAddress + // fails. Zero value if the peer did not advertise an IP. + RelaySrvIP netip.Addr // SessionID is the unique identifier of the session, used to discard old messages SessionID *ICESessionID } @@ -217,8 +222,9 @@ func (h *Handshaker) buildOfferAnswer() OfferAnswer { answer.SessionID = &sid } - if addr, err := h.relay.RelayInstanceAddress(); err == nil { + if addr, ip, err := h.relay.RelayInstanceAddress(); err == nil { answer.RelaySrvAddress = addr + answer.RelaySrvIP = ip } return answer diff --git a/client/internal/peer/signaler.go b/client/internal/peer/signaler.go index f6eb87cca..5e437d96b 100644 --- a/client/internal/peer/signaler.go +++ b/client/internal/peer/signaler.go @@ -54,19 +54,19 @@ func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string, log.Warnf("failed to get session ID bytes: %v", err) } } - msg, err := signal.MarshalCredential( - s.wgPrivateKey, - offerAnswer.WgListenPort, - remoteKey, - &signal.Credential{ + msg, err := signal.MarshalCredential(s.wgPrivateKey, remoteKey, signal.CredentialPayload{ + Type: bodyType, + WgListenPort: offerAnswer.WgListenPort, + Credential: &signal.Credential{ UFrag: offerAnswer.IceCredentials.UFrag, Pwd: offerAnswer.IceCredentials.Pwd, }, - bodyType, - offerAnswer.RosenpassPubKey, - offerAnswer.RosenpassAddr, - offerAnswer.RelaySrvAddress, - sessionIDBytes) + RosenpassPubKey: offerAnswer.RosenpassPubKey, + RosenpassAddr: offerAnswer.RosenpassAddr, + RelaySrvAddress: offerAnswer.RelaySrvAddress, + RelaySrvIP: offerAnswer.RelaySrvIP, + SessionID: sessionIDBytes, + }) if err != nil { return err } diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index abedc208e..7bd19b0e1 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -919,7 +919,7 @@ func (d *Status) GetRelayStates() []relay.ProbeResult { // if the server connection is not established then we will use the general address // in case of connection we will use the instance specific address - instanceAddr, err := d.relayMgr.RelayInstanceAddress() + instanceAddr, _, err := d.relayMgr.RelayInstanceAddress() if err != nil { // TODO add their status for _, r := range d.relayMgr.ServerURLs() { diff --git a/client/internal/peer/worker_relay.go b/client/internal/peer/worker_relay.go index 06309fbaf..0402992c9 100644 --- a/client/internal/peer/worker_relay.go +++ b/client/internal/peer/worker_relay.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net" + "net/netip" "sync" "sync/atomic" @@ -53,15 +54,19 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.relaySupportedOnRemotePeer.Store(true) // the relayManager will return with error in case if the connection has lost with relay server - currentRelayAddress, err := w.relayManager.RelayInstanceAddress() + currentRelayAddress, _, err := w.relayManager.RelayInstanceAddress() if err != nil { w.log.Errorf("failed to handle new offer: %s", err) return } srv := w.preferredRelayServer(currentRelayAddress, remoteOfferAnswer.RelaySrvAddress) + var serverIP netip.Addr + if srv == remoteOfferAnswer.RelaySrvAddress { + serverIP = remoteOfferAnswer.RelaySrvIP + } - relayedConn, err := w.relayManager.OpenConn(w.peerCtx, srv, w.config.Key) + relayedConn, err := w.relayManager.OpenConn(w.peerCtx, srv, w.config.Key, serverIP) if err != nil { if errors.Is(err, relayClient.ErrConnAlreadyExists) { w.log.Debugf("handled offer by reusing existing relay connection") @@ -90,7 +95,7 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) { }) } -func (w *WorkerRelay) RelayInstanceAddress() (string, error) { +func (w *WorkerRelay) RelayInstanceAddress() (string, netip.Addr, error) { return w.relayManager.RelayInstanceAddress() } diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go index b10b05617..1800bddb2 100644 --- a/shared/relay/client/client.go +++ b/shared/relay/client/client.go @@ -2,8 +2,12 @@ package client import ( "context" + "errors" "fmt" "net" + "net/netip" + "net/url" + "strings" "sync" "time" @@ -146,6 +150,7 @@ func (cc *connContainer) close() { type Client struct { log *log.Entry connectionURL string + serverIP netip.Addr authTokenStore *auth.TokenStore hashedID messages.PeerID @@ -170,13 +175,22 @@ type Client struct { } // NewClient creates a new client for the relay server. The client is not connected to the server until the Connect +// is called. func NewClient(serverURL string, authTokenStore *auth.TokenStore, peerID string, mtu uint16) *Client { + return NewClientWithServerIP(serverURL, netip.Addr{}, authTokenStore, peerID, mtu) +} + +// NewClientWithServerIP creates a new client for the relay server with a known server IP. serverIP, when valid, is +// dialed directly first; the FQDN is only attempted if the IP-based dial fails. TLS verification still uses the +// FQDN from serverURL via SNI. +func NewClientWithServerIP(serverURL string, serverIP netip.Addr, authTokenStore *auth.TokenStore, peerID string, mtu uint16) *Client { hashedID := messages.HashID(peerID) relayLog := log.WithFields(log.Fields{"relay": serverURL}) c := &Client{ log: relayLog, connectionURL: serverURL, + serverIP: serverIP, authTokenStore: authTokenStore, hashedID: hashedID, mtu: mtu, @@ -304,6 +318,23 @@ func (c *Client) ServerInstanceURL() (string, error) { return c.instanceURL.String(), nil } +// ConnectedIP returns the IP address of the live relay-server connection, +// extracted from the underlying socket's RemoteAddr. Zero value if not +// connected or if the address is not an IP literal. +func (c *Client) ConnectedIP() netip.Addr { + c.mu.Lock() + conn := c.relayConn + c.mu.Unlock() + if conn == nil { + return netip.Addr{} + } + addr := conn.RemoteAddr() + if addr == nil { + return netip.Addr{} + } + return extractIPLiteral(addr.String()) +} + // SetOnDisconnectListener sets a function that will be called when the connection to the relay server is closed. func (c *Client) SetOnDisconnectListener(fn func(string)) { c.listenerMutex.Lock() @@ -332,10 +363,23 @@ func (c *Client) Close() error { func (c *Client) connect(ctx context.Context) (*RelayAddr, error) { dialers := c.getDialers() - rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, c.connectionURL, dialers...) - conn, err := rd.Dial(ctx) - if err != nil { - return nil, err + var conn net.Conn + if c.serverIP.IsValid() { + var err error + conn, err = c.dialRaceDirect(ctx, dialers) + if err != nil { + c.log.Infof("dial via server IP %s failed, falling back to FQDN: %v", c.serverIP, err) + conn = nil + } + } + + if conn == nil { + rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, c.connectionURL, dialers...) + var err error + conn, err = rd.Dial(ctx) + if err != nil { + return nil, fmt.Errorf("dial via FQDN: %w", err) + } } c.relayConn = conn @@ -351,6 +395,52 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) { return instanceURL, nil } +// dialRaceDirect dials c.serverIP, preserving the original FQDN as the TLS ServerName for SNI. +func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) (net.Conn, error) { + directURL, serverName, err := substituteHost(c.connectionURL, c.serverIP) + if err != nil { + return nil, fmt.Errorf("substitute host: %w", err) + } + + c.log.Debugf("dialing via server IP %s (SNI=%s)", c.serverIP, serverName) + + rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, directURL, dialers...). + WithServerName(serverName) + return rd.Dial(ctx) +} + +// substituteHost replaces the host portion of a rel/rels URL with ip, +// preserving the scheme and port. Returns the rewritten URL and the +// original host to use as the TLS ServerName, or empty if the original +// host is itself an IP literal (SNI requires a DNS name). +func substituteHost(serverURL string, ip netip.Addr) (string, string, error) { + u, err := url.Parse(serverURL) + if err != nil { + return "", "", fmt.Errorf("parse %q: %w", serverURL, err) + } + if u.Scheme == "" || u.Host == "" { + return "", "", fmt.Errorf("invalid relay URL %q", serverURL) + } + if !ip.IsValid() { + return "", "", errors.New("invalid server IP") + } + origHost := u.Hostname() + if _, err := netip.ParseAddr(origHost); err == nil { + origHost = "" + } + ip = ip.Unmap() + newHost := ip.String() + if ip.Is6() { + newHost = "[" + newHost + "]" + } + if port := u.Port(); port != "" { + u.Host = newHost + ":" + port + } else { + u.Host = newHost + } + return u.String(), origHost, nil +} + func (c *Client) handShake(ctx context.Context) (*RelayAddr, error) { msg, err := messages.MarshalAuthMsg(c.hashedID, c.authTokenStore.TokenBinary()) if err != nil { @@ -716,3 +806,21 @@ func (c *Client) handlePeersWentOfflineMsg(buf []byte) { } c.stateSubscription.OnPeersWentOffline(peersID) } + +// extractIPLiteral returns the IP from address forms produced by the relay +// dialers (URL or host:port). Zero value if the host is not an IP. +func extractIPLiteral(s string) netip.Addr { + if u, err := url.Parse(s); err == nil && u.Host != "" { + s = u.Host + } + host, _, err := net.SplitHostPort(s) + if err != nil { + host = s + } + host = strings.Trim(host, "[]") + ip, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{} + } + return ip.Unmap() +} diff --git a/shared/relay/client/client_serverip_test.go b/shared/relay/client/client_serverip_test.go new file mode 100644 index 000000000..7e699e37d --- /dev/null +++ b/shared/relay/client/client_serverip_test.go @@ -0,0 +1,280 @@ +package client + +import ( + "context" + "fmt" + "net" + "net/netip" + "testing" + "time" + + "go.opentelemetry.io/otel" + + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/relay/server" + "github.com/netbirdio/netbird/shared/relay/auth/allow" +) + +// TestClient_ServerIPRecoversFromUnresolvableFQDN verifies that when the +// primary FQDN-based dial fails (unresolvable .invalid host), Connect +// recovers via the server IP and SNI still uses the FQDN. +func TestClient_ServerIPRecoversFromUnresolvableFQDN(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + listenAddr, port := freeAddr(t) + srvCfg := server.Config{ + Meter: otel.Meter(""), + ExposedAddress: fmt.Sprintf("rel://test-unresolvable-host.invalid:%d", port), + TLSSupport: false, + AuthValidator: &allow.Auth{}, + } + srv, err := server.NewServer(srvCfg) + if err != nil { + t.Fatalf("create server: %s", err) + } + + errChan := make(chan error, 1) + go func() { + if err := srv.Listen(server.ListenerConfig{Address: listenAddr}); err != nil { + errChan <- err + } + }() + t.Cleanup(func() { + if err := srv.Shutdown(context.Background()); err != nil { + t.Errorf("shutdown server: %s", err) + } + }) + if err := waitForServerToStart(errChan); err != nil { + t.Fatalf("server failed to start: %s", err) + } + + t.Run("no server IP, primary fails", func(t *testing.T) { + c := NewClient(srvCfg.ExposedAddress, hmacTokenStore, "alice-noip", iface.DefaultMTU) + err := c.Connect(ctx) + if err == nil { + _ = c.Close() + t.Fatalf("expected connect to fail without server IP, got nil") + } + }) + + t.Run("server IP recovers", func(t *testing.T) { + c := NewClientWithServerIP(srvCfg.ExposedAddress, netip.MustParseAddr("127.0.0.1"), hmacTokenStore, "alice-with-ip", iface.DefaultMTU) + if err := c.Connect(ctx); err != nil { + t.Fatalf("connect with server IP: %s", err) + } + t.Cleanup(func() { _ = c.Close() }) + + if !c.Ready() { + t.Fatalf("client not ready after connect") + } + if got := c.ConnectedIP(); got.String() != "127.0.0.1" { + t.Fatalf("ConnectedIP = %q, want 127.0.0.1", got) + } + }) +} + +// TestClient_ConnectedIPAfterFQDNDial verifies ConnectedIP returns the +// resolved IP after a successful FQDN-based dial. The underlying socket's +// RemoteAddr must be exposed through the dialer wrappers; if it returns +// the dial-time URL instead, ConnectedIP returns empty and the dial +// IP we advertise to peers is empty too. +func TestClient_ConnectedIPAfterFQDNDial(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + listenAddr, port := freeAddr(t) + srvCfg := server.Config{ + Meter: otel.Meter(""), + ExposedAddress: fmt.Sprintf("rel://localhost:%d", port), + TLSSupport: false, + AuthValidator: &allow.Auth{}, + } + srv, err := server.NewServer(srvCfg) + if err != nil { + t.Fatalf("create server: %s", err) + } + errChan := make(chan error, 1) + go func() { + if err := srv.Listen(server.ListenerConfig{Address: listenAddr}); err != nil { + errChan <- err + } + }() + t.Cleanup(func() { _ = srv.Shutdown(context.Background()) }) + if err := waitForServerToStart(errChan); err != nil { + t.Fatalf("server failed to start: %s", err) + } + + c := NewClient(srvCfg.ExposedAddress, hmacTokenStore, "alice-fqdn", iface.DefaultMTU) + if err := c.Connect(ctx); err != nil { + t.Fatalf("connect: %s", err) + } + t.Cleanup(func() { _ = c.Close() }) + + got := c.ConnectedIP().String() + if got != "127.0.0.1" && got != "::1" { + t.Fatalf("ConnectedIP after FQDN dial = %q, want 127.0.0.1 or ::1", got) + } +} + +func TestSubstituteHost(t *testing.T) { + tests := []struct { + name string + serverURL string + ip string + wantURL string + wantServerName string + wantErr bool + }{ + { + name: "rels with port", + serverURL: "rels://relay.netbird.io:443", + ip: "10.0.0.5", + wantURL: "rels://10.0.0.5:443", + wantServerName: "relay.netbird.io", + }, + { + name: "rel with port", + serverURL: "rel://relay.example.com:80", + ip: "192.0.2.1", + wantURL: "rel://192.0.2.1:80", + wantServerName: "relay.example.com", + }, + { + name: "ipv6 server IP bracketed", + serverURL: "rels://relay.example.com:443", + ip: "2001:db8::1", + wantURL: "rels://[2001:db8::1]:443", + wantServerName: "relay.example.com", + }, + { + name: "no port", + serverURL: "rels://relay.example.com", + ip: "10.0.0.5", + wantURL: "rels://10.0.0.5", + wantServerName: "relay.example.com", + }, + { + name: "ipv6 server with port returns empty SNI", + serverURL: "rels://[2001:db8::5]:443", + ip: "10.0.0.5", + wantURL: "rels://10.0.0.5:443", + wantServerName: "", + }, + { + name: "ipv4 server with port returns empty SNI", + serverURL: "rels://10.0.0.5:443", + ip: "10.0.0.6", + wantURL: "rels://10.0.0.6:443", + wantServerName: "", + }, + { + name: "ipv6 server IP no port", + serverURL: "rels://relay.example.com", + ip: "2001:db8::1", + wantURL: "rels://[2001:db8::1]", + wantServerName: "relay.example.com", + }, + { + name: "missing scheme", + serverURL: "relay.example.com:443", + ip: "10.0.0.5", + wantErr: true, + }, + { + name: "empty", + serverURL: "", + ip: "10.0.0.5", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ip netip.Addr + if tt.ip != "" { + ip = netip.MustParseAddr(tt.ip) + } + gotURL, gotName, err := substituteHost(tt.serverURL, ip) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if gotURL != tt.wantURL { + t.Errorf("URL = %q, want %q", gotURL, tt.wantURL) + } + if gotName != tt.wantServerName { + t.Errorf("ServerName = %q, want %q", gotName, tt.wantServerName) + } + }) + } +} + +func TestClient_ConnectedIPEmptyWhenNotConnected(t *testing.T) { + c := NewClient("rel://example.invalid:80", hmacTokenStore, "x", iface.DefaultMTU) + if got := c.ConnectedIP(); got.IsValid() { + t.Fatalf("ConnectedIP on disconnected client = %q, want zero", got) + } +} + +// staticAddr is a net.Addr that returns a fixed string. Used to verify +// ConnectedIP parses RemoteAddr correctly. +type staticAddr struct{ s string } + +func (a staticAddr) Network() string { return "tcp" } +func (a staticAddr) String() string { return a.s } + +type stubConn struct { + net.Conn + remote net.Addr +} + +func (s stubConn) RemoteAddr() net.Addr { return s.remote } + +func TestClient_ConnectedIPParsesRemoteAddr(t *testing.T) { + tests := []struct { + name string + s string + want string + }{ + {"hostport ipv4", "127.0.0.1:50301", "127.0.0.1"}, + {"hostport ipv6 bracketed", "[::1]:50301", "::1"}, + {"url with ipv4", "rel://127.0.0.1:50301", "127.0.0.1"}, + {"url with ipv6", "rels://[2001:db8::1]:443", "2001:db8::1"}, + {"fqdn url returns empty", "rel://relay.example.com:50301", ""}, + {"fqdn hostport returns empty", "relay.example.com:50301", ""}, + {"plain ipv4 no port", "10.0.0.1", "10.0.0.1"}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{relayConn: stubConn{remote: staticAddr{s: tt.s}}} + got := c.ConnectedIP() + var gotStr string + if got.IsValid() { + gotStr = got.String() + } + if gotStr != tt.want { + t.Errorf("ConnectedIP(%q) = %q, want %q", tt.s, gotStr, tt.want) + } + }) + } +} + +// freeAddr returns a 127.0.0.1 address with an OS-assigned port. The +// listener is closed before returning, so the port is briefly free for +// the caller to bind. Avoids hardcoded ports that can collide. +func freeAddr(t *testing.T) (string, int) { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("get free port: %s", err) + } + addr := l.Addr().(*net.TCPAddr) + _ = l.Close() + return addr.String(), addr.Port +} diff --git a/shared/relay/client/dialer/quic/quic.go b/shared/relay/client/dialer/quic/quic.go index 2d7b00a80..602803b19 100644 --- a/shared/relay/client/dialer/quic/quic.go +++ b/shared/relay/client/dialer/quic/quic.go @@ -23,7 +23,7 @@ func (d Dialer) Protocol() string { return Network } -func (d Dialer) Dial(ctx context.Context, address string) (net.Conn, error) { +func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, error) { quicURL, err := prepareURL(address) if err != nil { return nil, err @@ -32,11 +32,14 @@ func (d Dialer) Dial(ctx context.Context, address string) (net.Conn, error) { // Get the base TLS config tlsClientConfig := quictls.ClientQUICTLSConfig() - // Set ServerName to hostname if not an IP address - host, _, splitErr := net.SplitHostPort(quicURL) - if splitErr == nil && net.ParseIP(host) == nil { - // It's a hostname, not an IP - modify directly - tlsClientConfig.ServerName = host + switch { + case serverName != "" && net.ParseIP(serverName) == nil: + tlsClientConfig.ServerName = serverName + default: + host, _, splitErr := net.SplitHostPort(quicURL) + if splitErr == nil && net.ParseIP(host) == nil { + tlsClientConfig.ServerName = host + } } quicConfig := &quic.Config{ diff --git a/shared/relay/client/dialer/race_dialer.go b/shared/relay/client/dialer/race_dialer.go index 34359d17e..15208b858 100644 --- a/shared/relay/client/dialer/race_dialer.go +++ b/shared/relay/client/dialer/race_dialer.go @@ -14,7 +14,9 @@ const ( ) type DialeFn interface { - Dial(ctx context.Context, address string) (net.Conn, error) + // Dial connects to address. serverName, when non-empty, overrides the TLS + // ServerName used for SNI/cert validation. Empty means derive from address. + Dial(ctx context.Context, address, serverName string) (net.Conn, error) Protocol() string } @@ -27,6 +29,7 @@ type dialResult struct { type RaceDial struct { log *log.Entry serverURL string + serverName string dialerFns []DialeFn connectionTimeout time.Duration } @@ -40,6 +43,16 @@ func NewRaceDial(log *log.Entry, connectionTimeout time.Duration, serverURL stri } } +// WithServerName sets a TLS SNI/cert validation override. Used when serverURL +// contains an IP literal but the cert is issued for a different hostname. +// +// Mutates the receiver and is not safe for concurrent reconfiguration; a +// RaceDial is intended to be constructed per dial and discarded. +func (r *RaceDial) WithServerName(serverName string) *RaceDial { + r.serverName = serverName + return r +} + func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) { connChan := make(chan dialResult, len(r.dialerFns)) winnerConn := make(chan net.Conn, 1) @@ -64,7 +77,7 @@ func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dia defer cancel() r.log.Infof("dialing Relay server via %s", dfn.Protocol()) - conn, err := dfn.Dial(ctx, r.serverURL) + conn, err := dfn.Dial(ctx, r.serverURL, r.serverName) connChan <- dialResult{Conn: conn, Protocol: dfn.Protocol(), Err: err} } diff --git a/shared/relay/client/dialer/race_dialer_test.go b/shared/relay/client/dialer/race_dialer_test.go index aa18df578..a53edc00e 100644 --- a/shared/relay/client/dialer/race_dialer_test.go +++ b/shared/relay/client/dialer/race_dialer_test.go @@ -28,7 +28,7 @@ type MockDialer struct { protocolStr string } -func (m *MockDialer) Dial(ctx context.Context, address string) (net.Conn, error) { +func (m *MockDialer) Dial(ctx context.Context, address, _ string) (net.Conn, error) { return m.dialFunc(ctx, address) } diff --git a/shared/relay/client/dialer/ws/conn.go b/shared/relay/client/dialer/ws/conn.go index d5b719f51..9497fab89 100644 --- a/shared/relay/client/dialer/ws/conn.go +++ b/shared/relay/client/dialer/ws/conn.go @@ -12,14 +12,24 @@ import ( type Conn struct { ctx context.Context *websocket.Conn - remoteAddr WebsocketAddr + remoteAddr net.Addr } -func NewConn(wsConn *websocket.Conn, serverAddress string) net.Conn { +// NewConn builds a relay ws.Conn. underlying is the raw TCP/TLS conn captured +// from the http transport's DialContext; when set, RemoteAddr returns its +// peer address (an IP literal). When nil (e.g. wasm), RemoteAddr falls back +// to the dial-time URL. +func NewConn(wsConn *websocket.Conn, serverAddress string, underlying net.Conn) net.Conn { + var addr net.Addr = WebsocketAddr{serverAddress} + if underlying != nil { + if ra := underlying.RemoteAddr(); ra != nil { + addr = ra + } + } return &Conn{ ctx: context.Background(), Conn: wsConn, - remoteAddr: WebsocketAddr{serverAddress}, + remoteAddr: addr, } } diff --git a/shared/relay/client/dialer/ws/dialopts_generic.go b/shared/relay/client/dialer/ws/dialopts_generic.go index 9dfe698d0..8008d89d3 100644 --- a/shared/relay/client/dialer/ws/dialopts_generic.go +++ b/shared/relay/client/dialer/ws/dialopts_generic.go @@ -2,10 +2,14 @@ package ws -import "github.com/coder/websocket" +import ( + "net" -func createDialOptions() *websocket.DialOptions { + "github.com/coder/websocket" +) + +func createDialOptions(serverName string, underlyingOut *net.Conn) *websocket.DialOptions { return &websocket.DialOptions{ - HTTPClient: httpClientNbDialer(), + HTTPClient: httpClientNbDialer(serverName, underlyingOut), } } diff --git a/shared/relay/client/dialer/ws/dialopts_js.go b/shared/relay/client/dialer/ws/dialopts_js.go index 7eac27531..5b11fe765 100644 --- a/shared/relay/client/dialer/ws/dialopts_js.go +++ b/shared/relay/client/dialer/ws/dialopts_js.go @@ -2,9 +2,13 @@ package ws -import "github.com/coder/websocket" +import ( + "net" -func createDialOptions() *websocket.DialOptions { - // WASM version doesn't support HTTPClient + "github.com/coder/websocket" +) + +func createDialOptions(_ string, _ *net.Conn) *websocket.DialOptions { + // WASM version doesn't support HTTPClient or custom TLS config. return &websocket.DialOptions{} } diff --git a/shared/relay/client/dialer/ws/ws.go b/shared/relay/client/dialer/ws/ws.go index 37b189e05..301486514 100644 --- a/shared/relay/client/dialer/ws/ws.go +++ b/shared/relay/client/dialer/ws/ws.go @@ -26,13 +26,14 @@ func (d Dialer) Protocol() string { return "WS" } -func (d Dialer) Dial(ctx context.Context, address string) (net.Conn, error) { +func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, error) { wsURL, err := prepareURL(address) if err != nil { return nil, err } - opts := createDialOptions() + var underlying net.Conn + opts := createDialOptions(serverName, &underlying) parsedURL, err := url.Parse(wsURL) if err != nil { @@ -52,7 +53,7 @@ func (d Dialer) Dial(ctx context.Context, address string) (net.Conn, error) { _ = resp.Body.Close() } - conn := NewConn(wsConn, address) + conn := NewConn(wsConn, address, underlying) return conn, nil } @@ -64,7 +65,10 @@ func prepareURL(address string) (string, error) { return strings.Replace(address, "rel", "ws", 1), nil } -func httpClientNbDialer() *http.Client { +// httpClientNbDialer builds the http client used by the websocket library. +// underlyingOut, when non-nil, is populated with the raw conn from the +// transport's DialContext so the caller can read its RemoteAddr. +func httpClientNbDialer(serverName string, underlyingOut *net.Conn) *http.Client { customDialer := nbnet.NewDialer() certPool, err := x509.SystemCertPool() @@ -75,10 +79,15 @@ func httpClientNbDialer() *http.Client { customTransport := &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return customDialer.DialContext(ctx, network, addr) + c, err := customDialer.DialContext(ctx, network, addr) + if err == nil && underlyingOut != nil { + *underlyingOut = c + } + return c, err }, TLSClientConfig: &tls.Config{ - RootCAs: certPool, + RootCAs: certPool, + ServerName: serverName, }, } diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go index 37104bfe7..3858b3c83 100644 --- a/shared/relay/client/manager.go +++ b/shared/relay/client/manager.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "net" + "net/netip" "reflect" "sync" "time" @@ -75,6 +76,9 @@ type Manager struct { mtu uint16 maxBackoffInterval time.Duration + + cleanupInterval time.Duration + keepUnusedServerTime time.Duration } // NewManager creates a new manager instance. @@ -95,6 +99,8 @@ func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uin }, relayClients: make(map[string]*RelayTrack), onDisconnectedListeners: make(map[string]*list.List), + cleanupInterval: relayCleanupInterval, + keepUnusedServerTime: keepUnusedServerTime, } for _, opt := range opts { opt(m) @@ -130,7 +136,10 @@ func (m *Manager) Serve() error { // OpenConn opens a connection to the given peer key. If the peer is on the same relay server, the connection will be // established via the relay server. If the peer is on a different relay server, the manager will establish a new // connection to the relay server. It returns back with a net.Conn what represent the remote peer connection. -func (m *Manager) OpenConn(ctx context.Context, serverAddress, peerKey string) (net.Conn, error) { +// +// serverIP, when valid and serverAddress is foreign, is used as a dial target if the FQDN-based dial fails. +// Ignored for the local home-server path. TLS verification still uses the FQDN via SNI. +func (m *Manager) OpenConn(ctx context.Context, serverAddress, peerKey string, serverIP netip.Addr) (net.Conn, error) { m.relayClientMu.RLock() defer m.relayClientMu.RUnlock() @@ -151,7 +160,7 @@ func (m *Manager) OpenConn(ctx context.Context, serverAddress, peerKey string) ( netConn, err = m.relayClient.OpenConn(ctx, peerKey) } else { log.Debugf("open peer connection via foreign server: %s", serverAddress) - netConn, err = m.openConnVia(ctx, serverAddress, peerKey) + netConn, err = m.openConnVia(ctx, serverAddress, peerKey, serverIP) } if err != nil { return nil, err @@ -203,16 +212,22 @@ func (m *Manager) AddCloseListener(serverAddress string, onClosedListener OnServ return nil } -// RelayInstanceAddress returns the address of the permanent relay server. It could change if the network connection is -// lost. This address will be sent to the target peer to choose the common relay server for the communication. -func (m *Manager) RelayInstanceAddress() (string, error) { +// RelayInstanceAddress returns the address and resolved IP of the permanent relay server. It could change if the +// network connection is lost. The address is sent to the target peer to choose the common relay server for the +// communication; the IP is sent alongside so remote peers can dial directly without their own DNS lookup. Both +// values are read under the same lock so they cannot diverge across a reconnection. +func (m *Manager) RelayInstanceAddress() (string, netip.Addr, error) { m.relayClientMu.RLock() defer m.relayClientMu.RUnlock() if m.relayClient == nil { - return "", ErrRelayClientNotConnected + return "", netip.Addr{}, ErrRelayClientNotConnected } - return m.relayClient.ServerInstanceURL() + addr, err := m.relayClient.ServerInstanceURL() + if err != nil { + return "", netip.Addr{}, err + } + return addr, m.relayClient.ConnectedIP(), nil } // ServerURLs returns the addresses of the relay servers. @@ -236,7 +251,7 @@ func (m *Manager) UpdateToken(token *relayAuth.Token) error { return m.tokenStore.UpdateToken(token) } -func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string) (net.Conn, error) { +func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string, serverIP netip.Addr) (net.Conn, error) { // check if already has a connection to the desired relay server m.relayClientsMutex.RLock() rt, ok := m.relayClients[serverAddress] @@ -271,7 +286,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string m.relayClients[serverAddress] = rt m.relayClientsMutex.Unlock() - relayClient := NewClient(serverAddress, m.tokenStore, m.peerID, m.mtu) + relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu) err := relayClient.Connect(m.ctx) if err != nil { rt.err = err @@ -364,7 +379,7 @@ func (m *Manager) isForeignServer(address string) (bool, error) { } func (m *Manager) startCleanupLoop() { - ticker := time.NewTicker(relayCleanupInterval) + ticker := time.NewTicker(m.cleanupInterval) defer ticker.Stop() for { select { @@ -389,7 +404,7 @@ func (m *Manager) cleanUpUnusedRelays() { continue } - if time.Since(rt.created) <= keepUnusedServerTime { + if time.Since(rt.created) <= m.keepUnusedServerTime { rt.Unlock() continue } diff --git a/shared/relay/client/manager_serverip_test.go b/shared/relay/client/manager_serverip_test.go new file mode 100644 index 000000000..a354beade --- /dev/null +++ b/shared/relay/client/manager_serverip_test.go @@ -0,0 +1,144 @@ +package client + +import ( + "context" + "io" + "net/netip" + "testing" + "time" + + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/relay/server" +) + +// TestManager_ForeignRelayServerIP exercises the foreign-relay path +// end-to-end through Manager.OpenConn. Alice and Bob register on different +// relay servers; Alice dials Bob's foreign relay using an unresolvable +// FQDN. Without a server IP the dial fails; with Bob's advertised IP it +// recovers and a payload round-trips between the peers. +func TestManager_ForeignRelayServerIP(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + // Alice's home relay + homeCfg := server.ListenerConfig{Address: "127.0.0.1:52401"} + homeSrv, err := server.NewServer(newManagerTestServerConfig(homeCfg.Address)) + if err != nil { + t.Fatalf("create home server: %s", err) + } + homeErr := make(chan error, 1) + go func() { + if err := homeSrv.Listen(homeCfg); err != nil { + homeErr <- err + } + }() + t.Cleanup(func() { _ = homeSrv.Shutdown(context.Background()) }) + if err := waitForServerToStart(homeErr); err != nil { + t.Fatalf("home server: %s", err) + } + + // Bob's foreign relay + foreignCfg := server.ListenerConfig{Address: "127.0.0.1:52402"} + foreignSrv, err := server.NewServer(newManagerTestServerConfig(foreignCfg.Address)) + if err != nil { + t.Fatalf("create foreign server: %s", err) + } + foreignErr := make(chan error, 1) + go func() { + if err := foreignSrv.Listen(foreignCfg); err != nil { + foreignErr <- err + } + }() + t.Cleanup(func() { _ = foreignSrv.Shutdown(context.Background()) }) + if err := waitForServerToStart(foreignErr); err != nil { + t.Fatalf("foreign server: %s", err) + } + + mCtx, mCancel := context.WithCancel(ctx) + t.Cleanup(mCancel) + + mgrAlice := NewManager(mCtx, toURL(homeCfg), "alice", iface.DefaultMTU) + if err := mgrAlice.Serve(); err != nil { + t.Fatalf("alice manager serve: %s", err) + } + + mgrBob := NewManager(mCtx, toURL(foreignCfg), "bob", iface.DefaultMTU) + if err := mgrBob.Serve(); err != nil { + t.Fatalf("bob manager serve: %s", err) + } + + // Bob's real relay URL and the IP that would ride along in signal as relayServerIP. + bobRealAddr, bobAdvertisedIP, err := mgrBob.RelayInstanceAddress() + if err != nil { + t.Fatalf("bob relay address: %s", err) + } + if !bobAdvertisedIP.IsValid() { + t.Fatalf("expected valid RelayInstanceIP for bob, got zero") + } + + // .invalid is reserved (RFC 2606), so DNS resolution always fails. + const brokenFQDN = "rel://relay-bob-instance.invalid:52402" + if brokenFQDN == bobRealAddr { + t.Fatalf("broken FQDN must differ from bob's real address (%s)", bobRealAddr) + } + + t.Run("no server IP, dial fails", func(t *testing.T) { + dialCtx, dialCancel := context.WithTimeout(ctx, 5*time.Second) + defer dialCancel() + _, err := mgrAlice.OpenConn(dialCtx, brokenFQDN, "bob", netip.Addr{}) + if err == nil { + t.Fatalf("expected OpenConn to fail without server IP, got success") + } + }) + + t.Run("server IP recovers", func(t *testing.T) { + // Bob waits for Alice's incoming peer connection on his side. + bobSideCh := make(chan error, 1) + go func() { + conn, err := mgrBob.OpenConn(ctx, bobRealAddr, "alice", netip.Addr{}) + if err != nil { + bobSideCh <- err + return + } + buf := make([]byte, 1024) + n, err := conn.Read(buf) + if err != nil { + bobSideCh <- err + return + } + if _, err := conn.Write(buf[:n]); err != nil { + bobSideCh <- err + return + } + bobSideCh <- nil + }() + + aliceConn, err := mgrAlice.OpenConn(ctx, brokenFQDN, "bob", bobAdvertisedIP) + if err != nil { + t.Fatalf("alice OpenConn with server IP: %s", err) + } + t.Cleanup(func() { _ = aliceConn.Close() }) + + payload := []byte("alice-to-bob") + if _, err := aliceConn.Write(payload); err != nil { + t.Fatalf("alice write: %s", err) + } + + buf := make([]byte, len(payload)) + if _, err := io.ReadFull(aliceConn, buf); err != nil { + t.Fatalf("alice read echo: %s", err) + } + if string(buf) != string(payload) { + t.Fatalf("echo mismatch: got %q want %q", buf, payload) + } + + select { + case err := <-bobSideCh: + if err != nil { + t.Fatalf("bob side: %s", err) + } + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for bob side") + } + }) +} diff --git a/shared/relay/client/manager_test.go b/shared/relay/client/manager_test.go index 5bbcad886..9e964f688 100644 --- a/shared/relay/client/manager_test.go +++ b/shared/relay/client/manager_test.go @@ -3,6 +3,7 @@ package client import ( "context" "fmt" + "net/netip" "testing" "time" @@ -101,15 +102,15 @@ func TestForeignConn(t *testing.T) { if err := clientBob.Serve(); err != nil { t.Fatalf("failed to serve manager: %s", err) } - bobsSrvAddr, err := clientBob.RelayInstanceAddress() + bobsSrvAddr, _, err := clientBob.RelayInstanceAddress() if err != nil { t.Fatalf("failed to get relay address: %s", err) } - connAliceToBob, err := clientAlice.OpenConn(ctx, bobsSrvAddr, "bob") + connAliceToBob, err := clientAlice.OpenConn(ctx, bobsSrvAddr, "bob", netip.Addr{}) if err != nil { t.Fatalf("failed to bind channel: %s", err) } - connBobToAlice, err := clientBob.OpenConn(ctx, bobsSrvAddr, "alice") + connBobToAlice, err := clientBob.OpenConn(ctx, bobsSrvAddr, "alice", netip.Addr{}) if err != nil { t.Fatalf("failed to bind channel: %s", err) } @@ -209,7 +210,7 @@ func TestForeginConnClose(t *testing.T) { if err != nil { t.Fatalf("failed to serve manager: %s", err) } - conn, err := mgr.OpenConn(ctx, toURL(srvCfg2)[0], "bob") + conn, err := mgr.OpenConn(ctx, toURL(srvCfg2)[0], "bob", netip.Addr{}) if err != nil { t.Fatalf("failed to bind channel: %s", err) } @@ -301,7 +302,7 @@ func TestForeignAutoClose(t *testing.T) { } t.Log("open connection to another peer") - if _, err = mgr.OpenConn(ctx, foreignServerURL, "anotherpeer"); err == nil { + if _, err = mgr.OpenConn(ctx, foreignServerURL, "anotherpeer", netip.Addr{}); err == nil { t.Fatalf("should have failed to open connection to another peer") } @@ -367,11 +368,11 @@ func TestAutoReconnect(t *testing.T) { if err != nil { t.Fatalf("failed to serve manager: %s", err) } - ra, err := clientAlice.RelayInstanceAddress() + ra, _, err := clientAlice.RelayInstanceAddress() if err != nil { t.Errorf("failed to get relay address: %s", err) } - conn, err := clientAlice.OpenConn(ctx, ra, "bob") + conn, err := clientAlice.OpenConn(ctx, ra, "bob", netip.Addr{}) if err != nil { t.Errorf("failed to bind channel: %s", err) } @@ -391,7 +392,7 @@ func TestAutoReconnect(t *testing.T) { } log.Infof("reopent the connection") - _, err = clientAlice.OpenConn(ctx, ra, "bob") + _, err = clientAlice.OpenConn(ctx, ra, "bob", netip.Addr{}) if err != nil { t.Errorf("failed to open channel: %s", err) } @@ -453,7 +454,7 @@ func TestNotifierDoubleAdd(t *testing.T) { t.Fatalf("failed to serve manager: %s", err) } - conn1, err := clientAlice.OpenConn(ctx, clientAlice.ServerURLs()[0], "bob") + conn1, err := clientAlice.OpenConn(ctx, clientAlice.ServerURLs()[0], "bob", netip.Addr{}) if err != nil { t.Fatalf("failed to bind channel: %s", err) } diff --git a/shared/signal/client/client.go b/shared/signal/client/client.go index 5347c80e9..9dc6ccd37 100644 --- a/shared/signal/client/client.go +++ b/shared/signal/client/client.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net/netip" "strings" "github.com/netbirdio/netbird/shared/signal/proto" @@ -14,17 +15,17 @@ import ( // A set of tools to exchange connection details (Wireguard endpoints) with the remote peer. -// Status is the status of the client -type Status string - -const StreamConnected Status = "Connected" -const StreamDisconnected Status = "Disconnected" - const ( + StreamConnected Status = "Connected" + StreamDisconnected Status = "Disconnected" + // DirectCheck indicates support to direct mode checks DirectCheck uint32 = 1 ) +// Status is the status of the client +type Status string + type Client interface { io.Closer StreamConnected() bool @@ -38,6 +39,24 @@ type Client interface { SetOnReconnectedListener(func()) } +// Credential is an instance of a GrpcClient's Credential +type Credential struct { + UFrag string + Pwd string +} + +// CredentialPayload bundles the fields of a signal Body for MarshalCredential. +type CredentialPayload struct { + Type proto.Body_Type + WgListenPort int + Credential *Credential + RosenpassPubKey []byte + RosenpassAddr string + RelaySrvAddress string + RelaySrvIP netip.Addr + SessionID []byte +} + // UnMarshalCredential parses the credentials from the message and returns a Credential instance func UnMarshalCredential(msg *proto.Message) (*Credential, error) { @@ -52,27 +71,27 @@ func UnMarshalCredential(msg *proto.Message) (*Credential, error) { } // MarshalCredential marshal a Credential instance and returns a Message object -func MarshalCredential(myKey wgtypes.Key, myPort int, remoteKey string, credential *Credential, t proto.Body_Type, rosenpassPubKey []byte, rosenpassAddr string, relaySrvAddress string, sessionID []byte) (*proto.Message, error) { +func MarshalCredential(myKey wgtypes.Key, remoteKey string, p CredentialPayload) (*proto.Message, error) { + body := &proto.Body{ + Type: p.Type, + Payload: fmt.Sprintf("%s:%s", p.Credential.UFrag, p.Credential.Pwd), + WgListenPort: uint32(p.WgListenPort), + NetBirdVersion: version.NetbirdVersion(), + RosenpassConfig: &proto.RosenpassConfig{ + RosenpassPubKey: p.RosenpassPubKey, + RosenpassServerAddr: p.RosenpassAddr, + }, + SessionId: p.SessionID, + } + if p.RelaySrvAddress != "" { + body.RelayServerAddress = &p.RelaySrvAddress + } + if p.RelaySrvIP.IsValid() { + body.RelayServerIP = p.RelaySrvIP.Unmap().AsSlice() + } return &proto.Message{ Key: myKey.PublicKey().String(), RemoteKey: remoteKey, - Body: &proto.Body{ - Type: t, - Payload: fmt.Sprintf("%s:%s", credential.UFrag, credential.Pwd), - WgListenPort: uint32(myPort), - NetBirdVersion: version.NetbirdVersion(), - RosenpassConfig: &proto.RosenpassConfig{ - RosenpassPubKey: rosenpassPubKey, - RosenpassServerAddr: rosenpassAddr, - }, - RelayServerAddress: relaySrvAddress, - SessionId: sessionID, - }, + Body: body, }, nil } - -// Credential is an instance of a GrpcClient's Credential -type Credential struct { - UFrag string - Pwd string -} diff --git a/shared/signal/proto/signalexchange.pb.go b/shared/signal/proto/signalexchange.pb.go index d9c61a846..0c80fb489 100644 --- a/shared/signal/proto/signalexchange.pb.go +++ b/shared/signal/proto/signalexchange.pb.go @@ -229,8 +229,13 @@ type Body struct { // RosenpassConfig is a Rosenpass config of the remote peer our peer tries to connect to RosenpassConfig *RosenpassConfig `protobuf:"bytes,7,opt,name=rosenpassConfig,proto3" json:"rosenpassConfig,omitempty"` // relayServerAddress is url of the relay server - RelayServerAddress string `protobuf:"bytes,8,opt,name=relayServerAddress,proto3" json:"relayServerAddress,omitempty"` - SessionId []byte `protobuf:"bytes,10,opt,name=sessionId,proto3,oneof" json:"sessionId,omitempty"` + RelayServerAddress *string `protobuf:"bytes,8,opt,name=relayServerAddress,proto3,oneof" json:"relayServerAddress,omitempty"` + SessionId []byte `protobuf:"bytes,10,opt,name=sessionId,proto3,oneof" json:"sessionId,omitempty"` + // relayServerIP is the IP the sender is connected to on its relay server, + // encoded as 4 bytes (IPv4) or 16 bytes (IPv6). Receivers may use it as a + // fallback dial target when DNS resolution of relayServerAddress fails. + // SNI/TLS verification still uses relayServerAddress. + RelayServerIP []byte `protobuf:"bytes,11,opt,name=relayServerIP,proto3,oneof" json:"relayServerIP,omitempty"` } func (x *Body) Reset() { @@ -315,8 +320,8 @@ func (x *Body) GetRosenpassConfig() *RosenpassConfig { } func (x *Body) GetRelayServerAddress() string { - if x != nil { - return x.RelayServerAddress + if x != nil && x.RelayServerAddress != nil { + return *x.RelayServerAddress } return "" } @@ -328,6 +333,13 @@ func (x *Body) GetSessionId() []byte { return nil } +func (x *Body) GetRelayServerIP() []byte { + if x != nil { + return x.RelayServerIP + } + return nil +} + // Mode indicates a connection mode type Mode struct { state protoimpl.MessageState @@ -451,7 +463,7 @@ var file_signalexchange_proto_rawDesc = []byte{ 0x52, 0x09, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x52, - 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xe4, 0x03, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d, + 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xc3, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, @@ -471,40 +483,46 @@ var file_signalexchange_proto_rawDesc = []byte{ 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x52, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x33, 0x0a, 0x12, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x12, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, - 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x49, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x22, 0x43, 0x0a, 0x04, 0x54, 0x79, 0x70, + 0x28, 0x09, 0x48, 0x00, 0x52, 0x12, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x01, + 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29, + 0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x18, + 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x43, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44, 0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10, - 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x42, 0x0c, - 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x2e, 0x0a, 0x04, - 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, - 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, - 0x52, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, - 0x28, 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, - 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, - 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, - 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, - 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, - 0x0a, 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, - 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, - 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, - 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, - 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, - 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, - 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, - 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x42, 0x15, + 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, + 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, + 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, + 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, + 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, + 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, + 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, + 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, + 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, + 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, + 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, + 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, + 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, + 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, + 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, + 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, + 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, + 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/shared/signal/proto/signalexchange.proto b/shared/signal/proto/signalexchange.proto index 0a33ad78b..96a4001e3 100644 --- a/shared/signal/proto/signalexchange.proto +++ b/shared/signal/proto/signalexchange.proto @@ -63,9 +63,17 @@ message Body { RosenpassConfig rosenpassConfig = 7; // relayServerAddress is url of the relay server - string relayServerAddress = 8; + optional string relayServerAddress = 8; + + reserved 9; optional bytes sessionId = 10; + + // relayServerIP is the IP the sender is connected to on its relay server, + // encoded as 4 bytes (IPv4) or 16 bytes (IPv6). Receivers may use it as a + // fallback dial target when DNS resolution of relayServerAddress fails. + // SNI/TLS verification still uses relayServerAddress. + optional bytes relayServerIP = 11; } // Mode indicates a connection mode From 6262b0d841a5a4c1bd758d45332a6dba51cb09dd Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Mon, 4 May 2026 12:47:13 +0300 Subject: [PATCH 15/80] [management] Track pending approval in peer event metadata (#6040) --- management/server/peer.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/management/server/peer.go b/management/server/peer.go index d1c52002e..25c6ecd8c 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -818,6 +818,9 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe if !addedByUser { opEvent.Meta["setup_key_name"] = peerAddConfig.SetupKeyName } + if newPeer.Status != nil && newPeer.Status.RequiresApproval { + opEvent.Meta["pending_approval"] = true + } if !temporary { am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta) From a21f6ecb0a5d7ba45b4bf570a7af62ba1f66447d Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 4 May 2026 11:59:01 +0200 Subject: [PATCH 16/80] [client] release Status.mux before invoking notifier callbacks (#6039) The Status recorder used to fire notifier callbacks while holding d.mux: - notifyPeerListChanged / notifyPeerStateChangeListeners ran from inside the locked section of every Update*/AddPeerStateRoute/etc. - notifyAddressChanged ran from UpdateLocalPeerState and CleanLocalPeerState while d.mux was held. - onConnectionChanged was registered with a defer above defer d.mux.Unlock, so it executed before the mutex was released in the Mark*Connected/ Disconnected helpers. - notifyPeerStateChangeListeners did a blocking channel send under d.mux, so a slow subscriber stalled every other d.mux holder. A listener that re-enters the recorder (e.g. calls GetFullStatus from within a callback) deadlocks against d.mux, and any callback that takes longer than expected stalls every other state query for its duration. Capture the values needed for notification under the lock, release d.mux, then call the notifier. Build per-peer router-state snapshots inside the lock and dispatch them via dispatchRouterPeers afterwards. The router-peer channel send stays blocking, but now happens outside d.mux so a slow consumer cannot stall any other d.mux holder, and no peer state transitions are silently dropped. The notifier itself is unchanged: its internal state was already protected by its own locks, and the field d.notifier is set once in NewRecorder and never reassigned, so reading it without d.mux is safe. Also fix a pre-existing race in Test_notifier_RemoveListener / Test_notifier_SetListener: setListener spawns a goroutine that writes listener.peers, but the tests read listener.peers without waiting for it. --- client/internal/peer/notifier_test.go | 17 ++ client/internal/peer/status.go | 229 +++++++++++++++++--------- 2 files changed, 170 insertions(+), 76 deletions(-) diff --git a/client/internal/peer/notifier_test.go b/client/internal/peer/notifier_test.go index bbdc00e13..0b7722b0c 100644 --- a/client/internal/peer/notifier_test.go +++ b/client/internal/peer/notifier_test.go @@ -8,6 +8,7 @@ import ( type mocListener struct { lastState int wg sync.WaitGroup + peersWg sync.WaitGroup peers int } @@ -33,6 +34,7 @@ func (l *mocListener) OnAddressChanged(host, addr string) { } func (l *mocListener) OnPeersListChanged(size int) { l.peers = size + l.peersWg.Done() } func (l *mocListener) setWaiter() { @@ -43,6 +45,14 @@ func (l *mocListener) wait() { l.wg.Wait() } +func (l *mocListener) setPeersWaiter() { + l.peersWg.Add(1) +} + +func (l *mocListener) waitPeers() { + l.peersWg.Wait() +} + func Test_notifier_serverState(t *testing.T) { type scenario struct { @@ -72,11 +82,13 @@ func Test_notifier_serverState(t *testing.T) { func Test_notifier_SetListener(t *testing.T) { listener := &mocListener{} listener.setWaiter() + listener.setPeersWaiter() n := newNotifier() n.lastNotification = stateConnecting n.setListener(listener) listener.wait() + listener.waitPeers() if listener.lastState != n.lastNotification { t.Errorf("invalid state: %d, expected: %d", listener.lastState, n.lastNotification) } @@ -85,9 +97,14 @@ func Test_notifier_SetListener(t *testing.T) { func Test_notifier_RemoveListener(t *testing.T) { listener := &mocListener{} listener.setWaiter() + listener.setPeersWaiter() n := newNotifier() n.lastNotification = stateConnecting n.setListener(listener) + // setListener replays cached state on a goroutine; wait for both the state + // and peers callbacks to finish so we don't race on listener.peers. + listener.wait() + listener.waitPeers() n.removeListener() n.peerListChanged(1) diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 7bd19b0e1..e8e61f660 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -320,10 +320,10 @@ func (d *Status) RemovePeer(peerPubKey string) error { // UpdatePeerState updates peer status func (d *Status) UpdatePeerState(receivedState State) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[receivedState.PubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -343,23 +343,29 @@ func (d *Status) UpdatePeerState(receivedState State) error { d.peers[receivedState.PubKey] = peerState - if hasConnStatusChanged(oldState, receivedState.ConnStatus) { - d.notifyPeerListChanged() - } - + notifyList := hasConnStatusChanged(oldState, receivedState.ConnStatus) // when we close the connection we will not notify the router manager - if receivedState.ConnStatus == StatusIdle { - d.notifyPeerStateChangeListeners(receivedState.PubKey) + notifyRouter := receivedState.ConnStatus == StatusIdle + routerSnapshot := d.snapshotRouterPeersLocked(receivedState.PubKey, notifyRouter) + numPeers := d.numOfPeers() + + d.mux.Unlock() + + if notifyList { + d.notifier.peerListChanged(numPeers) + } + if notifyRouter { + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } return nil } func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.ResID) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[peer] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -371,17 +377,20 @@ func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.R d.routeIDLookup.AddRemoteRouteID(resourceId, pref) } + numPeers := d.numOfPeers() + d.mux.Unlock() + // todo: consider to make sense of this notification or not - d.notifyPeerListChanged() + d.notifier.peerListChanged(numPeers) return nil } func (d *Status) RemovePeerStateRoute(peer string, route string) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[peer] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -393,8 +402,11 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error { d.routeIDLookup.RemoveRemoteRouteID(pref) } + numPeers := d.numOfPeers() + d.mux.Unlock() + // todo: consider to make sense of this notification or not - d.notifyPeerListChanged() + d.notifier.peerListChanged(numPeers) return nil } @@ -410,10 +422,10 @@ func (d *Status) CheckRoutes(ip netip.Addr) ([]byte, bool) { func (d *Status) UpdatePeerICEState(receivedState State) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[receivedState.PubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -431,22 +443,28 @@ func (d *Status) UpdatePeerICEState(receivedState State) error { d.peers[receivedState.PubKey] = peerState - if hasConnStatusChanged(oldState, receivedState.ConnStatus) { - d.notifyPeerListChanged() - } + notifyList := hasConnStatusChanged(oldState, receivedState.ConnStatus) + notifyRouter := hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) + routerSnapshot := d.snapshotRouterPeersLocked(receivedState.PubKey, notifyRouter) + numPeers := d.numOfPeers() - if hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) { - d.notifyPeerStateChangeListeners(receivedState.PubKey) + d.mux.Unlock() + + if notifyList { + d.notifier.peerListChanged(numPeers) + } + if notifyRouter { + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } return nil } func (d *Status) UpdatePeerRelayedState(receivedState State) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[receivedState.PubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -461,22 +479,28 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error { d.peers[receivedState.PubKey] = peerState - if hasConnStatusChanged(oldState, receivedState.ConnStatus) { - d.notifyPeerListChanged() - } + notifyList := hasConnStatusChanged(oldState, receivedState.ConnStatus) + notifyRouter := hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) + routerSnapshot := d.snapshotRouterPeersLocked(receivedState.PubKey, notifyRouter) + numPeers := d.numOfPeers() - if hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) { - d.notifyPeerStateChangeListeners(receivedState.PubKey) + d.mux.Unlock() + + if notifyList { + d.notifier.peerListChanged(numPeers) + } + if notifyRouter { + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } return nil } func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[receivedState.PubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -490,22 +514,28 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error d.peers[receivedState.PubKey] = peerState - if hasConnStatusChanged(oldState, receivedState.ConnStatus) { - d.notifyPeerListChanged() - } + notifyList := hasConnStatusChanged(oldState, receivedState.ConnStatus) + notifyRouter := hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) + routerSnapshot := d.snapshotRouterPeersLocked(receivedState.PubKey, notifyRouter) + numPeers := d.numOfPeers() - if hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) { - d.notifyPeerStateChangeListeners(receivedState.PubKey) + d.mux.Unlock() + + if notifyList { + d.notifier.peerListChanged(numPeers) + } + if notifyRouter { + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } return nil } func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[receivedState.PubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -522,12 +552,18 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error { d.peers[receivedState.PubKey] = peerState - if hasConnStatusChanged(oldState, receivedState.ConnStatus) { - d.notifyPeerListChanged() - } + notifyList := hasConnStatusChanged(oldState, receivedState.ConnStatus) + notifyRouter := hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) + routerSnapshot := d.snapshotRouterPeersLocked(receivedState.PubKey, notifyRouter) + numPeers := d.numOfPeers() - if hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) { - d.notifyPeerStateChangeListeners(receivedState.PubKey) + d.mux.Unlock() + + if notifyList { + d.notifier.peerListChanged(numPeers) + } + if notifyRouter { + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } return nil } @@ -594,17 +630,33 @@ func (d *Status) UpdatePeerSSHHostKey(peerPubKey string, sshHostKey []byte) erro // FinishPeerListModifications this event invoke the notification func (d *Status) FinishPeerListModifications() { d.mux.Lock() - defer d.mux.Unlock() if !d.peerListChangedForNotification { + d.mux.Unlock() return } d.peerListChangedForNotification = false - d.notifyPeerListChanged() + numPeers := d.numOfPeers() + // snapshot per-peer router state to deliver after the lock is released + type routerDispatch struct { + peerID string + snapshot map[string]RouterState + } + dispatches := make([]routerDispatch, 0, len(d.peers)) for key := range d.peers { - d.notifyPeerStateChangeListeners(key) + snapshot := d.snapshotRouterPeersLocked(key, true) + if snapshot != nil { + dispatches = append(dispatches, routerDispatch{peerID: key, snapshot: snapshot}) + } + } + + d.mux.Unlock() + + d.notifier.peerListChanged(numPeers) + for _, rd := range dispatches { + d.dispatchRouterPeers(rd.peerID, rd.snapshot) } } @@ -655,10 +707,12 @@ func (d *Status) GetLocalPeerState() LocalPeerState { // UpdateLocalPeerState updates local peer status func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) { d.mux.Lock() - defer d.mux.Unlock() - d.localPeer = localPeerState - d.notifyAddressChanged() + fqdn := d.localPeer.FQDN + ip := d.localPeer.IP + d.mux.Unlock() + + d.notifier.localAddressChanged(fqdn, ip) } // AddLocalPeerStateRoute adds a route to the local peer state @@ -721,30 +775,36 @@ func (d *Status) CleanLocalPeerStateRoutes() { // CleanLocalPeerState cleans local peer status func (d *Status) CleanLocalPeerState() { d.mux.Lock() - defer d.mux.Unlock() - d.localPeer = LocalPeerState{} - d.notifyAddressChanged() + fqdn := d.localPeer.FQDN + ip := d.localPeer.IP + d.mux.Unlock() + + d.notifier.localAddressChanged(fqdn, ip) } // MarkManagementDisconnected sets ManagementState to disconnected func (d *Status) MarkManagementDisconnected(err error) { d.mux.Lock() - defer d.mux.Unlock() - defer d.onConnectionChanged() - d.managementState = false d.managementError = err + mgm := d.managementState + sig := d.signalState + d.mux.Unlock() + + d.notifier.updateServerStates(mgm, sig) } // MarkManagementConnected sets ManagementState to connected func (d *Status) MarkManagementConnected() { d.mux.Lock() - defer d.mux.Unlock() - defer d.onConnectionChanged() - d.managementState = true d.managementError = nil + mgm := d.managementState + sig := d.signalState + d.mux.Unlock() + + d.notifier.updateServerStates(mgm, sig) } // UpdateSignalAddress update the address of the signal server @@ -778,21 +838,25 @@ func (d *Status) UpdateLazyConnection(enabled bool) { // MarkSignalDisconnected sets SignalState to disconnected func (d *Status) MarkSignalDisconnected(err error) { d.mux.Lock() - defer d.mux.Unlock() - defer d.onConnectionChanged() - d.signalState = false d.signalError = err + mgm := d.managementState + sig := d.signalState + d.mux.Unlock() + + d.notifier.updateServerStates(mgm, sig) } // MarkSignalConnected sets SignalState to connected func (d *Status) MarkSignalConnected() { d.mux.Lock() - defer d.mux.Unlock() - defer d.onConnectionChanged() - d.signalState = true d.signalError = nil + mgm := d.managementState + sig := d.signalState + d.mux.Unlock() + + d.notifier.updateServerStates(mgm, sig) } func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) { @@ -1012,18 +1076,17 @@ func (d *Status) RemoveConnectionListener() { d.notifier.removeListener() } -func (d *Status) onConnectionChanged() { - d.notifier.updateServerStates(d.managementState, d.signalState) -} - -// notifyPeerStateChangeListeners notifies route manager about the change in peer state -func (d *Status) notifyPeerStateChangeListeners(peerID string) { - subs, ok := d.changeNotify[peerID] - if !ok { - return +// snapshotRouterPeersLocked builds the RouterState map for a peer's subscribers. +// Caller MUST hold d.mux. Returns nil when there are no subscribers for peerID +// or when notify is false. The snapshot is consumed later by dispatchRouterPeers +// outside the lock so the channel send cannot stall any d.mux holder. +func (d *Status) snapshotRouterPeersLocked(peerID string, notify bool) map[string]RouterState { + if !notify { + return nil + } + if _, ok := d.changeNotify[peerID]; !ok { + return nil } - - // collect the relevant data for router peers routerPeers := make(map[string]RouterState, len(d.changeNotify)) for pid := range d.changeNotify { s, ok := d.peers[pid] @@ -1031,13 +1094,35 @@ func (d *Status) notifyPeerStateChangeListeners(peerID string) { log.Warnf("router peer not found in peers list: %s", pid) continue } - routerPeers[pid] = RouterState{ Status: s.ConnStatus, Relayed: s.Relayed, Latency: s.Latency, } } + return routerPeers +} + +// dispatchRouterPeers delivers a previously snapshotted router-state map to +// the peer's subscribers. Caller MUST NOT hold d.mux. The method takes a +// fresh, short read of d.changeNotify under the lock to grab subscriber +// channels, then sends outside the lock so a slow consumer cannot block other +// d.mux holders. The send itself stays blocking (only short-circuited by the +// subscriber's context) so peer state transitions are not silently dropped. +func (d *Status) dispatchRouterPeers(peerID string, routerPeers map[string]RouterState) { + if routerPeers == nil { + return + } + + d.mux.Lock() + subsMap, ok := d.changeNotify[peerID] + subs := make([]*StatusChangeSubscription, 0, len(subsMap)) + if ok { + for _, sub := range subsMap { + subs = append(subs, sub) + } + } + d.mux.Unlock() for _, sub := range subs { select { @@ -1047,14 +1132,6 @@ func (d *Status) notifyPeerStateChangeListeners(peerID string) { } } -func (d *Status) notifyPeerListChanged() { - d.notifier.peerListChanged(d.numOfPeers()) -} - -func (d *Status) notifyAddressChanged() { - d.notifier.localAddressChanged(d.localPeer.FQDN, d.localPeer.IP) -} - func (d *Status) numOfPeers() int { return len(d.peers) + len(d.offlinePeers) } From a547fc74edd71268767d258a6ebe8513fa65f467 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 4 May 2026 11:59:25 +0200 Subject: [PATCH 17/80] [client] Use ctx.Err() instead of gRPC codes.Canceled to detect shutdown (#6019) Detecting shutdown by inspecting the gRPC status code conflates a local context cancellation with a server- or proxy-sent codes.Canceled. When the latter occurs (e.g. an intermediary proxy resets the stream), the retry loop silently terminates and the client never reconnects. Switch to ctx.Err() in the signal Receive loop and management Sync/Job handlers, and stop matching gRPC Canceled/DeadlineExceeded in the flow client's isContextDone helper. With this change, a server-sent Canceled is treated as a transient error and the backoff retry loop continues. --- flow/client/client.go | 15 +++++------- shared/management/client/grpc.go | 39 ++++++++++++-------------------- shared/signal/client/grpc.go | 2 +- 3 files changed, 21 insertions(+), 35 deletions(-) diff --git a/flow/client/client.go b/flow/client/client.go index 8ad637974..180a4b441 100644 --- a/flow/client/client.go +++ b/flow/client/client.go @@ -13,11 +13,9 @@ import ( "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" "google.golang.org/grpc" - "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" - "google.golang.org/grpc/status" nbgrpc "github.com/netbirdio/netbird/client/grpc" "github.com/netbirdio/netbird/flow/proto" @@ -301,12 +299,11 @@ func defaultBackoff(ctx context.Context, interval time.Duration) backoff.BackOff }, ctx) } +// isContextDone reports whether the local context has been canceled or has +// exceeded its deadline. It deliberately does not inspect gRPC status codes: +// a server- or proxy-sent codes.Canceled / codes.DeadlineExceeded must not +// short-circuit our retry loop, since retrying is the correct response when +// the local context is still alive. func isContextDone(err error) bool { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return true - } - if s, ok := status.FromError(err); ok { - return s.Code() == codes.Canceled || s.Code() == codes.DeadlineExceeded - } - return false + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) } diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 2a51a777d..80625fe06 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -246,27 +246,23 @@ func (c *GrpcClient) handleJobStream( for { jobReq, err := c.receiveJobRequest(ctx, stream, serverPubKey) if err != nil { + if ctx.Err() != nil { + log.Debugf("job stream context has been canceled, this usually indicates shutdown") + return nil + } if s, ok := gstatus.FromError(err); ok { switch s.Code() { case codes.PermissionDenied: c.notifyDisconnected(err) return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer - case codes.Canceled: - log.Debugf("job stream context has been canceled, this usually indicates shutdown") - return err case codes.Unimplemented: log.Warn("Job feature is not supported by the current management server version. " + "Please update the management service to use this feature.") return nil - default: - log.Warnf("job stream disconnected, will retry silently. Reason: %v", err) - return err } - } else { - // non-gRPC error - log.Warnf("job stream disconnected, will retry silently. Reason: %v", err) - return err } + log.Warnf("job stream disconnected, will retry silently. Reason: %v", err) + return err } if jobReq == nil || len(jobReq.ID) == 0 { @@ -381,22 +377,15 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes. err = c.receiveUpdatesEvents(stream, serverPubKey, msgHandler) if err != nil { c.notifyDisconnected(err) - if s, ok := gstatus.FromError(err); ok { - switch s.Code() { - case codes.PermissionDenied: - return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer - case codes.Canceled: - log.Debugf("management connection context has been canceled, this usually indicates shutdown") - return nil - default: - log.Warnf("disconnected from the Management service but will retry silently. Reason: %v", err) - return err - } - } else { - // non-gRPC error - log.Warnf("disconnected from the Management service but will retry silently. Reason: %v", err) - return err + if ctx.Err() != nil { + log.Debugf("management connection context has been canceled, this usually indicates shutdown") + return nil } + if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied { + return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer + } + log.Warnf("disconnected from the Management service but will retry silently. Reason: %v", err) + return err } return nil diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index d0f598dd7..b245b2296 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -167,7 +167,7 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes // start receiving messages from the Signal stream (from other peers through signal) err = c.receive(stream) if err != nil { - if s, ok := status.FromError(err); ok && s.Code() == codes.Canceled { + if ctx.Err() != nil { log.Debugf("signal connection context has been canceled, this usually indicates shutdown") return nil } From f9771de3f54c85b3c4d2c912cd898eb472cb72f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 4 May 2026 13:00:13 +0200 Subject: [PATCH 18/80] [client/ui-wails] Switch release pipelines from Fyne to Wails UI Repoint goreleaser configs and the release workflow at client/ui-wails so the published Linux deb/rpm, Windows binaries and macOS UI binaries are built from the Wails source. Linux nfpm deps swap libappindicator/Fyne GL stack for libgtk-3, libwebkit2gtk-4.1 and libayatana-appindicator3, and the packaged .desktop file launches the binary with WEBKIT_DISABLE_DMABUF_RENDERER=1 so RDP/VM sessions render correctly. Frontend bindings are now committed; the release jobs add Node 20 and pnpm 9 and run the frontend build via the goreleaser before-hook. --- .github/workflows/release.yml | 24 +- .goreleaser_ui.yaml | 25 +- .goreleaser_ui_darwin.yaml | 9 +- client/ui-wails/.gitignore | 1 - client/ui-wails/build/linux/netbird.desktop | 8 + .../client/ui-wails/services/connection.ts | 40 + .../netbird/client/ui-wails/services/debug.ts | 35 + .../netbird/client/ui-wails/services/index.ts | 47 + .../client/ui-wails/services/models.ts | 1067 +++++++++++++++++ .../client/ui-wails/services/networks.ts | 33 + .../netbird/client/ui-wails/services/peers.ts | 39 + .../client/ui-wails/services/profiles.ts | 52 + .../client/ui-wails/services/settings.ts | 35 + .../client/ui-wails/services/update.ts | 30 + .../wailsapp/wails/v3/internal/eventcreate.ts | 28 + .../wailsapp/wails/v3/internal/eventdata.d.ts | 21 + .../v3/pkg/services/notifications/index.ts | 13 + .../v3/pkg/services/notifications/models.ts | 107 ++ .../notifications/notificationservice.ts | 62 + 19 files changed, 1662 insertions(+), 14 deletions(-) create mode 100644 client/ui-wails/build/linux/netbird.desktop create mode 100644 client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/debug.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/index.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/networks.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/peers.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/profiles.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/settings.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts create mode 100644 client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 826c05ff3..2bc9563ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -306,8 +306,18 @@ jobs: - name: check git status run: git --no-pager diff --exit-code + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64 + run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libayatana-appindicator3-dev gcc-mingw-w64-x86-64 - name: Decode GPG signing key if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository @@ -327,9 +337,9 @@ jobs: - name: Install goversioninfo run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e - name: Generate windows syso amd64 - run: goversioninfo -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso + run: goversioninfo -64 -icon client/ui-wails/build/windows/icon.ico -manifest client/ui-wails/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui-wails/resources_windows_amd64.syso - name: Generate windows syso arm64 - run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso + run: goversioninfo -arm -64 -icon client/ui-wails/build/windows/icon.ico -manifest client/ui-wails/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui-wails/resources_windows_arm64.syso - name: Run GoReleaser uses: goreleaser/goreleaser-action@v4 @@ -393,6 +403,14 @@ jobs: run: go mod tidy - name: check git status run: git --no-pager diff --exit-code + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Set up pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 - name: Run GoReleaser id: goreleaser uses: goreleaser/goreleaser-action@v4 diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 470f1deaa..e18aac435 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -1,9 +1,14 @@ version: 2 project_name: netbird-ui + +before: + hooks: + - sh -c 'cd client/ui-wails/frontend && pnpm install --frozen-lockfile && pnpm build' + builds: - id: netbird-ui - dir: client/ui + dir: client/ui-wails binary: netbird-ui env: - CGO_ENABLED=1 @@ -16,7 +21,7 @@ builds: mod_timestamp: "{{ .CommitTimestamp }}" - id: netbird-ui-windows-amd64 - dir: client/ui + dir: client/ui-wails binary: netbird-ui env: - CGO_ENABLED=1 @@ -31,7 +36,7 @@ builds: mod_timestamp: "{{ .CommitTimestamp }}" - id: netbird-ui-windows-arm64 - dir: client/ui + dir: client/ui-wails binary: netbird-ui env: - CGO_ENABLED=1 @@ -70,12 +75,15 @@ nfpms: scripts: postinstall: "release_files/ui-post-install.sh" contents: - - src: client/ui/build/netbird.desktop + - src: client/ui-wails/build/linux/netbird.desktop dst: /usr/share/applications/netbird.desktop - - src: client/ui/assets/netbird.png + - src: client/ui-wails/build/appicon.png dst: /usr/share/pixmaps/netbird.png dependencies: - netbird + - libgtk-3-0 + - libwebkit2gtk-4.1-0 + - libayatana-appindicator3-1 - maintainer: Netbird description: Netbird client UI. @@ -89,12 +97,15 @@ nfpms: scripts: postinstall: "release_files/ui-post-install.sh" contents: - - src: client/ui/build/netbird.desktop + - src: client/ui-wails/build/linux/netbird.desktop dst: /usr/share/applications/netbird.desktop - - src: client/ui/assets/netbird.png + - src: client/ui-wails/build/appicon.png dst: /usr/share/pixmaps/netbird.png dependencies: - netbird + - gtk3 + - webkit2gtk4.1 + - libayatana-appindicator3 rpm: signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' diff --git a/.goreleaser_ui_darwin.yaml b/.goreleaser_ui_darwin.yaml index 0a0082075..4ee9b4507 100644 --- a/.goreleaser_ui_darwin.yaml +++ b/.goreleaser_ui_darwin.yaml @@ -1,9 +1,14 @@ version: 2 project_name: netbird-ui + +before: + hooks: + - sh -c 'cd client/ui-wails/frontend && pnpm install --frozen-lockfile && pnpm build' + builds: - id: netbird-ui-darwin - dir: client/ui + dir: client/ui-wails binary: netbird-ui env: - CGO_ENABLED=1 @@ -20,8 +25,6 @@ builds: ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" - tags: - - load_wgnt_from_rsrc universal_binaries: - id: netbird-ui-darwin diff --git a/client/ui-wails/.gitignore b/client/ui-wails/.gitignore index d779b3d07..0f4fa7363 100644 --- a/client/ui-wails/.gitignore +++ b/client/ui-wails/.gitignore @@ -2,6 +2,5 @@ bin frontend/dist frontend/node_modules -frontend/bindings build/linux/appimage/build build/windows/nsis/MicrosoftEdgeWebview2Setup.exe diff --git a/client/ui-wails/build/linux/netbird.desktop b/client/ui-wails/build/linux/netbird.desktop new file mode 100644 index 000000000..0b702fc07 --- /dev/null +++ b/client/ui-wails/build/linux/netbird.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Netbird +Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui +Icon=netbird +Type=Application +Terminal=false +Categories=Utility; +Keywords=netbird; \ No newline at end of file diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts new file mode 100644 index 000000000..bff04759e --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts @@ -0,0 +1,40 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Connection groups the daemon RPCs that drive login / connect / disconnect. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function Down(): $CancellablePromise { + return $Call.ByID(1062334452); +} + +export function Login(p: $models.LoginParams): $CancellablePromise<$models.LoginResult> { + return $Call.ByID(782816741, p).then(($result: any) => { + return $$createType0($result); + }); +} + +export function Logout(p: $models.LogoutParams): $CancellablePromise { + return $Call.ByID(4028053230, p); +} + +export function Up(p: $models.UpParams): $CancellablePromise { + return $Call.ByID(1178388469, p); +} + +export function WaitSSOLogin(p: $models.WaitSSOParams): $CancellablePromise { + return $Call.ByID(3487329509, p); +} + +// Private type creation functions +const $$createType0 = $models.LoginResult.createFrom; diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/debug.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/debug.ts new file mode 100644 index 000000000..4711064bb --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/debug.ts @@ -0,0 +1,35 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Debug groups debug / log-level / packet-trace RPCs. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function Bundle(p: $models.DebugBundleParams): $CancellablePromise<$models.DebugBundleResult> { + return $Call.ByID(1875836985, p).then(($result: any) => { + return $$createType0($result); + }); +} + +export function GetLogLevel(): $CancellablePromise<$models.LogLevel> { + return $Call.ByID(2713455331).then(($result: any) => { + return $$createType1($result); + }); +} + +export function SetLogLevel(lvl: $models.LogLevel): $CancellablePromise { + return $Call.ByID(2627038775, lvl); +} + +// Private type creation functions +const $$createType0 = $models.DebugBundleResult.createFrom; +const $$createType1 = $models.LogLevel.createFrom; diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/index.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/index.ts new file mode 100644 index 000000000..90203c174 --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/index.ts @@ -0,0 +1,47 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as Connection from "./connection.js"; +import * as Debug from "./debug.js"; +import * as Networks from "./networks.js"; +import * as Peers from "./peers.js"; +import * as Profiles from "./profiles.js"; +import * as Settings from "./settings.js"; +import * as Update from "./update.js"; +export { + Connection, + Debug, + Networks, + Peers, + Profiles, + Settings, + Update +}; + +export { + ActiveProfile, + Config, + ConfigParams, + DebugBundleParams, + DebugBundleResult, + Features, + LocalPeer, + LogLevel, + LoginParams, + LoginResult, + LogoutParams, + Network, + PeerLink, + PeerStatus, + Profile, + ProfileRef, + SelectNetworksParams, + SetConfigParams, + Status, + SystemEvent, + UpParams, + UpdateAvailable, + UpdateProgress, + UpdateResult, + WaitSSOParams +} from "./models.js"; diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.ts new file mode 100644 index 000000000..d22a345e0 --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.ts @@ -0,0 +1,1067 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Create as $Create } from "@wailsio/runtime"; + +/** + * ActiveProfile is the result of GetActiveProfile. + */ +export class ActiveProfile { + "profileName": string; + "username": string; + + /** Creates a new ActiveProfile instance. */ + constructor($$source: Partial = {}) { + if (!("profileName" in $$source)) { + this["profileName"] = ""; + } + if (!("username" in $$source)) { + this["username"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new ActiveProfile instance from a string or object. + */ + static createFrom($$source: any = {}): ActiveProfile { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new ActiveProfile($$parsedSource as Partial); + } +} + +/** + * Config is the daemon configuration the UI exposes in the settings window. + * Pointer fields mark "set" vs "unset" so the UI can omit a value to keep the + * daemon's current setting (matching SetConfigRequest's optional semantics). + */ +export class Config { + "managementUrl": string; + "adminUrl": string; + "configFile": string; + "logFile": string; + "preSharedKey": string; + "interfaceName": string; + "wireguardPort": number; + "mtu": number; + "disableAutoConnect": boolean; + "serverSshAllowed": boolean; + "rosenpassEnabled": boolean; + "rosenpassPermissive": boolean; + "disableNotifications": boolean; + "lazyConnectionEnabled": boolean; + "blockInbound": boolean; + "networkMonitor": boolean; + "disableClientRoutes": boolean; + "disableServerRoutes": boolean; + "disableDns": boolean; + "blockLanAccess": boolean; + "enableSshRoot": boolean; + "enableSshSftp": boolean; + "enableSshLocalPortForwarding": boolean; + "enableSshRemotePortForwarding": boolean; + "disableSshAuth": boolean; + "sshJwtCacheTtl": number; + + /** Creates a new Config instance. */ + constructor($$source: Partial = {}) { + if (!("managementUrl" in $$source)) { + this["managementUrl"] = ""; + } + if (!("adminUrl" in $$source)) { + this["adminUrl"] = ""; + } + if (!("configFile" in $$source)) { + this["configFile"] = ""; + } + if (!("logFile" in $$source)) { + this["logFile"] = ""; + } + if (!("preSharedKey" in $$source)) { + this["preSharedKey"] = ""; + } + if (!("interfaceName" in $$source)) { + this["interfaceName"] = ""; + } + if (!("wireguardPort" in $$source)) { + this["wireguardPort"] = 0; + } + if (!("mtu" in $$source)) { + this["mtu"] = 0; + } + if (!("disableAutoConnect" in $$source)) { + this["disableAutoConnect"] = false; + } + if (!("serverSshAllowed" in $$source)) { + this["serverSshAllowed"] = false; + } + if (!("rosenpassEnabled" in $$source)) { + this["rosenpassEnabled"] = false; + } + if (!("rosenpassPermissive" in $$source)) { + this["rosenpassPermissive"] = false; + } + if (!("disableNotifications" in $$source)) { + this["disableNotifications"] = false; + } + if (!("lazyConnectionEnabled" in $$source)) { + this["lazyConnectionEnabled"] = false; + } + if (!("blockInbound" in $$source)) { + this["blockInbound"] = false; + } + if (!("networkMonitor" in $$source)) { + this["networkMonitor"] = false; + } + if (!("disableClientRoutes" in $$source)) { + this["disableClientRoutes"] = false; + } + if (!("disableServerRoutes" in $$source)) { + this["disableServerRoutes"] = false; + } + if (!("disableDns" in $$source)) { + this["disableDns"] = false; + } + if (!("blockLanAccess" in $$source)) { + this["blockLanAccess"] = false; + } + if (!("enableSshRoot" in $$source)) { + this["enableSshRoot"] = false; + } + if (!("enableSshSftp" in $$source)) { + this["enableSshSftp"] = false; + } + if (!("enableSshLocalPortForwarding" in $$source)) { + this["enableSshLocalPortForwarding"] = false; + } + if (!("enableSshRemotePortForwarding" in $$source)) { + this["enableSshRemotePortForwarding"] = false; + } + if (!("disableSshAuth" in $$source)) { + this["disableSshAuth"] = false; + } + if (!("sshJwtCacheTtl" in $$source)) { + this["sshJwtCacheTtl"] = 0; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Config instance from a string or object. + */ + static createFrom($$source: any = {}): Config { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new Config($$parsedSource as Partial); + } +} + +/** + * ConfigParams selects which profile/user to read or write config for. + */ +export class ConfigParams { + "profileName": string; + "username": string; + + /** Creates a new ConfigParams instance. */ + constructor($$source: Partial = {}) { + if (!("profileName" in $$source)) { + this["profileName"] = ""; + } + if (!("username" in $$source)) { + this["username"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new ConfigParams instance from a string or object. + */ + static createFrom($$source: any = {}): ConfigParams { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new ConfigParams($$parsedSource as Partial); + } +} + +/** + * DebugBundleParams configures what the daemon collects when generating a + * debug bundle. + */ +export class DebugBundleParams { + "anonymize": boolean; + "systemInfo": boolean; + "uploadUrl": string; + "logFileCount": number; + + /** Creates a new DebugBundleParams instance. */ + constructor($$source: Partial = {}) { + if (!("anonymize" in $$source)) { + this["anonymize"] = false; + } + if (!("systemInfo" in $$source)) { + this["systemInfo"] = false; + } + if (!("uploadUrl" in $$source)) { + this["uploadUrl"] = ""; + } + if (!("logFileCount" in $$source)) { + this["logFileCount"] = 0; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DebugBundleParams instance from a string or object. + */ + static createFrom($$source: any = {}): DebugBundleParams { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new DebugBundleParams($$parsedSource as Partial); + } +} + +/** + * DebugBundleResult mirrors DebugBundleResponse — Path is set on local-only + * bundles, UploadedKey on successful uploads, UploadFailureReason on failed + * uploads. + */ +export class DebugBundleResult { + "path": string; + "uploadedKey": string; + "uploadFailureReason": string; + + /** Creates a new DebugBundleResult instance. */ + constructor($$source: Partial = {}) { + if (!("path" in $$source)) { + this["path"] = ""; + } + if (!("uploadedKey" in $$source)) { + this["uploadedKey"] = ""; + } + if (!("uploadFailureReason" in $$source)) { + this["uploadFailureReason"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DebugBundleResult instance from a string or object. + */ + static createFrom($$source: any = {}): DebugBundleResult { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new DebugBundleResult($$parsedSource as Partial); + } +} + +/** + * Features reports which UI surfaces the daemon has disabled. The Fyne UI uses + * these flags to grey out menu items the operator turned off server-side. + */ +export class Features { + "disableProfiles": boolean; + "disableUpdateSettings": boolean; + "disableNetworks": boolean; + + /** Creates a new Features instance. */ + constructor($$source: Partial = {}) { + if (!("disableProfiles" in $$source)) { + this["disableProfiles"] = false; + } + if (!("disableUpdateSettings" in $$source)) { + this["disableUpdateSettings"] = false; + } + if (!("disableNetworks" in $$source)) { + this["disableNetworks"] = false; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Features instance from a string or object. + */ + static createFrom($$source: any = {}): Features { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new Features($$parsedSource as Partial); + } +} + +/** + * LocalPeer mirrors LocalPeerState — what this client looks like on the mesh. + */ +export class LocalPeer { + "ip": string; + "pubKey": string; + "fqdn": string; + "networks": string[]; + + /** Creates a new LocalPeer instance. */ + constructor($$source: Partial = {}) { + if (!("ip" in $$source)) { + this["ip"] = ""; + } + if (!("pubKey" in $$source)) { + this["pubKey"] = ""; + } + if (!("fqdn" in $$source)) { + this["fqdn"] = ""; + } + if (!("networks" in $$source)) { + this["networks"] = []; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new LocalPeer instance from a string or object. + */ + static createFrom($$source: any = {}): LocalPeer { + const $$createField3_0 = $$createType0; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("networks" in $$parsedSource) { + $$parsedSource["networks"] = $$createField3_0($$parsedSource["networks"]); + } + return new LocalPeer($$parsedSource as Partial); + } +} + +/** + * LogLevel is a single log-level value the daemon understands ("error", + * "warn", "info", "debug", "trace"). + */ +export class LogLevel { + "level": string; + + /** Creates a new LogLevel instance. */ + constructor($$source: Partial = {}) { + if (!("level" in $$source)) { + this["level"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new LogLevel instance from a string or object. + */ + static createFrom($$source: any = {}): LogLevel { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new LogLevel($$parsedSource as Partial); + } +} + +/** + * LoginParams carries the fields the UI sets when starting a login. + */ +export class LoginParams { + "profileName": string; + "username": string; + "managementUrl": string; + "setupKey": string; + "preSharedKey": string; + "hostname": string; + "hint": string; + + /** Creates a new LoginParams instance. */ + constructor($$source: Partial = {}) { + if (!("profileName" in $$source)) { + this["profileName"] = ""; + } + if (!("username" in $$source)) { + this["username"] = ""; + } + if (!("managementUrl" in $$source)) { + this["managementUrl"] = ""; + } + if (!("setupKey" in $$source)) { + this["setupKey"] = ""; + } + if (!("preSharedKey" in $$source)) { + this["preSharedKey"] = ""; + } + if (!("hostname" in $$source)) { + this["hostname"] = ""; + } + if (!("hint" in $$source)) { + this["hint"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new LoginParams instance from a string or object. + */ + static createFrom($$source: any = {}): LoginParams { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new LoginParams($$parsedSource as Partial); + } +} + +/** + * LoginResult is the daemon's reply to a Login call. + */ +export class LoginResult { + "needsSsoLogin": boolean; + "userCode": string; + "verificationUri": string; + "verificationUriComplete": string; + + /** Creates a new LoginResult instance. */ + constructor($$source: Partial = {}) { + if (!("needsSsoLogin" in $$source)) { + this["needsSsoLogin"] = false; + } + if (!("userCode" in $$source)) { + this["userCode"] = ""; + } + if (!("verificationUri" in $$source)) { + this["verificationUri"] = ""; + } + if (!("verificationUriComplete" in $$source)) { + this["verificationUriComplete"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new LoginResult instance from a string or object. + */ + static createFrom($$source: any = {}): LoginResult { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new LoginResult($$parsedSource as Partial); + } +} + +/** + * LogoutParams selects the profile the daemon should log out. + */ +export class LogoutParams { + "profileName": string; + "username": string; + + /** Creates a new LogoutParams instance. */ + constructor($$source: Partial = {}) { + if (!("profileName" in $$source)) { + this["profileName"] = ""; + } + if (!("username" in $$source)) { + this["username"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new LogoutParams instance from a string or object. + */ + static createFrom($$source: any = {}): LogoutParams { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new LogoutParams($$parsedSource as Partial); + } +} + +/** + * Network is one routed network the daemon offers to the client. + */ +export class Network { + "id": string; + "range": string; + "selected": boolean; + "domains": string[]; + "resolvedIps": { [_ in string]?: string[] }; + + /** Creates a new Network instance. */ + constructor($$source: Partial = {}) { + if (!("id" in $$source)) { + this["id"] = ""; + } + if (!("range" in $$source)) { + this["range"] = ""; + } + if (!("selected" in $$source)) { + this["selected"] = false; + } + if (!("domains" in $$source)) { + this["domains"] = []; + } + if (!("resolvedIps" in $$source)) { + this["resolvedIps"] = {}; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Network instance from a string or object. + */ + static createFrom($$source: any = {}): Network { + const $$createField3_0 = $$createType0; + const $$createField4_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("domains" in $$parsedSource) { + $$parsedSource["domains"] = $$createField3_0($$parsedSource["domains"]); + } + if ("resolvedIps" in $$parsedSource) { + $$parsedSource["resolvedIps"] = $$createField4_0($$parsedSource["resolvedIps"]); + } + return new Network($$parsedSource as Partial); + } +} + +/** + * PeerLink is one of the named connections between this peer and its mgmt + * or signal server. + */ +export class PeerLink { + "url": string; + "connected": boolean; + "error"?: string; + + /** Creates a new PeerLink instance. */ + constructor($$source: Partial = {}) { + if (!("url" in $$source)) { + this["url"] = ""; + } + if (!("connected" in $$source)) { + this["connected"] = false; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new PeerLink instance from a string or object. + */ + static createFrom($$source: any = {}): PeerLink { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new PeerLink($$parsedSource as Partial); + } +} + +/** + * PeerStatus is the frontend-facing shape of a daemon PeerState. Carries + * enough detail for the dashboard's compact peer row plus the on-click + * troubleshooting expansion (ICE candidate types, endpoints, handshake age). + */ +export class PeerStatus { + "ip": string; + "pubKey": string; + "connStatus": string; + "connStatusUpdateUnix": number; + "relayed": boolean; + "localIceCandidateType": string; + "remoteIceCandidateType": string; + "localIceCandidateEndpoint": string; + "remoteIceCandidateEndpoint": string; + "fqdn": string; + "bytesRx": number; + "bytesTx": number; + "latencyMs": number; + "relayAddress": string; + "lastHandshakeUnix": number; + "rosenpassEnabled": boolean; + "networks": string[]; + + /** Creates a new PeerStatus instance. */ + constructor($$source: Partial = {}) { + if (!("ip" in $$source)) { + this["ip"] = ""; + } + if (!("pubKey" in $$source)) { + this["pubKey"] = ""; + } + if (!("connStatus" in $$source)) { + this["connStatus"] = ""; + } + if (!("connStatusUpdateUnix" in $$source)) { + this["connStatusUpdateUnix"] = 0; + } + if (!("relayed" in $$source)) { + this["relayed"] = false; + } + if (!("localIceCandidateType" in $$source)) { + this["localIceCandidateType"] = ""; + } + if (!("remoteIceCandidateType" in $$source)) { + this["remoteIceCandidateType"] = ""; + } + if (!("localIceCandidateEndpoint" in $$source)) { + this["localIceCandidateEndpoint"] = ""; + } + if (!("remoteIceCandidateEndpoint" in $$source)) { + this["remoteIceCandidateEndpoint"] = ""; + } + if (!("fqdn" in $$source)) { + this["fqdn"] = ""; + } + if (!("bytesRx" in $$source)) { + this["bytesRx"] = 0; + } + if (!("bytesTx" in $$source)) { + this["bytesTx"] = 0; + } + if (!("latencyMs" in $$source)) { + this["latencyMs"] = 0; + } + if (!("relayAddress" in $$source)) { + this["relayAddress"] = ""; + } + if (!("lastHandshakeUnix" in $$source)) { + this["lastHandshakeUnix"] = 0; + } + if (!("rosenpassEnabled" in $$source)) { + this["rosenpassEnabled"] = false; + } + if (!("networks" in $$source)) { + this["networks"] = []; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new PeerStatus instance from a string or object. + */ + static createFrom($$source: any = {}): PeerStatus { + const $$createField16_0 = $$createType0; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("networks" in $$parsedSource) { + $$parsedSource["networks"] = $$createField16_0($$parsedSource["networks"]); + } + return new PeerStatus($$parsedSource as Partial); + } +} + +/** + * Profile is one named daemon profile. + */ +export class Profile { + "name": string; + "isActive": boolean; + + /** Creates a new Profile instance. */ + constructor($$source: Partial = {}) { + if (!("name" in $$source)) { + this["name"] = ""; + } + if (!("isActive" in $$source)) { + this["isActive"] = false; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Profile instance from a string or object. + */ + static createFrom($$source: any = {}): Profile { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new Profile($$parsedSource as Partial); + } +} + +/** + * ProfileRef identifies a profile by name+username. + */ +export class ProfileRef { + "profileName": string; + "username": string; + + /** Creates a new ProfileRef instance. */ + constructor($$source: Partial = {}) { + if (!("profileName" in $$source)) { + this["profileName"] = ""; + } + if (!("username" in $$source)) { + this["username"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new ProfileRef instance from a string or object. + */ + static createFrom($$source: any = {}): ProfileRef { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new ProfileRef($$parsedSource as Partial); + } +} + +/** + * SelectNetworksParams selects which networks to enable / disable. + * All means "every available network" (used by Select-All / Deselect-All buttons); + * Append means "leave the existing selection in place and merge these IDs in". + */ +export class SelectNetworksParams { + "networkIds": string[]; + "append": boolean; + "all": boolean; + + /** Creates a new SelectNetworksParams instance. */ + constructor($$source: Partial = {}) { + if (!("networkIds" in $$source)) { + this["networkIds"] = []; + } + if (!("append" in $$source)) { + this["append"] = false; + } + if (!("all" in $$source)) { + this["all"] = false; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SelectNetworksParams instance from a string or object. + */ + static createFrom($$source: any = {}): SelectNetworksParams { + const $$createField0_0 = $$createType0; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("networkIds" in $$parsedSource) { + $$parsedSource["networkIds"] = $$createField0_0($$parsedSource["networkIds"]); + } + return new SelectNetworksParams($$parsedSource as Partial); + } +} + +/** + * SetConfigParams is a partial update — only fields with non-nil pointers + * are sent to the daemon. The frontend uses this to flip individual toggles. + */ +export class SetConfigParams { + "profileName": string; + "username": string; + "managementUrl": string; + "adminUrl": string; + "interfaceName"?: string | null; + "wireguardPort"?: number | null; + "mtu"?: number | null; + "preSharedKey"?: string | null; + "disableAutoConnect"?: boolean | null; + "serverSshAllowed"?: boolean | null; + "rosenpassEnabled"?: boolean | null; + "rosenpassPermissive"?: boolean | null; + "disableNotifications"?: boolean | null; + "lazyConnectionEnabled"?: boolean | null; + "blockInbound"?: boolean | null; + "networkMonitor"?: boolean | null; + "disableClientRoutes"?: boolean | null; + "disableServerRoutes"?: boolean | null; + "disableDns"?: boolean | null; + "disableFirewall"?: boolean | null; + "blockLanAccess"?: boolean | null; + "enableSshRoot"?: boolean | null; + "enableSshSftp"?: boolean | null; + "enableSshLocalPortForwarding"?: boolean | null; + "enableSshRemotePortForwarding"?: boolean | null; + "disableSshAuth"?: boolean | null; + "sshJwtCacheTtl"?: number | null; + + /** Creates a new SetConfigParams instance. */ + constructor($$source: Partial = {}) { + if (!("profileName" in $$source)) { + this["profileName"] = ""; + } + if (!("username" in $$source)) { + this["username"] = ""; + } + if (!("managementUrl" in $$source)) { + this["managementUrl"] = ""; + } + if (!("adminUrl" in $$source)) { + this["adminUrl"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SetConfigParams instance from a string or object. + */ + static createFrom($$source: any = {}): SetConfigParams { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new SetConfigParams($$parsedSource as Partial); + } +} + +/** + * Status is the snapshot the frontend renders on the dashboard. + */ +export class Status { + "status": string; + "daemonVersion": string; + "management": PeerLink; + "signal": PeerLink; + "local": LocalPeer; + "peers": PeerStatus[]; + "events": SystemEvent[]; + + /** Creates a new Status instance. */ + constructor($$source: Partial = {}) { + if (!("status" in $$source)) { + this["status"] = ""; + } + if (!("daemonVersion" in $$source)) { + this["daemonVersion"] = ""; + } + if (!("management" in $$source)) { + this["management"] = (new PeerLink()); + } + if (!("signal" in $$source)) { + this["signal"] = (new PeerLink()); + } + if (!("local" in $$source)) { + this["local"] = (new LocalPeer()); + } + if (!("peers" in $$source)) { + this["peers"] = []; + } + if (!("events" in $$source)) { + this["events"] = []; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Status instance from a string or object. + */ + static createFrom($$source: any = {}): Status { + const $$createField2_0 = $$createType2; + const $$createField3_0 = $$createType2; + const $$createField4_0 = $$createType3; + const $$createField5_0 = $$createType5; + const $$createField6_0 = $$createType7; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("management" in $$parsedSource) { + $$parsedSource["management"] = $$createField2_0($$parsedSource["management"]); + } + if ("signal" in $$parsedSource) { + $$parsedSource["signal"] = $$createField3_0($$parsedSource["signal"]); + } + if ("local" in $$parsedSource) { + $$parsedSource["local"] = $$createField4_0($$parsedSource["local"]); + } + if ("peers" in $$parsedSource) { + $$parsedSource["peers"] = $$createField5_0($$parsedSource["peers"]); + } + if ("events" in $$parsedSource) { + $$parsedSource["events"] = $$createField6_0($$parsedSource["events"]); + } + return new Status($$parsedSource as Partial); + } +} + +/** + * SystemEvent is the frontend-facing shape of a daemon SystemEvent. + */ +export class SystemEvent { + "id": string; + "severity": string; + "category": string; + "message": string; + "userMessage": string; + "timestamp": number; + "metadata": { [_ in string]?: string }; + + /** Creates a new SystemEvent instance. */ + constructor($$source: Partial = {}) { + if (!("id" in $$source)) { + this["id"] = ""; + } + if (!("severity" in $$source)) { + this["severity"] = ""; + } + if (!("category" in $$source)) { + this["category"] = ""; + } + if (!("message" in $$source)) { + this["message"] = ""; + } + if (!("userMessage" in $$source)) { + this["userMessage"] = ""; + } + if (!("timestamp" in $$source)) { + this["timestamp"] = 0; + } + if (!("metadata" in $$source)) { + this["metadata"] = {}; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SystemEvent instance from a string or object. + */ + static createFrom($$source: any = {}): SystemEvent { + const $$createField6_0 = $$createType8; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("metadata" in $$parsedSource) { + $$parsedSource["metadata"] = $$createField6_0($$parsedSource["metadata"]); + } + return new SystemEvent($$parsedSource as Partial); + } +} + +/** + * UpParams selects the profile the daemon should bring up. + */ +export class UpParams { + "profileName": string; + "username": string; + + /** Creates a new UpParams instance. */ + constructor($$source: Partial = {}) { + if (!("profileName" in $$source)) { + this["profileName"] = ""; + } + if (!("username" in $$source)) { + this["username"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new UpParams instance from a string or object. + */ + static createFrom($$source: any = {}): UpParams { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new UpParams($$parsedSource as Partial); + } +} + +/** + * UpdateAvailable carries the new_version_available metadata. + */ +export class UpdateAvailable { + "version": string; + "enforced": boolean; + + /** Creates a new UpdateAvailable instance. */ + constructor($$source: Partial = {}) { + if (!("version" in $$source)) { + this["version"] = ""; + } + if (!("enforced" in $$source)) { + this["enforced"] = false; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new UpdateAvailable instance from a string or object. + */ + static createFrom($$source: any = {}): UpdateAvailable { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new UpdateAvailable($$parsedSource as Partial); + } +} + +/** + * UpdateProgress carries the progress_window metadata. + */ +export class UpdateProgress { + "action": string; + "version": string; + + /** Creates a new UpdateProgress instance. */ + constructor($$source: Partial = {}) { + if (!("action" in $$source)) { + this["action"] = ""; + } + if (!("version" in $$source)) { + this["version"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new UpdateProgress instance from a string or object. + */ + static createFrom($$source: any = {}): UpdateProgress { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new UpdateProgress($$parsedSource as Partial); + } +} + +/** + * UpdateResult mirrors TriggerUpdateResponse: Success false carries an error + * message in ErrorMsg. + */ +export class UpdateResult { + "success": boolean; + "errorMsg": string; + + /** Creates a new UpdateResult instance. */ + constructor($$source: Partial = {}) { + if (!("success" in $$source)) { + this["success"] = false; + } + if (!("errorMsg" in $$source)) { + this["errorMsg"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new UpdateResult instance from a string or object. + */ + static createFrom($$source: any = {}): UpdateResult { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new UpdateResult($$parsedSource as Partial); + } +} + +/** + * WaitSSOParams carries the fields the UI passes to WaitSSOLogin. + */ +export class WaitSSOParams { + "userCode": string; + "hostname": string; + + /** Creates a new WaitSSOParams instance. */ + constructor($$source: Partial = {}) { + if (!("userCode" in $$source)) { + this["userCode"] = ""; + } + if (!("hostname" in $$source)) { + this["hostname"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new WaitSSOParams instance from a string or object. + */ + static createFrom($$source: any = {}): WaitSSOParams { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new WaitSSOParams($$parsedSource as Partial); + } +} + +// Private type creation functions +const $$createType0 = $Create.Array($Create.Any); +const $$createType1 = $Create.Map($Create.Any, $$createType0); +const $$createType2 = PeerLink.createFrom; +const $$createType3 = LocalPeer.createFrom; +const $$createType4 = PeerStatus.createFrom; +const $$createType5 = $Create.Array($$createType4); +const $$createType6 = SystemEvent.createFrom; +const $$createType7 = $Create.Array($$createType6); +const $$createType8 = $Create.Map($Create.Any, $Create.Any); diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/networks.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/networks.ts new file mode 100644 index 000000000..37967ff58 --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/networks.ts @@ -0,0 +1,33 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Networks groups the daemon RPCs that read and toggle routed networks. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function Deselect(p: $models.SelectNetworksParams): $CancellablePromise { + return $Call.ByID(2335193802, p); +} + +export function List(): $CancellablePromise<$models.Network[]> { + return $Call.ByID(719769457).then(($result: any) => { + return $$createType1($result); + }); +} + +export function Select(p: $models.SelectNetworksParams): $CancellablePromise { + return $Call.ByID(3714393053, p); +} + +// Private type creation functions +const $$createType0 = $models.Network.createFrom; +const $$createType1 = $Create.Array($$createType0); diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/peers.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/peers.ts new file mode 100644 index 000000000..2d361d41b --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/peers.ts @@ -0,0 +1,39 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Peers serves the dashboard data: one polled Status RPC and a long-running + * SubscribeEvents stream that re-emits every event over the Wails event bus. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +/** + * Get returns the current daemon status snapshot. + */ +export function Get(): $CancellablePromise<$models.Status> { + return $Call.ByID(196038193).then(($result: any) => { + return $$createType0($result); + }); +} + +/** + * Watch starts the background loops that feed the frontend: a status + * stream (push-driven on connection-state change) and an event stream + * (DNS / network / auth / connectivity / update notifications). + * Safe to call once at boot; both loops self-restart on stream errors + * via exponential backoff. + */ +export function Watch(): $CancellablePromise { + return $Call.ByID(741320382); +} + +// Private type creation functions +const $$createType0 = $models.Status.createFrom; diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/profiles.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/profiles.ts new file mode 100644 index 000000000..f41cdba25 --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/profiles.ts @@ -0,0 +1,52 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Profiles groups the daemon RPCs that manage named profiles. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function Add(p: $models.ProfileRef): $CancellablePromise { + return $Call.ByID(701512397, p); +} + +export function GetActive(): $CancellablePromise<$models.ActiveProfile> { + return $Call.ByID(2605259596).then(($result: any) => { + return $$createType0($result); + }); +} + +export function List(username: string): $CancellablePromise<$models.Profile[]> { + return $Call.ByID(1745269178, username).then(($result: any) => { + return $$createType2($result); + }); +} + +export function Remove(p: $models.ProfileRef): $CancellablePromise { + return $Call.ByID(2506403914, p); +} + +export function Switch(p: $models.ProfileRef): $CancellablePromise { + return $Call.ByID(3405248534, p); +} + +/** + * Username returns the OS username the daemon expects for profile lookups. + * The frontend calls this once at boot and reuses the result. + */ +export function Username(): $CancellablePromise { + return $Call.ByID(1939223418); +} + +// Private type creation functions +const $$createType0 = $models.ActiveProfile.createFrom; +const $$createType1 = $models.Profile.createFrom; +const $$createType2 = $Create.Array($$createType1); diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/settings.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/settings.ts new file mode 100644 index 000000000..7dff9029f --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/settings.ts @@ -0,0 +1,35 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Settings groups the daemon RPCs that read and write the daemon config. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function GetConfig(p: $models.ConfigParams): $CancellablePromise<$models.Config> { + return $Call.ByID(2849966711, p).then(($result: any) => { + return $$createType0($result); + }); +} + +export function GetFeatures(): $CancellablePromise<$models.Features> { + return $Call.ByID(376812026).then(($result: any) => { + return $$createType1($result); + }); +} + +export function SetConfig(p: $models.SetConfigParams): $CancellablePromise { + return $Call.ByID(565510651, p); +} + +// Private type creation functions +const $$createType0 = $models.Config.createFrom; +const $$createType1 = $models.Features.createFrom; diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts new file mode 100644 index 000000000..2f4a04289 --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts @@ -0,0 +1,30 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Update groups the RPCs that drive the enforced-update install flow. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function GetInstallerResult(): $CancellablePromise<$models.UpdateResult> { + return $Call.ByID(2190725314).then(($result: any) => { + return $$createType0($result); + }); +} + +export function Trigger(): $CancellablePromise<$models.UpdateResult> { + return $Call.ByID(2415339649).then(($result: any) => { + return $$createType0($result); + }); +} + +// Private type creation functions +const $$createType0 = $models.UpdateResult.createFrom; diff --git a/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts b/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts new file mode 100644 index 000000000..67cad2058 --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts @@ -0,0 +1,28 @@ +//@ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as services$0 from "../../../../netbirdio/netbird/client/ui-wails/services/models.js"; + +function configure() { + Object.freeze(Object.assign($Create.Events, { + "netbird:event": $$createType0, + "netbird:status": $$createType1, + "netbird:update:available": $$createType2, + "netbird:update:progress": $$createType3, + })); +} + +// Private type creation functions +const $$createType0 = services$0.SystemEvent.createFrom; +const $$createType1 = services$0.Status.createFrom; +const $$createType2 = services$0.UpdateAvailable.createFrom; +const $$createType3 = services$0.UpdateProgress.createFrom; + +configure(); diff --git a/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts new file mode 100644 index 000000000..063187544 --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -0,0 +1,21 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import type { Events } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import type * as services$0 from "../../../../netbirdio/netbird/client/ui-wails/services/models.js"; + +declare module "@wailsio/runtime" { + namespace Events { + interface CustomEvents { + "netbird:event": services$0.SystemEvent; + "netbird:status": services$0.Status; + "netbird:update:available": services$0.UpdateAvailable; + "netbird:update:progress": services$0.UpdateProgress; + } + } +} diff --git a/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts b/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts new file mode 100644 index 000000000..71eda3bb9 --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts @@ -0,0 +1,13 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as NotificationService from "./notificationservice.js"; +export { + NotificationService +}; + +export { + NotificationAction, + NotificationCategory, + NotificationOptions +} from "./models.js"; diff --git a/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts b/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts new file mode 100644 index 000000000..3fbcb8270 --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts @@ -0,0 +1,107 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Create as $Create } from "@wailsio/runtime"; + +/** + * NotificationAction represents an action button for a notification. + */ +export class NotificationAction { + "id"?: string; + "title"?: string; + + /** + * (macOS-specific) + */ + "destructive"?: boolean; + + /** Creates a new NotificationAction instance. */ + constructor($$source: Partial = {}) { + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationAction instance from a string or object. + */ + static createFrom($$source: any = {}): NotificationAction { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new NotificationAction($$parsedSource as Partial); + } +} + +/** + * NotificationCategory groups actions for notifications. + */ +export class NotificationCategory { + "id"?: string; + "actions"?: NotificationAction[]; + "hasReplyField"?: boolean; + "replyPlaceholder"?: string; + "replyButtonTitle"?: string; + + /** Creates a new NotificationCategory instance. */ + constructor($$source: Partial = {}) { + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationCategory instance from a string or object. + */ + static createFrom($$source: any = {}): NotificationCategory { + const $$createField1_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("actions" in $$parsedSource) { + $$parsedSource["actions"] = $$createField1_0($$parsedSource["actions"]); + } + return new NotificationCategory($$parsedSource as Partial); + } +} + +/** + * NotificationOptions contains configuration for a notification + */ +export class NotificationOptions { + "id": string; + "title": string; + + /** + * (macOS and Linux only) + */ + "subtitle"?: string; + "body"?: string; + "categoryId"?: string; + "data"?: { [_ in string]?: any }; + + /** Creates a new NotificationOptions instance. */ + constructor($$source: Partial = {}) { + if (!("id" in $$source)) { + this["id"] = ""; + } + if (!("title" in $$source)) { + this["title"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationOptions instance from a string or object. + */ + static createFrom($$source: any = {}): NotificationOptions { + const $$createField5_0 = $$createType2; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("data" in $$parsedSource) { + $$parsedSource["data"] = $$createField5_0($$parsedSource["data"]); + } + return new NotificationOptions($$parsedSource as Partial); + } +} + +// Private type creation functions +const $$createType0 = NotificationAction.createFrom; +const $$createType1 = $Create.Array($$createType0); +const $$createType2 = $Create.Map($Create.Any, $Create.Any); diff --git a/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.ts b/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.ts new file mode 100644 index 000000000..859f3570f --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.ts @@ -0,0 +1,62 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Service represents the notifications service + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function CheckNotificationAuthorization(): $CancellablePromise { + return $Call.ByID(2216952893); +} + +export function RegisterNotificationCategory(category: $models.NotificationCategory): $CancellablePromise { + return $Call.ByID(2917562919, category); +} + +export function RemoveAllDeliveredNotifications(): $CancellablePromise { + return $Call.ByID(3956282340); +} + +export function RemoveAllPendingNotifications(): $CancellablePromise { + return $Call.ByID(108821341); +} + +export function RemoveDeliveredNotification(identifier: string): $CancellablePromise { + return $Call.ByID(975691940, identifier); +} + +export function RemoveNotification(identifier: string): $CancellablePromise { + return $Call.ByID(3966653866, identifier); +} + +export function RemoveNotificationCategory(categoryID: string): $CancellablePromise { + return $Call.ByID(2032615554, categoryID); +} + +export function RemovePendingNotification(identifier: string): $CancellablePromise { + return $Call.ByID(3729049703, identifier); +} + +/** + * Public methods that delegate to the implementation. + */ +export function RequestNotificationAuthorization(): $CancellablePromise { + return $Call.ByID(3933442950); +} + +export function SendNotification(options: $models.NotificationOptions): $CancellablePromise { + return $Call.ByID(3968228732, options); +} + +export function SendNotificationWithActions(options: $models.NotificationOptions): $CancellablePromise { + return $Call.ByID(1886542847, options); +} From a1743dbf9b21621e5ffa6f4adbed028b8306f6d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 4 May 2026 14:00:52 +0200 Subject: [PATCH 19/80] [client/ui-wails] Fix Fedora ayatana-appindicator package name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RPM dependency name on Fedora is libayatana-appindicator-gtk3 (not libayatana-appindicator3 — that's the Debian/Ubuntu spelling). Verified with dnf install on Fedora 40. --- .goreleaser_ui.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index e18aac435..5721f61c8 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -105,7 +105,7 @@ nfpms: - netbird - gtk3 - webkit2gtk4.1 - - libayatana-appindicator3 + - libayatana-appindicator-gtk3 rpm: signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' From df58935cc001b0004189f961654bba3e64b2f3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 4 May 2026 14:34:45 +0200 Subject: [PATCH 20/80] [client/ui-wails] Set NetBird window and app icon on Linux Wails3 falls back to its bundled bird logo when no Icon is supplied via application.Options or LinuxWindow. Embed the 256x256 NetBird PNG and wire it through both fields, plus set ProgramName=netbird so GTK's g_set_prgname picks up the icon from the installed .desktop file. Tested on Fedora 40 + KDE Plasma; the titlebar and taskbar now show the NetBird logo. --- client/ui-wails/assets/netbird.png | Bin 0 -> 4800 bytes client/ui-wails/icons.go | 3 +++ client/ui-wails/main.go | 7 +++++++ 3 files changed, 10 insertions(+) create mode 100644 client/ui-wails/assets/netbird.png diff --git a/client/ui-wails/assets/netbird.png b/client/ui-wails/assets/netbird.png new file mode 100644 index 0000000000000000000000000000000000000000..a92e9ed4cd17d7998a819f1b80f7551b7004fd6e GIT binary patch literal 4800 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 Date: Mon, 4 May 2026 16:08:10 +0200 Subject: [PATCH 21/80] [client/ui-wails] Skip tray click-to-toggle on Linux GNOME Shell + AppIndicator extension opens the attached menu on left-click in addition to firing SNI Activate, so binding the window toggle to the click handler made both the window and the menu pop on a single click. The default Wails3 SystemTray.applySmartDefaults made it worse: AttachWindow alone is enough to install ToggleWindow as the implicit click handler, so dropping OnClick wasn't sufficient. Mirror the legacy Fyne client: skip both AttachWindow and OnClick on Linux and expose the main window through an explicit "Open NetBird" menu item. Windows and macOS keep the click-to-toggle behaviour where the OS cleanly separates left and right click. --- client/ui-wails/tray.go | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/client/ui-wails/tray.go b/client/ui-wails/tray.go index 5cd770150..4b1730ac4 100644 --- a/client/ui-wails/tray.go +++ b/client/ui-wails/tray.go @@ -75,8 +75,32 @@ func NewTray( t.applyIcon() t.tray.SetTooltip("NetBird") t.tray.SetMenu(t.buildMenu()) - t.tray.AttachWindow(window) - t.tray.OnClick(func() { t.toggleWindow() }) + // Tray click handling is platform-specific by design: + // + // On Windows and macOS the OS-level tray protocol cleanly separates left + // and right click. AttachWindow plus an explicit OnClick gives the + // expected "click the icon to toggle the window, right-click to open the + // menu" UX, and the platform never delivers both events at once. + // + // On Linux the tray rides on the org.kde.StatusNotifierItem D-Bus protocol + // (libayatana-appindicator). The SNI Activate signal *is* left-click, but + // several environments — GNOME Shell with the AppIndicator extension is + // the loudest offender — also pop the attached menu on left-click, + // regardless of the ItemIsMenu property the spec defines for that purpose. + // Worse, AttachWindow on its own is enough to trigger this: Wails3's + // SystemTray.applySmartDefaults installs ToggleWindow as the default + // click handler whenever a window is attached, so even without an + // explicit OnClick the window pops up alongside the menu. The result + // looks like a bug to users. + // + // Mirror the legacy Fyne client's behaviour on Linux: skip both + // AttachWindow and OnClick so left-click only opens the menu, and expose + // the window through an explicit "Open NetBird" item. Right-click still + // opens the menu through Wails' default rightClickHandler fallback. + if runtime.GOOS != "linux" { + t.tray.AttachWindow(window) + t.tray.OnClick(func() { t.toggleWindow() }) + } app.Event.On(services.EventStatus, t.onStatusEvent) app.Event.On(services.EventSystem, t.onSystemEvent) @@ -101,6 +125,13 @@ func (t *Tray) buildMenu() *application.Menu { t.statusItem = menu.Add("Disconnected").SetEnabled(false) menu.AddSeparator() + // On Linux the tray icon's left-click handler is intentionally unbound + // (see NewTray for the rationale), so expose the window through an + // explicit menu entry. Windows and macOS get the window via left-click. + if runtime.GOOS == "linux" { + menu.Add("Open NetBird").OnClick(func(*application.Context) { t.ShowWindow() }) + menu.AddSeparator() + } t.upItem = menu.Add("Connect").OnClick(func(*application.Context) { t.handleConnect() }) t.downItem = menu.Add("Disconnect").OnClick(func(*application.Context) { t.handleDisconnect() }) t.downItem.SetEnabled(false) From ce53981b553067948f7e972fba28f6ccd2e53836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 4 May 2026 16:20:15 +0200 Subject: [PATCH 22/80] [client/ui-wails] Fix Windows manifest version format Win32 assembly manifests require a four-part version (MAJOR.MINOR.BUILD.REVISION per the Microsoft schema). The Wails template shipped a three-part "0.0.1", which Windows rejects with "Activation context generation failed (...) The value 0.0.1 of attribute version in element assemblyIdentity is invalid", so the .exe never reaches main(). Pad to "0.0.1.0". --- client/ui-wails/build/windows/wails.exe.manifest | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ui-wails/build/windows/wails.exe.manifest b/client/ui-wails/build/windows/wails.exe.manifest index bfcd1c6ed..f8b7b8e14 100644 --- a/client/ui-wails/build/windows/wails.exe.manifest +++ b/client/ui-wails/build/windows/wails.exe.manifest @@ -1,6 +1,6 @@ - + @@ -19,4 +19,4 @@ - \ No newline at end of file + From ba6e10cef3b276ea8991384af1caef360eac4edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 4 May 2026 17:12:12 +0200 Subject: [PATCH 23/80] [client/ui-wails] Pad macOS tray PNGs for proper menubar sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wails3's macOS systray sets the NSImage size to the status bar thickness (~22pt) on a square frame. The legacy Fyne PNGs had almost no horizontal margin (the logo filled all 256x256), so under that explicit resize the glyph stretched to the full menubar height — noticeably larger than neighbouring SF Symbols-style indicators. Pad each *-macos.png from 256x256 to 366x366 with transparent gravity:center extent, leaving the glyph at ~70% of the rendered size. Same source PNGs, no resampling, just more breathing room around the alpha-only template. --- .../netbird-systemtray-connected-macos.png | Bin 3858 -> 3690 bytes .../netbird-systemtray-connecting-macos.png | Bin 3843 -> 3725 bytes .../netbird-systemtray-disconnected-macos.png | Bin 3491 -> 3474 bytes .../assets/netbird-systemtray-error-macos.png | Bin 3837 -> 3555 bytes ...bird-systemtray-update-connected-macos.png | Bin 3570 -> 3328 bytes ...d-systemtray-update-disconnected-macos.png | Bin 3816 -> 3747 bytes 6 files changed, 0 insertions(+), 0 deletions(-) diff --git a/client/ui-wails/assets/netbird-systemtray-connected-macos.png b/client/ui-wails/assets/netbird-systemtray-connected-macos.png index ead210250d7a4b25461aa0311fcb6c13cec75057..d29a7ade8821d87b12982d8b051ab5294a7a329d 100644 GIT binary patch literal 3690 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 OIuXWJMzz=7AN~`y+wr{s literal 3858 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 diff --git a/client/ui-wails/assets/netbird-systemtray-connecting-macos.png b/client/ui-wails/assets/netbird-systemtray-connecting-macos.png index 0fe7fa0dbe50bcf8c08e45c7cf13b7e6e2348335..306c6ddf555e6049c57ce8bcf98eee80bd2db730 100644 GIT binary patch literal 3725 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 literal 3843 zcmb7Hi9ZwWAKxsKV`S@a4LQ1zww8BQ9L&hqTVnMm(VHeJ{&1|y{dKffC8 zlC}S(ws`iw%h`)dEHU_pdi6w_!N!#5PadL=QO=_N`v+iSQXXKHruP4hlr2#ImWi<% zoXzW?5qCCq9zWzAKF>etFjCUW=XJz{cY;N>4EbkxDG*Z)mIC{22W_B*_&~D~F>|Qs zfuCgL?asi2(Og$66y)<{L5gjPW2J8>|Lb96mYj=*R~wyiLKm`Q>^?GkDZx=zBsMz< zbs%^$vu6X95kUemyud|bem^J2p;jpI9KDUwfk+KV4!sD8{!p`Q*v~9*wYF@!=pY_~ zAr2Ez;#o{1kUxY<_qITrzOmuUr5U`{M+KMgcyPtro-FD#kME!@KROYJThh7^Y3l~^ z>Y1mAMg0b{{{u_&^{Gm&9vpb`dM`a12q+-u)pAxi}#xJhT=C5oEYp&EtDXkU@1YgYf8?DRRDT3IPSqFv} zdNn#Q60x4J)0xqYTf5n6r{%S~S1=tnVRzn|>o8qESav)wmjB4E=S#Yq_SnbS$%<}F zcBO=8wQ%YtPu|(K&xi4;=gmCeJwKI!*yJG=`@h7gnSl{XB*vJw;xUR-t?&a+iGRO3{#qA>%JU2A!L zJcly<{`*rRWE=KE8E2*PP_bVBFYlT9_|uSDbV}J;jjqiQmGnM}m7|RBO*X2Ngr1uW zB`57iAB>tDcg%oubmKB3k2{7iHY{@pXqG5=O@HDdO793TXK0W;|fg17hgDb z5xg)sz$6g(=MG&-GE(oT*l?tM44khezIS;X$xhQ`{%|nQavaP;k5K?MtN+FfL)=nb!f4e5ae%ZaR;@ zsofv|=}9vlZk&B*dIEy^PFSRyPGNh~5lfYI=nrG|Jh{AjeeGFRA2chIQcr(-a%%CF zDox}5klXZ*2Tz_JLv7*ACsI0{rwI@3lH)fIIw`{SRj$M8&#DH4*+V&rnJ&vAIw3cq zdL-+r!+x|Zy-B>FgkT5l$r+hQo2sAEpaS?(Iz!wf6qwm_^S|ibf)q9}&y;xC`{CpYm*VlNw`a&1Tt*Ix9CKJ5YPME!DqK3y zk;u2&b>Vcl(G9gu&&3Au7!{}P4v^8BG2FkWEiX>}9pD1)+(z^T%+U{1Aj^rzNKTEe zPN>?#)|O<|P?T?28*XK)>VES-rO##MvPlN2`^&ifO)5Fdv*3RNFI^ z!8B}8brLTHBi*+#Gt7MW1Wff~yU)r4gMk;OOIYD3LZ!y-zQL!vSt>yFgz7*(Tjb>^ z*_oi@uGnI;Y&?x(dOy zp^!xA)o4swSsBtpxh76U7ukpL zD7e`W&q-c`{ZN`q*J&6EdQT%9k&0`vQ&Rn6AX`0ivG!@xty zO_^q9wS$!a_(vA)7v7D0F4b8zS z)%E}%xGK(}@Dgk;aRtG}`)kO5Tf{Q=an_G$C{yZ0CEB`NvE{N^%foMI74cu2{x*<) zqH1^N`OyX2*0Bs4?Zb(gTS66NO3;#kzV<%6Cxe7QXC&9hhwO%>m@U8R5oObY+F9(m zN=nX(uTr%X`v7(aeKMS9U9vB~Q@x?ufw{ zi4Q)`-S}MnD>5~@leqVzV|V)k8>14|K4#{t@4KzD!K&dC|-Iacb_{NbFk^QM>K3R8lS2S)AN> zVEfw+FeCk^La5}j6j!J5v;NZ}=VJ-;?~^tbF|A;G+k|CGw{M)L59rvexjDem3gZwF0d2TF+M>YM$^;==&;#R#mcmEPT7P|n^; zo**r6dJe9cT6`x#^9HDM_Po8XsMg-bM2IqtX6RY?PnF)BpWfY=l7Y*D$WFzK^bf;+ zfXF2&u5_-cm7+-fU5GLRp+`zHIS>J?*mLS=aLi~-@6Y_NDi9->17sCBgm zk9#Tl=wGlsli<{HB$f6Gh^+#q>+6yj1$j`TzaZCS-I9kykEPu2Woz9!K0s|D>=FxO!|G?UYHQa} z!k79sCQ>`7wO|j8-ERm=;ZXg2ak`gfj9oC5AyJd zs_x6};}7SXgm0@;iFuoZ(@RT-iL()7Z}ROXLIuM#ghw24%kZnnyQbPjyNJ4CF&Qpf z?$*{UIf7hO0j1U6Dgke#eO`WO5KzeAZpu=0rBK3zm^)?)zICGC4Fbl}8?QD@f0j^@ zb25K-DtKHukAErIEIlz$Xkc>Qb)CD1Jnfo-Zys?JSQiO_doia&5okNTs~o8brk*9aoEAK5)IA?J-XFk2lM?Wc@u5gXt;i_MaGY0vhUaPG&)EPWL`oog+udaT zB6)Klpg5-`QRH2bEBJxSG2;8L!w1~EAd?@doK8=b@fu`2p~icXr)7#QQEl@ zGG=+o_W|V$q~(b6)o;lmae{Q|jgjJi)u)!P)QO`0mT~dU1I;qr8911$K@~?e!Nd|$BiRY+4A1%H%bRk!}QBOn^N0-s23Il4>-A!F@%~$XZEWn zO_^<#{O+;$);BHRg%5QSW;hb{7-fcZwHA)2vw+z?Xo}oMA~t3?ux_*x3@4*>Zqaf!z^Od7GSp$rMsaeFT ztAI0JQ3h=T;F@)JLheS`P2unf-Jy~oc$6+Jq z&Excy#~w2yrw$L>vXwdeP2b<^_xk<*`2G34U$5)FuIqh$KJWMEeqXQqy6=oDjaT!5IKV!grd4m=KZ1o9}do{4Y6Q27vg(0FaUf0Bb@h|PYGd=xX|5v{m31dvuJ#T&+Z{V`Uk8uyq$9p6MHThNZ;szyIASf!lU}xJ zpy%!0j@q+qCF|_rq(&k4nv8+q!Vv~X7zb5^L!Y6-m%&>e7+ei zUYq;^#TytRtWoN$wX9N=goYmR-oi8aO2*rMU?`){am$!o5HuaGJ-mOFE*9+*LMG}CGEvn-U^gs%2BOH&s+HZe_AU~u(vhz@iW7Z$*eLZ3*3+^rh- zNh|;@9CJ6CJ5EMeg~DN$w07$p5P$e>NH)Elck|}f#TbNVT)z1#4BE%Z-gLFMG-CXD zYO+ep=g*_xnyfe{OtAd>x>3m|UNQ}*%{5H6R3Lxn#|Ix=a#UT4D`R?nq2J^M8{(WJ zs?9~bYl3~#9(sCpX<^jI<*}ojH*Y>?>2?u3`rq5bDJiD@lq- z#Isy?t?bV_s?n4@*Pu1=9#1@|s3?J7U2^-x0&Py0i(YUf(xaFm;Tt74q6}3+g0`L_ zBdm_MddNoQAaJHbo)ptr8*9W?nGET%y0T?~ddGA~_OibSHe%Fyf?XH?SGecAX57LY#g;BhI|KKOl{+bc66w7%fZlRyWXgZZj zcOuMsdHk-E+!Utv5!=Y}OB&mGkD&I4=MzXM;sL7%@jcWBD-XAERVrZZ z6&835Cx-q8zcw6O5UOeY9k4;p-{&w&q+o?+F7w< zuf8G$@i7h*DuYer%9XB;vsPItMd$7QIPr{f^!~lA@=y$Z7i>QUbBRwie1Ek75$;mDhU*Al9TUzjK zOTz8XI2F!9rSIlbA4Miw4lZiJ;^gy9`cF&hwLO9NKn>PVc`K&%3639?#aFEgMes&( zN0ky)XN+(#QzIK*MMe8jjs}XOtdkU`#sl%OKM)BBEF?0nHi_9CP z&2}H^ndFrlWylEhanNc9LAosJ=}FUY%DqzCPWp6k1>6qVEZnu-CnuBIm+VtCi#Rms zK!~2+IZ#gq>DYDBJ%P0V>$p862TO5$D9=sLOS%P){BUso8bnl+Jo8lNg6F!*M=lcv zOR3yLW|FV}@O`K7iCP9d`K>lKv?ohmMmfUk8U0N zz8*oc=?|6PsA~IZz*01s=ZA6Op{EuaJj(^ru}y@H-!6|-t<)DOyb2AOwREGD4uJWl z!J-;Hr1OQ-RDQs>?`PL$23 z!0g}Q1Ki6;M1pYZ^%l3gYndgm9wO^`>Z>41WEs=UbxN;or9J^0K#9cJQ$oA>Pd``F zt5^tq?AxD3X_bDnCDif&hvY1=*>smX%$xUSR4i}g@asdCB`V_|D?k1=n^=W5iy$3` z*Da&zv(=GKB>HD(QOt)}(rGvL=fPaX*BK}xT0>K>EeP+CkhrGmIe>nW-moq==PDZ3 zu$&0NRj~2_{rRY?VP)vuXHlO|hqLvuI9Z3(pUk^;gH$L5m`$IA_o;+C!M(i+OtzQdR{fMqFUc%I zy*p1W&7~Lr9h3SDB?;lQ%~@G`2Y?Nn^rGEKratq3ZM<51u9TTPOBO>%K{v+rlyQ5~Fz+{9ml?;ooyN8C4P}vU#o1*K2h3@2Qa_ zay{qVAAdIQ^4`laJH^;HnAE^YQ7*pXy*~i!)o7EH(}GmC@SSg6H0!)2T`(4Ru@Nj; z$SE-*LO<=AZq@zU@&FbI!W^3TGRn9=m-X-A=0NkV67g1=yw*ekrr;4$*&I)ReiE5J z69N7}8*Elo7hjM?pKAm&dPxqm16N5x<)$8T9VZ2Zv1S~jnUfXkS~h%H;t7Nab}Y}B zTvaRmxLJ}kc(@Z?Kv#cn+lVyd6(N>OwNU$%rUoWf*dY@Oy{>^5fC5fQ|O7Y-{m`JESL1_I}YzsLIgc-FVRM&aRGw;QFXS_4qw zIX?V-tA@AQvz~!er8;&;t5zp7T`0=*JV~FY`u+-hej2aKa@6>s-no;-A8G>}M%!&E zp$olf;B4g%h3KfDWazu)^=H!+bv7nAG-zjmNvCz)Kqs9+r;Kc<%t%eV^HWrez_Z^^ zz!hZH6kcqjhlu)$tw%vpLn}*VVh`@`Re9y3^CDr9{l5NZKu5u~m^4by`f$AAw0%L1 zSAVR#${lFf`<>@tE9qpDTaJj7PB%p7`@zDyB~&X6L`t=OjDu!EpAP0rtQ|LU zW?XrRwc~6rPp88exa{8WBKCVB4C!h{?Bf>h6v+0S*7#){M0lL|H*`)4FhpOWcECep z2Y>dmx4SU0N!?<+n%v^1?vt)?=-_=Qc2R>{NpAUNT;G!pb=0HLW4|6gs_qt5!>o&| ze00Z@uHCeVo#NYP))K9|J+}#sqSp)3vl+7Z&0%Ioyos<-yE^|_B7?0SjRW1dTMbLW zdG^1J8h48SMQuM4iJM?wmfR~-dsP(E#IpGzaHibbDVi8pCzl)a;gE0}-KRQq0)L^3 zPN@5fs@(LQ04V?5DNg}wvKUs{-XJ!1c9Qzv<=XQNUf#aeCE-1*9$n;iL*%URt3wYY z{c^F!G<#_k4f<&y1$Pm>F!Cra4P4*2nYxyoW%LOzHg;;V?aA9XbOy=jB>|e_K>Ndx zl2=tj;&dTb!dQ0${2GqHrE2_8%fFS?L=DwQk{-}BcixBp+>g4DxvhRhGt=~oHhr*A zS2)#+v2l+HxET{@?jIE>>;MypiJ39P)EIKo6>{1f@|(G-i4g>14uMQ9ywmtU35bY* zkf6B#KcTW=Z>ccB;C~ciLJ)z`F*gyB|2yZnsrhk;`SBC~7LkqOX9{xwI~&IfmFIm^ F{|k6OYw7?1 literal 3491 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 VFbHp#EYMSdj14UGKVEZ=`9J&Jr*QxP diff --git a/client/ui-wails/assets/netbird-systemtray-error-macos.png b/client/ui-wails/assets/netbird-systemtray-error-macos.png index 9a9998bcfd1f2f4b686f2c08ac4be42198111d83..580fe647c29bce036a816fd20f7ce0a6ac0824cb 100644 GIT binary patch literal 3555 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 OdBMWYyyBea{eJ@L!Le-s literal 3837 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 literal 3570 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 OSX)cSE7j({5B>{<{Ua~{ literal 3816 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 Date: Mon, 4 May 2026 17:36:30 +0200 Subject: [PATCH 24/80] [client] Drop Mesa3D opengl32.dll, bootstrap WebView2 in Windows installers Wails3 uses the WebKit-style WebView2 runtime instead of Fyne's OpenGL backend, so the Mesa3D opengl32.dll payload that the Fyne build needed for RDP/VM rendering can leave the .exe and .msi installers. Add a WebView2 bootstrap step that probes the EdgeUpdate registry markers (both HKLM\WOW6432Node and HKCU) and silently runs MicrosoftEdgeWebview2Setup.exe only if the runtime is missing. NSIS uses an inline macro adapted from Wails3's wails_tools.nsh; WiX uses a deferred CustomAction gated on RegistrySearch properties. Both expect the bootstrapper payload at client/MicrosoftEdgeWebview2Setup.exe, which the sign-pipelines build step generates with `wails3 generate webview2bootstrapper`. The matching sign-pipelines change lives in that repo's PR. The uninstall section keeps an unconditional `Delete opengl32.dll` so upgrades from older Fyne builds clean up the leftover file. --- client/installer.nsis | 41 +++++++++++++++++++++++++++++++++++++++-- client/netbird.wxs | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/client/installer.nsis b/client/installer.nsis index 8b2b8ea39..b50b8e91d 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -270,6 +270,43 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}" CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}" SectionEnd +# Install the Microsoft Edge WebView2 runtime if it isn't already present. +# Macro adapted from Wails3's NSIS template (wails_tools.nsh): a registry +# probe followed by a silent install of the embedded evergreen bootstrapper. +# The MicrosoftEdgeWebview2Setup.exe payload is staged next to this script +# by the sign-pipelines build step (`wails3 generate webview2bootstrapper`). +!macro nb.webview2runtime + SetRegView 64 + # Per-machine install marker — populated when the runtime ships with + # Edge or has been installed by an admin previously. + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto webview2_ok + ${EndIf} + # Per-user fallback for HKCU installs. + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto webview2_ok + ${EndIf} + + SetDetailsPrint both + DetailPrint "Installing: WebView2 Runtime" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + webview2_ok: +!macroend + +Section -WebView2 + !insertmacro nb.webview2runtime +SectionEnd + Section -Post ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install' ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start' @@ -316,9 +353,9 @@ DetailPrint "Deleting application files..." Delete "$INSTDIR\${UI_APP_EXE}" Delete "$INSTDIR\${MAIN_APP_EXE}" Delete "$INSTDIR\wintun.dll" -!if ${ARCH} == "amd64" +# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove +# any leftover copy on uninstall so old upgrades don't leave it behind. Delete "$INSTDIR\opengl32.dll" -!endif DetailPrint "Removing application directory..." RmDir /r "$INSTDIR" diff --git a/client/netbird.wxs b/client/netbird.wxs index 23aa250f4..1d928d8d2 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -29,9 +29,6 @@ - - - + + + + + + + + + + + + + + + From 4268a5cfb7046bf713feac4b862539d20769d944 Mon Sep 17 00:00:00 2001 From: Lauri Tirkkonen Date: Tue, 5 May 2026 01:24:52 +0900 Subject: [PATCH 25/80] [client] Use atomic write/rename pattern for ssh config --- client/ssh/config/manager.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go index 6e584b2c3..5d69fd35c 100644 --- a/client/ssh/config/manager.go +++ b/client/ssh/config/manager.go @@ -224,15 +224,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string { func (m *Manager) writeSSHConfig(sshConfig string) error { sshConfigPath := filepath.Join(m.sshConfigDir, m.sshConfigFile) + sshConfigPathTmp := sshConfigPath + ".tmp" if err := os.MkdirAll(m.sshConfigDir, 0755); err != nil { return fmt.Errorf("create SSH config directory %s: %w", m.sshConfigDir, err) } - if err := writeFileWithTimeout(sshConfigPath, []byte(sshConfig), 0644); err != nil { + if err := writeFileWithTimeout(sshConfigPathTmp, []byte(sshConfig), 0644); err != nil { return fmt.Errorf("write SSH config file %s: %w", sshConfigPath, err) } + if err := os.Rename(sshConfigPathTmp, sshConfigPath); err != nil { + return fmt.Errorf("rename ssh config %s -> %s: %w", sshConfigPathTmp, sshConfigPath, err) + } + log.Infof("Created NetBird SSH client config: %s", sshConfigPath) return nil } From bde632c3b2f4fbf1252f0b37209f000661f466d9 Mon Sep 17 00:00:00 2001 From: alexsavio Date: Mon, 4 May 2026 18:49:39 +0200 Subject: [PATCH 26/80] [client] Replace WG interface monitor polling with netlink subscription on Linux (#5857) --- client/internal/wg_iface_monitor.go | 31 +---- client/internal/wg_iface_monitor_linux.go | 134 ++++++++++++++++++++++ client/internal/wg_iface_monitor_other.go | 56 +++++++++ 3 files changed, 195 insertions(+), 26 deletions(-) create mode 100644 client/internal/wg_iface_monitor_linux.go create mode 100644 client/internal/wg_iface_monitor_other.go diff --git a/client/internal/wg_iface_monitor.go b/client/internal/wg_iface_monitor.go index a870c1145..2a2fa2366 100644 --- a/client/internal/wg_iface_monitor.go +++ b/client/internal/wg_iface_monitor.go @@ -6,7 +6,6 @@ import ( "fmt" "net" "runtime" - "time" log "github.com/sirupsen/logrus" @@ -28,6 +27,10 @@ func NewWGIfaceMonitor() *WGIfaceMonitor { // Start begins monitoring the WireGuard interface. // It relies on the provided context cancellation to stop. +// +// On Linux the watcher is event-driven (RTNLGRP_LINK netlink subscription) +// to avoid the allocation churn of repeatedly dumping the kernel link +// table; on other platforms it falls back to a low-frequency poll. func (m *WGIfaceMonitor) Start(ctx context.Context, ifaceName string) (shouldRestart bool, err error) { defer close(m.done) @@ -56,31 +59,7 @@ func (m *WGIfaceMonitor) Start(ctx context.Context, ifaceName string) (shouldRes log.Infof("Interface monitor: watching %s (index: %d)", ifaceName, expectedIndex) - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - log.Infof("Interface monitor: stopped for %s", ifaceName) - return false, fmt.Errorf("wg interface monitor stopped: %v", ctx.Err()) - case <-ticker.C: - currentIndex, err := getInterfaceIndex(ifaceName) - if err != nil { - // Interface was deleted - log.Infof("Interface monitor: %s deleted", ifaceName) - return true, fmt.Errorf("interface %s deleted: %w", ifaceName, err) - } - - // Check if interface index changed (interface was recreated) - if currentIndex != expectedIndex { - log.Infof("Interface monitor: %s recreated (index changed from %d to %d), restarting engine", - ifaceName, expectedIndex, currentIndex) - return true, nil - } - } - } - + return watchInterface(ctx, ifaceName, expectedIndex) } // getInterfaceIndex returns the index of a network interface by name. diff --git a/client/internal/wg_iface_monitor_linux.go b/client/internal/wg_iface_monitor_linux.go new file mode 100644 index 000000000..2662b99d6 --- /dev/null +++ b/client/internal/wg_iface_monitor_linux.go @@ -0,0 +1,134 @@ +//go:build linux + +package internal + +import ( + "context" + "fmt" + "syscall" + + log "github.com/sirupsen/logrus" + "github.com/vishvananda/netlink" +) + +// watchInterface uses an RTNLGRP_LINK netlink subscription to detect +// deletion or recreation of the WireGuard interface. +// +// The previous implementation polled net.InterfaceByName every 2 s, which +// on Linux issues syscall.NetlinkRIB(RTM_GETLINK, ...) and dumps the +// entire kernel link table on every call. On hosts with many veth +// interfaces (containers, bridges) the resulting allocation churn was on +// the order of ~1 GB/day from this single ticker, which on small ARM +// hosts manifested as a slow RSS climb (see netbirdio/netbird#3678). +// +// The event-driven version below allocates only when the kernel actually +// publishes a link event for the tracked interface — typically zero +// allocations between events. +func watchInterface(ctx context.Context, ifaceName string, expectedIndex int) (bool, error) { + done := make(chan struct{}) + defer close(done) + + // Buffer the channel to absorb event bursts (e.g. when many veth + // pairs are created/destroyed at once by container runtimes). + linkChan := make(chan netlink.LinkUpdate, 32) + if err := netlink.LinkSubscribe(linkChan, done); err != nil { + // Return shouldRestart=true so the engine recovers monitoring + // via triggerClientRestart instead of silently losing it for + // the rest of the process lifetime. + return true, fmt.Errorf("subscribe to link updates: %w", err) + } + + // Race window: the interface could have been deleted (or recreated) + // between the initial getInterfaceIndex() in Start and LinkSubscribe + // completing its handshake with the kernel. Re-check explicitly so we + // do not block forever waiting for an event that already fired. + if currentIndex, err := getInterfaceIndex(ifaceName); err != nil { + log.Infof("Interface monitor: %s deleted before subscription completed", ifaceName) + return true, fmt.Errorf("interface %s deleted: %w", ifaceName, err) + } else if currentIndex != expectedIndex { + log.Infof("Interface monitor: %s recreated (index changed from %d to %d) before subscription completed", + ifaceName, expectedIndex, currentIndex) + return true, nil + } + + for { + select { + case <-ctx.Done(): + log.Infof("Interface monitor: stopped for %s", ifaceName) + return false, fmt.Errorf("wg interface monitor stopped: %w", ctx.Err()) + + case update, ok := <-linkChan: + if !ok { + // The vishvananda/netlink subscription goroutine closes + // the channel on receive errors. Signal the engine to + // restart so monitoring is re-established instead of + // silently ending. + log.Warnf("Interface monitor: link subscription channel closed unexpectedly for %s", ifaceName) + return true, fmt.Errorf("link subscription channel closed unexpectedly") + } + if restart, err := inspectLinkEvent(update, ifaceName, expectedIndex); restart { + return true, err + } + } + } +} + +// inspectLinkEvent classifies a single netlink link update against the +// tracked WireGuard interface. It returns (true, err) when the engine +// should restart monitoring; (false, nil) means the event is unrelated +// and the caller should keep waiting. +// +// The error component, when non-nil, describes the kernel-side reason +// (deletion or rename); the recreation case returns (true, nil) since +// no error condition is reported. +func inspectLinkEvent(update netlink.LinkUpdate, ifaceName string, expectedIndex int) (bool, error) { + eventIndex := int(update.Index) + eventName := "" + if attrs := update.Attrs(); attrs != nil { + eventName = attrs.Name + } + + switch update.Header.Type { + case syscall.RTM_DELLINK: + return inspectDelLink(eventIndex, ifaceName, expectedIndex) + case syscall.RTM_NEWLINK: + return inspectNewLink(eventIndex, eventName, ifaceName, expectedIndex) + } + return false, nil +} + +// inspectDelLink reports a restart when an RTM_DELLINK arrives for the +// tracked interface index. +func inspectDelLink(eventIndex int, ifaceName string, expectedIndex int) (bool, error) { + if eventIndex != expectedIndex { + return false, nil + } + log.Infof("Interface monitor: %s deleted", ifaceName) + return true, fmt.Errorf("interface %s deleted", ifaceName) +} + +// inspectNewLink reports a restart when an RTM_NEWLINK either: +// +// 1. Introduces a link with our name at a different index (recreation +// after a delete), or +// +// 2. Reports a link still at our index but with a different name +// (in-place rename). The previous polling implementation caught +// this implicitly because net.InterfaceByName(ifaceName) would +// start failing; the event-driven version has to test it. +// +// Same name + same index is just a flag/state change on the existing +// interface and is ignored. +func inspectNewLink(eventIndex int, eventName, ifaceName string, expectedIndex int) (bool, error) { + if eventName == ifaceName && eventIndex != expectedIndex { + log.Infof("Interface monitor: %s recreated (index changed from %d to %d), restarting engine", + ifaceName, expectedIndex, eventIndex) + return true, nil + } + if eventIndex == expectedIndex && eventName != "" && eventName != ifaceName { + log.Infof("Interface monitor: %s renamed to %s (index %d), restarting engine", + ifaceName, eventName, expectedIndex) + return true, fmt.Errorf("interface %s renamed to %s", ifaceName, eventName) + } + return false, nil +} diff --git a/client/internal/wg_iface_monitor_other.go b/client/internal/wg_iface_monitor_other.go new file mode 100644 index 000000000..afebbf4df --- /dev/null +++ b/client/internal/wg_iface_monitor_other.go @@ -0,0 +1,56 @@ +//go:build !linux + +package internal + +import ( + "context" + "fmt" + "time" + + log "github.com/sirupsen/logrus" +) + +// watchInterface polls net.InterfaceByName at a fixed interval to detect +// deletion or recreation of the WireGuard interface. +// +// This is the fallback used on non-Linux desktop and server platforms +// (darwin, windows, freebsd). It is also compiled on android and ios so +// the package builds on every supported GOOS, but it is never reached +// at runtime there because Start() in wg_iface_monitor.go exits early +// on mobile platforms. +// +// The Linux build (see wg_iface_monitor_linux.go) uses an event-driven +// RTNLGRP_LINK netlink subscription instead, because on Linux +// net.InterfaceByName issues syscall.NetlinkRIB(RTM_GETLINK, ...) which +// dumps the entire kernel link table on every call and produces +// significant allocation churn (netbirdio/netbird#3678). +// +// Windows is also reported in #3678 as affected by RSS climb. A future +// follow-up could implement an event-driven watcher there using +// NotifyIpInterfaceChange from iphlpapi. +func watchInterface(ctx context.Context, ifaceName string, expectedIndex int) (bool, error) { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Infof("Interface monitor: stopped for %s", ifaceName) + return false, fmt.Errorf("wg interface monitor stopped: %w", ctx.Err()) + case <-ticker.C: + currentIndex, err := getInterfaceIndex(ifaceName) + if err != nil { + // Interface was deleted + log.Infof("Interface monitor: %s deleted", ifaceName) + return true, fmt.Errorf("interface %s deleted: %w", ifaceName, err) + } + + // Check if interface index changed (interface was recreated) + if currentIndex != expectedIndex { + log.Infof("Interface monitor: %s recreated (index changed from %d to %d), restarting engine", + ifaceName, expectedIndex, currentIndex) + return true, nil + } + } + } +} From 104990dfdd5d9eae1760da5f57ba47a2df7052a6 Mon Sep 17 00:00:00 2001 From: JungwooShin <166088609+typhoon1217@users.noreply.github.com> Date: Tue, 5 May 2026 01:59:29 +0900 Subject: [PATCH 27/80] [client] Display QR code for device auth login URL (#5415) --- client/cmd/login.go | 14 +++++++++++--- client/cmd/qr.go | 25 +++++++++++++++++++++++++ client/cmd/qr_test.go | 26 ++++++++++++++++++++++++++ client/cmd/up.go | 5 +++++ go.mod | 2 ++ go.sum | 4 ++++ 6 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 client/cmd/qr.go create mode 100644 client/cmd/qr_test.go diff --git a/client/cmd/login.go b/client/cmd/login.go index 4521a67c9..bd37e30f1 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -10,6 +10,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "golang.org/x/term" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" @@ -23,6 +24,7 @@ import ( func init() { loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc) + loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc) loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc) loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location") } @@ -256,7 +258,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, } func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.LoginResponse, client proto.DaemonServiceClient, pm *profilemanager.ProfileManager) error { - openURL(cmd, loginResp.VerificationURIComplete, loginResp.UserCode, noBrowser) + openURL(cmd, loginResp.VerificationURIComplete, loginResp.UserCode, noBrowser, showQR) resp, err := client.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode, Hostname: hostName}) if err != nil { @@ -324,7 +326,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err) } - openURL(cmd, flowInfo.VerificationURIComplete, flowInfo.UserCode, noBrowser) + openURL(cmd, flowInfo.VerificationURIComplete, flowInfo.UserCode, noBrowser, showQR) tokenInfo, err := oAuthFlow.WaitToken(context.TODO(), flowInfo) if err != nil { @@ -334,7 +336,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro return &tokenInfo, nil } -func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBrowser bool) { +func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBrowser, showQR bool) { var codeMsg string if userCode != "" && !strings.Contains(verificationURIComplete, userCode) { codeMsg = fmt.Sprintf("and enter the code %s to authenticate.", userCode) @@ -348,6 +350,12 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBro verificationURIComplete + " " + codeMsg) } + if showQR { + if f, ok := cmd.OutOrStdout().(*os.File); ok && term.IsTerminal(int(f.Fd())) { + printQRCode(f, verificationURIComplete) + } + } + cmd.Println("") if !noBrowser { diff --git a/client/cmd/qr.go b/client/cmd/qr.go new file mode 100644 index 000000000..8b2c489ff --- /dev/null +++ b/client/cmd/qr.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "io" + + "github.com/mdp/qrterminal/v3" +) + +// printQRCode prints a QR code for the given URL to the writer. +// Called only when the user explicitly requests QR output via --qr. +func printQRCode(w io.Writer, url string) { + if url == "" { + return + } + qrterminal.GenerateWithConfig(url, qrterminal.Config{ + Level: qrterminal.M, + Writer: w, + HalfBlocks: true, + BlackChar: qrterminal.BLACK_BLACK, + WhiteChar: qrterminal.WHITE_WHITE, + BlackWhiteChar: qrterminal.BLACK_WHITE, + WhiteBlackChar: qrterminal.WHITE_BLACK, + QuietZone: qrterminal.QUIET_ZONE, + }) +} diff --git a/client/cmd/qr_test.go b/client/cmd/qr_test.go new file mode 100644 index 000000000..d12705b9e --- /dev/null +++ b/client/cmd/qr_test.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "bytes" + "testing" +) + +func TestPrintQRCode_EmptyURL(t *testing.T) { + var buf bytes.Buffer + + printQRCode(&buf, "") + + if buf.Len() != 0 { + t.Error("expected no output for empty URL") + } +} + +func TestPrintQRCode_WritesOutput(t *testing.T) { + var buf bytes.Buffer + + printQRCode(&buf, "https://example.com/auth") + + if buf.Len() == 0 { + t.Error("expected QR code output for non-empty URL") + } +} diff --git a/client/cmd/up.go b/client/cmd/up.go index f5766522a..f4136cb23 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -39,6 +39,9 @@ const ( noBrowserFlag = "no-browser" noBrowserDesc = "do not open the browser for SSO login" + showQRFlag = "qr" + showQRDesc = "show QR code for the SSO login URL (useful for headless machines without browser access)" + profileNameFlag = "profile" profileNameDesc = "profile name to use for the login. If not specified, the last used profile will be used." ) @@ -48,6 +51,7 @@ var ( dnsLabels []string dnsLabelsValidated domain.List noBrowser bool + showQR bool profileName string configPath string @@ -80,6 +84,7 @@ func init() { ) upCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc) + upCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc) upCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc) upCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) NetBird config file location. ") diff --git a/go.mod b/go.mod index 8e6a481d2..e82e6b10d 100644 --- a/go.mod +++ b/go.mod @@ -71,6 +71,7 @@ require ( github.com/libp2p/go-netroute v0.2.1 github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 github.com/mdlayher/socket v0.5.1 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/miekg/dns v1.1.59 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 @@ -308,6 +309,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + rsc.io/qr v0.2.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 2abf55142..a71f47d8d 100644 --- a/go.sum +++ b/go.sum @@ -415,6 +415,8 @@ github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0 github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mholt/acmez/v2 v2.0.1 h1:3/3N0u1pLjMK4sNEAFSI+bcvzbPhRpY383sy1kLHJ6k= github.com/mholt/acmez/v2 v2.0.1/go.mod h1:fX4c9r5jYwMyMsC+7tkYRxHibkOTgta5DIFGoe67e1U= github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= @@ -915,3 +917,5 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= From 77a0992dc21644b2b041b7ff45000e232b928158 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 5 May 2026 02:59:41 +0900 Subject: [PATCH 28/80] [misc] Disable govet inline analyzer and tidy go.mod (#6066) --- .golangci.yaml | 5 +++++ go.mod | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.golangci.yaml b/.golangci.yaml index d81ad1377..900af4ac0 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -58,6 +58,11 @@ linters: govet: enable: - nilness + disable: + # The inline analyzer flags x/exp/maps Clone/Clear with //go:fix inline + # directives but cannot perform the rewrite due to generic type + # parameter inference limitations in the Go inliner. + - inline enable-all: false revive: rules: diff --git a/go.mod b/go.mod index e82e6b10d..e24312a1a 100644 --- a/go.mod +++ b/go.mod @@ -309,8 +309,8 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect - rsc.io/qr v0.2.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + rsc.io/qr v0.2.0 // indirect ) replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 From 97db82492946bce8db6a4ef820ef2d59365f1512 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 4 May 2026 20:43:25 +0200 Subject: [PATCH 29/80] [management] fix proxy reconnect (#6063) --- .../modules/reverseproxy/proxy/manager.go | 6 +- .../reverseproxy/proxy/manager/manager.go | 40 +++++---- .../reverseproxy/proxy/manager_mock.go | 29 +++---- .../modules/reverseproxy/proxy/proxy.go | 3 +- management/internals/shared/grpc/proxy.go | 47 ++++++++--- management/server/store/sql_store.go | 44 ++++++---- management/server/store/store.go | 3 +- management/server/store/store_mock.go | 23 +++++- proxy/management_integration_test.go | 81 +++++++++++++++++-- 9 files changed, 199 insertions(+), 77 deletions(-) diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index aa7cd8630..53c52b3aa 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -11,9 +11,9 @@ import ( // Manager defines the interface for proxy operations type Manager interface { - Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string, capabilities *Capabilities) error - Disconnect(ctx context.Context, proxyID string) error - Heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error + Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, capabilities *Capabilities) (*Proxy, error) + Disconnect(ctx context.Context, proxyID, sessionID string) error + Heartbeat(ctx context.Context, p *Proxy) error GetActiveClusterAddresses(ctx context.Context) ([]string, error) GetActiveClusters(ctx context.Context) ([]Cluster, error) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go index d13334e83..341e8c943 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -13,7 +13,8 @@ import ( // store defines the interface for proxy persistence operations type store interface { SaveProxy(ctx context.Context, p *proxy.Proxy) error - UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error + DisconnectProxy(ctx context.Context, proxyID, sessionID string) error + UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) GetActiveProxyClusters(ctx context.Context) ([]proxy.Cluster, error) GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool @@ -43,7 +44,7 @@ func NewManager(store store, meter metric.Meter) (*Manager, error) { // Connect registers a new proxy connection in the database. // capabilities may be nil for old proxies that do not report them. -func (m Manager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string, capabilities *proxy.Capabilities) error { +func (m Manager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, capabilities *proxy.Capabilities) (*proxy.Proxy, error) { now := time.Now() var caps proxy.Capabilities if capabilities != nil { @@ -51,6 +52,7 @@ func (m Manager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress } p := &proxy.Proxy{ ID: proxyID, + SessionID: sessionID, ClusterAddress: clusterAddress, IPAddress: ipAddress, LastSeen: now, @@ -61,48 +63,42 @@ func (m Manager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress if err := m.store.SaveProxy(ctx, p); err != nil { log.WithContext(ctx).Errorf("failed to register proxy %s: %v", proxyID, err) - return err + return nil, err } log.WithContext(ctx).WithFields(log.Fields{ "proxyID": proxyID, + "sessionID": sessionID, "clusterAddress": clusterAddress, "ipAddress": ipAddress, }).Info("proxy connected") - return nil + return p, nil } -// Disconnect marks a proxy as disconnected in the database -func (m Manager) Disconnect(ctx context.Context, proxyID string) error { - now := time.Now() - p := &proxy.Proxy{ - ID: proxyID, - Status: "disconnected", - DisconnectedAt: &now, - LastSeen: now, - } - - if err := m.store.SaveProxy(ctx, p); err != nil { - log.WithContext(ctx).Errorf("failed to disconnect proxy %s: %v", proxyID, err) +// Disconnect marks a proxy as disconnected in the database. +func (m Manager) Disconnect(ctx context.Context, proxyID, sessionID string) error { + if err := m.store.DisconnectProxy(ctx, proxyID, sessionID); err != nil { + log.WithContext(ctx).Errorf("failed to disconnect proxy %s session %s: %v", proxyID, sessionID, err) return err } log.WithContext(ctx).WithFields(log.Fields{ - "proxyID": proxyID, + "proxyID": proxyID, + "sessionID": sessionID, }).Info("proxy disconnected") return nil } -// Heartbeat updates the proxy's last seen timestamp -func (m Manager) Heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { - if err := m.store.UpdateProxyHeartbeat(ctx, proxyID, clusterAddress, ipAddress); err != nil { - log.WithContext(ctx).Debugf("failed to update proxy %s heartbeat: %v", proxyID, err) +// Heartbeat updates the proxy's last seen timestamp. +func (m Manager) Heartbeat(ctx context.Context, p *proxy.Proxy) error { + if err := m.store.UpdateProxyHeartbeat(ctx, p); err != nil { + log.WithContext(ctx).Debugf("failed to update proxy %s heartbeat: %v", p.ID, err) return err } - log.WithContext(ctx).Tracef("updated heartbeat for proxy %s", proxyID) + log.WithContext(ctx).Tracef("updated heartbeat for proxy %s session %s", p.ID, p.SessionID) m.metrics.IncrementProxyHeartbeatCount() return nil } diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index 282ca0ba5..98d97b3c6 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -93,31 +93,32 @@ func (mr *MockManagerMockRecorder) ClusterSupportsCrowdSec(ctx, clusterAddr inte } // Connect mocks base method. -func (m *MockManager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string, capabilities *Capabilities) error { +func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, capabilities *Capabilities) (*Proxy, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Connect", ctx, proxyID, clusterAddress, ipAddress, capabilities) - ret0, _ := ret[0].(error) - return ret0 + ret := m.ctrl.Call(m, "Connect", ctx, proxyID, sessionID, clusterAddress, ipAddress, capabilities) + ret0, _ := ret[0].(*Proxy) + ret1, _ := ret[1].(error) + return ret0, ret1 } // Connect indicates an expected call of Connect. -func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, clusterAddress, ipAddress, capabilities interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddress, ipAddress, capabilities interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, clusterAddress, ipAddress, capabilities) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, capabilities) } // Disconnect mocks base method. -func (m *MockManager) Disconnect(ctx context.Context, proxyID string) error { +func (m *MockManager) Disconnect(ctx context.Context, proxyID, sessionID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Disconnect", ctx, proxyID) + ret := m.ctrl.Call(m, "Disconnect", ctx, proxyID, sessionID) ret0, _ := ret[0].(error) return ret0 } // Disconnect indicates an expected call of Disconnect. -func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID, sessionID interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockManager)(nil).Disconnect), ctx, proxyID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockManager)(nil).Disconnect), ctx, proxyID, sessionID) } // GetActiveClusterAddresses mocks base method. @@ -151,17 +152,17 @@ func (mr *MockManagerMockRecorder) GetActiveClusters(ctx interface{}) *gomock.Ca } // Heartbeat mocks base method. -func (m *MockManager) Heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { +func (m *MockManager) Heartbeat(ctx context.Context, p *Proxy) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Heartbeat", ctx, proxyID, clusterAddress, ipAddress) + ret := m.ctrl.Call(m, "Heartbeat", ctx, p) ret0, _ := ret[0].(error) return ret0 } // Heartbeat indicates an expected call of Heartbeat. -func (mr *MockManagerMockRecorder) Heartbeat(ctx, proxyID, clusterAddress, ipAddress interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) Heartbeat(ctx, p interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, proxyID, clusterAddress, ipAddress) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, p) } // MockController is a mock of Controller interface. diff --git a/management/internals/modules/reverseproxy/proxy/proxy.go b/management/internals/modules/reverseproxy/proxy/proxy.go index 339c82446..dcedb8811 100644 --- a/management/internals/modules/reverseproxy/proxy/proxy.go +++ b/management/internals/modules/reverseproxy/proxy/proxy.go @@ -18,12 +18,13 @@ type Capabilities struct { // Proxy represents a reverse proxy instance type Proxy struct { ID string `gorm:"primaryKey;type:varchar(255)"` + SessionID string `gorm:"type:varchar(36)"` ClusterAddress string `gorm:"type:varchar(255);not null;index:idx_proxy_cluster_status"` IPAddress string `gorm:"type:varchar(45)"` LastSeen time.Time `gorm:"not null;index:idx_proxy_last_seen"` ConnectedAt *time.Time DisconnectedAt *time.Time - Status string `gorm:"type:varchar(20);not null;index:idx_proxy_cluster_status"` + Status string `gorm:"type:varchar(20);not null;index:idx_proxy_cluster_status"` Capabilities Capabilities `gorm:"embedded"` CreatedAt time.Time UpdatedAt time.Time diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index a5e352e75..d811a0f69 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -16,6 +16,7 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" log "github.com/sirupsen/logrus" "golang.org/x/oauth2" "google.golang.org/grpc/codes" @@ -89,6 +90,7 @@ const pkceVerifierTTL = 10 * time.Minute // proxyConnection represents a connected proxy type proxyConnection struct { proxyID string + sessionID string address string capabilities *proto.ProxyCapabilities stream proto.ProxyService_GetMappingUpdateServer @@ -166,9 +168,22 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest return status.Errorf(codes.InvalidArgument, "proxy address is invalid") } + sessionID := uuid.NewString() + + if old, loaded := s.connectedProxies.Load(proxyID); loaded { + oldConn := old.(*proxyConnection) + log.WithFields(log.Fields{ + "proxy_id": proxyID, + "old_session_id": oldConn.sessionID, + "new_session_id": sessionID, + }).Info("Superseding existing proxy connection") + oldConn.cancel() + } + connCtx, cancel := context.WithCancel(ctx) conn := &proxyConnection{ proxyID: proxyID, + sessionID: sessionID, address: proxyAddress, capabilities: req.GetCapabilities(), stream: stream, @@ -188,12 +203,13 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest caps = &proxy.Capabilities{ SupportsCustomPorts: c.SupportsCustomPorts, RequireSubdomain: c.RequireSubdomain, - SupportsCrowdsec: c.SupportsCrowdsec, + SupportsCrowdsec: c.SupportsCrowdsec, } } - if err := s.proxyManager.Connect(ctx, proxyID, proxyAddress, peerInfo, caps); err != nil { + proxyRecord, err := s.proxyManager.Connect(ctx, proxyID, sessionID, proxyAddress, peerInfo, caps) + if err != nil { log.WithContext(ctx).Warnf("failed to register proxy %s in database: %v", proxyID, err) - s.connectedProxies.Delete(proxyID) + s.connectedProxies.CompareAndDelete(proxyID, conn) if unregErr := s.proxyController.UnregisterProxyFromCluster(ctx, conn.address, proxyID); unregErr != nil { log.WithContext(ctx).Debugf("cleanup after Connect failure for proxy %s: %v", proxyID, unregErr) } @@ -202,22 +218,27 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest log.WithFields(log.Fields{ "proxy_id": proxyID, + "session_id": sessionID, "address": proxyAddress, "cluster_addr": proxyAddress, "total_proxies": len(s.GetConnectedProxies()), }).Info("Proxy registered in cluster") defer func() { - if err := s.proxyManager.Disconnect(context.Background(), proxyID); err != nil { - log.Warnf("Failed to mark proxy %s as disconnected: %v", proxyID, err) + if !s.connectedProxies.CompareAndDelete(proxyID, conn) { + log.Infof("Proxy %s session %s: skipping cleanup, superseded by new connection", proxyID, sessionID) + cancel() + return } - s.connectedProxies.Delete(proxyID) if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, proxyID); err != nil { log.Warnf("Failed to unregister proxy %s from cluster: %v", proxyID, err) } + if err := s.proxyManager.Disconnect(context.Background(), proxyID, sessionID); err != nil { + log.Warnf("Failed to mark proxy %s as disconnected: %v", proxyID, err) + } cancel() - log.Infof("Proxy %s disconnected", proxyID) + log.Infof("Proxy %s session %s disconnected", proxyID, sessionID) }() if err := s.sendSnapshot(ctx, conn); err != nil { @@ -227,29 +248,31 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest errChan := make(chan error, 2) go s.sender(conn, errChan) - // Start heartbeat goroutine - go s.heartbeat(connCtx, proxyID, proxyAddress, peerInfo) + go s.heartbeat(connCtx, proxyRecord) select { case err := <-errChan: + log.WithContext(ctx).Warnf("Failed to send update: %v", err) return fmt.Errorf("send update to proxy %s: %w", proxyID, err) case <-connCtx.Done(): + log.WithContext(ctx).Infof("Proxy %s context canceled", proxyID) return connCtx.Err() } } // heartbeat updates the proxy's last_seen timestamp every minute -func (s *ProxyServiceServer) heartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) { +func (s *ProxyServiceServer) heartbeat(ctx context.Context, p *proxy.Proxy) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: - if err := s.proxyManager.Heartbeat(ctx, proxyID, clusterAddress, ipAddress); err != nil { - log.WithContext(ctx).Debugf("Failed to update proxy %s heartbeat: %v", proxyID, err) + if err := s.proxyManager.Heartbeat(ctx, p); err != nil { + log.WithContext(ctx).Debugf("Failed to update proxy %s heartbeat: %v", p.ID, err) } case <-ctx.Done(): + log.WithContext(ctx).Infof("proxy %s heartbeat stopped: context canceled", p.ID) return } } diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 0a716d08d..1fa3d08ee 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -5437,13 +5437,35 @@ func (s *SqlStore) SaveProxy(ctx context.Context, p *proxy.Proxy) error { return nil } -// UpdateProxyHeartbeat updates the last_seen timestamp for a proxy or creates a new entry if it doesn't exist -func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { +// DisconnectProxy marks a proxy as disconnected only if the session ID matches. +// This prevents a slow-to-close old session from overwriting a newer reconnection. +func (s *SqlStore) DisconnectProxy(ctx context.Context, proxyID, sessionID string) error { + now := time.Now() + result := s.db. + Model(&proxy.Proxy{}). + Where("id = ? AND session_id = ?", proxyID, sessionID). + Updates(map[string]any{ + "status": "disconnected", + "disconnected_at": now, + "last_seen": now, + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to disconnect proxy %s session %s: %v", proxyID, sessionID, result.Error) + return status.Errorf(status.Internal, "failed to disconnect proxy") + } + if result.RowsAffected == 0 { + log.WithContext(ctx).Debugf("proxy %s session %s: no row updated (superseded by newer session)", proxyID, sessionID) + } + return nil +} + +// UpdateProxyHeartbeat updates the last_seen timestamp for the proxy's current session. +func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error { now := time.Now() result := s.db. Model(&proxy.Proxy{}). - Where("id = ? AND status = ?", proxyID, "connected"). + Where("id = ? AND session_id = ?", p.ID, p.SessionID). Update("last_seen", now) if result.Error != nil { @@ -5452,17 +5474,11 @@ func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAdd } if result.RowsAffected == 0 { - p := &proxy.Proxy{ - ID: proxyID, - ClusterAddress: clusterAddress, - IPAddress: ipAddress, - LastSeen: now, - ConnectedAt: &now, - Status: "connected", - } - if err := s.db.Save(p).Error; err != nil { - log.WithContext(ctx).Errorf("failed to create proxy on heartbeat: %v", err) - return status.Errorf(status.Internal, "failed to create proxy on heartbeat") + p.LastSeen = now + p.ConnectedAt = &now + p.Status = "connected" + if err := s.db.Create(p).Error; err != nil { + log.WithContext(ctx).Debugf("proxy %s session %s: heartbeat fallback insert skipped: %v", p.ID, p.SessionID, err) } } diff --git a/management/server/store/store.go b/management/server/store/store.go index 0d8b0678a..447c85547 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -284,7 +284,8 @@ type Store interface { DeleteServiceTargets(ctx context.Context, accountID string, serviceID string) error SaveProxy(ctx context.Context, proxy *proxy.Proxy) error - UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error + DisconnectProxy(ctx context.Context, proxyID, sessionID string) error + UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) GetActiveProxyClusters(ctx context.Context) ([]proxy.Cluster, error) GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index beee13d96..d8bd826a8 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -178,6 +178,7 @@ func (mr *MockStoreMockRecorder) GetClusterSupportsCrowdSec(ctx, clusterAddr int mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsCrowdSec", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsCrowdSec), ctx, clusterAddr) } + // Close mocks base method. func (m *MockStore) Close(ctx context.Context) error { m.ctrl.T.Helper() @@ -2799,6 +2800,20 @@ func (mr *MockStoreMockRecorder) SaveProxy(ctx, proxy interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveProxy", reflect.TypeOf((*MockStore)(nil).SaveProxy), ctx, proxy) } +// DisconnectProxy mocks base method. +func (m *MockStore) DisconnectProxy(ctx context.Context, proxyID, sessionID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DisconnectProxy", ctx, proxyID, sessionID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DisconnectProxy indicates an expected call of DisconnectProxy. +func (mr *MockStoreMockRecorder) DisconnectProxy(ctx, proxyID, sessionID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisconnectProxy", reflect.TypeOf((*MockStore)(nil).DisconnectProxy), ctx, proxyID, sessionID) +} + // SaveProxyAccessToken mocks base method. func (m *MockStore) SaveProxyAccessToken(ctx context.Context, token *types2.ProxyAccessToken) error { m.ctrl.T.Helper() @@ -2995,17 +3010,17 @@ func (mr *MockStoreMockRecorder) UpdateGroups(ctx, accountID, groups interface{} } // UpdateProxyHeartbeat mocks base method. -func (m *MockStore) UpdateProxyHeartbeat(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { +func (m *MockStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateProxyHeartbeat", ctx, proxyID, clusterAddress, ipAddress) + ret := m.ctrl.Call(m, "UpdateProxyHeartbeat", ctx, p) ret0, _ := ret[0].(error) return ret0 } // UpdateProxyHeartbeat indicates an expected call of UpdateProxyHeartbeat. -func (mr *MockStoreMockRecorder) UpdateProxyHeartbeat(ctx, proxyID, clusterAddress, ipAddress interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateProxyHeartbeat(ctx, p interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProxyHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateProxyHeartbeat), ctx, proxyID, clusterAddress, ipAddress) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProxyHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateProxyHeartbeat), ctx, p) } // UpdateService mocks base method. diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index 4b1ecf922..e9eae3210 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -201,15 +201,15 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, // testProxyManager is a mock implementation of proxy.Manager for testing. type testProxyManager struct{} -func (m *testProxyManager) Connect(_ context.Context, _, _, _ string, _ *nbproxy.Capabilities) error { +func (m *testProxyManager) Connect(_ context.Context, proxyID, sessionID, _, _ string, _ *nbproxy.Capabilities) (*nbproxy.Proxy, error) { + return &nbproxy.Proxy{ID: proxyID, SessionID: sessionID, Status: "connected"}, nil +} + +func (m *testProxyManager) Disconnect(_ context.Context, _, _ string) error { return nil } -func (m *testProxyManager) Disconnect(_ context.Context, _ string) error { - return nil -} - -func (m *testProxyManager) Heartbeat(_ context.Context, _, _, _ string) error { +func (m *testProxyManager) Heartbeat(_ context.Context, _ *nbproxy.Proxy) error { return nil } @@ -656,3 +656,72 @@ func TestIntegration_ProxyConnection_MultipleProxiesReceiveUpdates(t *testing.T) assert.Equal(t, 2, count, "Proxy %s should receive 2 mappings", proxyID) } } + +// TestIntegration_ProxyConnection_FastReconnectDoesNotLoseState verifies that +// when a proxy reconnects before the old stream's cleanup runs, the new +// connection is NOT removed by the stale defer. +func TestIntegration_ProxyConnection_FastReconnectDoesNotLoseState(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + clusterAddress := "test.proxy.io" + proxyID := "test-proxy-race" + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx1, cancel1 := context.WithCancel(context.Background()) + stream1, err := client.GetMappingUpdate(ctx1, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + for i := 0; i < 2; i++ { + _, err := stream1.Recv() + require.NoError(t, err) + } + + require.Contains(t, setup.proxyService.GetConnectedProxies(), proxyID, + "proxy should be registered after first connection") + + ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel2() + + stream2, err := client.GetMappingUpdate(ctx2, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + for i := 0; i < 2; i++ { + _, err := stream2.Recv() + require.NoError(t, err) + } + + cancel1() + + time.Sleep(200 * time.Millisecond) + + assert.Contains(t, setup.proxyService.GetConnectedProxies(), proxyID, + "proxy should still be registered after old connection cleanup — old defer must not remove new connection") + + setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, + Id: "rp-1", + AccountId: "test-account-1", + Domain: "app1.test.proxy.io", + }}, + }) + + msg, err := stream2.Recv() + require.NoError(t, err, "new stream should still receive updates") + require.NotEmpty(t, msg.GetMapping(), "update should contain the mapping") + assert.Equal(t, "rp-1", msg.GetMapping()[0].GetId()) +} From bc609c3ae77b8bf4d63f89d3e39b54fdb0157b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 5 May 2026 11:56:57 +0200 Subject: [PATCH 30/80] [client/ui-wails] Wire up enforced-update tray menu item Surface the Fyne UI's "Download latest version" / "Install version X.Y.Z" About-submenu entry in the Wails tray. The item starts hidden and is revealed by onUpdateAvailable when the daemon emits EventUpdateAvailable; opt-in updates open github.com/netbirdio/netbird/releases/latest in the browser, enforced updates surface the in-window /update progress page and call TriggerUpdate on the daemon. Also lift every user-facing string and external URL in tray.go into named const declarations at the top of the file, so future copy edits and (eventual) localisation have a single source of truth. The /update React route is the frontend counterpart and is owned by the React side of the refactor. --- client/ui-wails/main.go | 5 +- client/ui-wails/tray.go | 181 +++++++++++++++++++++++++++++++++------- 2 files changed, 152 insertions(+), 34 deletions(-) diff --git a/client/ui-wails/main.go b/client/ui-wails/main.go index 2fb1ba768..99287f3fa 100644 --- a/client/ui-wails/main.go +++ b/client/ui-wails/main.go @@ -93,6 +93,7 @@ func main() { settings := services.NewSettings(conn) profiles := services.NewProfiles(conn) peers := services.NewPeers(conn, app.Event) + update := services.NewUpdate(conn) notifier := notifications.New() app.RegisterService(application.NewService(connection)) @@ -100,7 +101,7 @@ func main() { app.RegisterService(application.NewService(services.NewNetworks(conn))) app.RegisterService(application.NewService(profiles)) app.RegisterService(application.NewService(services.NewDebug(conn))) - app.RegisterService(application.NewService(services.NewUpdate(conn))) + app.RegisterService(application.NewService(update)) app.RegisterService(application.NewService(peers)) app.RegisterService(application.NewService(notifier)) @@ -128,7 +129,7 @@ func main() { window.Hide() }) - tray = NewTray(app, window, connection, settings, profiles, peers, notifier) + tray = NewTray(app, window, connection, settings, profiles, peers, notifier, update) listenForShowSignal(context.Background(), tray) peers.Watch(context.Background()) diff --git a/client/ui-wails/tray.go b/client/ui-wails/tray.go index 4b1730ac4..7d95bb7d3 100644 --- a/client/ui-wails/tray.go +++ b/client/ui-wails/tray.go @@ -9,6 +9,7 @@ import ( "sort" "strings" "sync" + "time" log "github.com/sirupsen/logrus" "github.com/wailsapp/wails/v3/pkg/application" @@ -17,6 +18,60 @@ import ( "github.com/netbirdio/netbird/client/ui-wails/services" ) +// User-facing strings exposed in the tray, OS notifications and the +// browser-opened URLs. Centralised here so future copy edits and (one +// day) localisation have a single source of truth. +const ( + trayTooltip = "NetBird" + + // Top-level menu entries. + menuStatusDisconnected = "Disconnected" + menuOpenNetBird = "Open NetBird" + menuConnect = "Connect" + menuDisconnect = "Disconnect" + menuExitNode = "Exit Node" + menuNetworks = "Networks" + menuQuit = "Quit" + + // Settings submenu. + menuSettings = "Settings" + menuAllowSSH = "Allow SSH" + menuConnectOnStartup = "Connect on Startup" + menuQuantumResistance = "Enable Quantum-Resistance" + menuLazyConnections = "Enable Lazy Connections" + menuBlockInbound = "Block Inbound Connections" + menuNotifications = "Notifications" + menuAdvancedSettings = "Advanced Settings" + menuCreateDebugBundle = "Create Debug Bundle" + + // About submenu and update flow. + menuAbout = "About" + menuGitHub = "GitHub" + menuDocumentation = "Documentation" + menuDownloadLatestVersion = "Download latest version" + // menuInstallVersionPrefix is rewritten with the target version when + // the management server enforces the update. + menuInstallVersionPrefix = "Install version " + + // OS notifications. + notifyUpdateTitle = "NetBird update available" + notifyUpdateBodyFmt = "NetBird %s is available." + notifyUpdateEnforcedSuffix = " Your administrator requires this update." + notifyErrorTitle = "Error" + notifyErrorConnect = "Failed to connect" + notifyErrorDisconnect = "Failed to disconnect" + notifyErrorSettingsFmt = "Failed to update %s settings" + + // Notification IDs (used to coalesce duplicate toasts). + notifyIDUpdatePrefix = "netbird-update-" + notifyIDEvent = "netbird-event-" + notifyIDTrayError = "netbird-tray-error" + + // External URLs. + urlGitHubRepo = "https://github.com/netbirdio/netbird" + urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest" +) + // Tray builds and updates the systray menu. It mirrors the layout of the Fyne // systray 1:1 and routes clicks back to the gRPC services. Dynamic state // (status icon, exit-node submenu) is driven by the netbird:status event. @@ -29,6 +84,7 @@ type Tray struct { profiles *services.Profiles peers *services.Peers notifier *notifications.NotificationService + update *services.Update statusItem *application.MenuItem upItem *application.MenuItem @@ -41,10 +97,13 @@ type Tray struct { lazyConnItem *application.MenuItem blockInItem *application.MenuItem notifyItem *application.MenuItem + updateItem *application.MenuItem mu sync.Mutex connected bool hasUpdate bool + updateVersion string + updateEnforced bool exitNodes []string lastStatus string notificationsEnabled bool @@ -60,6 +119,7 @@ func NewTray( profiles *services.Profiles, peers *services.Peers, notifier *notifications.NotificationService, + update *services.Update, ) *Tray { t := &Tray{ app: app, @@ -69,11 +129,12 @@ func NewTray( profiles: profiles, peers: peers, notifier: notifier, + update: update, notificationsEnabled: true, } t.tray = app.SystemTray.New() t.applyIcon() - t.tray.SetTooltip("NetBird") + t.tray.SetTooltip(trayTooltip) t.tray.SetMenu(t.buildMenu()) // Tray click handling is platform-specific by design: // @@ -122,59 +183,65 @@ func (t *Tray) ShowWindow() { func (t *Tray) buildMenu() *application.Menu { menu := application.NewMenu() - t.statusItem = menu.Add("Disconnected").SetEnabled(false) + t.statusItem = menu.Add(menuStatusDisconnected).SetEnabled(false) menu.AddSeparator() // On Linux the tray icon's left-click handler is intentionally unbound // (see NewTray for the rationale), so expose the window through an // explicit menu entry. Windows and macOS get the window via left-click. if runtime.GOOS == "linux" { - menu.Add("Open NetBird").OnClick(func(*application.Context) { t.ShowWindow() }) + menu.Add(menuOpenNetBird).OnClick(func(*application.Context) { t.ShowWindow() }) menu.AddSeparator() } - t.upItem = menu.Add("Connect").OnClick(func(*application.Context) { t.handleConnect() }) - t.downItem = menu.Add("Disconnect").OnClick(func(*application.Context) { t.handleDisconnect() }) + t.upItem = menu.Add(menuConnect).OnClick(func(*application.Context) { t.handleConnect() }) + t.downItem = menu.Add(menuDisconnect).OnClick(func(*application.Context) { t.handleDisconnect() }) t.downItem.SetEnabled(false) menu.AddSeparator() - settingsSub := menu.AddSubmenu("Settings") - t.allowSSHItem = settingsSub.AddCheckbox("Allow SSH", false).OnClick(func(*application.Context) { + settingsSub := menu.AddSubmenu(menuSettings) + t.allowSSHItem = settingsSub.AddCheckbox(menuAllowSSH, false).OnClick(func(*application.Context) { t.flipFlag("ssh", t.allowSSHItem.Checked()) }) - t.autoConnItem = settingsSub.AddCheckbox("Connect on Startup", false).OnClick(func(*application.Context) { + t.autoConnItem = settingsSub.AddCheckbox(menuConnectOnStartup, false).OnClick(func(*application.Context) { t.flipFlag("auto", t.autoConnItem.Checked()) }) - t.rosenpassItem = settingsSub.AddCheckbox("Enable Quantum-Resistance", false).OnClick(func(*application.Context) { + t.rosenpassItem = settingsSub.AddCheckbox(menuQuantumResistance, false).OnClick(func(*application.Context) { t.flipFlag("rosenpass", t.rosenpassItem.Checked()) }) - t.lazyConnItem = settingsSub.AddCheckbox("Enable Lazy Connections", false).OnClick(func(*application.Context) { + t.lazyConnItem = settingsSub.AddCheckbox(menuLazyConnections, false).OnClick(func(*application.Context) { t.flipFlag("lazy", t.lazyConnItem.Checked()) }) - t.blockInItem = settingsSub.AddCheckbox("Block Inbound Connections", false).OnClick(func(*application.Context) { + t.blockInItem = settingsSub.AddCheckbox(menuBlockInbound, false).OnClick(func(*application.Context) { t.flipFlag("blockin", t.blockInItem.Checked()) }) - t.notifyItem = settingsSub.AddCheckbox("Notifications", true).OnClick(func(*application.Context) { + t.notifyItem = settingsSub.AddCheckbox(menuNotifications, true).OnClick(func(*application.Context) { t.flipFlag("notify", t.notifyItem.Checked()) }) settingsSub.AddSeparator() - settingsSub.Add("Advanced Settings").OnClick(func(*application.Context) { t.openRoute("/settings") }) - settingsSub.Add("Create Debug Bundle").OnClick(func(*application.Context) { t.openRoute("/debug") }) + settingsSub.Add(menuAdvancedSettings).OnClick(func(*application.Context) { t.openRoute("/settings") }) + settingsSub.Add(menuCreateDebugBundle).OnClick(func(*application.Context) { t.openRoute("/debug") }) - t.exitNodeItem = menu.Add("Exit Node").SetEnabled(false) + t.exitNodeItem = menu.Add(menuExitNode).SetEnabled(false) - t.networksItem = menu.Add("Networks").OnClick(func(*application.Context) { t.openRoute("/networks") }) + t.networksItem = menu.Add(menuNetworks).OnClick(func(*application.Context) { t.openRoute("/networks") }) menu.AddSeparator() - about := menu.AddSubmenu("About") - about.Add("GitHub").OnClick(func(*application.Context) { - _ = t.app.Browser.OpenURL("https://github.com/netbirdio/netbird") + about := menu.AddSubmenu(menuAbout) + about.Add(menuGitHub).OnClick(func(*application.Context) { + _ = t.app.Browser.OpenURL(urlGitHubRepo) }) - about.Add("Documentation").SetEnabled(false) + about.Add(menuDocumentation).SetEnabled(false) + // Hidden until the daemon emits EventUpdateAvailable. The label is + // rewritten in onUpdateAvailable to match the legacy Fyne UI: + // menuDownloadLatestVersion for opt-in, menuInstallVersionPrefix+version + // when the management server enforces the update. + t.updateItem = about.Add(menuDownloadLatestVersion).OnClick(func(*application.Context) { t.handleUpdate() }) + t.updateItem.SetHidden(true) menu.AddSeparator() - menu.Add("Quit").OnClick(func(*application.Context) { t.app.Quit() }) + menu.Add(menuQuit).OnClick(func(*application.Context) { t.app.Quit() }) return menu } @@ -205,7 +272,7 @@ func (t *Tray) handleConnect() { defer cancel() if err := t.connection.Up(ctx, services.UpParams{}); err != nil { log.Errorf("connect: %v", err) - t.notifyError("Failed to connect") + t.notifyError(notifyErrorConnect) t.upItem.SetEnabled(true) } }() @@ -218,7 +285,7 @@ func (t *Tray) handleDisconnect() { defer cancel() if err := t.connection.Down(ctx); err != nil { log.Errorf("disconnect: %v", err) - t.notifyError("Failed to disconnect") + t.notifyError(notifyErrorDisconnect) t.downItem.SetEnabled(true) } }() @@ -270,7 +337,7 @@ func (t *Tray) flipFlag(name string, checked bool) { if err := t.settings.SetConfig(ctx, req); err != nil { log.Errorf("set %s: %v", label, err) - t.notifyError("Failed to update " + label + " settings") + t.notifyError(fmt.Sprintf(notifyErrorSettingsFmt, label)) if item != nil { item.SetChecked(!checked) // revert } @@ -321,11 +388,12 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { if id := se.Metadata["id"]; id != "" { body += fmt.Sprintf(" ID: %s", id) } - t.notify(eventTitle(se), body, "netbird-event-"+se.ID) + t.notify(eventTitle(se), body, notifyIDEvent+se.ID) } // onUpdateAvailable runs when the daemon reports a new netbird version. It -// flips the tray's hasUpdate flag (icon swap) and posts an OS notification. +// flips the tray's hasUpdate flag (icon swap), reveals the update menu +// item with the right label, and posts an OS notification. // The notification is what the legacy Fyne UI used to alert the user. func (t *Tray) onUpdateAvailable(ev *application.CustomEvent) { upd, ok := ev.Data.(services.UpdateAvailable) @@ -336,22 +404,72 @@ func (t *Tray) onUpdateAvailable(ev *application.CustomEvent) { log.Infof("tray onUpdateAvailable: version=%s enforced=%v", upd.Version, upd.Enforced) t.mu.Lock() t.hasUpdate = true + t.updateVersion = upd.Version + t.updateEnforced = upd.Enforced t.mu.Unlock() t.applyIcon() - body := fmt.Sprintf("NetBird %s is available.", upd.Version) + if t.updateItem != nil { + // Match the Fyne wording: enforced updates name the version + // because the install starts on click; opt-in updates just + // route the user to the latest release. + if upd.Enforced { + t.updateItem.SetLabel(menuInstallVersionPrefix + upd.Version) + } else { + t.updateItem.SetLabel(menuDownloadLatestVersion) + } + t.updateItem.SetHidden(false) + } + + body := fmt.Sprintf(notifyUpdateBodyFmt, upd.Version) if upd.Enforced { - body += " Your administrator requires this update." + body += notifyUpdateEnforcedSuffix } if err := t.notifier.SendNotification(notifications.NotificationOptions{ - ID: "netbird-update-" + upd.Version, - Title: "NetBird update available", + ID: notifyIDUpdatePrefix + upd.Version, + Title: notifyUpdateTitle, Body: body, }); err != nil { log.Debugf("send update notification: %v", err) } } +// handleUpdate runs when the user clicks the "Download latest version" / +// "Install version X" menu item. Enforced updates trigger the daemon's +// installer flow and surface the in-window /update progress page; +// opt-in updates just open the GitHub releases page in the browser. +func (t *Tray) handleUpdate() { + t.mu.Lock() + enforced := t.updateEnforced + version := t.updateVersion + t.mu.Unlock() + + if !enforced { + _ = t.app.Browser.OpenURL(urlGitHubReleases) + return + } + + // Surface the progress page first so the user sees the install + // kick off; the daemon then drives the rest via the InstallerResult + // RPC the /update page is polling. + if t.window != nil { + url := "/#/update" + if version != "" { + url += "?version=" + version + } + t.window.SetURL(url) + t.window.Show() + } + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := t.update.Trigger(ctx); err != nil { + log.Errorf("trigger update: %v", err) + } + }() +} + // onUpdateProgress runs when the daemon enters the install phase of an // enforced update. The Fyne UI used to spawn a separate process with the // update window; here the window is already in-process, so we just route to @@ -549,7 +667,7 @@ func (t *Tray) notify(title, body, id string) { // failures. Each tray click site already logs the underlying error; this // adds the user-visible toast. func (t *Tray) notifyError(message string) { - t.notify("Error", message, "netbird-tray-error") + t.notify(notifyErrorTitle, message, notifyIDTrayError) } func exitNodesFromStatus(st services.Status) []string { @@ -602,4 +720,3 @@ func titleCase(s string) string { } return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) } - From 3f8de2a149724c6a3847bc8b0aa25ccbf220650c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 5 May 2026 12:03:09 +0200 Subject: [PATCH 31/80] [client/ui-wails] Hide Dock entry on macOS via LSUIElement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy Fyne client and the sign-pipelines-built .pkg both run NetBird in macOS Accessory mode (LSUIElement=1) — tray-only, no Dock entry, no Cmd-Tab presence. The Wails build's bundled Info.plist (used by `task darwin:package` for local development) didn't carry the flag, so the .app bundle a developer builds locally diverged from the signed release. Add LSUIElement to both Info.plist and Info.dev.plist so the local dev flow matches what users see. --- client/ui-wails/build/darwin/Info.dev.plist | 2 ++ client/ui-wails/build/darwin/Info.plist | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/client/ui-wails/build/darwin/Info.dev.plist b/client/ui-wails/build/darwin/Info.dev.plist index 7c5feb0dd..644c5f657 100644 --- a/client/ui-wails/build/darwin/Info.dev.plist +++ b/client/ui-wails/build/darwin/Info.dev.plist @@ -23,6 +23,8 @@ 10.15.0 NSHighResolutionCapable true + LSUIElement + 1 NSHumanReadableCopyright © 2026, My Company NSAppTransportSecurity diff --git a/client/ui-wails/build/darwin/Info.plist b/client/ui-wails/build/darwin/Info.plist index 7449c69ad..44edfca97 100644 --- a/client/ui-wails/build/darwin/Info.plist +++ b/client/ui-wails/build/darwin/Info.plist @@ -23,6 +23,11 @@ 10.15.0 NSHighResolutionCapable true + + LSUIElement + 1 NSHumanReadableCopyright © 2026, My Company From 2e61b42e92d4b45f0ae3373e21fc635e4a474249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 5 May 2026 12:19:41 +0200 Subject: [PATCH 32/80] [client/ui-wails] Slim the tray menu, move toggles to Settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fyne 1:1 tray pulled the entire daemon-config knobset (Allow SSH, Connect on Startup, Quantum-Resistance, Lazy Connections, Block Inbound, Notifications) into a Settings submenu — useful in a tray-only UI but redundant now that the Wails app has a real Settings page. Drop the submenu and route a single top-level "Settings" entry to /settings; "Create Debug Bundle" stays at the top level for support workflows. Side effects: - flipFlag and ptrBool go away with the checkbox callbacks. - loadConfig keeps seeding notificationsEnabled (the tray still gates OS toasts in onSystemEvent on it) but no longer mirrors any other config field. - Unused menu/notify constants (Allow SSH, Connect on Startup, ..., notifyErrorSettingsFmt) are removed from the central const block. --- client/ui-wails/tray.go | 158 +++++++--------------------------------- 1 file changed, 28 insertions(+), 130 deletions(-) diff --git a/client/ui-wails/tray.go b/client/ui-wails/tray.go index 7d95bb7d3..428673c9c 100644 --- a/client/ui-wails/tray.go +++ b/client/ui-wails/tray.go @@ -33,15 +33,11 @@ const ( menuNetworks = "Networks" menuQuit = "Quit" - // Settings submenu. + // Settings + diagnostics. The settings page replaces the Fyne tray's + // Settings submenu (per-toggle checkboxes for SSH, auto-connect, + // Rosenpass, lazy connections, block-inbound, notifications); those + // live in the in-window Settings page now. menuSettings = "Settings" - menuAllowSSH = "Allow SSH" - menuConnectOnStartup = "Connect on Startup" - menuQuantumResistance = "Enable Quantum-Resistance" - menuLazyConnections = "Enable Lazy Connections" - menuBlockInbound = "Block Inbound Connections" - menuNotifications = "Notifications" - menuAdvancedSettings = "Advanced Settings" menuCreateDebugBundle = "Create Debug Bundle" // About submenu and update flow. @@ -60,7 +56,6 @@ const ( notifyErrorTitle = "Error" notifyErrorConnect = "Failed to connect" notifyErrorDisconnect = "Failed to disconnect" - notifyErrorSettingsFmt = "Failed to update %s settings" // Notification IDs (used to coalesce duplicate toasts). notifyIDUpdatePrefix = "netbird-update-" @@ -86,18 +81,12 @@ type Tray struct { notifier *notifications.NotificationService update *services.Update - statusItem *application.MenuItem - upItem *application.MenuItem - downItem *application.MenuItem - exitNodeItem *application.MenuItem - networksItem *application.MenuItem - allowSSHItem *application.MenuItem - autoConnItem *application.MenuItem - rosenpassItem *application.MenuItem - lazyConnItem *application.MenuItem - blockInItem *application.MenuItem - notifyItem *application.MenuItem - updateItem *application.MenuItem + statusItem *application.MenuItem + upItem *application.MenuItem + downItem *application.MenuItem + exitNodeItem *application.MenuItem + networksItem *application.MenuItem + updateItem *application.MenuItem mu sync.Mutex connected bool @@ -199,35 +188,20 @@ func (t *Tray) buildMenu() *application.Menu { menu.AddSeparator() - settingsSub := menu.AddSubmenu(menuSettings) - t.allowSSHItem = settingsSub.AddCheckbox(menuAllowSSH, false).OnClick(func(*application.Context) { - t.flipFlag("ssh", t.allowSSHItem.Checked()) - }) - t.autoConnItem = settingsSub.AddCheckbox(menuConnectOnStartup, false).OnClick(func(*application.Context) { - t.flipFlag("auto", t.autoConnItem.Checked()) - }) - t.rosenpassItem = settingsSub.AddCheckbox(menuQuantumResistance, false).OnClick(func(*application.Context) { - t.flipFlag("rosenpass", t.rosenpassItem.Checked()) - }) - t.lazyConnItem = settingsSub.AddCheckbox(menuLazyConnections, false).OnClick(func(*application.Context) { - t.flipFlag("lazy", t.lazyConnItem.Checked()) - }) - t.blockInItem = settingsSub.AddCheckbox(menuBlockInbound, false).OnClick(func(*application.Context) { - t.flipFlag("blockin", t.blockInItem.Checked()) - }) - t.notifyItem = settingsSub.AddCheckbox(menuNotifications, true).OnClick(func(*application.Context) { - t.flipFlag("notify", t.notifyItem.Checked()) - }) - settingsSub.AddSeparator() - settingsSub.Add(menuAdvancedSettings).OnClick(func(*application.Context) { t.openRoute("/settings") }) - settingsSub.Add(menuCreateDebugBundle).OnClick(func(*application.Context) { t.openRoute("/debug") }) - t.exitNodeItem = menu.Add(menuExitNode).SetEnabled(false) - t.networksItem = menu.Add(menuNetworks).OnClick(func(*application.Context) { t.openRoute("/networks") }) menu.AddSeparator() + // Settings, runtime toggles (SSH, Quantum-Resistance, lazy connection, + // block-inbound, auto-connect, notifications) and profile switching + // all live in the in-window Settings page now. The tray menu only + // surfaces the day-to-day actions. + menu.Add(menuSettings).OnClick(func(*application.Context) { t.openRoute("/settings") }) + menu.Add(menuCreateDebugBundle).OnClick(func(*application.Context) { t.openRoute("/debug") }) + + menu.AddSeparator() + about := menu.AddSubmenu(menuAbout) about.Add(menuGitHub).OnClick(func(*application.Context) { _ = t.app.Browser.OpenURL(urlGitHubRepo) @@ -291,69 +265,6 @@ func (t *Tray) handleDisconnect() { }() } -// flipFlag pushes a partial SetConfig for one tray-toggled boolean. On -// failure the tray checkbox is reverted to keep it in sync with the daemon -// and an error notification is fired so the user knows the change didn't -// stick. The "notify" flag also updates the in-process gate that decides -// whether daemon SystemEvents become OS notifications. -func (t *Tray) flipFlag(name string, checked bool) { - go func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - t.mu.Lock() - profile, username := t.activeProfile, t.activeUsername - t.mu.Unlock() - - req := services.SetConfigParams{ProfileName: profile, Username: username} - var ( - label string - item *application.MenuItem - ) - switch name { - case "ssh": - req.ServerSSHAllowed = ptrBool(checked) - label, item = "SSH", t.allowSSHItem - case "auto": - // "Connect on Startup" is the inverse of disableAutoConnect. - req.DisableAutoConnect = ptrBool(!checked) - label, item = "auto-connect", t.autoConnItem - case "rosenpass": - req.RosenpassEnabled = ptrBool(checked) - label, item = "Rosenpass", t.rosenpassItem - case "lazy": - req.LazyConnectionEnabled = ptrBool(checked) - label, item = "lazy connection", t.lazyConnItem - case "blockin": - req.BlockInbound = ptrBool(checked) - label, item = "block inbound", t.blockInItem - case "notify": - req.DisableNotifications = ptrBool(!checked) - label, item = "notifications", t.notifyItem - default: - log.Debugf("tray flipFlag: unknown flag %q", name) - return - } - - if err := t.settings.SetConfig(ctx, req); err != nil { - log.Errorf("set %s: %v", label, err) - t.notifyError(fmt.Sprintf(notifyErrorSettingsFmt, label)) - if item != nil { - item.SetChecked(!checked) // revert - } - return - } - - if name == "notify" { - t.mu.Lock() - t.notificationsEnabled = checked - t.mu.Unlock() - } - }() -} - -func ptrBool(b bool) *bool { return &b } - func (t *Tray) onStatusEvent(ev *application.CustomEvent) { st, ok := ev.Data.(services.Status) if !ok { @@ -604,9 +515,15 @@ func (t *Tray) iconForState() (icon, dark []byte) { } } -// loadConfig syncs the tray-submenu checkboxes with the daemon's stored -// config and seeds the notifications gate. Called once at startup from a -// goroutine so a slow or unreachable daemon does not block menu construction. +// loadConfig seeds the in-process notifications gate from the daemon's +// stored config and caches the active-profile identity for any future +// SetConfig calls. Called once at startup from a goroutine so a slow or +// unreachable daemon does not block menu construction. +// +// The Settings page in the main window is the source of truth for every +// other knob (SSH, auto-connect, Rosenpass, lazy connections, block-inbound, +// notifications); we only mirror the notifications flag because the tray +// itself uses it to gate OS toasts in onSystemEvent. func (t *Tray) loadConfig() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -627,25 +544,6 @@ func (t *Tray) loadConfig() { t.activeUsername = active.Username t.notificationsEnabled = !cfg.DisableNotifications t.mu.Unlock() - - if t.allowSSHItem != nil { - t.allowSSHItem.SetChecked(cfg.ServerSSHAllowed) - } - if t.autoConnItem != nil { - t.autoConnItem.SetChecked(!cfg.DisableAutoConnect) - } - if t.rosenpassItem != nil { - t.rosenpassItem.SetChecked(cfg.RosenpassEnabled) - } - if t.lazyConnItem != nil { - t.lazyConnItem.SetChecked(cfg.LazyConnectionEnabled) - } - if t.blockInItem != nil { - t.blockInItem.SetChecked(cfg.BlockInbound) - } - if t.notifyItem != nil { - t.notifyItem.SetChecked(!cfg.DisableNotifications) - } } // notify wraps the Wails notification service with the tray's standard From dd9c15072f938fcba04be8d8ac310d468da1a410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 5 May 2026 12:56:59 +0200 Subject: [PATCH 33/80] [ci] Skip client/ui-wails in go test runs main.go embeds frontend/dist with //go:embed, so any go-list-based test sweep that touches the package fails at compile time before pnpm build has populated the directory. The release pipeline runs the frontend build via the goreleaser before-hook; the test workflows do not, and should not, ship a Node toolchain just to compile a UI binary that has no Go-side unit tests anyway. Add a /client/ui-wails exclude to the test go-list filter on Linux, Darwin and Windows. --- .github/workflows/golang-test-darwin.yml | 6 +++++- .github/workflows/golang-test-linux.yml | 6 +++++- .github/workflows/golang-test-windows.yml | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index 0528ed086..38c992c5a 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -43,5 +43,9 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) + # Exclude client/ui-wails: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. + run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui-wails) diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 450c44aea..4fa796018 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -154,7 +154,11 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) + # Exclude client/ui-wails: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. + run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui-wails) test_client_on_docker: name: "Client (Docker) / Unit" diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index 8e672043d..102229b9e 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -64,8 +64,12 @@ jobs: - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy - name: Generate test script + # Exclude client/ui-wails: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. run: | - $packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } + $packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui-wails' } $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" $cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd From 93275f9052eed8e0b1a71d6fd39fe98c03e50312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 5 May 2026 13:09:37 +0200 Subject: [PATCH 34/80] Bump github.com/wailsapp/wails/v3 to v3.0.0-alpha.84 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the alpha.84 patch series. The only API change relative to alpha.78 is a new macOS Liquid Glass effect option (NSGlassEffectView) that NetBird does not use, so this is a drop-in dependency bump. netbird-ui builds cleanly, go vet has no new findings, and the existing Linux tray workaround (skip AttachWindow + OnClick on Linux) is still required — Wails3 systemtray_linux.go's openMenu remains a "not implemented on Linux" stub and SystemTray.applySmartDefaults still auto-installs ToggleWindow as the click handler when a window is attached. The alpha CLI's transitive github.com/goreleaser/nfpm/v2 v2.44.1 is not imported by any NetBird production binary (verified with `go list -deps` on netbird-ui and the daemon entry points); it only ships inside the wails3 developer CLI used for local packaging. The Snyk advisory for nfpm therefore does not affect netbird-ui or the daemon. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 83189175a..7f314caeb 100644 --- a/go.mod +++ b/go.mod @@ -106,7 +106,7 @@ require ( github.com/ti-mo/conntrack v0.5.1 github.com/ti-mo/netfilter v0.5.2 github.com/vmihailenco/msgpack/v5 v5.4.1 - github.com/wailsapp/wails/v3 v3.0.0-alpha.78 + github.com/wailsapp/wails/v3 v3.0.0-alpha.84 github.com/yusufpapurcu/wmi v1.2.4 github.com/zcalusic/sysinfo v1.1.3 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 diff --git a/go.sum b/go.sum index 3b349f7ac..2b12824b9 100644 --- a/go.sum +++ b/go.sum @@ -709,8 +709,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= -github.com/wailsapp/wails/v3 v3.0.0-alpha.78 h1:31nJb4N8X+SIBZ88RNkptFA1eUnBOH805tDV0sN7Vpk= -github.com/wailsapp/wails/v3 v3.0.0-alpha.78/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8= +github.com/wailsapp/wails/v3 v3.0.0-alpha.84 h1:Ll1XbFYApiJzKltnkxqW7YD1DlYPzILgFQBBfjLwubk= +github.com/wailsapp/wails/v3 v3.0.0-alpha.84/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= From fffb9dd219aeab2dcea8cf2797de9556191a2046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 5 May 2026 13:53:40 +0200 Subject: [PATCH 35/80] [client/ui-wails] Add Forwarding service for the exposed-services list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the daemon's existing ForwardingRules RPC as a Wails service so the React frontend can render the reverse-proxy / exposed-services list in the planned dashboard. Forwarding.List() returns one ForwardingRule per active rule with protocol, destination port (single or range), translated address / hostname, and translated port. The PortInfo oneof from the proto is flattened to a `{port?: number, range?: {start, end}}` shape so TS consumers don't have to peek at proto-internal type discriminators. Regenerate frontend/bindings (forwarding.ts, models.ts, index.ts) so the React side picks up the new service. peers.ts churn is a doc comment refresh only — no API change. --- .../client/ui-wails/services/forwarding.ts | 29 ++++ .../netbird/client/ui-wails/services/index.ts | 5 + .../client/ui-wails/services/models.ts | 147 +++++++++++++++--- .../netbird/client/ui-wails/services/peers.ts | 13 +- client/ui-wails/main.go | 1 + client/ui-wails/services/forwarding.go | 87 +++++++++++ 6 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/forwarding.ts create mode 100644 client/ui-wails/services/forwarding.go diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/forwarding.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/forwarding.ts new file mode 100644 index 000000000..b1c18c3a2 --- /dev/null +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/forwarding.ts @@ -0,0 +1,29 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Forwarding groups the daemon RPCs that surface exposed/forwarded services. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +/** + * List returns the current set of forwarding rules from the daemon's + * reverse proxy. The frontend renders these as the "exposed services" list. + */ +export function List(): $CancellablePromise<$models.ForwardingRule[]> { + return $Call.ByID(3893357601).then(($result: any) => { + return $$createType1($result); + }); +} + +// Private type creation functions +const $$createType0 = $models.ForwardingRule.createFrom; +const $$createType1 = $Create.Array($$createType0); diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/index.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/index.ts index 90203c174..bb3a7b821 100644 --- a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/index.ts +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/index.ts @@ -3,6 +3,7 @@ import * as Connection from "./connection.js"; import * as Debug from "./debug.js"; +import * as Forwarding from "./forwarding.js"; import * as Networks from "./networks.js"; import * as Peers from "./peers.js"; import * as Profiles from "./profiles.js"; @@ -11,6 +12,7 @@ import * as Update from "./update.js"; export { Connection, Debug, + Forwarding, Networks, Peers, Profiles, @@ -25,6 +27,7 @@ export { DebugBundleParams, DebugBundleResult, Features, + ForwardingRule, LocalPeer, LogLevel, LoginParams, @@ -33,6 +36,8 @@ export { Network, PeerLink, PeerStatus, + PortInfo, + PortRange, Profile, ProfileRef, SelectNetworksParams, diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.ts index d22a345e0..ee6f26b96 100644 --- a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.ts +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.ts @@ -291,6 +291,55 @@ export class Features { } } +/** + * ForwardingRule is one entry from the daemon's reverse-proxy table — + * what we ship to the frontend's "exposed services" view. + */ +export class ForwardingRule { + "protocol": string; + "destinationPort": PortInfo; + "translatedAddress": string; + "translatedHostname": string; + "translatedPort": PortInfo; + + /** Creates a new ForwardingRule instance. */ + constructor($$source: Partial = {}) { + if (!("protocol" in $$source)) { + this["protocol"] = ""; + } + if (!("destinationPort" in $$source)) { + this["destinationPort"] = (new PortInfo()); + } + if (!("translatedAddress" in $$source)) { + this["translatedAddress"] = ""; + } + if (!("translatedHostname" in $$source)) { + this["translatedHostname"] = ""; + } + if (!("translatedPort" in $$source)) { + this["translatedPort"] = (new PortInfo()); + } + + Object.assign(this, $$source); + } + + /** + * Creates a new ForwardingRule instance from a string or object. + */ + static createFrom($$source: any = {}): ForwardingRule { + const $$createField1_0 = $$createType0; + const $$createField4_0 = $$createType0; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("destinationPort" in $$parsedSource) { + $$parsedSource["destinationPort"] = $$createField1_0($$parsedSource["destinationPort"]); + } + if ("translatedPort" in $$parsedSource) { + $$parsedSource["translatedPort"] = $$createField4_0($$parsedSource["translatedPort"]); + } + return new ForwardingRule($$parsedSource as Partial); + } +} + /** * LocalPeer mirrors LocalPeerState — what this client looks like on the mesh. */ @@ -322,7 +371,7 @@ export class LocalPeer { * Creates a new LocalPeer instance from a string or object. */ static createFrom($$source: any = {}): LocalPeer { - const $$createField3_0 = $$createType0; + const $$createField3_0 = $$createType1; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("networks" in $$parsedSource) { $$parsedSource["networks"] = $$createField3_0($$parsedSource["networks"]); @@ -503,8 +552,8 @@ export class Network { * Creates a new Network instance from a string or object. */ static createFrom($$source: any = {}): Network { - const $$createField3_0 = $$createType0; - const $$createField4_0 = $$createType1; + const $$createField3_0 = $$createType1; + const $$createField4_0 = $$createType2; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("domains" in $$parsedSource) { $$parsedSource["domains"] = $$createField3_0($$parsedSource["domains"]); @@ -631,7 +680,7 @@ export class PeerStatus { * Creates a new PeerStatus instance from a string or object. */ static createFrom($$source: any = {}): PeerStatus { - const $$createField16_0 = $$createType0; + const $$createField16_0 = $$createType1; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("networks" in $$parsedSource) { $$parsedSource["networks"] = $$createField16_0($$parsedSource["networks"]); @@ -640,6 +689,61 @@ export class PeerStatus { } } +/** + * PortInfo carries the destination or translated port for a forwarding rule. + * Exactly one of Port or Range is populated, mirroring the daemon's oneof. + */ +export class PortInfo { + "port"?: number | null; + "range"?: PortRange | null; + + /** Creates a new PortInfo instance. */ + constructor($$source: Partial = {}) { + + Object.assign(this, $$source); + } + + /** + * Creates a new PortInfo instance from a string or object. + */ + static createFrom($$source: any = {}): PortInfo { + const $$createField1_0 = $$createType4; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("range" in $$parsedSource) { + $$parsedSource["range"] = $$createField1_0($$parsedSource["range"]); + } + return new PortInfo($$parsedSource as Partial); + } +} + +/** + * PortRange describes a contiguous port range. Both ends are inclusive. + */ +export class PortRange { + "start": number; + "end": number; + + /** Creates a new PortRange instance. */ + constructor($$source: Partial = {}) { + if (!("start" in $$source)) { + this["start"] = 0; + } + if (!("end" in $$source)) { + this["end"] = 0; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new PortRange instance from a string or object. + */ + static createFrom($$source: any = {}): PortRange { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new PortRange($$parsedSource as Partial); + } +} + /** * Profile is one named daemon profile. */ @@ -725,7 +829,7 @@ export class SelectNetworksParams { * Creates a new SelectNetworksParams instance from a string or object. */ static createFrom($$source: any = {}): SelectNetworksParams { - const $$createField0_0 = $$createType0; + const $$createField0_0 = $$createType1; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("networkIds" in $$parsedSource) { $$parsedSource["networkIds"] = $$createField0_0($$parsedSource["networkIds"]); @@ -837,11 +941,11 @@ export class Status { * Creates a new Status instance from a string or object. */ static createFrom($$source: any = {}): Status { - const $$createField2_0 = $$createType2; - const $$createField3_0 = $$createType2; - const $$createField4_0 = $$createType3; - const $$createField5_0 = $$createType5; - const $$createField6_0 = $$createType7; + const $$createField2_0 = $$createType5; + const $$createField3_0 = $$createType5; + const $$createField4_0 = $$createType6; + const $$createField5_0 = $$createType8; + const $$createField6_0 = $$createType10; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("management" in $$parsedSource) { $$parsedSource["management"] = $$createField2_0($$parsedSource["management"]); @@ -905,7 +1009,7 @@ export class SystemEvent { * Creates a new SystemEvent instance from a string or object. */ static createFrom($$source: any = {}): SystemEvent { - const $$createField6_0 = $$createType8; + const $$createField6_0 = $$createType11; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("metadata" in $$parsedSource) { $$parsedSource["metadata"] = $$createField6_0($$parsedSource["metadata"]); @@ -1056,12 +1160,15 @@ export class WaitSSOParams { } // Private type creation functions -const $$createType0 = $Create.Array($Create.Any); -const $$createType1 = $Create.Map($Create.Any, $$createType0); -const $$createType2 = PeerLink.createFrom; -const $$createType3 = LocalPeer.createFrom; -const $$createType4 = PeerStatus.createFrom; -const $$createType5 = $Create.Array($$createType4); -const $$createType6 = SystemEvent.createFrom; -const $$createType7 = $Create.Array($$createType6); -const $$createType8 = $Create.Map($Create.Any, $Create.Any); +const $$createType0 = PortInfo.createFrom; +const $$createType1 = $Create.Array($Create.Any); +const $$createType2 = $Create.Map($Create.Any, $$createType1); +const $$createType3 = PortRange.createFrom; +const $$createType4 = $Create.Nullable($$createType3); +const $$createType5 = PeerLink.createFrom; +const $$createType6 = LocalPeer.createFrom; +const $$createType7 = PeerStatus.createFrom; +const $$createType8 = $Create.Array($$createType7); +const $$createType9 = SystemEvent.createFrom; +const $$createType10 = $Create.Array($$createType9); +const $$createType11 = $Create.Map($Create.Any, $Create.Any); diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/peers.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/peers.ts index 2d361d41b..98032de6e 100644 --- a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/peers.ts +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/peers.ts @@ -25,9 +25,16 @@ export function Get(): $CancellablePromise<$models.Status> { } /** - * Watch starts the background loops that feed the frontend: a status - * stream (push-driven on connection-state change) and an event stream - * (DNS / network / auth / connectivity / update notifications). + * Watch starts the background loops that feed the frontend: + * - statusStreamLoop: push-driven snapshots on connection-state change + * (Connected/Disconnected/Connecting, peer list, address). Drives the + * tray icon, Status page, and Peers page. + * - toastStreamLoop: DNS / network / auth / connectivity / update + * SystemEvent stream. Drives OS notifications, the Recent Events + * list, and the update-overlay flag. The daemon-side RPC is named + * SubscribeEvents — only the loop's local alias differs to keep the + * two streams distinguishable in this file. + * * Safe to call once at boot; both loops self-restart on stream errors * via exponential backoff. */ diff --git a/client/ui-wails/main.go b/client/ui-wails/main.go index 99287f3fa..bef421e16 100644 --- a/client/ui-wails/main.go +++ b/client/ui-wails/main.go @@ -99,6 +99,7 @@ func main() { app.RegisterService(application.NewService(connection)) app.RegisterService(application.NewService(settings)) app.RegisterService(application.NewService(services.NewNetworks(conn))) + app.RegisterService(application.NewService(services.NewForwarding(conn))) app.RegisterService(application.NewService(profiles)) app.RegisterService(application.NewService(services.NewDebug(conn))) app.RegisterService(application.NewService(update)) diff --git a/client/ui-wails/services/forwarding.go b/client/ui-wails/services/forwarding.go new file mode 100644 index 000000000..4626bf764 --- /dev/null +++ b/client/ui-wails/services/forwarding.go @@ -0,0 +1,87 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/proto" +) + +// PortRange describes a contiguous port range. Both ends are inclusive. +type PortRange struct { + Start uint32 `json:"start"` + End uint32 `json:"end"` +} + +// PortInfo carries the destination or translated port for a forwarding rule. +// Exactly one of Port or Range is populated, mirroring the daemon's oneof. +type PortInfo struct { + Port *uint32 `json:"port,omitempty"` + Range *PortRange `json:"range,omitempty"` +} + +// ForwardingRule is one entry from the daemon's reverse-proxy table — +// what we ship to the frontend's "exposed services" view. +type ForwardingRule struct { + Protocol string `json:"protocol"` + DestinationPort PortInfo `json:"destinationPort"` + TranslatedAddress string `json:"translatedAddress"` + TranslatedHostname string `json:"translatedHostname"` + TranslatedPort PortInfo `json:"translatedPort"` +} + +// Forwarding groups the daemon RPCs that surface exposed/forwarded services. +type Forwarding struct { + conn DaemonConn +} + +func NewForwarding(conn DaemonConn) *Forwarding { + return &Forwarding{conn: conn} +} + +// List returns the current set of forwarding rules from the daemon's +// reverse proxy. The frontend renders these as the "exposed services" list. +func (s *Forwarding) List(ctx context.Context) ([]ForwardingRule, error) { + cli, err := s.conn.Client() + if err != nil { + return nil, err + } + resp, err := cli.ForwardingRules(ctx, &proto.EmptyRequest{}) + if err != nil { + return nil, err + } + out := make([]ForwardingRule, 0, len(resp.GetRules())) + for _, r := range resp.GetRules() { + out = append(out, forwardingRuleFromProto(r)) + } + return out, nil +} + +func forwardingRuleFromProto(r *proto.ForwardingRule) ForwardingRule { + return ForwardingRule{ + Protocol: r.GetProtocol(), + DestinationPort: portInfoFromProto(r.GetDestinationPort()), + TranslatedAddress: r.GetTranslatedAddress(), + TranslatedHostname: r.GetTranslatedHostname(), + TranslatedPort: portInfoFromProto(r.GetTranslatedPort()), + } +} + +func portInfoFromProto(p *proto.PortInfo) PortInfo { + if p == nil { + return PortInfo{} + } + switch sel := p.GetPortSelection().(type) { + case *proto.PortInfo_Port: + port := sel.Port + return PortInfo{Port: &port} + case *proto.PortInfo_Range_: + r := sel.Range + if r == nil { + return PortInfo{} + } + return PortInfo{Range: &PortRange{Start: r.GetStart(), End: r.GetEnd()}} + } + return PortInfo{} +} \ No newline at end of file From 0c136fffb96b6caaae367e28efc3f2582654244f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 5 May 2026 15:10:23 +0200 Subject: [PATCH 36/80] [ci] Add sonar-project.properties to exclude the Wails React frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonar's default scanner picks up TypeScript / JSX from the frontend tree but applies rules that don't fit a UI codebase reviewed visually (component dead-code detection, hook-shape conventions, ...). Skip client/ui-wails/frontend from both analysis and coverage so neither the rules engine nor the coverage gate trips on UI changes. The Go side of the Wails UI (client/ui-wails/*.go, services/) is left in scope on purpose — same Go standards as the rest of the repo. --- sonar-project.properties | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..948557321 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,11 @@ +sonar.projectKey=netbirdio_netbird +sonar.organization=netbirdio + +# Exclude the React frontend from analysis. The Go side of the Wails UI +# (client/ui-wails/*.go and services/) is intentionally kept in scope — +# it's regular Go code with the same standards as the rest of the repo. +sonar.exclusions=client/ui-wails/frontend/** + +# Mirror the same paths under coverage so missing coverage on UI code +# doesn't drag the overall coverage metric down. +sonar.coverage.exclusions=client/ui-wails/frontend/** \ No newline at end of file From dc02542a9ef9eb2008cc930d0bf2790db8a1f8e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 5 May 2026 15:12:49 +0200 Subject: [PATCH 37/80] [ci] Skip client/ui-wails/main.go in golangci-lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.go uses //go:embed all:frontend/dist, which fails the typecheck phase when frontend/dist is empty (the release pipeline populates it via `pnpm build`; the lint workflow does not). Excluding just main.go keeps the rest of the package — services/, tray.go, grpc.go, the signal handlers — in scope. --- .golangci.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.golangci.yaml b/.golangci.yaml index 900af4ac0..d1b7ac271 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -118,6 +118,12 @@ linters: - third_party$ - builtin$ - examples$ + # client/ui-wails/main.go uses //go:embed all:frontend/dist; that + # directory is populated by `pnpm build` in the release pipeline + # and is missing at lint time, so the typecheck phase fails before + # any rule runs. Skip just main.go — the rest of the package + # (services/, tray.go, grpc.go, ...) still gets linted. + - client/ui-wails/main\.go$ issues: max-same-issues: 5 formatters: From b3eb5f245344c1d415495c0eba21fdce70e5fe45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 5 May 2026 15:15:15 +0200 Subject: [PATCH 38/80] [ci] Skip lockfiles in codespell pnpm-lock.yaml and package-lock.json embed package hashes that look like English words to codespell (e.g. "nD" -> "and"), causing false positives that can't be fixed because the lockfile is auto-generated. Add the standard lockfile patterns to the skip list alongside the existing go.mod/go.sum/proxy-web entries. --- .github/workflows/golangci-lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 62dfe9bce..99181d8c9 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -20,7 +20,7 @@ jobs: uses: codespell-project/actions-codespell@v2 with: ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA - skip: go.mod,go.sum,**/proxy/web/** + skip: go.mod,go.sum,**/proxy/web/**,**/pnpm-lock.yaml,**/package-lock.json golangci: strategy: fail-fast: false From cd8e71002fe6c8bf1197ca30abe5d2f5e813adc1 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 5 May 2026 22:26:27 +0900 Subject: [PATCH 39/80] [client] Bump go-netroute to v0.4.0 and drop fork (#6062) --- client/internal/portforward/pcp/nat.go | 15 ++++++- .../systemops/systemops_generic.go | 16 ++++++++ .../systemops/systemops_generic_test.go | 6 ++- .../systemops/v6route_bsd_test.go | 30 ++++++++++++++ .../systemops/v6route_linux_test.go | 41 +++++++++++++++++++ .../systemops/v6route_windows_test.go | 34 +++++++++++++++ go.mod | 18 ++++---- go.sum | 32 +++++++-------- 8 files changed, 163 insertions(+), 29 deletions(-) create mode 100644 client/internal/routemanager/systemops/v6route_bsd_test.go create mode 100644 client/internal/routemanager/systemops/v6route_linux_test.go create mode 100644 client/internal/routemanager/systemops/v6route_windows_test.go diff --git a/client/internal/portforward/pcp/nat.go b/client/internal/portforward/pcp/nat.go index 1dc24274b..6491e7367 100644 --- a/client/internal/portforward/pcp/nat.go +++ b/client/internal/portforward/pcp/nat.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/netip" + "runtime" "sync" "time" @@ -177,7 +178,12 @@ func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) { return nil, nil, err } - _, gateway, localIP, err = router.Route(net.IPv4zero) + dst := net.IPv4zero + if runtime.GOOS == "linux" { + // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux. + dst = net.IPv4(0, 0, 0, 1) + } + _, gateway, localIP, err = router.Route(dst) if err != nil { return nil, nil, err } @@ -196,7 +202,12 @@ func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) { return nil, nil, err } - _, gateway, localIP, err = router.Route(net.IPv6zero) + dst := net.IPv6zero + if runtime.GOOS == "linux" { + // ::2 + dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2} + } + _, gateway, localIP, err = router.Route(dst) if err != nil { return nil, nil, err } diff --git a/client/internal/routemanager/systemops/systemops_generic.go b/client/internal/routemanager/systemops/systemops_generic.go index 4211eb057..bf7b95a28 100644 --- a/client/internal/routemanager/systemops/systemops_generic.go +++ b/client/internal/routemanager/systemops/systemops_generic.go @@ -342,6 +342,22 @@ func GetNextHop(ip netip.Addr) (Nexthop, error) { if err != nil { return Nexthop{}, fmt.Errorf("new netroute: %w", err) } + + // go-netroute v0.4.0 rejects unspecified destinations on Linux with a hard + // client-side check. Substitute the lowest non-loopback address so the + // lookup falls through to the default route (::1 / 127.0.0.1 would match + // loopback, ::/0.0.0.0 are unspec). BSD/Windows pass the query straight to + // the kernel and need no substitution. + if runtime.GOOS == "linux" && ip.IsUnspecified() { + if ip.Is6() { + // ::2 + ip = netip.AddrFrom16([16]byte{15: 2}) + } else { + // 0.0.0.1 + ip = netip.AddrFrom4([4]byte{0, 0, 0, 1}) + } + } + intf, gateway, preferredSrc, err := r.Route(ip.AsSlice()) if err != nil { log.Debugf("Failed to get route for %s: %v", ip, err) diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go index 01916fbe3..08e354a78 100644 --- a/client/internal/routemanager/systemops/systemops_generic_test.go +++ b/client/internal/routemanager/systemops/systemops_generic_test.go @@ -354,9 +354,13 @@ func TestAddRouteToNonVPNIntf(t *testing.T) { require.NoError(t, err, "Should be able to get IPv4 default route") t.Logf("Initial IPv4 next hop: %s", initialNextHopV4) + if testCase.prefix.Addr().Is6() && !testCase.expectError { + ensureIPv6DefaultRoute(t) + } + initialNextHopV6, err := GetNextHop(netip.IPv6Unspecified()) if testCase.prefix.Addr().Is6() && - (errors.Is(err, vars.ErrRouteNotFound) || initialNextHopV6.Intf != nil && strings.HasPrefix(initialNextHopV6.Intf.Name, "utun")) { + initialNextHopV6.Intf != nil && strings.HasPrefix(initialNextHopV6.Intf.Name, "utun") { t.Skip("Skipping test as no ipv6 default route is available") } if err != nil && !errors.Is(err, vars.ErrRouteNotFound) { diff --git a/client/internal/routemanager/systemops/v6route_bsd_test.go b/client/internal/routemanager/systemops/v6route_bsd_test.go new file mode 100644 index 000000000..98ce29c6d --- /dev/null +++ b/client/internal/routemanager/systemops/v6route_bsd_test.go @@ -0,0 +1,30 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package systemops + +import ( + "bytes" + "os/exec" + "testing" +) + +// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback +// interface so route lookups for global IPv6 prefixes resolve in environments +// without v6 connectivity. If a default already exists it is left alone. +func ensureIPv6DefaultRoute(t *testing.T) { + t.Helper() + + out, err := exec.Command("route", "-6", "add", "default", "-iface", "lo0").CombinedOutput() + if err != nil { + // Existing default; nothing to install or clean up. + if bytes.Contains(out, []byte("route already in table")) { + return + } + t.Skipf("install IPv6 fallback default route: %v: %s", err, out) + } + t.Cleanup(func() { + if out, err := exec.Command("route", "-6", "delete", "default").CombinedOutput(); err != nil { + t.Logf("delete IPv6 fallback default route: %v: %s", err, out) + } + }) +} diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go new file mode 100644 index 000000000..0b17cefff --- /dev/null +++ b/client/internal/routemanager/systemops/v6route_linux_test.go @@ -0,0 +1,41 @@ +//go:build linux && !android + +package systemops + +import ( + "errors" + "net" + "syscall" + "testing" + + "github.com/stretchr/testify/require" + "github.com/vishvananda/netlink" +) + +// ensureIPv6DefaultRoute installs a low-preference IPv6 default route via the +// loopback interface so route lookups for global IPv6 prefixes resolve in +// environments without v6 connectivity. Any pre-existing default route wins +// because of its lower metric. +func ensureIPv6DefaultRoute(t *testing.T) { + t.Helper() + + lo, err := netlink.LinkByName("lo") + require.NoError(t, err, "find loopback interface") + + route := &netlink.Route{ + Dst: &net.IPNet{IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)}, + LinkIndex: lo.Attrs().Index, + Priority: 1 << 20, + } + if err := netlink.RouteAdd(route); err != nil { + if errors.Is(err, syscall.EEXIST) { + return + } + t.Skipf("install IPv6 fallback default route: %v", err) + } + t.Cleanup(func() { + if err := netlink.RouteDel(route); err != nil && !errors.Is(err, syscall.ESRCH) { + t.Logf("delete IPv6 fallback default route: %v", err) + } + }) +} diff --git a/client/internal/routemanager/systemops/v6route_windows_test.go b/client/internal/routemanager/systemops/v6route_windows_test.go new file mode 100644 index 000000000..f79277b87 --- /dev/null +++ b/client/internal/routemanager/systemops/v6route_windows_test.go @@ -0,0 +1,34 @@ +//go:build windows + +package systemops + +import ( + "bytes" + "os/exec" + "testing" +) + +const loopbackIfaceWindows = "Loopback Pseudo-Interface 1" + +// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback +// interface so route lookups for global IPv6 prefixes resolve in environments +// without v6 connectivity. If a default already exists it is left alone. +func ensureIPv6DefaultRoute(t *testing.T) { + t.Helper() + + script := `New-NetRoute -DestinationPrefix "::/0" -InterfaceAlias "` + loopbackIfaceWindows + `" -RouteMetric 9999 -PolicyStore ActiveStore -ErrorAction Stop` + out, err := exec.Command("powershell", "-Command", script).CombinedOutput() + if err != nil { + // Existing default; nothing to install or clean up. + if bytes.Contains(out, []byte("already exists")) { + return + } + t.Skipf("install IPv6 fallback default route: %v: %s", err, out) + } + t.Cleanup(func() { + script := `Remove-NetRoute -DestinationPrefix "::/0" -InterfaceAlias "` + loopbackIfaceWindows + `" -Confirm:$false -ErrorAction Stop` + if out, err := exec.Command("powershell", "-Command", script).CombinedOutput(); err != nil { + t.Logf("delete IPv6 fallback default route: %v: %s", err, out) + } + }) +} diff --git a/go.mod b/go.mod index e24312a1a..bc4e8af15 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,8 @@ require ( github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.9 github.com/vishvananda/netlink v1.3.1 - golang.org/x/crypto v0.49.0 - golang.org/x/sys v0.42.0 + golang.org/x/crypto v0.50.0 + golang.org/x/sys v0.43.0 golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 golang.zx2c4.com/wireguard/windows v0.5.3 @@ -68,7 +68,7 @@ require ( github.com/jackc/pgx/v5 v5.5.5 github.com/libdns/route53 v1.5.0 github.com/libp2p/go-nat v0.2.0 - github.com/libp2p/go-netroute v0.2.1 + github.com/libp2p/go-netroute v0.4.0 github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 github.com/mdlayher/socket v0.5.1 github.com/mdp/qrterminal/v3 v3.2.1 @@ -118,11 +118,11 @@ require ( goauthentik.io/api/v3 v3.2023051.3 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b golang.org/x/mobile v0.0.0-20251113184115-a159579294ab - golang.org/x/mod v0.33.0 - golang.org/x/net v0.52.0 + golang.org/x/mod v0.34.0 + golang.org/x/net v0.53.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 golang.org/x/time v0.15.0 google.golang.org/api v0.276.0 gopkg.in/yaml.v3 v3.0.1 @@ -303,8 +303,8 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/image v0.33.0 // indirect - golang.org/x/text v0.35.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect @@ -323,8 +323,6 @@ replace github.com/cloudflare/circl => github.com/cunicu/circl v0.0.0-2023080111 replace github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 -replace github.com/libp2p/go-netroute => github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944 - replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.0 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 diff --git a/go.sum b/go.sum index a71f47d8d..d54dc01e6 100644 --- a/go.sum +++ b/go.sum @@ -395,6 +395,8 @@ github.com/libdns/route53 v1.5.0 h1:2SKdpPFl/qgWsXQvsLNJJAoX7rSxlk7zgoL4jnWdXVA= github.com/libdns/route53 v1.5.0/go.mod h1:joT4hKmaTNKHEwb7GmZ65eoDz1whTu7KKYPS8ZqIh6Q= github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk= github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk= +github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= +github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9CiRXhi1r8lUJ4W5idG3CiaBZGojNU= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/YdXctQ7qbcizZkw5mZ6l8Ogrl1dodBzVJduwI= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= @@ -453,8 +455,6 @@ github.com/netbirdio/dex v0.244.0 h1:1GOvi8wnXYassnKGildzNqRHq0RbcfEUw7LKYpKIN7U github.com/netbirdio/dex v0.244.0/go.mod h1:STGInJhPcAflrHmDO7vyit2kSq03PdL+8zQPoGALtcU= github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus= github.com/netbirdio/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944 h1:TDtJKmM6Sf8uYFx/dMeqNOL90KUoRscdfpFZ3Im89uk= -github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944/go.mod h1:sHA6TRxjQ6RLbnI+3R4DZo2Eseg/iKiPRfNmcuNySVQ= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8= @@ -711,8 +711,8 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= @@ -729,8 +729,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -749,8 +749,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -801,8 +801,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -815,8 +815,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -828,8 +828,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -843,8 +843,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 18909390c21e3c4bba34f18684aa957afe7cf908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 5 May 2026 15:30:40 +0200 Subject: [PATCH 40/80] [ci] Use go list -e so the ui-wails embed doesn't blank the test list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix added /client/ui-wails to the grep -v / Where-Object filter, but go list aborts at the first broken package and emits an empty stdout when client/ui-wails/main.go's //go:embed all:frontend/dist fails to resolve. The command substitution then expands to nothing, and `go test` falls back to the repo root — which has no Go files and fails the job. `go list -e` keeps listing remaining packages after a parse error, so the existing path-based filter now actually does its job. Touches all three test workflows (Linux native + docker, Darwin, Windows). --- .github/workflows/golang-test-darwin.yml | 6 +++++- .github/workflows/golang-test-linux.yml | 8 ++++++-- .github/workflows/golang-test-windows.yml | 5 ++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index 38c992c5a..f1f6a5cc1 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -47,5 +47,9 @@ jobs: # which fails to compile until the frontend has been built. The Wails UI # has no Go-side unit tests, and its release pipeline runs `pnpm build` # before goreleaser. - run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui-wails) + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the grep then drops the broken package by path. Without -e, + # go list aborts with empty stdout and `go test` falls back to the repo + # root, which has no Go files. + run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui-wails) diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 4fa796018..1bcad5345 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -158,7 +158,11 @@ jobs: # which fails to compile until the frontend has been built. The Wails UI # has no Go-side unit tests, and its release pipeline runs `pnpm build` # before goreleaser. - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui-wails) + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the grep then drops the broken package by path. Without -e, + # go list aborts with empty stdout and `go test` falls back to the repo + # root, which has no Go files. + run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui-wails) test_client_on_docker: name: "Client (Docker) / Unit" @@ -218,7 +222,7 @@ jobs: sh -c ' \ apk update; apk add --no-cache \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ - go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server) + go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server) ' test_relay: diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index 102229b9e..c5180ea29 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -68,8 +68,11 @@ jobs: # which fails to compile until the frontend has been built. The Wails UI # has no Go-side unit tests, and its release pipeline runs `pnpm build` # before goreleaser. + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the Where-Object pipeline then drops the broken package by + # path. Without -e, go list aborts with empty stdout. run: | - $packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui-wails' } + $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui-wails' } $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" $cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd From 6f93cf6ac3ae99af8e8928d9d09c24711eb0c371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 5 May 2026 15:37:25 +0200 Subject: [PATCH 41/80] [client/ui-wails] Group Tray's services into a TrayServices struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewTray's eight-parameter signature crossed Sonar's seven-parameter threshold once Update joined the dependency list. Bundle the six service pointers (Connection, Settings, Profiles, Peers, Notifier, Update) into a TrayServices struct, leaving NewTray with three arguments — the two Wails platform handles plus the service bag. Tray.svc replaces the individual fields; call sites use t.svc.Connection etc. Adding another service later is now a one-line struct change instead of a NewTray signature break. --- client/ui-wails/main.go | 9 ++++++- client/ui-wails/tray.go | 60 +++++++++++++++++++---------------------- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/client/ui-wails/main.go b/client/ui-wails/main.go index bef421e16..bd7fa4674 100644 --- a/client/ui-wails/main.go +++ b/client/ui-wails/main.go @@ -130,7 +130,14 @@ func main() { window.Hide() }) - tray = NewTray(app, window, connection, settings, profiles, peers, notifier, update) + tray = NewTray(app, window, TrayServices{ + Connection: connection, + Settings: settings, + Profiles: profiles, + Peers: peers, + Notifier: notifier, + Update: update, + }) listenForShowSignal(context.Background(), tray) peers.Watch(context.Background()) diff --git a/client/ui-wails/tray.go b/client/ui-wails/tray.go index 428673c9c..afed50fd7 100644 --- a/client/ui-wails/tray.go +++ b/client/ui-wails/tray.go @@ -70,16 +70,24 @@ const ( // Tray builds and updates the systray menu. It mirrors the layout of the Fyne // systray 1:1 and routes clicks back to the gRPC services. Dynamic state // (status icon, exit-node submenu) is driven by the netbird:status event. +// TrayServices bundles the daemon-RPC and notification services the tray +// menu needs. Grouped into a single struct so NewTray stays under the +// linter's parameter-count threshold and so adding another service later +// is a one-line struct change instead of a NewTray signature break. +type TrayServices struct { + Connection *services.Connection + Settings *services.Settings + Profiles *services.Profiles + Peers *services.Peers + Notifier *notifications.NotificationService + Update *services.Update +} + type Tray struct { - app *application.App - tray *application.SystemTray - window *application.WebviewWindow - connection *services.Connection - settings *services.Settings - profiles *services.Profiles - peers *services.Peers - notifier *notifications.NotificationService - update *services.Update + app *application.App + tray *application.SystemTray + window *application.WebviewWindow + svc TrayServices statusItem *application.MenuItem upItem *application.MenuItem @@ -100,25 +108,11 @@ type Tray struct { activeUsername string } -func NewTray( - app *application.App, - window *application.WebviewWindow, - connection *services.Connection, - settings *services.Settings, - profiles *services.Profiles, - peers *services.Peers, - notifier *notifications.NotificationService, - update *services.Update, -) *Tray { +func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray { t := &Tray{ app: app, window: window, - connection: connection, - settings: settings, - profiles: profiles, - peers: peers, - notifier: notifier, - update: update, + svc: svc, notificationsEnabled: true, } t.tray = app.SystemTray.New() @@ -244,7 +238,7 @@ func (t *Tray) handleConnect() { go func() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - if err := t.connection.Up(ctx, services.UpParams{}); err != nil { + if err := t.svc.Connection.Up(ctx, services.UpParams{}); err != nil { log.Errorf("connect: %v", err) t.notifyError(notifyErrorConnect) t.upItem.SetEnabled(true) @@ -257,7 +251,7 @@ func (t *Tray) handleDisconnect() { go func() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - if err := t.connection.Down(ctx); err != nil { + if err := t.svc.Connection.Down(ctx); err != nil { log.Errorf("disconnect: %v", err) t.notifyError(notifyErrorDisconnect) t.downItem.SetEnabled(true) @@ -336,7 +330,7 @@ func (t *Tray) onUpdateAvailable(ev *application.CustomEvent) { if upd.Enforced { body += notifyUpdateEnforcedSuffix } - if err := t.notifier.SendNotification(notifications.NotificationOptions{ + if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{ ID: notifyIDUpdatePrefix + upd.Version, Title: notifyUpdateTitle, Body: body, @@ -375,7 +369,7 @@ func (t *Tray) handleUpdate() { go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if _, err := t.update.Trigger(ctx); err != nil { + if _, err := t.svc.Update.Trigger(ctx); err != nil { log.Errorf("trigger update: %v", err) } }() @@ -528,12 +522,12 @@ func (t *Tray) loadConfig() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - active, err := t.profiles.GetActive(ctx) + active, err := t.svc.Profiles.GetActive(ctx) if err != nil { log.Debugf("get active profile: %v", err) return } - cfg, err := t.settings.GetConfig(ctx, services.ConfigParams(active)) + cfg, err := t.svc.Settings.GetConfig(ctx, services.ConfigParams(active)) if err != nil { log.Debugf("get config: %v", err) return @@ -549,10 +543,10 @@ func (t *Tray) loadConfig() { // notify wraps the Wails notification service with the tray's standard // id-prefix scheme and swallows errors (notifications are best-effort). func (t *Tray) notify(title, body, id string) { - if t.notifier == nil { + if t.svc.Notifier == nil { return } - if err := t.notifier.SendNotification(notifications.NotificationOptions{ + if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{ ID: id, Title: title, Body: body, From 31395f8bd2b8e2200077217c755500f2d7e9618c Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 5 May 2026 23:18:22 +0900 Subject: [PATCH 42/80] [client] Use fwmark-aware route lookup for raw socket UDP checksum source (#6070) * Use fwmark-aware route lookup for raw socket UDP checksum source * Guard nil raw socket in sharedsock WriteTo --- sharedsock/sock_linux.go | 56 +++++++++++++++------------------------- 1 file changed, 21 insertions(+), 35 deletions(-) diff --git a/sharedsock/sock_linux.go b/sharedsock/sock_linux.go index 523beb32b..4855e1aed 100644 --- a/sharedsock/sock_linux.go +++ b/sharedsock/sock_linux.go @@ -10,15 +10,13 @@ import ( "context" "fmt" "net" - "sync" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" - "github.com/google/gopacket/routing" - "github.com/libp2p/go-netroute" "github.com/mdlayher/socket" log "github.com/sirupsen/logrus" + "github.com/vishvananda/netlink" "golang.org/x/sync/errgroup" "golang.org/x/sys/unix" @@ -37,8 +35,6 @@ type SharedSocket struct { conn6 *socket.Conn port int mtu uint16 - routerMux sync.RWMutex - router routing.Router packetDemux chan rcvdPacket cancel context.CancelFunc } @@ -82,11 +78,6 @@ func Listen(port int, filter BPFFilter, mtu uint16) (_ net.PacketConn, err error } }() - rawSock.router, err = netroute.New() - if err != nil { - return nil, fmt.Errorf("failed to create raw socket router: %w", err) - } - rawSock.conn4, err = socket.Socket(unix.AF_INET, unix.SOCK_RAW, unix.IPPROTO_UDP, "raw_udp4", nil) if err != nil { return nil, fmt.Errorf("failed to create ipv4 raw socket: %w", err) @@ -127,31 +118,26 @@ func Listen(port int, filter BPFFilter, mtu uint16) (_ net.PacketConn, err error go rawSock.read(rawSock.conn6.Recvfrom) } - go rawSock.updateRouter() - return rawSock, nil } -// updateRouter updates the listener routing table client -// this is needed to avoid outdated information across different client networks -func (s *SharedSocket) updateRouter() { - ticker := time.NewTicker(15 * time.Second) - defer ticker.Stop() - for { - select { - case <-s.ctx.Done(): - return - case <-ticker.C: - router, err := netroute.New() - if err != nil { - log.Errorf("Failed to create and update packet router for stunListener: %s", err) - continue - } - s.routerMux.Lock() - s.router = router - s.routerMux.Unlock() +// resolveSrc returns the source IP the kernel will pick for a packet sent to +// dst by these raw sockets, mirroring the fwmark the kernel will see on send. +func (s *SharedSocket) resolveSrc(dst net.IP) (net.IP, error) { + opts := &netlink.RouteGetOptions{} + if nbnet.AdvancedRouting() { + opts.Mark = nbnet.ControlPlaneMark + } + routes, err := netlink.RouteGetWithOptions(dst, opts) + if err != nil { + return nil, fmt.Errorf("route get %s: %w", dst, err) + } + for _, r := range routes { + if r.Src != nil { + return r.Src, nil } } + return nil, fmt.Errorf("no source IP for %s", dst) } // LocalAddr returns the local address, preferring IPv4 for backward compatibility. @@ -310,15 +296,15 @@ func (s *SharedSocket) WriteTo(buf []byte, rAddr net.Addr) (n int, err error) { DstPort: layers.UDPPort(rUDPAddr.Port), } - s.routerMux.RLock() - defer s.routerMux.RUnlock() - - _, _, src, err := s.router.Route(rUDPAddr.IP) + src, err := s.resolveSrc(rUDPAddr.IP) if err != nil { - return 0, fmt.Errorf("got an error while checking route, err: %w", err) + return 0, fmt.Errorf("resolve source for %s: %w", rUDPAddr.IP, err) } rSockAddr, conn, nwLayer := s.getWriterObjects(src, rUDPAddr.IP) + if conn == nil { + return 0, fmt.Errorf("no raw socket for %s", rUDPAddr.IP) + } if err := udp.SetNetworkLayerForChecksum(nwLayer); err != nil { return -1, fmt.Errorf("failed to set network layer for checksum: %w", err) From 1795bc801d3832af13846a1030ea824eac9b0a5c Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Tue, 5 May 2026 16:53:01 +0200 Subject: [PATCH 43/80] chores: updated discussions and issues templates (#6073) --- .../ideas-feature-requests.yml | 130 ++++++++++ .github/DISCUSSION_TEMPLATE/issue-triage.yml | 237 ++++++++++++++++++ .github/DISCUSSION_TEMPLATE/q-a-support.yml | 146 +++++++++++ .github/ISSUE_TEMPLATE/bug-issue-report.md | 71 ------ .github/ISSUE_TEMPLATE/config.yml | 26 +- .github/ISSUE_TEMPLATE/feature_request.md | 20 -- .github/ISSUE_TEMPLATE/validated_issue.yml | 128 ++++++++++ 7 files changed, 660 insertions(+), 98 deletions(-) create mode 100644 .github/DISCUSSION_TEMPLATE/ideas-feature-requests.yml create mode 100644 .github/DISCUSSION_TEMPLATE/issue-triage.yml create mode 100644 .github/DISCUSSION_TEMPLATE/q-a-support.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug-issue-report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/validated_issue.yml diff --git a/.github/DISCUSSION_TEMPLATE/ideas-feature-requests.yml b/.github/DISCUSSION_TEMPLATE/ideas-feature-requests.yml new file mode 100644 index 000000000..f57a62107 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ideas-feature-requests.yml @@ -0,0 +1,130 @@ +body: + - type: markdown + attributes: + value: | + ## Ideas & Feature Requests + + Use this category for feature requests, enhancements, integrations, and product ideas. + + NetBird uses community traction in discussions — upvotes, replies, affected users, and use-case detail — as an input when deciding what should become a maintainer-curated issue or roadmap item. A clear problem statement is more useful than a solution-only request. + + Please search first and add your use case to an existing discussion when one already exists. + + - type: checkboxes + id: preflight + attributes: + label: Before posting + options: + - label: I searched existing discussions and issues for similar requests. + required: true + - label: I checked the documentation to confirm this is not already supported. + required: true + - label: This is a product idea or enhancement request, not a support question. + required: true + - label: I removed or anonymized sensitive details from examples and screenshots. + required: true + + - type: dropdown + id: area + attributes: + label: Product area + description: Select every area this request touches. + multiple: true + options: + - Client / Agent + - CLI + - Desktop UI + - Mobile app + - Dashboard / Admin UI + - Management service / API + - Signal service + - Relay + - DNS + - Routes / Exit nodes + - NetBird SSH + - Access control policies + - Posture checks + - Identity provider / SSO + - Self-hosting / Deployment + - Kubernetes / Operator + - Terraform / Automation + - Documentation + - Other / not sure + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem or use case + description: What are you trying to accomplish, and what is difficult or impossible today? + placeholder: | + As a ... + I want to ... + Because ... + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the behavior, workflow, API, UI, or integration you would like to see. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives or workarounds considered + description: What have you tried today? Why is the current workaround not enough? + + - type: textarea + id: impact + attributes: + label: Community impact and priority + description: Help us understand who benefits and how urgent this is. + placeholder: | + - Number of users/teams/peers affected: + - Deployment type: Cloud / self-hosted / both + - Frequency: daily / weekly / occasional + - Blocking production adoption? yes/no + - Related comments, discussions, or customer requests: + validations: + required: true + + - type: textarea + id: examples + attributes: + label: Examples from other tools or products + description: If another tool solves this well, link or describe the behavior. + + - type: textarea + id: security + attributes: + label: Security, privacy, and compatibility considerations + description: Note any access-control, audit, data retention, network, platform, or backward-compatibility concerns. + + - type: textarea + id: implementation + attributes: + label: Implementation ideas + description: Optional. If you are familiar with the codebase or API, share possible implementation notes. + + - type: dropdown + id: contribution + attributes: + label: Are you willing to help? + options: + - Yes, I can submit a PR if the approach is accepted. + - Yes, I can test or validate a proposed implementation. + - Yes, I can provide more use-case details. + - Not at this time. + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add screenshots, diagrams, links, or anything else that helps explain the request. diff --git a/.github/DISCUSSION_TEMPLATE/issue-triage.yml b/.github/DISCUSSION_TEMPLATE/issue-triage.yml new file mode 100644 index 000000000..b13ec066e --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/issue-triage.yml @@ -0,0 +1,237 @@ +body: + - type: markdown + attributes: + value: | + ## Issue Triage + + Use this category for reproducible bugs and regressions in NetBird. + + The more context you include, the faster we can validate and act on your report. If you're not sure whether something is a bug, **Q&A / Support** is a good starting point — we can always move the conversation here once we've confirmed it's a product issue. + + Intermittent issues are useful too. Include the trigger, frequency, timing, and any logs or debug evidence you have, and we'll work from there. + + Please don't include secrets, tokens, private keys, internal hostnames, or public IPs. Security vulnerabilities should be reported through the repository security policy rather than a public discussion. + + - type: checkboxes + id: preflight + attributes: + label: Before posting + options: + - label: I searched existing discussions and issues, including closed ones, and checked the relevant docs. + required: true + - label: I believe this is a product bug rather than a configuration or setup question. + required: true + - label: I can reproduce this issue, or for intermittent issues I've included trigger, frequency, and timing details below. + required: true + - label: I removed or anonymized sensitive data from logs, screenshots, and configuration. + required: true + + - type: dropdown + id: area + attributes: + label: Affected area + description: Select every area this report touches. + multiple: true + options: + - Client / Agent + - Reverse Proxy + - CLI + - Desktop UI + - Mobile app + - Peer connectivity + - DNS + - Routes / Exit nodes + - NetBird SSH + - Relay / Signal / NAT traversal + - Login / Authentication / IdP + - Dashboard / Admin UI + - Management service / API + - Access control policies / Posture checks + - Self-hosting / Deployment + - Kubernetes / Operator + - Documentation + - Other / not sure + validations: + required: true + + - type: dropdown + id: deployment + attributes: + label: Deployment type + options: + - NetBird Cloud + - Self-hosted - quickstart script + - Self-hosted - advanced/custom deployment + - Local development build + - Not sure / environment I do not fully control + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Operating system or environment + description: Select every environment involved in the reproduction. + multiple: true + options: + - Linux + - macOS + - Windows + - Android + - iOS + - FreeBSD + - OpenWRT + - Docker + - Kubernetes + - Synology + - Browser + - Other / not sure + validations: + required: true + + - type: textarea + id: version + attributes: + label: NetBird version and upgrade status + description: Run `netbird version` where applicable. For self-hosted deployments, include management, signal, relay, and dashboard versions if available. If you cannot test on a current/supported version, explain why. + placeholder: | + Example: + - Client: 0.30.2 + - Management: 0.30.2 + - Signal: 0.30.2 + - Relay: 0.30.2 + - Dashboard: 0.30.2 + - Upgrade status: reproduced on current version / cannot upgrade because ... + validations: + required: true + + - type: dropdown + id: regression + attributes: + label: Did this work before? + options: + - Yes, this worked before + - No, this never worked + - Not sure + validations: + required: true + + - type: textarea + id: regression-details + attributes: + label: Regression details + description: If this worked before, include the last known working version, first known broken version, and any recent upgrade, configuration, network, or IdP changes. + placeholder: | + - Last known working version: + - First known broken version: + - Recent changes: + + - type: textarea + id: summary + attributes: + label: Summary + description: Briefly describe the reproducible bug. + placeholder: What is broken? + validations: + required: true + + - type: textarea + id: current-behavior + attributes: + label: Current behavior + description: What happens now? Include exact errors, timeouts, UI messages, or failed commands when possible. + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: What did you expect to happen instead? + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Provide the smallest set of steps that reliably reproduces the bug. If the issue is intermittent, include the trigger, frequency, timing, and relevant timestamps. + placeholder: | + 1. Configure ... + 2. Run ... + 3. Observe ... + + For intermittent issues: + - Trigger: + - Frequency: + - Timing/timestamps: + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment and topology + description: Include the relevant topology and software involved in the reproduction. For UI/docs-only reports, write `N/A` if this does not apply. Use `None`, `Unknown`, or `N/A` where appropriate. + placeholder: | + - Peer A: + - Peer B: + - Same LAN or different networks: + - NAT/CGNAT/corporate firewall/mobile network: + - Other VPN software: + - Firewall, DNS, or endpoint security software: + - Routes, DNS, policies, posture checks, or SSH rules involved: + - IdP, reverse proxy, or browser involved: + validations: + required: true + + - type: textarea + id: self-hosted-details + attributes: + label: Self-hosted details, if available + description: Optional. If you use self-hosting and have access to these details, include them. If you do not administer the environment, provide what you know and say what you cannot access. + placeholder: | + - Deployment method: quickstart / Docker Compose / Helm / operator / custom + - Management/signal/relay/dashboard versions: + - Reverse proxy: + - IdP/provider: + - STUN/TURN/coturn/relay details: + - Relevant component logs: + + - type: textarea + id: logs + attributes: + label: Logs, status output, or debug evidence + description: | + For client, connectivity, DNS, route, relay/signal, or self-hosted reports, logs are essential — please include anonymized output from `netbird status -dA`, or a debug bundle via `netbird debug for 1m -AS -U`. Debug bundles are automatically deleted after 30 days. + + For UI, dashboard, or documentation reports, leave the pre-filled `N/A`. + value: "N/A" + render: shell + validations: + required: true + + - type: textarea + id: related-reports + attributes: + label: Related issues or discussions + description: Optional. Link similar reports you found while searching, if any. + placeholder: | + - Related issue/discussion: + - Why this may be the same or different: + + - type: textarea + id: impact + attributes: + label: Impact + description: Optional. Help us understand priority. How many users, peers, environments, or workflows are affected? Is there a workaround? + placeholder: | + - Affected users/peers: + - Business or production impact: + - Workaround available: + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add links to related discussions, issues, docs, screenshots, recordings, or anything else that may help validation. diff --git a/.github/DISCUSSION_TEMPLATE/q-a-support.yml b/.github/DISCUSSION_TEMPLATE/q-a-support.yml new file mode 100644 index 000000000..725f8737c --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/q-a-support.yml @@ -0,0 +1,146 @@ +body: + - type: markdown + attributes: + value: | + ## Q&A / Support + + Use this category for questions about configuration, setup, self-hosted deployments, troubleshooting, and general NetBird usage. + + This is community support and does not provide an SLA. For NetBird Cloud support, use the official support channel linked from the issue creation page. Please do not post secrets, tokens, private keys, internal hostnames, or public IPs unless you intentionally want them public. + + If your question turns into a reproducible product defect, DevRel or a maintainer may ask you to open or move the conversation to Issue Triage. + + - type: checkboxes + id: preflight + attributes: + label: Before posting + options: + - label: I searched existing discussions and issues for similar questions. + required: true + - label: I reviewed the relevant NetBird documentation or troubleshooting guide. + required: true + - label: I removed or anonymized sensitive data from logs, screenshots, and configuration. + required: true + + - type: dropdown + id: topic + attributes: + label: Topic + multiple: true + options: + - Getting started + - Self-hosting + - Client / Agent + - CLI + - Desktop UI + - Mobile app + - Dashboard / Admin UI + - DNS + - Routes / Exit nodes + - NetBird SSH + - Relay + - Access control policies + - Posture checks + - Identity provider / SSO + - API + - Kubernetes / Operator + - Terraform / Automation + - Documentation + - Other / not sure + validations: + required: true + + - type: dropdown + id: deployment + attributes: + label: Deployment type + options: + - NetBird Cloud + - Self-hosted - quickstart script + - Self-hosted - advanced/custom deployment + - Local development build + - Not sure + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Operating system or environment + multiple: true + options: + - Linux + - macOS + - Windows + - Android + - iOS + - FreeBSD + - OpenWRT + - Docker + - Kubernetes + - Synology + - Browser + - Other / not sure + validations: + required: true + + - type: input + id: version + attributes: + label: NetBird version + description: Run `netbird version` where applicable. For self-hosted deployments, include component versions if relevant. + placeholder: "Example: client 0.30.2, management 0.30.2" + + - type: textarea + id: question + attributes: + label: Question + description: What are you trying to understand or accomplish? + placeholder: Describe your question clearly. + validations: + required: true + + - type: textarea + id: goal + attributes: + label: Desired outcome + description: What would a successful answer help you do? + placeholder: | + I want to configure ... + I expected ... + I need help deciding ... + + - type: textarea + id: attempted + attributes: + label: What have you tried? + description: Include commands, documentation links, configuration attempts, or troubleshooting steps already tried. + placeholder: | + - Read ... + - Ran ... + - Changed ... + - Observed ... + + - type: textarea + id: environment + attributes: + label: Relevant environment details + description: Include redacted topology, IdP/provider, reverse proxy, firewall, DNS, route, policy, or self-hosted setup details that may affect the answer. + placeholder: | + - Deployment: + - Components involved: + - Network/topology: + - Related config: + + - type: textarea + id: logs + attributes: + label: Logs or output + description: Optional. Include anonymized logs, command output, screenshots, or `netbird status -dA` if relevant. + render: shell + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add links, diagrams, screenshots, or other details that may help the community answer. diff --git a/.github/ISSUE_TEMPLATE/bug-issue-report.md b/.github/ISSUE_TEMPLATE/bug-issue-report.md deleted file mode 100644 index df670db06..000000000 --- a/.github/ISSUE_TEMPLATE/bug-issue-report.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: Bug/Issue report -about: Create a report to help us improve -title: '' -labels: ['triage-needed'] -assignees: '' - ---- - -**Describe the problem** - -A clear and concise description of what the problem is. - -**To Reproduce** - -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** - -A clear and concise description of what you expected to happen. - -**Are you using NetBird Cloud?** - -Please specify whether you use NetBird Cloud or self-host NetBird's control plane. - -**NetBird version** - -`netbird version` - -**Is any other VPN software installed?** - -If yes, which one? - -**Debug output** - -To help us resolve the problem, please attach the following anonymized status output - - netbird status -dA - -Create and upload a debug bundle, and share the returned file key: - - netbird debug for 1m -AS -U - -*Uploaded files are automatically deleted after 30 days.* - - -Alternatively, create the file only and attach it here manually: - - netbird debug for 1m -AS - - -**Screenshots** - -If applicable, add screenshots to help explain your problem. - -**Additional context** - -Add any other context about the problem here. - -**Have you tried these troubleshooting steps?** -- [ ] Reviewed [client troubleshooting](https://docs.netbird.io/how-to/troubleshooting-client) (if applicable) -- [ ] Checked for newer NetBird versions -- [ ] Searched for similar issues on GitHub (including closed ones) -- [ ] Restarted the NetBird client -- [ ] Disabled other VPN software -- [ ] Checked firewall settings - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e9ffaf8a3..ee3e84df6 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,14 +1,26 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - - name: Community Support + - name: Start an Issue Triage discussion + url: https://github.com/netbirdio/netbird/discussions/new?category=issue-triage + about: Report a bug, regression, or unexpected behavior so DevRel can validate it before it becomes an issue. + - name: Propose an idea or feature request + url: https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests + about: Share feature requests, enhancements, and integration ideas for community feedback and prioritization. + - name: Ask a Q&A / Support question + url: https://github.com/netbirdio/netbird/discussions/new?category=q-a-support + about: Get help with setup, configuration, self-hosting, troubleshooting, and general usage. + - name: Security vulnerability disclosure + url: https://github.com/netbirdio/netbird/security/policy + about: Please do not report security vulnerabilities in public issues or discussions. + - name: Community Support Forum url: https://forum.netbird.io/ - about: Community support forum + about: Community support forum. - name: Cloud Support url: https://docs.netbird.io/help/report-bug-issues - about: Contact us for support - - name: Client/Connection Troubleshooting + about: Contact NetBird for Cloud support. + - name: Client / Connection Troubleshooting url: https://docs.netbird.io/help/troubleshooting-client - about: See our client troubleshooting guide for help addressing common issues + about: See the client troubleshooting guide for common connectivity issues. - name: Self-host Troubleshooting url: https://docs.netbird.io/selfhosted/troubleshooting - about: See our self-host troubleshooting guide for help addressing common issues + about: See the self-host troubleshooting guide for common deployment issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 4a3e5782c..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: ['feature-request'] -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/validated_issue.yml b/.github/ISSUE_TEMPLATE/validated_issue.yml new file mode 100644 index 000000000..2a21b73b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/validated_issue.yml @@ -0,0 +1,128 @@ +name: Validated issue +description: Maintainer/DevRel only. Create an issue after a discussion has been validated or for internally validated work. +title: "[Validated]: " +body: + - type: markdown + attributes: + value: | + ## Discussion-first issue policy + + Issues are maintainer-curated work items. Community reports and feature requests should start in [Discussions](https://github.com/netbirdio/netbird/discussions) so DevRel can validate, reproduce, and route them before engineering time is committed. + + Use this form when: + - A discussion has been validated and should become actionable work. + - A maintainer is opening internally validated work that can bypass the discussion-first flow. + + Issues opened without a relevant validated discussion or maintainer context may be closed and redirected to Discussions. + + - type: checkboxes + id: validation-checks + attributes: + label: Validation checklist + options: + - label: This issue is linked to a validated discussion, or it is being opened directly by a maintainer. + required: true + - label: The report has enough context for engineering to act on it without re-triaging from scratch. + required: true + - label: Sensitive data, secrets, private keys, internal hostnames, and public IPs have been removed or intentionally disclosed. + required: true + + - type: dropdown + id: issue-type + attributes: + label: Issue type + options: + - Bug / Regression + - Feature / Enhancement + - Documentation + - Maintenance / Refactor + - Cross-repository coordination + - Other + validations: + required: true + + - type: input + id: source-discussion + attributes: + label: Source discussion + description: Link the GitHub Discussion that was validated. Maintainers bypassing the flow can write "Maintainer-created" and explain why below. + placeholder: https://github.com/netbirdio/netbird/discussions/1234 + validations: + required: true + + - type: input + id: validation-owner + attributes: + label: Validation owner + description: GitHub handle of the DevRel team member or maintainer who validated this work. + placeholder: "@username" + validations: + required: true + + - type: dropdown + id: target-repository + attributes: + label: Target repository + description: Where should the implementation work happen? + options: + - netbirdio/netbird + - netbirdio/dashboard + - netbirdio/kubernetes-operator + - netbirdio/docs + - Multiple repositories + - Unknown / needs routing + validations: + required: true + + - type: textarea + id: summary + attributes: + label: Summary + description: Concise description of the validated work. + placeholder: What needs to be fixed, changed, documented, or built? + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Validation evidence + description: For bugs, include reproduction status, affected versions, logs, and environment. For features, include community traction, affected users, and alignment notes. + placeholder: | + - Reproduced by: + - Affected versions / platforms: + - Community signal: + - Related logs or screenshots: + validations: + required: true + + - type: textarea + id: scope + attributes: + label: Proposed scope + description: Describe what is in scope and, if helpful, what is explicitly out of scope. + placeholder: | + In scope: + - ... + + Out of scope: + - ... + validations: + required: true + + - type: textarea + id: acceptance-criteria + attributes: + label: Acceptance criteria + description: What must be true for this issue to be closed? + placeholder: | + - [ ] ... + - [ ] ... + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Links to related PRs, docs, issues in other repositories, roadmap items, or implementation notes. From 3c28d297252e59ebbbdc00eaa6cb0f880807ba68 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 5 May 2026 18:12:18 +0300 Subject: [PATCH 44/80] [management] Map Entra oid claim as Dex user ID (#6067) --- idp/dex/connector.go | 62 ++++++++---- idp/dex/connector_test.go | 205 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+), 21 deletions(-) create mode 100644 idp/dex/connector_test.go diff --git a/idp/dex/connector.go b/idp/dex/connector.go index 8aba92999..fb20fdcc3 100644 --- a/idp/dex/connector.go +++ b/idp/dex/connector.go @@ -89,21 +89,33 @@ func (p *Provider) ListConnectors(ctx context.Context) ([]*ConnectorConfig, erro } // UpdateConnector updates an existing connector in Dex storage. -// It merges incoming updates with existing values to prevent data loss on partial updates. +// It overlays user-mutable config fields (issuer, clientID, clientSecret, +// redirectURI) onto the stored connector config, and updates the connector name +// when cfg.Name is set. Empty fields on cfg leave stored values unchanged, so +// partial updates preserve create-time defaults such as scopes, claimMapping, +// and userIDKey. func (p *Provider) UpdateConnector(ctx context.Context, cfg *ConnectorConfig) error { if err := p.storage.UpdateConnector(ctx, cfg.ID, func(old storage.Connector) (storage.Connector, error) { - oldCfg, err := p.parseStorageConnector(old) - if err != nil { - return storage.Connector{}, fmt.Errorf("failed to parse existing connector: %w", err) + if cfg.Type != "" && cfg.Type != inferIdentityProviderType(old.Type, cfg.ID, nil) { + return storage.Connector{}, errors.New("connector type change not allowed") } - mergeConnectorConfig(cfg, oldCfg) - - storageConn, err := p.buildStorageConnector(cfg) + configData, err := overlayConnectorConfig(old.Config, cfg) if err != nil { - return storage.Connector{}, fmt.Errorf("failed to build connector: %w", err) + return storage.Connector{}, fmt.Errorf("failed to overlay connector config: %w", err) } - return storageConn, nil + + name := cfg.Name + if name == "" { + name = old.Name + } + + return storage.Connector{ + ID: cfg.ID, + Type: old.Type, + Name: name, + Config: configData, + }, nil }); err != nil { return fmt.Errorf("failed to update connector: %w", err) } @@ -112,23 +124,27 @@ func (p *Provider) UpdateConnector(ctx context.Context, cfg *ConnectorConfig) er return nil } -// mergeConnectorConfig preserves existing values for empty fields in the update. -func mergeConnectorConfig(cfg, oldCfg *ConnectorConfig) { - if cfg.ClientSecret == "" { - cfg.ClientSecret = oldCfg.ClientSecret +// overlayConnectorConfig writes only the user-mutable fields onto the existing +// stored config, preserving every other field (scopes, claimMapping, userIDKey, +// insecure flags, etc.). Empty fields on cfg leave the existing value alone. +func overlayConnectorConfig(oldConfig []byte, cfg *ConnectorConfig) ([]byte, error) { + var m map[string]any + if err := decodeConnectorConfig(oldConfig, &m); err != nil { + return nil, err } - if cfg.RedirectURI == "" { - cfg.RedirectURI = oldCfg.RedirectURI + if cfg.Issuer != "" { + m["issuer"] = cfg.Issuer } - if cfg.Issuer == "" && cfg.Type == oldCfg.Type { - cfg.Issuer = oldCfg.Issuer + if cfg.ClientID != "" { + m["clientID"] = cfg.ClientID } - if cfg.ClientID == "" { - cfg.ClientID = oldCfg.ClientID + if cfg.ClientSecret != "" { + m["clientSecret"] = cfg.ClientSecret } - if cfg.Name == "" { - cfg.Name = oldCfg.Name + if cfg.RedirectURI != "" { + m["redirectURI"] = cfg.RedirectURI } + return encodeConnectorConfig(m) } // DeleteConnector removes a connector from Dex storage. @@ -216,6 +232,10 @@ func buildOIDCConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, oidcConfig["getUserInfo"] = true case "entra": oidcConfig["claimMapping"] = map[string]string{"email": "preferred_username"} + // Use the Entra Object ID (oid) instead of the default OIDC sub claim. + // Entra issues sub as a per-app pairwise identifier that does not match + // the stable Object ID. + oidcConfig["userIDKey"] = "oid" case "okta": oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"} case "pocketid": diff --git a/idp/dex/connector_test.go b/idp/dex/connector_test.go new file mode 100644 index 000000000..4253e02b7 --- /dev/null +++ b/idp/dex/connector_test.go @@ -0,0 +1,205 @@ +package dex + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/sql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProvider(t *testing.T) (*Provider, func()) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "dex-connector-test-*") + require.NoError(t, err) + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + s, err := (&sql.SQLite3{File: filepath.Join(tmpDir, "dex.db")}).Open(logger) + require.NoError(t, err) + + return &Provider{storage: s, logger: logger}, func() { + _ = s.Close() + _ = os.RemoveAll(tmpDir) + } +} + +func TestBuildOIDCConnectorConfig_EntraSetsUserIDKey(t *testing.T) { + cfg := &ConnectorConfig{ + ID: "entra-test", + Name: "Entra", + Type: "entra", + Issuer: "https://login.microsoftonline.com/tid/v2.0", + ClientID: "client-id", + ClientSecret: "client-secret", + } + data, err := buildOIDCConnectorConfig(cfg, "https://example.com/oauth2/callback") + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(data, &m)) + + assert.Equal(t, "oid", m["userIDKey"], "entra connectors must default userIDKey to oid") + assert.Equal(t, map[string]any{"email": "preferred_username"}, m["claimMapping"]) +} + +func TestBuildOIDCConnectorConfig_NonEntraDoesNotSetUserIDKey(t *testing.T) { + // ensures the Entra userIDKey override does not leak into other OIDC providers, + // which already use a stable sub claim. + for _, typ := range []string{"oidc", "zitadel", "okta", "pocketid", "authentik", "keycloak", "adfs"} { + t.Run(typ, func(t *testing.T) { + data, err := buildOIDCConnectorConfig(&ConnectorConfig{Type: typ}, "https://example.com/oauth2/callback") + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(data, &m)) + _, ok := m["userIDKey"] + assert.False(t, ok, "%s connectors must not have userIDKey set", typ) + }) + } +} + +func TestUpdateConnector_PreservesCreateTimeDefaults(t *testing.T) { + ctx := context.Background() + p, cleanup := newTestProvider(t) + defer cleanup() + + created, err := p.CreateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Name: "Entra", + Type: "entra", + Issuer: "https://login.microsoftonline.com/tid/v2.0", + ClientID: "client-id", + ClientSecret: "old-secret", + RedirectURI: "https://example.com/oauth2/callback", + }) + require.NoError(t, err) + require.Equal(t, "entra-test", created.ID) + + // Rotate only the client secret. + err = p.UpdateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Type: "entra", + ClientSecret: "new-secret", + }) + require.NoError(t, err) + + conn, err := p.storage.GetConnector(ctx, "entra-test") + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(conn.Config, &m)) + + assert.Equal(t, "new-secret", m["clientSecret"], "clientSecret should be rotated") + assert.Equal(t, "client-id", m["clientID"], "clientID must survive (overlay should leave it alone)") + assert.Equal(t, "https://login.microsoftonline.com/tid/v2.0", m["issuer"]) + assert.Equal(t, "oid", m["userIDKey"], "userIDKey must survive update") + assert.Equal(t, map[string]any{"email": "preferred_username"}, m["claimMapping"], "claimMapping must survive update") +} + +func TestUpdateConnector_DoesNotAddUserIDKeyToExistingConnector(t *testing.T) { + ctx := context.Background() + p, cleanup := newTestProvider(t) + defer cleanup() + + // Seed a connector directly into storage without userIDKey + preFixConfig, err := json.Marshal(map[string]any{ + "issuer": "https://login.microsoftonline.com/tid/v2.0", + "clientID": "client-id", + "clientSecret": "old-secret", + "redirectURI": "https://example.com/oauth2/callback", + "scopes": []string{"openid", "profile", "email"}, + "claimMapping": map[string]string{"email": "preferred_username"}, + }) + require.NoError(t, err) + + require.NoError(t, p.storage.CreateConnector(ctx, storage.Connector{ + ID: "entra-prefix", + Type: "oidc", + Name: "Entra", + Config: preFixConfig, + })) + + // Rotate client secret via UpdateConnector. + err = p.UpdateConnector(ctx, &ConnectorConfig{ + ID: "entra-prefix", + Type: "entra", + ClientSecret: "new-secret", + }) + require.NoError(t, err) + + conn, err := p.storage.GetConnector(ctx, "entra-prefix") + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(conn.Config, &m)) + + assert.Equal(t, "new-secret", m["clientSecret"]) + _, has := m["userIDKey"] + assert.False(t, has, "userIDKey must not be auto-added to a connector that did not have it before") +} + +func TestUpdateConnector_RejectsTypeChange(t *testing.T) { + ctx := context.Background() + p, cleanup := newTestProvider(t) + defer cleanup() + + _, err := p.CreateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Name: "Entra", + Type: "entra", + Issuer: "https://login.microsoftonline.com/tid/v2.0", + ClientID: "client-id", + ClientSecret: "secret", + RedirectURI: "https://example.com/oauth2/callback", + }) + require.NoError(t, err) + + // Attempt to switch the connector to okta. + err = p.UpdateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Type: "okta", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "connector type change not allowed") + + // stored connector type/config unchanged after the rejected update. + conn, err := p.storage.GetConnector(ctx, "entra-test") + require.NoError(t, err) + assert.Equal(t, "oidc", conn.Type) + var m map[string]any + require.NoError(t, json.Unmarshal(conn.Config, &m)) + assert.Equal(t, "oid", m["userIDKey"]) +} + +func TestUpdateConnector_AllowsSameTypeUpdate(t *testing.T) { + ctx := context.Background() + p, cleanup := newTestProvider(t) + defer cleanup() + + _, err := p.CreateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Name: "Entra", + Type: "entra", + Issuer: "https://login.microsoftonline.com/old/v2.0", + ClientID: "client-id", + ClientSecret: "secret", + RedirectURI: "https://example.com/oauth2/callback", + }) + require.NoError(t, err) + + err = p.UpdateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Type: "entra", + Issuer: "https://login.microsoftonline.com/new/v2.0", + }) + require.NoError(t, err) + + conn, err := p.storage.GetConnector(ctx, "entra-test") + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(conn.Config, &m)) + assert.Equal(t, "https://login.microsoftonline.com/new/v2.0", m["issuer"]) +} From cfb1b3fe31c37db79a67434ab620ddb0eca41faf Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 5 May 2026 18:40:42 +0200 Subject: [PATCH 45/80] [proxy] consolidate mapping update (#6072) --- management/internals/shared/grpc/proxy.go | 118 ++++++--- .../shared/grpc/proxy_snapshot_test.go | 174 ++++++++++++++ .../internals/shared/grpc/proxy_test.go | 3 + proxy/management_integration_test.go | 50 ++-- proxy/server.go | 45 +++- proxy/snapshot_reconcile_test.go | 227 ++++++++++++++++++ 6 files changed, 559 insertions(+), 58 deletions(-) create mode 100644 management/internals/shared/grpc/proxy_snapshot_test.go create mode 100644 proxy/snapshot_reconcile_test.go diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index d811a0f69..6763a3ba3 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -11,6 +11,8 @@ import ( "fmt" "net/http" "net/url" + "os" + "strconv" "strings" "sync" "time" @@ -82,11 +84,40 @@ type ProxyServiceServer struct { // Store for PKCE verifiers pkceVerifierStore *PKCEVerifierStore + // tokenTTL is the lifetime of one-time tokens generated for proxy + // authentication. Defaults to defaultProxyTokenTTL when zero. + tokenTTL time.Duration + + // snapshotBatchSize is the number of mappings per gRPC message during + // initial snapshot delivery. Configurable via NB_PROXY_SNAPSHOT_BATCH_SIZE. + snapshotBatchSize int + cancel context.CancelFunc } const pkceVerifierTTL = 10 * time.Minute +const defaultProxyTokenTTL = 5 * time.Minute + +const defaultSnapshotBatchSize = 500 + +func snapshotBatchSizeFromEnv() int { + if v := os.Getenv("NB_PROXY_SNAPSHOT_BATCH_SIZE"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return defaultSnapshotBatchSize +} + +// proxyTokenTTL returns the configured token TTL or the default when unset. +func (s *ProxyServiceServer) proxyTokenTTL() time.Duration { + if s.tokenTTL > 0 { + return s.tokenTTL + } + return defaultProxyTokenTTL +} + // proxyConnection represents a connected proxy type proxyConnection struct { proxyID string @@ -110,6 +141,7 @@ func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeT peersManager: peersManager, usersManager: usersManager, proxyManager: proxyMgr, + snapshotBatchSize: snapshotBatchSizeFromEnv(), cancel: cancel, } go s.cleanupStaleProxies(ctx) @@ -192,11 +224,6 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest cancel: cancel, } - s.connectedProxies.Store(proxyID, conn) - if err := s.proxyController.RegisterProxyToCluster(ctx, conn.address, proxyID); err != nil { - log.WithContext(ctx).Warnf("Failed to register proxy %s in cluster: %v", proxyID, err) - } - // Register proxy in database with capabilities var caps *proxy.Capabilities if c := req.GetCapabilities(); c != nil { @@ -209,13 +236,31 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest proxyRecord, err := s.proxyManager.Connect(ctx, proxyID, sessionID, proxyAddress, peerInfo, caps) if err != nil { log.WithContext(ctx).Warnf("failed to register proxy %s in database: %v", proxyID, err) - s.connectedProxies.CompareAndDelete(proxyID, conn) - if unregErr := s.proxyController.UnregisterProxyFromCluster(ctx, conn.address, proxyID); unregErr != nil { - log.WithContext(ctx).Debugf("cleanup after Connect failure for proxy %s: %v", proxyID, unregErr) - } + cancel() return status.Errorf(codes.Internal, "register proxy in database: %v", err) } + s.connectedProxies.Store(proxyID, conn) + if err := s.proxyController.RegisterProxyToCluster(ctx, conn.address, proxyID); err != nil { + log.WithContext(ctx).Warnf("Failed to register proxy %s in cluster: %v", proxyID, err) + } + + if err := s.sendSnapshot(ctx, conn); err != nil { + if s.connectedProxies.CompareAndDelete(proxyID, conn) { + if unregErr := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, proxyID); unregErr != nil { + log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", proxyID, unregErr) + } + } + cancel() + if disconnErr := s.proxyManager.Disconnect(context.Background(), proxyID, sessionID); disconnErr != nil { + log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", proxyID, disconnErr) + } + return fmt.Errorf("send snapshot to proxy %s: %w", proxyID, err) + } + + errChan := make(chan error, 2) + go s.sender(conn, errChan) + log.WithFields(log.Fields{ "proxy_id": proxyID, "session_id": sessionID, @@ -241,13 +286,6 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest log.Infof("Proxy %s session %s disconnected", proxyID, sessionID) }() - if err := s.sendSnapshot(ctx, conn); err != nil { - return fmt.Errorf("send snapshot to proxy %s: %w", proxyID, err) - } - - errChan := make(chan error, 2) - go s.sender(conn, errChan) - go s.heartbeat(connCtx, proxyRecord) select { @@ -290,22 +328,27 @@ func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnec return err } + // Send mappings in batches to reduce per-message gRPC overhead while + // staying well within the default 4 MB message size limit. + for i := 0; i < len(mappings); i += s.snapshotBatchSize { + end := i + s.snapshotBatchSize + if end > len(mappings) { + end = len(mappings) + } + if err := conn.stream.Send(&proto.GetMappingUpdateResponse{ + Mapping: mappings[i:end], + InitialSyncComplete: end == len(mappings), + }); err != nil { + return fmt.Errorf("send snapshot batch: %w", err) + } + } + if len(mappings) == 0 { if err := conn.stream.Send(&proto.GetMappingUpdateResponse{ InitialSyncComplete: true, }); err != nil { return fmt.Errorf("send snapshot completion: %w", err) } - return nil - } - - for i, m := range mappings { - if err := conn.stream.Send(&proto.GetMappingUpdateResponse{ - Mapping: []*proto.ProxyMapping{m}, - InitialSyncComplete: i == len(mappings)-1, - }); err != nil { - return fmt.Errorf("send proxy mapping: %w", err) - } } return nil @@ -323,13 +366,9 @@ func (s *ProxyServiceServer) snapshotServiceMappings(ctx context.Context, conn * continue } - token, err := s.tokenStore.GenerateToken(service.AccountID, service.ID, 5*time.Minute) + token, err := s.tokenStore.GenerateToken(service.AccountID, service.ID, s.proxyTokenTTL()) if err != nil { - log.WithFields(log.Fields{ - "service": service.Name, - "account": service.AccountID, - }).WithError(err).Error("failed to generate auth token for snapshot") - continue + return nil, fmt.Errorf("generate auth token for service %s: %w", service.ID, err) } m := service.ToProtoMapping(rpservice.Create, token, s.GetOIDCValidationConfig()) @@ -409,13 +448,16 @@ func (s *ProxyServiceServer) SendServiceUpdate(update *proto.GetMappingUpdateRes conn := value.(*proxyConnection) resp := s.perProxyMessage(update, conn.proxyID) if resp == nil { + log.Warnf("Token generation failed for proxy %s, disconnecting to force resync", conn.proxyID) + conn.cancel() return true } select { case conn.sendChan <- resp: log.Debugf("Sent service update to proxy server %s", conn.proxyID) default: - log.Warnf("Failed to send service update to proxy server %s (channel full)", conn.proxyID) + log.Warnf("Send channel full for proxy %s, disconnecting to force resync", conn.proxyID) + conn.cancel() } return true }) @@ -495,13 +537,16 @@ func (s *ProxyServiceServer) SendServiceUpdateToCluster(ctx context.Context, upd } msg := s.perProxyMessage(updateResponse, proxyID) if msg == nil { + log.WithContext(ctx).Warnf("Token generation failed for proxy %s in cluster %s, disconnecting to force resync", proxyID, clusterAddr) + conn.cancel() continue } select { case conn.sendChan <- msg: log.WithContext(ctx).Debugf("Sent service update with id %s to proxy %s in cluster %s", update.Id, proxyID, clusterAddr) default: - log.WithContext(ctx).Warnf("Failed to send service update to proxy %s in cluster %s (channel full)", proxyID, clusterAddr) + log.WithContext(ctx).Warnf("Send channel full for proxy %s in cluster %s, disconnecting to force resync", proxyID, clusterAddr) + conn.cancel() } } } @@ -527,7 +572,8 @@ func proxyAcceptsMapping(conn *proxyConnection, mapping *proto.ProxyMapping) boo // perProxyMessage returns a copy of update with a fresh one-time token for // create/update operations. For delete operations the original mapping is // used unchanged because proxies do not need to authenticate for removal. -// Returns nil if token generation fails (the proxy should be skipped). +// Returns nil if token generation fails; the caller must disconnect the +// proxy so it can resync via a fresh snapshot on reconnect. func (s *ProxyServiceServer) perProxyMessage(update *proto.GetMappingUpdateResponse, proxyID string) *proto.GetMappingUpdateResponse { resp := make([]*proto.ProxyMapping, 0, len(update.Mapping)) for _, mapping := range update.Mapping { @@ -536,7 +582,7 @@ func (s *ProxyServiceServer) perProxyMessage(update *proto.GetMappingUpdateRespo continue } - token, err := s.tokenStore.GenerateToken(mapping.AccountId, mapping.Id, 5*time.Minute) + token, err := s.tokenStore.GenerateToken(mapping.AccountId, mapping.Id, s.proxyTokenTTL()) if err != nil { log.Warnf("Failed to generate token for proxy %s: %v", proxyID, err) return nil diff --git a/management/internals/shared/grpc/proxy_snapshot_test.go b/management/internals/shared/grpc/proxy_snapshot_test.go new file mode 100644 index 000000000..e0c7425c5 --- /dev/null +++ b/management/internals/shared/grpc/proxy_snapshot_test.go @@ -0,0 +1,174 @@ +package grpc + +import ( + "context" + "fmt" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// recordingStream captures all messages sent via Send so tests can inspect +// batching behaviour without a real gRPC transport. +type recordingStream struct { + grpc.ServerStream + messages []*proto.GetMappingUpdateResponse +} + +func (s *recordingStream) Send(m *proto.GetMappingUpdateResponse) error { + s.messages = append(s.messages, m) + return nil +} + +func (s *recordingStream) Context() context.Context { return context.Background() } +func (s *recordingStream) SetHeader(metadata.MD) error { return nil } +func (s *recordingStream) SendHeader(metadata.MD) error { return nil } +func (s *recordingStream) SetTrailer(metadata.MD) {} +func (s *recordingStream) SendMsg(any) error { return nil } +func (s *recordingStream) RecvMsg(any) error { return nil } + +// makeServices creates n enabled services assigned to the given cluster. +func makeServices(n int, cluster string) []*rpservice.Service { + services := make([]*rpservice.Service, n) + for i := range n { + services[i] = &rpservice.Service{ + ID: fmt.Sprintf("svc-%d", i), + AccountID: "acct-1", + Name: fmt.Sprintf("svc-%d", i), + Domain: fmt.Sprintf("svc-%d.example.com", i), + ProxyCluster: cluster, + Enabled: true, + Targets: []*rpservice.Target{ + {TargetType: rpservice.TargetTypeHost, TargetId: "host-1"}, + }, + } + } + return services +} + +func newSnapshotTestServer(t *testing.T, batchSize int) *ProxyServiceServer { + t.Helper() + s := &ProxyServiceServer{ + tokenStore: NewOneTimeTokenStore(context.Background(), testCacheStore(t)), + snapshotBatchSize: batchSize, + } + s.SetProxyController(newTestProxyController()) + return s +} + +func TestSendSnapshot_BatchesMappings(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 3 + const totalServices = 7 // 3 + 3 + 1 + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + + stream := &recordingStream{} + conn := &proxyConnection{ + proxyID: "proxy-a", + address: cluster, + stream: stream, + } + + err := s.sendSnapshot(context.Background(), conn) + require.NoError(t, err) + + // Expect ceil(7/3) = 3 messages + require.Len(t, stream.messages, 3, "should send ceil(totalServices/batchSize) messages") + + assert.Len(t, stream.messages[0].Mapping, 3) + assert.False(t, stream.messages[0].InitialSyncComplete, "first batch should not be sync-complete") + + assert.Len(t, stream.messages[1].Mapping, 3) + assert.False(t, stream.messages[1].InitialSyncComplete, "middle batch should not be sync-complete") + + assert.Len(t, stream.messages[2].Mapping, 1) + assert.True(t, stream.messages[2].InitialSyncComplete, "last batch must be sync-complete") + + // Verify all service IDs are present exactly once + seen := make(map[string]bool) + for _, msg := range stream.messages { + for _, m := range msg.Mapping { + assert.False(t, seen[m.Id], "duplicate service ID %s", m.Id) + seen[m.Id] = true + } + } + assert.Len(t, seen, totalServices) +} + +func TestSendSnapshot_ExactBatchMultiple(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 3 + const totalServices = 6 // exactly 2 batches + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + + stream := &recordingStream{} + conn := &proxyConnection{proxyID: "proxy-a", address: cluster, stream: stream} + + require.NoError(t, s.sendSnapshot(context.Background(), conn)) + require.Len(t, stream.messages, 2) + + assert.Len(t, stream.messages[0].Mapping, 3) + assert.False(t, stream.messages[0].InitialSyncComplete) + + assert.Len(t, stream.messages[1].Mapping, 3) + assert.True(t, stream.messages[1].InitialSyncComplete) +} + +func TestSendSnapshot_SingleBatch(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 100 + const totalServices = 5 + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + + stream := &recordingStream{} + conn := &proxyConnection{proxyID: "proxy-a", address: cluster, stream: stream} + + require.NoError(t, s.sendSnapshot(context.Background(), conn)) + require.Len(t, stream.messages, 1, "all mappings should fit in one batch") + assert.Len(t, stream.messages[0].Mapping, totalServices) + assert.True(t, stream.messages[0].InitialSyncComplete) +} + +func TestSendSnapshot_EmptySnapshot(t *testing.T) { + const cluster = "cluster.example.com" + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(nil, nil) + + s := newSnapshotTestServer(t, 500) + s.serviceManager = mgr + + stream := &recordingStream{} + conn := &proxyConnection{proxyID: "proxy-a", address: cluster, stream: stream} + + require.NoError(t, s.sendSnapshot(context.Background(), conn)) + require.Len(t, stream.messages, 1, "empty snapshot must still send sync-complete") + assert.Empty(t, stream.messages[0].Mapping) + assert.True(t, stream.messages[0].InitialSyncComplete) +} diff --git a/management/internals/shared/grpc/proxy_test.go b/management/internals/shared/grpc/proxy_test.go index de4e96d93..5a7a457df 100644 --- a/management/internals/shared/grpc/proxy_test.go +++ b/management/internals/shared/grpc/proxy_test.go @@ -85,11 +85,14 @@ func registerFakeProxy(s *ProxyServiceServer, proxyID, clusterAddr string) chan // registerFakeProxyWithCaps adds a fake proxy connection with explicit capabilities. func registerFakeProxyWithCaps(s *ProxyServiceServer, proxyID, clusterAddr string, caps *proto.ProxyCapabilities) chan *proto.GetMappingUpdateResponse { ch := make(chan *proto.GetMappingUpdateResponse, 10) + ctx, cancel := context.WithCancel(context.Background()) conn := &proxyConnection{ proxyID: proxyID, address: clusterAddr, capabilities: caps, sendChan: ch, + ctx: ctx, + cancel: cancel, } s.connectedProxies.Store(proxyID, conn) diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index e9eae3210..99bbdad0c 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -364,14 +364,16 @@ func TestIntegration_ProxyConnection_HappyPath(t *testing.T) { }) require.NoError(t, err) - // Receive all mappings from the snapshot - server sends each mapping individually mappingsByID := make(map[string]*proto.ProxyMapping) - for i := 0; i < 2; i++ { + for { msg, err := stream.Recv() require.NoError(t, err) for _, m := range msg.GetMapping() { mappingsByID[m.GetId()] = m } + if msg.GetInitialSyncComplete() { + break + } } // Should receive 2 mappings total @@ -411,12 +413,14 @@ func TestIntegration_ProxyConnection_SendsClusterAddress(t *testing.T) { }) require.NoError(t, err) - // Receive all mappings - server sends each mapping individually mappings := make([]*proto.ProxyMapping, 0) - for i := 0; i < 2; i++ { + for { msg, err := stream.Recv() require.NoError(t, err) mappings = append(mappings, msg.GetMapping()...) + if msg.GetInitialSyncComplete() { + break + } } // Should receive the 2 mappings matching the cluster @@ -440,13 +444,15 @@ func TestIntegration_ProxyConnection_Reconnect_ReceivesSameConfig(t *testing.T) clusterAddress := "test.proxy.io" proxyID := "test-proxy-reconnect" - // Helper to receive all mappings from a stream - receiveMappings := func(stream proto.ProxyService_GetMappingUpdateClient, count int) []*proto.ProxyMapping { + receiveMappings := func(stream proto.ProxyService_GetMappingUpdateClient) []*proto.ProxyMapping { var mappings []*proto.ProxyMapping - for i := 0; i < count; i++ { + for { msg, err := stream.Recv() require.NoError(t, err) mappings = append(mappings, msg.GetMapping()...) + if msg.GetInitialSyncComplete() { + break + } } return mappings } @@ -460,7 +466,7 @@ func TestIntegration_ProxyConnection_Reconnect_ReceivesSameConfig(t *testing.T) }) require.NoError(t, err) - firstMappings := receiveMappings(stream1, 2) + firstMappings := receiveMappings(stream1) cancel1() time.Sleep(100 * time.Millisecond) @@ -476,7 +482,7 @@ func TestIntegration_ProxyConnection_Reconnect_ReceivesSameConfig(t *testing.T) }) require.NoError(t, err) - secondMappings := receiveMappings(stream2, 2) + secondMappings := receiveMappings(stream2) // Should receive the same mappings assert.Equal(t, len(firstMappings), len(secondMappings), @@ -542,12 +548,14 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T } } - // Helper to receive and apply all mappings receiveAndApply := func(stream proto.ProxyService_GetMappingUpdateClient) { - for i := 0; i < 2; i++ { + for { msg, err := stream.Recv() require.NoError(t, err) applyMappings(msg.GetMapping()) + if msg.GetInitialSyncComplete() { + break + } } } @@ -636,12 +644,14 @@ func TestIntegration_ProxyConnection_MultipleProxiesReceiveUpdates(t *testing.T) }) require.NoError(t, err) - // Receive all mappings - server sends each mapping individually count := 0 - for i := 0; i < 2; i++ { + for { msg, err := stream.Recv() require.NoError(t, err) count += len(msg.GetMapping()) + if msg.GetInitialSyncComplete() { + break + } } mu.Lock() @@ -681,9 +691,12 @@ func TestIntegration_ProxyConnection_FastReconnectDoesNotLoseState(t *testing.T) }) require.NoError(t, err) - for i := 0; i < 2; i++ { - _, err := stream1.Recv() + for { + msg, err := stream1.Recv() require.NoError(t, err) + if msg.GetInitialSyncComplete() { + break + } } require.Contains(t, setup.proxyService.GetConnectedProxies(), proxyID, @@ -699,9 +712,12 @@ func TestIntegration_ProxyConnection_FastReconnectDoesNotLoseState(t *testing.T) }) require.NoError(t, err) - for i := 0; i < 2; i++ { - _, err := stream2.Recv() + for { + msg, err := stream2.Recv() require.NoError(t, err) + if msg.GetInitialSyncComplete() { + break + } } cancel1() diff --git a/proxy/server.go b/proxy/server.go index fbd0d058e..6980e1df1 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -943,6 +943,8 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr operation := func() error { s.Logger.Debug("connecting to management mapping stream") + initialSyncDone = false + if s.healthChecker != nil { s.healthChecker.SetManagementConnected(false) } @@ -1000,6 +1002,11 @@ func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.Pr return ctx.Err() } + var snapshotIDs map[types.ServiceID]struct{} + if !*initialSyncDone { + snapshotIDs = make(map[types.ServiceID]struct{}) + } + for { // Check for context completion to gracefully shutdown. select { @@ -1020,17 +1027,45 @@ func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.Pr s.processMappings(ctx, msg.GetMapping()) s.Logger.Debug("Processing mapping update completed") - if !*initialSyncDone && msg.GetInitialSyncComplete() { - if s.healthChecker != nil { - s.healthChecker.SetInitialSyncComplete() + if !*initialSyncDone { + for _, m := range msg.GetMapping() { + snapshotIDs[types.ServiceID(m.GetId())] = struct{}{} + } + if msg.GetInitialSyncComplete() { + s.reconcileSnapshot(ctx, snapshotIDs) + snapshotIDs = nil + if s.healthChecker != nil { + s.healthChecker.SetInitialSyncComplete() + } + *initialSyncDone = true + s.Logger.Info("Initial mapping sync complete") } - *initialSyncDone = true - s.Logger.Info("Initial mapping sync complete") } } } } +// reconcileSnapshot removes local mappings that are absent from the snapshot. +// This ensures services deleted while the proxy was disconnected get cleaned up. +func (s *Server) reconcileSnapshot(ctx context.Context, snapshotIDs map[types.ServiceID]struct{}) { + s.portMu.RLock() + var stale []*proto.ProxyMapping + for svcID, mapping := range s.lastMappings { + if _, ok := snapshotIDs[svcID]; !ok { + stale = append(stale, mapping) + } + } + s.portMu.RUnlock() + + for _, mapping := range stale { + s.Logger.WithFields(log.Fields{ + "service_id": mapping.GetId(), + "domain": mapping.GetDomain(), + }).Info("Removing stale mapping absent from snapshot") + s.removeMapping(ctx, mapping) + } +} + func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) { for _, mapping := range mappings { s.Logger.WithFields(log.Fields{ diff --git a/proxy/snapshot_reconcile_test.go b/proxy/snapshot_reconcile_test.go new file mode 100644 index 000000000..042d8df77 --- /dev/null +++ b/proxy/snapshot_reconcile_test.go @@ -0,0 +1,227 @@ +package proxy + +import ( + "context" + "io" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/health" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// collectStaleIDs mirrors the stale-detection logic in reconcileSnapshot +// so we can verify it without triggering removeMapping (which requires full +// server wiring). This keeps the test focused on the detection algorithm. +func collectStaleIDs(lastMappings map[types.ServiceID]*proto.ProxyMapping, snapshotIDs map[types.ServiceID]struct{}) []types.ServiceID { + var stale []types.ServiceID + for svcID := range lastMappings { + if _, ok := snapshotIDs[svcID]; !ok { + stale = append(stale, svcID) + } + } + return stale +} + +// TestStaleDetection_PartialOverlap verifies that only services absent from +// the snapshot are flagged as stale. +func TestStaleDetection_PartialOverlap(t *testing.T) { + local := map[types.ServiceID]*proto.ProxyMapping{ + "svc-1": {Id: "svc-1"}, + "svc-2": {Id: "svc-2"}, + "svc-stale-a": {Id: "svc-stale-a"}, + "svc-stale-b": {Id: "svc-stale-b"}, + } + snapshot := map[types.ServiceID]struct{}{ + "svc-1": {}, + "svc-2": {}, + "svc-3": {}, // new service, not in local + } + + stale := collectStaleIDs(local, snapshot) + assert.Len(t, stale, 2) + staleSet := make(map[types.ServiceID]struct{}) + for _, id := range stale { + staleSet[id] = struct{}{} + } + assert.Contains(t, staleSet, types.ServiceID("svc-stale-a")) + assert.Contains(t, staleSet, types.ServiceID("svc-stale-b")) +} + +// TestStaleDetection_AllStale verifies an empty snapshot flags everything. +func TestStaleDetection_AllStale(t *testing.T) { + local := map[types.ServiceID]*proto.ProxyMapping{ + "svc-1": {Id: "svc-1"}, + "svc-2": {Id: "svc-2"}, + } + stale := collectStaleIDs(local, map[types.ServiceID]struct{}{}) + assert.Len(t, stale, 2) +} + +// TestStaleDetection_NoneStale verifies full overlap produces no stale entries. +func TestStaleDetection_NoneStale(t *testing.T) { + local := map[types.ServiceID]*proto.ProxyMapping{ + "svc-1": {Id: "svc-1"}, + "svc-2": {Id: "svc-2"}, + } + snapshot := map[types.ServiceID]struct{}{ + "svc-1": {}, + "svc-2": {}, + } + stale := collectStaleIDs(local, snapshot) + assert.Empty(t, stale) +} + +// TestStaleDetection_EmptyLocal verifies no stale entries when local is empty. +func TestStaleDetection_EmptyLocal(t *testing.T) { + stale := collectStaleIDs( + map[types.ServiceID]*proto.ProxyMapping{}, + map[types.ServiceID]struct{}{"svc-1": {}}, + ) + assert.Empty(t, stale) +} + +// TestReconcileSnapshot_NoStale verifies reconciliation is a no-op when all +// local mappings are present in the snapshot (removeMapping is never called). +func TestReconcileSnapshot_NoStale(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + s.lastMappings["svc-1"] = &proto.ProxyMapping{Id: "svc-1"} + s.lastMappings["svc-2"] = &proto.ProxyMapping{Id: "svc-2"} + + snapshotIDs := map[types.ServiceID]struct{}{ + "svc-1": {}, + "svc-2": {}, + } + // This should not panic — no stale entries means removeMapping is never called. + s.reconcileSnapshot(context.Background(), snapshotIDs) + + assert.Len(t, s.lastMappings, 2, "no mappings should be removed when all are in snapshot") +} + +// TestReconcileSnapshot_EmptyLocal verifies reconciliation is a no-op with +// no local mappings. +func TestReconcileSnapshot_EmptyLocal(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + s.reconcileSnapshot(context.Background(), map[types.ServiceID]struct{}{"svc-1": {}}) + assert.Empty(t, s.lastMappings) +} + +// --- handleMappingStream tests for batched snapshot ID accumulation --- + +// TestHandleMappingStream_BatchedSnapshotSyncComplete verifies that sync is +// marked done only after the final InitialSyncComplete message, even when +// the snapshot arrives in multiple batches. +func TestHandleMappingStream_BatchedSnapshotSyncComplete(t *testing.T) { + checker := health.NewChecker(nil, nil) + s := &Server{ + Logger: log.StandardLogger(), + healthChecker: checker, + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + + stream := &mockMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + {}, // batch 1: no sync-complete + {}, // batch 2: no sync-complete + {InitialSyncComplete: true}, // batch 3: sync done + }, + } + + syncDone := false + err := s.handleMappingStream(context.Background(), stream, &syncDone) + assert.NoError(t, err) + assert.True(t, syncDone, "sync should be marked done after final batch") +} + +// TestHandleMappingStream_PostSyncDoesNotReconcile verifies that messages +// arriving after InitialSyncComplete do not trigger a second reconciliation. +func TestHandleMappingStream_PostSyncDoesNotReconcile(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + + // Simulate state left over from a previous sync. + s.lastMappings["svc-1"] = &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1"} + s.lastMappings["svc-2"] = &proto.ProxyMapping{Id: "svc-2", AccountId: "acct-1"} + + stream := &mockMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + {}, // post-sync empty message — must not reconcile + }, + } + + syncDone := true // sync already completed in a previous stream + err := s.handleMappingStream(context.Background(), stream, &syncDone) + require.NoError(t, err) + + assert.Len(t, s.lastMappings, 2, + "post-sync messages must not trigger reconciliation — all entries should survive") +} + +// TestHandleMappingStream_ImmediateEOF_NoReconciliation verifies that if the +// stream closes before sync completes, no reconciliation occurs. +func TestHandleMappingStream_ImmediateEOF_NoReconciliation(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + + s.lastMappings["svc-stale"] = &proto.ProxyMapping{Id: "svc-stale", AccountId: "acct-1"} + + stream := &mockMappingStream{} // no messages → immediate EOF + + syncDone := false + err := s.handleMappingStream(context.Background(), stream, &syncDone) + assert.NoError(t, err) + assert.False(t, syncDone, "sync should not be marked done on immediate EOF") + + _, hasStale := s.lastMappings["svc-stale"] + assert.True(t, hasStale, "stale mapping should remain when sync never completed") +} + +// mockErrRecvStream returns an error on the second Recv to verify +// handleMappingStream returns without completing sync. +type mockErrRecvStream struct { + mockMappingStream + calls int +} + +func (m *mockErrRecvStream) Recv() (*proto.GetMappingUpdateResponse, error) { + m.calls++ + if m.calls == 1 { + return &proto.GetMappingUpdateResponse{}, nil + } + return nil, io.ErrUnexpectedEOF +} + +func TestHandleMappingStream_ErrorMidSync_NoReconciliation(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + + s.lastMappings["svc-stale"] = &proto.ProxyMapping{Id: "svc-stale", AccountId: "acct-1"} + + syncDone := false + err := s.handleMappingStream(context.Background(), &mockErrRecvStream{}, &syncDone) + assert.Error(t, err) + assert.False(t, syncDone) + + _, hasStale := s.lastMappings["svc-stale"] + assert.True(t, hasStale, "stale mapping should remain when sync was interrupted by error") +} From b19b7464eac5c58bb6a6780a033398a27f3d772f Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 5 May 2026 18:48:51 +0200 Subject: [PATCH 46/80] [management] fix flaky invite token test (#6077) --- management/server/types/user_invite_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/management/server/types/user_invite_test.go b/management/server/types/user_invite_test.go index 09dae3800..c77fb89e2 100644 --- a/management/server/types/user_invite_test.go +++ b/management/server/types/user_invite_test.go @@ -144,8 +144,11 @@ func TestValidateInviteToken_ModifiedToken(t *testing.T) { _, plainToken, err := GenerateInviteToken() require.NoError(t, err) - // Modify one character in the secret part - modifiedToken := plainToken[:5] + "X" + plainToken[6:] + replacement := "X" + if plainToken[5] == 'X' { + replacement = "Y" + } + modifiedToken := plainToken[:5] + replacement + plainToken[6:] err = ValidateInviteToken(modifiedToken) require.Error(t, err) } From bfeb9b19ecbe03cf1d2b3f44258a84b3dfe02868 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 6 May 2026 13:07:01 +0200 Subject: [PATCH 47/80] [management] remove permissions from geolocations api (#6091) --- .../handlers/policies/geolocations_handler.go | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/management/server/http/handlers/policies/geolocations_handler.go b/management/server/http/handlers/policies/geolocations_handler.go index a2d656a47..eea31ebc6 100644 --- a/management/server/http/handlers/policies/geolocations_handler.go +++ b/management/server/http/handlers/policies/geolocations_handler.go @@ -7,11 +7,8 @@ import ( "github.com/gorilla/mux" "github.com/netbirdio/netbird/management/server/account" - nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/management/server/permissions/modules" - "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" @@ -45,11 +42,6 @@ func newGeolocationsHandlerHandler(accountManager account.Manager, geolocationMa // getAllCountries retrieves a list of all countries func (l *geolocationsHandler) getAllCountries(w http.ResponseWriter, r *http.Request) { - if err := l.authenticateUser(r); err != nil { - util.WriteError(r.Context(), err, w) - return - } - if l.geolocationManager == nil { // TODO: update error message to include geo db self hosted doc link when ready util.WriteError(r.Context(), status.Errorf(status.PreconditionFailed, "Geo location database is not initialized"), w) @@ -71,11 +63,6 @@ func (l *geolocationsHandler) getAllCountries(w http.ResponseWriter, r *http.Req // getCitiesByCountry retrieves a list of cities based on the given country code func (l *geolocationsHandler) getCitiesByCountry(w http.ResponseWriter, r *http.Request) { - if err := l.authenticateUser(r); err != nil { - util.WriteError(r.Context(), err, w) - return - } - vars := mux.Vars(r) countryCode := vars["country"] if !countryCodeRegex.MatchString(countryCode) { @@ -102,27 +89,6 @@ func (l *geolocationsHandler) getCitiesByCountry(w http.ResponseWriter, r *http. util.WriteJSONObject(r.Context(), w, cities) } -func (l *geolocationsHandler) authenticateUser(r *http.Request) error { - ctx := r.Context() - - userAuth, err := nbcontext.GetUserAuthFromContext(ctx) - if err != nil { - return err - } - - accountID, userID := userAuth.AccountId, userAuth.UserId - - allowed, err := l.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read) - if err != nil { - return status.NewPermissionValidationError(err) - } - - if !allowed { - return status.NewPermissionDeniedError() - } - return nil -} - func toCountryResponse(country geolocation.Country) api.Country { return api.Country{ CountryName: country.CountryName, From 71a400f90fc522389739e70acdc6f800ed1e76c6 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 6 May 2026 20:23:43 +0900 Subject: [PATCH 48/80] [client] Include MTU and SSH auth/JWT cache config in debug bundle (#6071) --- client/internal/debug/debug.go | 7 ++ client/internal/debug/debug_test.go | 139 +++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 90560d028..0ad1401e7 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -607,6 +607,12 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) if g.internalConfig.EnableSSHRemotePortForwarding != nil { configContent.WriteString(fmt.Sprintf("EnableSSHRemotePortForwarding: %v\n", *g.internalConfig.EnableSSHRemotePortForwarding)) } + if g.internalConfig.DisableSSHAuth != nil { + configContent.WriteString(fmt.Sprintf("DisableSSHAuth: %v\n", *g.internalConfig.DisableSSHAuth)) + } + if g.internalConfig.SSHJWTCacheTTL != nil { + configContent.WriteString(fmt.Sprintf("SSHJWTCacheTTL: %d\n", *g.internalConfig.SSHJWTCacheTTL)) + } configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes)) configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes)) @@ -633,6 +639,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) } configContent.WriteString(fmt.Sprintf("LazyConnectionEnabled: %v\n", g.internalConfig.LazyConnectionEnabled)) + configContent.WriteString(fmt.Sprintf("MTU: %d\n", g.internalConfig.MTU)) } func (g *BundleGenerator) addProf() (err error) { diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 6b5bb911c..05d51e593 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -5,16 +5,21 @@ import ( "bytes" "encoding/json" "net" + "net/url" "os" "path/filepath" + "reflect" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) @@ -471,8 +476,8 @@ func TestSanitizeServiceEnvVars(t *testing.T) { anonymize: false, input: map[string]any{ jsonKeyServiceEnv: map[string]any{ - "HOME": "/root", - "PATH": "/usr/bin", + "HOME": "/root", + "PATH": "/usr/bin", "NB_LOG_LEVEL": "debug", }, }, @@ -489,9 +494,9 @@ func TestSanitizeServiceEnvVars(t *testing.T) { anonymize: false, input: map[string]any{ jsonKeyServiceEnv: map[string]any{ - "NB_SETUP_KEY": "abc123", - "NB_API_TOKEN": "tok_xyz", - "NB_LOG_LEVEL": "info", + "NB_SETUP_KEY": "abc123", + "NB_API_TOKEN": "tok_xyz", + "NB_LOG_LEVEL": "info", }, }, check: func(t *testing.T, params map[string]any) { @@ -766,3 +771,127 @@ Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes) assert.Contains(t, anonNftables, "chain input {") assert.Contains(t, anonNftables, "type filter hook input priority filter; policy accept;") } + +// TestAddConfig_AllFieldsCovered uses reflection to ensure every field in +// profilemanager.Config is either rendered in the debug bundle or explicitly +// excluded. When a new field is added to Config, this test fails until the +// developer either dumps it in addConfig/addCommonConfigFields or adds it to +// the excluded set with a justification. +func TestAddConfig_AllFieldsCovered(t *testing.T) { + excluded := map[string]string{ + "PrivateKey": "sensitive: WireGuard private key", + "PreSharedKey": "sensitive: WireGuard pre-shared key", + "SSHKey": "sensitive: SSH private key", + "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", + } + + mURL, _ := url.Parse("https://api.example.com:443") + aURL, _ := url.Parse("https://admin.example.com:443") + bTrue := true + iVal := 42 + cfg := &profilemanager.Config{ + PrivateKey: "priv", + PreSharedKey: "psk", + ManagementURL: mURL, + AdminURL: aURL, + WgIface: "wt0", + WgPort: 51820, + NetworkMonitor: &bTrue, + IFaceBlackList: []string{"eth0"}, + DisableIPv6Discovery: true, + RosenpassEnabled: true, + RosenpassPermissive: true, + ServerSSHAllowed: &bTrue, + EnableSSHRoot: &bTrue, + EnableSSHSFTP: &bTrue, + EnableSSHLocalPortForwarding: &bTrue, + EnableSSHRemotePortForwarding: &bTrue, + DisableSSHAuth: &bTrue, + SSHJWTCacheTTL: &iVal, + DisableClientRoutes: true, + DisableServerRoutes: true, + DisableDNS: true, + DisableFirewall: true, + BlockLANAccess: true, + BlockInbound: true, + DisableNotifications: &bTrue, + DNSLabels: domain.List{}, + SSHKey: "sshkey", + NATExternalIPs: []string{"1.2.3.4"}, + CustomDNSAddress: "1.1.1.1:53", + DisableAutoConnect: true, + DNSRouteInterval: 5 * time.Second, + ClientCertPath: "/tmp/cert", + ClientCertKeyPath: "/tmp/key", + LazyConnectionEnabled: true, + MTU: 1280, + } + + for _, anonymize := range []bool{false, true} { + t.Run("anonymize="+map[bool]string{true: "true", false: "false"}[anonymize], func(t *testing.T) { + g := &BundleGenerator{ + anonymizer: newAnonymizerForTest(), + internalConfig: cfg, + anonymize: anonymize, + } + + var sb strings.Builder + g.addCommonConfigFields(&sb) + rendered := sb.String() + renderAddConfigSpecific(g) + + val := reflect.ValueOf(cfg).Elem() + typ := val.Type() + var missing []string + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + if _, ok := excluded[name]; ok { + continue + } + if !strings.Contains(rendered, name+":") { + missing = append(missing, name) + } + } + if len(missing) > 0 { + t.Fatalf("Config field(s) not present in debug bundle output: %v\n"+ + "Either render the field in addCommonConfigFields/addConfig, "+ + "or add it to the excluded map with a justification.", missing) + } + }) + } +} + +// renderAddConfigSpecific renders the fields handled by the anonymize/non-anonymize +// branches in addConfig (ManagementURL, AdminURL, NATExternalIPs, CustomDNSAddress). +// addCommonConfigFields covers the rest. Keeping this in the test mirrors the +// production shape without needing to write an actual zip. +func renderAddConfigSpecific(g *BundleGenerator) string { + var sb strings.Builder + if g.anonymize { + if g.internalConfig.ManagementURL != nil { + sb.WriteString("ManagementURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String()) + "\n") + } + if g.internalConfig.AdminURL != nil { + sb.WriteString("AdminURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.AdminURL.String()) + "\n") + } + sb.WriteString("NATExternalIPs: x\n") + if g.internalConfig.CustomDNSAddress != "" { + sb.WriteString("CustomDNSAddress: " + g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress) + "\n") + } + } else { + if g.internalConfig.ManagementURL != nil { + sb.WriteString("ManagementURL: " + g.internalConfig.ManagementURL.String() + "\n") + } + if g.internalConfig.AdminURL != nil { + sb.WriteString("AdminURL: " + g.internalConfig.AdminURL.String() + "\n") + } + sb.WriteString("NATExternalIPs: x\n") + if g.internalConfig.CustomDNSAddress != "" { + sb.WriteString("CustomDNSAddress: " + g.internalConfig.CustomDNSAddress + "\n") + } + } + return sb.String() +} + +func newAnonymizerForTest() *anonymize.Anonymizer { + return anonymize.NewAnonymizer(anonymize.DefaultAddresses()) +} From f532976e05879f5a3cb56e016449e1a404457ac5 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 6 May 2026 20:42:47 +0900 Subject: [PATCH 49/80] [client] Add public key to debug bundle config.txt (#6092) --- client/internal/debug/debug.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 0ad1401e7..0a12a5326 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -21,6 +21,7 @@ import ( "time" log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/protobuf/encoding/protojson" "github.com/netbirdio/netbird/client/anonymize" @@ -583,6 +584,9 @@ func isSensitiveEnvVar(key string) bool { func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) { configContent.WriteString("NetBird Client Configuration:\n\n") + if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil { + configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", key.PublicKey().String())) + } configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface)) configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort)) if g.internalConfig.NetworkMonitor != nil { From 490b60ad0e9df0505d6652e38127408710150be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 6 May 2026 15:50:14 +0200 Subject: [PATCH 50/80] [ci] Suppress typecheck on the ui-wails embed instead of skipping main.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous attempt added client/ui-wails/main.go to the file path exclude list, but golangci-lint v2's path filter only suppresses issues from rule-based linters; the typecheck pre-pass that compiles the package still runs and fails with "pattern all:frontend/dist: no matching files found" before any rule fires. Replace the path-level skip with a targeted exclusions.rules entry that matches just that diagnostic on just that file. The rest of client/ui-wails (services/, tray.go, grpc.go, ...) keeps being linted normally. Validated locally by deleting frontend/dist and running `golangci-lint run client/ui-wails/...` — 0 issues with this config. --- .golangci.yaml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index d1b7ac271..7883961c3 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -114,16 +114,20 @@ linters: - linters: - staticcheck text: "QF1012" + # client/ui-wails/main.go uses //go:embed all:frontend/dist; the + # directory is populated by `pnpm build` in the release pipeline + # and missing at lint time, so the embed parses to "no matching + # files found" — surfaced by golangci-lint's typecheck pre-pass. + # Suppress just that one diagnostic; the rest of the package + # (services/, tray.go, grpc.go, ...) still gets linted normally. + - linters: + - typecheck + path: client/ui-wails/main\.go + text: "pattern all:frontend/dist" paths: - third_party$ - builtin$ - examples$ - # client/ui-wails/main.go uses //go:embed all:frontend/dist; that - # directory is populated by `pnpm build` in the release pipeline - # and is missing at lint time, so the typecheck phase fails before - # any rule runs. Skip just main.go — the rest of the package - # (services/, tray.go, grpc.go, ...) still gets linted. - - client/ui-wails/main\.go$ issues: max-same-issues: 5 formatters: From e6cbf3041502a5727dcceb0231a5c44759ab54dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 6 May 2026 15:57:34 +0200 Subject: [PATCH 51/80] [client/ui-wails] Surface daemon SessionExpired in the tray Port the Fyne UI's onSessionExpire 1:1 to the Wails tray so an SSO token expiry no longer leaves the user staring at a stale peer list. When applyStatus sees the transition into the daemon's StatusSessionExpired, fire a single OS notification (the lastStatus guard rate-limits it to the transition itself, mirroring the Fyne sendNotification flag) and bring the main window forward on the /login route so the frontend can drive the renewed SSO flow. The Fyne client achieved the same end with a runSelfCommand "login-url" helper; here the window is already in-process so we route to it directly. --- client/ui-wails/tray.go | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/client/ui-wails/tray.go b/client/ui-wails/tray.go index afed50fd7..cf7d99c8d 100644 --- a/client/ui-wails/tray.go +++ b/client/ui-wails/tray.go @@ -56,11 +56,18 @@ const ( notifyErrorTitle = "Error" notifyErrorConnect = "Failed to connect" notifyErrorDisconnect = "Failed to disconnect" + notifySessionExpiredTitle = "NetBird session expired" + notifySessionExpiredBody = "Your NetBird session has expired. Please log in again." // Notification IDs (used to coalesce duplicate toasts). - notifyIDUpdatePrefix = "netbird-update-" - notifyIDEvent = "netbird-event-" - notifyIDTrayError = "netbird-tray-error" + notifyIDUpdatePrefix = "netbird-update-" + notifyIDEvent = "netbird-event-" + notifyIDTrayError = "netbird-tray-error" + notifyIDSessionExpired = "netbird-session-expired" + + // Daemon status string for an SSO session that has expired and needs + // re-authentication. Mirrors internal.StatusSessionExpired. + statusSessionExpired = "SessionExpired" // External URLs. urlGitHubRepo = "https://github.com/netbirdio/netbird" @@ -405,6 +412,13 @@ func (t *Tray) applyStatus(st services.Status) { t.mu.Lock() connected := strings.EqualFold(st.Status, "Connected") iconChanged := connected != t.connected || st.Status != t.lastStatus + // Detect the transition into SessionExpired: the daemon emits the + // state on every Status snapshot for as long as the session stays + // expired, so without this guard we would re-fire the notification + // on every push. Mirrors the legacy Fyne client's sendNotification + // flag in onSessionExpire. + sessionExpiredEnter := strings.EqualFold(st.Status, statusSessionExpired) && + !strings.EqualFold(t.lastStatus, statusSessionExpired) t.connected = connected t.lastStatus = st.Status @@ -428,6 +442,24 @@ func (t *Tray) applyStatus(st services.Status) { if exitNodesChanged { t.rebuildExitNodes(exitNodes) } + if sessionExpiredEnter { + t.handleSessionExpired() + } +} + +// handleSessionExpired surfaces the SSO re-authentication path when the +// daemon reports StatusSessionExpired. Posts a single OS notification +// (the applyStatus guard ensures it fires only on the transition, not +// on every status snapshot) and brings the main window forward so the +// frontend's /login route can drive the renewed SSO flow. Mirrors the +// Fyne client's onSessionExpire, which used a runSelfCommand to spawn +// the login-url helper; here the window is already in-process. +func (t *Tray) handleSessionExpired() { + t.notify(notifySessionExpiredTitle, notifySessionExpiredBody, notifyIDSessionExpired) + if t.window != nil { + t.window.SetURL("/#/login") + t.window.Show() + } } func (t *Tray) rebuildExitNodes(nodes []string) { From 2b272e74c8c96206ca4b85ca4d773688f9d0571b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 6 May 2026 16:47:35 +0200 Subject: [PATCH 52/80] [client/ui-wails] In-process StatusNotifierWatcher + XEmbed tray bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wails3's Linux systray hands the icon off to whatever process owns org.kde.StatusNotifierWatcher on the session bus. Bare WMs (Fluxbox, OpenBox, i3, dwm, sway, vanilla GNOME without the AppIndicator extension) ship no watcher, so the icon registration silently fails and the tray never appears — leaving a tray-only app like NetBird unreachable. Add a Linux-only watcher fallback that claims the watcher name when nobody else does, plus an XEmbed bridge so legacy X11 system trays (_NET_SYSTEM_TRAY_S0) can still render the icon. Both no-op on other platforms via build tags. Pieces: - tray_watcher_linux.go: claims org.kde.StatusNotifierWatcher on a private session bus, exports the bare RegisterStatusNotifierItem / RegisterStatusNotifierHost surface, and spins up an XEmbed host per registered SNI item. - xembed_host_linux.go: per-item event loop. Polls X11 events with a 50ms ticker, listens for the SNI NewIcon signal, dispatches Activate / context menu through dbusmenu (com.canonical.dbusmenu). - xembed_tray_linux.{c,h}: the X11/cairo native bits. Window is created with CopyFromParent visual + ParentRelative background so transparent pixels show the toolbar beneath instead of solid black on 24-bit trays. cairo paints the IconPixmap with OVER blending so per-pixel alpha is honoured against the parent-relative base. GTK3 owns the context-menu popup; menu items round-trip through dbusmenu Event. - tray_linux.go: forces WEBKIT_DISABLE_DMABUF_RENDERER=1 in init() so developers running `task dev` / launching the binary directly get the same software rendering path the .desktop launcher already enables; the deb/rpm Exec wrapper covers installed users. - tray_watcher_other.go and xembed_host_other.go: build-tag stubs so main.go's startStatusNotifierWatcher() compiles on every platform. - main.go: calls startStatusNotifierWatcher() before NewTray so the Wails systray's RegisterStatusNotifierItem call hits a watcher we control on bare WMs. - build/linux/netbird-ui.desktop: regenerated by `task build` to wrap the dev launcher's Exec line with the WEBKIT_DISABLE_DMABUF_RENDERER env, matching what the tray_linux.go init does at runtime. Adapted from work originally prototyped on the prototype/ui-wails branch. Tested on Fluxbox (Debian 13): the icon appears in the slit/toolbar with the toolbar's background showing through transparent pixels, left-click opens the window, right-click brings up the GTK popup of the dbusmenu items. --- .../ui-wails/build/linux/netbird-ui.desktop | 2 +- client/ui-wails/main.go | 7 + client/ui-wails/tray_linux.go | 24 ++ client/ui-wails/tray_watcher_linux.go | 148 +++++++ client/ui-wails/tray_watcher_other.go | 6 + client/ui-wails/xembed_host_linux.go | 389 ++++++++++++++++++ client/ui-wails/xembed_host_other.go | 18 + client/ui-wails/xembed_tray_linux.c | 379 +++++++++++++++++ client/ui-wails/xembed_tray_linux.h | 71 ++++ 9 files changed, 1043 insertions(+), 1 deletion(-) create mode 100644 client/ui-wails/tray_linux.go create mode 100644 client/ui-wails/tray_watcher_linux.go create mode 100644 client/ui-wails/tray_watcher_other.go create mode 100644 client/ui-wails/xembed_host_linux.go create mode 100644 client/ui-wails/xembed_host_other.go create mode 100644 client/ui-wails/xembed_tray_linux.c create mode 100644 client/ui-wails/xembed_tray_linux.h diff --git a/client/ui-wails/build/linux/netbird-ui.desktop b/client/ui-wails/build/linux/netbird-ui.desktop index a46e530c1..6b6ed42a5 100755 --- a/client/ui-wails/build/linux/netbird-ui.desktop +++ b/client/ui-wails/build/linux/netbird-ui.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Type=Application Name=netbird-ui -Exec=netbird-ui +Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui Icon=netbird-ui Categories=Development; Terminal=false diff --git a/client/ui-wails/main.go b/client/ui-wails/main.go index bd7fa4674..e5f4d4a7a 100644 --- a/client/ui-wails/main.go +++ b/client/ui-wails/main.go @@ -130,6 +130,13 @@ func main() { window.Hide() }) + // Register an in-process StatusNotifierWatcher so the tray works on + // minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the + // AppIndicator extension) that don't ship one themselves. No-op on + // non-Linux platforms. Must run before NewTray so the Wails systray's + // RegisterStatusNotifierItem call hits a watcher we control. + startStatusNotifierWatcher() + tray = NewTray(app, window, TrayServices{ Connection: connection, Settings: settings, diff --git a/client/ui-wails/tray_linux.go b/client/ui-wails/tray_linux.go new file mode 100644 index 000000000..a213ce2f9 --- /dev/null +++ b/client/ui-wails/tray_linux.go @@ -0,0 +1,24 @@ +//go:build linux && !386 + +package main + +import "os" + +// init runs before Wails' own init(), so the env var is set in time. +func init() { + if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") != "" { + return + } + + // WebKitGTK's DMA-BUF renderer fails on many setups (VMs, containers, + // minimal WMs without proper GPU access) and leaves the window blank + // white. Wails only disables it for NVIDIA+Wayland, but the issue is + // broader. Always disable it — software rendering works fine for a + // small UI like this. + _ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1") +} + +// On Linux, the system tray provider may require the menu to be recreated +// rather than updated in place. The rebuildExitNodeMenu method in tray.go +// already handles this by removing and re-adding items; no additional +// Linux-specific workaround is needed for Wails v3. diff --git a/client/ui-wails/tray_watcher_linux.go b/client/ui-wails/tray_watcher_linux.go new file mode 100644 index 000000000..7a9b72096 --- /dev/null +++ b/client/ui-wails/tray_watcher_linux.go @@ -0,0 +1,148 @@ +//go:build linux && !(linux && 386) + +package main + +// startStatusNotifierWatcher registers org.kde.StatusNotifierWatcher on the +// session D-Bus if no other process has already claimed it. +// +// Minimal window managers (Fluxbox, OpenBox, i3, etc.) do not ship a +// StatusNotifier watcher, so tray icons using libayatana-appindicator or +// the KDE/freedesktop StatusNotifier protocol silently fail. +// +// By owning the watcher name in-process we allow the Wails v3 built-in tray +// to register itself — no external daemon or package needed. +// +// When an XEmbed system tray is available (_NET_SYSTEM_TRAY_S0), we also +// start an in-process XEmbed host that bridges the SNI icon into the +// XEmbed tray (Fluxbox, IceWM, etc.). + +import ( + "sync" + + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +const ( + watcherName = "org.kde.StatusNotifierWatcher" + watcherPath = "/StatusNotifierWatcher" + watcherIface = "org.kde.StatusNotifierWatcher" +) + +type statusNotifierWatcher struct { + conn *dbus.Conn + items []string + hosts map[string]*xembedHost + hostsMu sync.Mutex +} + +// RegisterStatusNotifierItem is the D-Bus method called by tray clients. +// The sender parameter is automatically injected by godbus with the caller's +// unique bus name (e.g. ":1.42"). It does not appear in the D-Bus signature. +func (w *statusNotifierWatcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error { + for _, s := range w.items { + if s == service { + return nil + } + } + w.items = append(w.items, service) + log.Debugf("StatusNotifierWatcher: registered item %q from %s", service, sender) + + go w.tryStartXembedHost(string(sender), dbus.ObjectPath(service)) + return nil +} + +// RegisterStatusNotifierHost is required by the protocol but unused here. +func (w *statusNotifierWatcher) RegisterStatusNotifierHost(service string) *dbus.Error { + log.Debugf("StatusNotifierWatcher: host registered %q", service) + return nil +} + +// tryStartXembedHost attempts to create an XEmbed tray icon for the given +// SNI item. If no XEmbed tray manager is available, this is a no-op. +func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus.ObjectPath) { + w.hostsMu.Lock() + defer w.hostsMu.Unlock() + + if _, exists := w.hosts[busName]; exists { + return + } + + // Use a private session bus so our signal subscriptions don't + // interfere with Wails' signal handler (which panics on unexpected signals). + sessionConn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("StatusNotifierWatcher: cannot open private session bus for XEmbed host: %v", err) + return + } + if err := sessionConn.Auth(nil); err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host auth failed: %v", err) + _ = sessionConn.Close() + return + } + if err := sessionConn.Hello(); err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host Hello failed: %v", err) + _ = sessionConn.Close() + return + } + + host, err := newXembedHost(sessionConn, busName, objPath) + if err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host not started: %v", err) + return + } + + w.hosts[busName] = host + go host.run() + log.Infof("StatusNotifierWatcher: XEmbed tray icon created for %s", busName) +} + +// startStatusNotifierWatcher claims org.kde.StatusNotifierWatcher on the +// session bus if it is not already provided by another process. +// Safe to call unconditionally — it does nothing when a real watcher is present. +func startStatusNotifierWatcher() { + conn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("StatusNotifierWatcher: cannot open private session bus: %v", err) + return + } + if err := conn.Auth(nil); err != nil { + log.Debugf("StatusNotifierWatcher: auth failed: %v", err) + _ = conn.Close() + return + } + if err := conn.Hello(); err != nil { + log.Debugf("StatusNotifierWatcher: Hello failed: %v", err) + _ = conn.Close() + return + } + + // Check whether another process already owns the watcher name. + var owner string + callErr := conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, watcherName).Store(&owner) + if callErr == nil && owner != "" { + log.Debugf("StatusNotifierWatcher: already owned by %s, skipping", owner) + _ = conn.Close() + return + } + + reply, err := conn.RequestName(watcherName, dbus.NameFlagDoNotQueue) + if err != nil || reply != dbus.RequestNameReplyPrimaryOwner { + log.Debugf("StatusNotifierWatcher: could not claim name (reply=%v err=%v)", reply, err) + _ = conn.Close() + return + } + + w := &statusNotifierWatcher{ + conn: conn, + hosts: make(map[string]*xembedHost), + } + if err := conn.ExportAll(w, dbus.ObjectPath(watcherPath), watcherIface); err != nil { + log.Errorf("StatusNotifierWatcher: export failed: %v", err) + _ = conn.Close() + return + } + + log.Infof("StatusNotifierWatcher: active on session bus (enables tray on minimal WMs)") + // Connection intentionally kept open for the lifetime of the process. +} diff --git a/client/ui-wails/tray_watcher_other.go b/client/ui-wails/tray_watcher_other.go new file mode 100644 index 000000000..aa8fc8ac7 --- /dev/null +++ b/client/ui-wails/tray_watcher_other.go @@ -0,0 +1,6 @@ +//go:build !linux || (linux && 386) + +package main + +// startStatusNotifierWatcher is a no-op on non-Linux platforms. +func startStatusNotifierWatcher() {} diff --git a/client/ui-wails/xembed_host_linux.go b/client/ui-wails/xembed_host_linux.go new file mode 100644 index 000000000..33d247b18 --- /dev/null +++ b/client/ui-wails/xembed_host_linux.go @@ -0,0 +1,389 @@ +//go:build linux && !(linux && 386) + +package main + +/* +#cgo pkg-config: x11 gtk+-3.0 cairo cairo-xlib +#cgo LDFLAGS: -lX11 +#include "xembed_tray_linux.h" +#include +#include +*/ +import "C" + +import ( + "errors" + "sync" + "time" + "unsafe" + + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +// activeMenuHost is the xembedHost that currently owns the popup menu. +// This is needed because C callbacks cannot carry Go pointers. +var ( + activeMenuHost *xembedHost + activeMenuHostMu sync.Mutex +) + +//export goMenuItemClicked +func goMenuItemClicked(id C.int) { + activeMenuHostMu.Lock() + h := activeMenuHost + activeMenuHostMu.Unlock() + + if h != nil { + go h.sendMenuEvent(int32(id)) + } +} + +// xembedHost manages one XEmbed tray icon for an SNI item. +type xembedHost struct { + conn *dbus.Conn + busName string + objPath dbus.ObjectPath + + dpy *C.Display + trayMgr C.Window + iconWin C.Window + iconSize int + + mu sync.Mutex + iconData []byte + iconW int + iconH int + + stopCh chan struct{} +} + +// newXembedHost creates an XEmbed tray icon for the given SNI item. +// Returns an error if no XEmbed tray manager is available (graceful fallback). +func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) { + dpy := C.XOpenDisplay(nil) + if dpy == nil { + return nil, errors.New("cannot open X display") + } + + screen := C.xembed_default_screen(dpy) + trayMgr := C.xembed_find_tray(dpy, screen) + if trayMgr == 0 { + C.XCloseDisplay(dpy) + return nil, errors.New("no XEmbed system tray found") + } + + // Query the tray manager's preferred icon size. + iconSize := int(C.xembed_get_icon_size(dpy, trayMgr)) + if iconSize <= 0 { + iconSize = 24 // fallback + } + + iconWin := C.xembed_create_icon(dpy, screen, C.int(iconSize), trayMgr) + if iconWin == 0 { + C.XCloseDisplay(dpy) + return nil, errors.New("failed to create icon window") + } + + if C.xembed_dock(dpy, trayMgr, iconWin) != 0 { + C.xembed_destroy_icon(dpy, iconWin) + C.XCloseDisplay(dpy) + return nil, errors.New("failed to dock icon") + } + + h := &xembedHost{ + conn: conn, + busName: busName, + objPath: objPath, + dpy: dpy, + trayMgr: trayMgr, + iconWin: iconWin, + iconSize: iconSize, + stopCh: make(chan struct{}), + } + + h.fetchAndDrawIcon() + return h, nil +} + +// fetchAndDrawIcon reads IconPixmap from the SNI item via D-Bus and draws it. +func (h *xembedHost) fetchAndDrawIcon() { + obj := h.conn.Object(h.busName, h.objPath) + variant, err := obj.GetProperty("org.kde.StatusNotifierItem.IconPixmap") + if err != nil { + log.Debugf("xembed: failed to get IconPixmap: %v", err) + return + } + + // IconPixmap is []struct{W, H int32; Pix []byte} on D-Bus, + // represented as a(iiay) signature. + type px struct { + W int32 + H int32 + Pix []byte + } + + var icons []px + if err := variant.Store(&icons); err != nil { + log.Debugf("xembed: failed to decode IconPixmap: %v", err) + return + } + + if len(icons) == 0 { + log.Debug("xembed: IconPixmap is empty") + return + } + + icon := icons[0] + if icon.W <= 0 || icon.H <= 0 || len(icon.Pix) < int(icon.W*icon.H*4) { + log.Debug("xembed: invalid IconPixmap data") + return + } + + h.mu.Lock() + h.iconData = icon.Pix + h.iconW = int(icon.W) + h.iconH = int(icon.H) + h.mu.Unlock() + + h.drawIcon() +} + +// drawIcon draws the cached icon data onto the X11 window. +func (h *xembedHost) drawIcon() { + h.mu.Lock() + data := h.iconData + w := h.iconW + ht := h.iconH + h.mu.Unlock() + + if data == nil || w <= 0 || ht <= 0 { + return + } + + cData := C.CBytes(data) + defer C.free(cData) + + C.xembed_draw_icon(h.dpy, h.iconWin, C.int(h.iconSize), + (*C.uchar)(cData), C.int(w), C.int(ht)) +} + +// run is the main event loop. It polls X11 events and listens for D-Bus +// NewIcon signals to keep the tray icon updated. +func (h *xembedHost) run() { + // Subscribe to NewIcon signals from the SNI item. + matchRule := "type='signal',interface='org.kde.StatusNotifierItem',member='NewIcon',sender='" + h.busName + "'" + if err := h.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil { + log.Debugf("xembed: failed to add signal match: %v", err) + } + + sigCh := make(chan *dbus.Signal, 16) + h.conn.Signal(sigCh) + defer h.conn.RemoveSignal(sigCh) + + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-h.stopCh: + return + + case sig := <-sigCh: + if sig == nil { + continue + } + if sig.Name == "org.kde.StatusNotifierItem.NewIcon" { + h.fetchAndDrawIcon() + } + + case <-ticker.C: + var outX, outY C.int + result := C.xembed_poll_event(h.dpy, h.iconWin, &outX, &outY) + + switch result { + case 1: // left click + go h.activate(int32(outX), int32(outY)) + case 2: // right click + go h.contextMenu(int32(outX), int32(outY)) + case 3: // expose + h.drawIcon() + case 4: // configure (resize) + newSize := int(outX) + if newSize > 0 && newSize != h.iconSize { + h.iconSize = newSize + h.drawIcon() + } + case -1: // tray died + log.Info("xembed: tray manager destroyed, cleaning up") + return + } + } + } +} + +func (h *xembedHost) activate(x, y int32) { + obj := h.conn.Object(h.busName, h.objPath) + if err := obj.Call("org.kde.StatusNotifierItem.Activate", 0, x, y).Err; err != nil { + log.Debugf("xembed: Activate call failed: %v", err) + } +} + +func (h *xembedHost) contextMenu(x, y int32) { + // Read the menu path from the SNI item's Menu property. + menuPath := dbus.ObjectPath("/StatusNotifierMenu") + + // Fetch menu layout from com.canonical.dbusmenu. + menuObj := h.conn.Object(h.busName, menuPath) + var revision uint32 + var layout dbusMenuLayout + err := menuObj.Call("com.canonical.dbusmenu.GetLayout", 0, + int32(0), // parentId (root) + int32(-1), // recursionDepth (all) + []string{}, // propertyNames (all) + ).Store(&revision, &layout) + if err != nil { + log.Debugf("xembed: GetLayout failed: %v", err) + return + } + + items := h.flattenMenu(layout) + log.Debugf("xembed: menu has %d items (revision %d)", len(items), revision) + if len(items) == 0 { + return + } + + // Build C menu item array. + cItems := make([]C.xembed_menu_item, len(items)) + cLabels := make([]*C.char, len(items)) // track for freeing + for i, mi := range items { + cItems[i].id = C.int(mi.id) + cItems[i].enabled = boolToInt(mi.enabled) + cItems[i].is_check = boolToInt(mi.isCheck) + cItems[i].checked = boolToInt(mi.checked) + cItems[i].is_separator = boolToInt(mi.isSeparator) + if mi.label != "" { + cLabels[i] = C.CString(mi.label) + cItems[i].label = cLabels[i] + } + } + defer func() { + for _, p := range cLabels { + if p != nil { + C.free(unsafe.Pointer(p)) + } + } + }() + + // Set the active menu host so the C callback can reach us. + activeMenuHostMu.Lock() + activeMenuHost = h + activeMenuHostMu.Unlock() + + C.xembed_show_popup_menu(&cItems[0], C.int(len(cItems)), + nil, C.int(x), C.int(y)) +} + +// dbusMenuLayout represents a com.canonical.dbusmenu layout item. +type dbusMenuLayout struct { + ID int32 + Properties map[string]dbus.Variant + Children []dbus.Variant +} + +type menuItemInfo struct { + id int32 + label string + enabled bool + isCheck bool + checked bool + isSeparator bool +} + +func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo { + var items []menuItemInfo + + for _, childVar := range layout.Children { + var child dbusMenuLayout + if err := dbus.Store([]interface{}{childVar.Value()}, &child); err != nil { + continue + } + + mi := menuItemInfo{ + id: child.ID, + enabled: true, + } + + if v, ok := child.Properties["type"]; ok { + if s, ok := v.Value().(string); ok && s == "separator" { + mi.isSeparator = true + items = append(items, mi) + continue + } + } + + if v, ok := child.Properties["label"]; ok { + if s, ok := v.Value().(string); ok { + mi.label = s + } + } + + if v, ok := child.Properties["enabled"]; ok { + if b, ok := v.Value().(bool); ok { + mi.enabled = b + } + } + + if v, ok := child.Properties["visible"]; ok { + if b, ok := v.Value().(bool); ok && !b { + continue // skip hidden items + } + } + + if v, ok := child.Properties["toggle-type"]; ok { + if s, ok := v.Value().(string); ok && s == "checkmark" { + mi.isCheck = true + } + } + + if v, ok := child.Properties["toggle-state"]; ok { + if n, ok := v.Value().(int32); ok && n == 1 { + mi.checked = true + } + } + + items = append(items, mi) + } + + return items +} + +func (h *xembedHost) sendMenuEvent(id int32) { + menuPath := dbus.ObjectPath("/StatusNotifierMenu") + menuObj := h.conn.Object(h.busName, menuPath) + data := dbus.MakeVariant("") + err := menuObj.Call("com.canonical.dbusmenu.Event", 0, + id, "clicked", data, uint32(0)).Err + if err != nil { + log.Debugf("xembed: menu Event call failed: %v", err) + } +} + +func boolToInt(b bool) C.int { + if b { + return 1 + } + return 0 +} + +func (h *xembedHost) stop() { + select { + case <-h.stopCh: + return // already stopped + default: + close(h.stopCh) + } + + C.xembed_destroy_icon(h.dpy, h.iconWin) + C.XCloseDisplay(h.dpy) +} diff --git a/client/ui-wails/xembed_host_other.go b/client/ui-wails/xembed_host_other.go new file mode 100644 index 000000000..c93d78413 --- /dev/null +++ b/client/ui-wails/xembed_host_other.go @@ -0,0 +1,18 @@ +//go:build !linux || (linux && 386) + +package main + +import ( + "errors" + + "github.com/godbus/dbus/v5" +) + +type xembedHost struct{} + +func newXembedHost(_ *dbus.Conn, _ string, _ dbus.ObjectPath) (*xembedHost, error) { + return nil, errors.New("XEmbed tray not supported on this platform") +} + +func (h *xembedHost) run() {} +func (h *xembedHost) stop() {} diff --git a/client/ui-wails/xembed_tray_linux.c b/client/ui-wails/xembed_tray_linux.c new file mode 100644 index 000000000..2db6a4e45 --- /dev/null +++ b/client/ui-wails/xembed_tray_linux.c @@ -0,0 +1,379 @@ +#include "xembed_tray_linux.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#define SYSTEM_TRAY_REQUEST_DOCK 0 +#define XEMBED_MAPPED (1 << 0) + +Window xembed_find_tray(Display *dpy, int screen) { + char atom_name[64]; + snprintf(atom_name, sizeof(atom_name), "_NET_SYSTEM_TRAY_S%d", screen); + Atom sel = XInternAtom(dpy, atom_name, False); + return XGetSelectionOwner(dpy, sel); +} + +int xembed_get_icon_size(Display *dpy, Window tray_mgr) { + Atom atom = XInternAtom(dpy, "_NET_SYSTEM_TRAY_ICON_SIZE", False); + Atom actual_type; + int actual_format; + unsigned long nitems, bytes_after; + unsigned char *prop = NULL; + int size = 0; + + if (XGetWindowProperty(dpy, tray_mgr, atom, 0, 1, False, + XA_CARDINAL, &actual_type, &actual_format, + &nitems, &bytes_after, &prop) == Success) { + if (prop && nitems == 1 && actual_format == 32) { + size = (int)(*(unsigned long *)prop); + } + if (prop) + XFree(prop); + } + return size; +} + +Window xembed_create_icon(Display *dpy, int screen, int size, + Window tray_mgr) { + (void)tray_mgr; /* unused; kept in signature for caller symmetry */ + Window root = RootWindow(dpy, screen); + + /* Inherit visual & depth from the parent (tray manager / root) so + ParentRelative background works on every tray. Many minimal + toolbars (Fluxbox slit, OpenBox, etc.) only offer a 24-bit + default visual and do not composite alpha; ParentRelative makes + the X server texture this window's background from the parent, + so transparent pixels in the icon show the toolbar beneath + instead of solid black. ARGB-aware trays still work because the + cairo OVER blend in xembed_draw_icon honours per-pixel alpha + against whatever base the X server painted underneath. */ + XSetWindowAttributes attrs; + memset(&attrs, 0, sizeof(attrs)); + attrs.event_mask = ButtonPressMask | StructureNotifyMask | ExposureMask; + attrs.background_pixmap = ParentRelative; + unsigned long mask = CWEventMask | CWBackPixmap; + + Window win = XCreateWindow( + dpy, root, + 0, 0, size, size, + 0, /* border width */ + CopyFromParent, /* depth */ + InputOutput, + CopyFromParent, /* visual */ + mask, + &attrs + ); + + /* Set _XEMBED_INFO: version=0, flags=XEMBED_MAPPED */ + Atom xembed_info = XInternAtom(dpy, "_XEMBED_INFO", False); + unsigned long info[2] = { 0, XEMBED_MAPPED }; + XChangeProperty(dpy, win, xembed_info, xembed_info, + 32, PropModeReplace, (unsigned char *)info, 2); + + return win; +} + +int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win) { + Atom opcode = XInternAtom(dpy, "_NET_SYSTEM_TRAY_OPCODE", False); + + XClientMessageEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.type = ClientMessage; + ev.window = tray_mgr; + ev.message_type = opcode; + ev.format = 32; + ev.data.l[0] = CurrentTime; + ev.data.l[1] = SYSTEM_TRAY_REQUEST_DOCK; + ev.data.l[2] = (long)icon_win; + + XSendEvent(dpy, tray_mgr, False, NoEventMask, (XEvent *)&ev); + XFlush(dpy); + return 0; +} + +void xembed_draw_icon(Display *dpy, Window icon_win, int win_size, + const unsigned char *data, int img_w, int img_h) { + if (!data || img_w <= 0 || img_h <= 0 || win_size <= 0) + return; + + /* Query the window's actual visual and depth so cairo composites + through the matching ARGB pipeline. */ + XWindowAttributes wa; + if (!XGetWindowAttributes(dpy, icon_win, &wa)) + return; + + /* Build a CAIRO_FORMAT_ARGB32 source surface from the SNI IconPixmap + bytes. SNI ships the pixels as [A,R,G,B,...] in network byte + order; cairo's ARGB32 stores native uint32 with B in the lowest + byte on little-endian hosts. Repack into native order with + pre-multiplied alpha so cairo can composite without tonemapping. */ + int stride = cairo_format_stride_for_width(CAIRO_FORMAT_ARGB32, img_w); + unsigned char *buf = (unsigned char *)calloc(stride * img_h, 1); + if (!buf) + return; + + for (int y = 0; y < img_h; y++) { + unsigned int *row = (unsigned int *)(buf + y * stride); + for (int x = 0; x < img_w; x++) { + int idx = (y * img_w + x) * 4; + unsigned int a = data[idx + 0]; + unsigned int r = data[idx + 1]; + unsigned int g = data[idx + 2]; + unsigned int b = data[idx + 3]; + + if (a == 0) { + row[x] = 0; + } else if (a == 255) { + row[x] = (a << 24) | (r << 16) | (g << 8) | b; + } else { + unsigned int pr = r * a / 255; + unsigned int pg = g * a / 255; + unsigned int pb = b * a / 255; + row[x] = (a << 24) | (pr << 16) | (pg << 8) | pb; + } + } + } + + cairo_surface_t *src = cairo_image_surface_create_for_data( + buf, CAIRO_FORMAT_ARGB32, img_w, img_h, stride); + if (cairo_surface_status(src) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(src); + free(buf); + return; + } + + /* Wrap the X11 window in a cairo XLib surface using its real visual. */ + cairo_surface_t *dst = cairo_xlib_surface_create( + dpy, icon_win, wa.visual, win_size, win_size); + if (cairo_surface_status(dst) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(dst); + cairo_surface_destroy(src); + free(buf); + return; + } + + /* Repaint the ParentRelative background first — without this the + window keeps the previously-drawn icon underneath when an icon + update arrives, and cairo's OVER blend would composite the new + icon on top of the stale one. XClearWindow forces the X server + to retexture from the parent (tray toolbar), giving us a clean + opaque base. */ + XClearWindow(dpy, icon_win); + + cairo_t *cr = cairo_create(dst); + + /* Scale the source onto the window with alpha compositing (default + OPERATOR_OVER). Transparent pixels keep the toolbar's pixels + visible underneath. */ + double sx = (double)win_size / img_w; + double sy = (double)win_size / img_h; + cairo_scale(cr, sx, sy); + cairo_set_source_surface(cr, src, 0, 0); + cairo_paint(cr); + + cairo_destroy(cr); + cairo_surface_destroy(dst); + cairo_surface_destroy(src); + free(buf); + XFlush(dpy); +} + +void xembed_destroy_icon(Display *dpy, Window icon_win) { + if (icon_win) + XDestroyWindow(dpy, icon_win); + XFlush(dpy); +} + +int xembed_poll_event(Display *dpy, Window icon_win, + int *out_x, int *out_y) { + *out_x = 0; + *out_y = 0; + + while (XPending(dpy) > 0) { + XEvent ev; + XNextEvent(dpy, &ev); + + switch (ev.type) { + case ButtonPress: + if (ev.xbutton.window == icon_win) { + *out_x = ev.xbutton.x_root; + *out_y = ev.xbutton.y_root; + if (ev.xbutton.button == Button1) + return 1; + if (ev.xbutton.button == Button3) + return 2; + } + break; + + case Expose: + if (ev.xexpose.window == icon_win && ev.xexpose.count == 0) + return 3; + break; + + case DestroyNotify: + if (ev.xdestroywindow.window == icon_win) + return -1; + break; + + case ConfigureNotify: + if (ev.xconfigure.window == icon_win) { + *out_x = ev.xconfigure.width; + *out_y = ev.xconfigure.height; + return 4; + } + break; + + case ReparentNotify: + /* Tray manager reparented us — this is expected after docking. */ + break; + + default: + break; + } + } + + return 0; +} + +/* --- GTK3 popup window menu support --- */ + +/* Implemented in Go via //export */ +extern void goMenuItemClicked(int id); + +/* The popup window, reused across invocations. */ +static GtkWidget *popup_win = NULL; + +typedef struct { + xembed_menu_item *items; + int count; + int x, y; +} popup_data; + +static void free_popup_data(popup_data *pd) { + if (!pd) return; + for (int i = 0; i < pd->count; i++) + free((void *)pd->items[i].label); + free(pd->items); + free(pd); +} + +static void on_button_clicked(GtkButton *btn, gpointer user_data) { + int id = GPOINTER_TO_INT(user_data); + if (popup_win) + gtk_widget_hide(popup_win); + goMenuItemClicked(id); +} + +static void on_check_toggled(GtkToggleButton *btn, gpointer user_data) { + int id = GPOINTER_TO_INT(user_data); + if (popup_win) + gtk_widget_hide(popup_win); + goMenuItemClicked(id); +} + +static gboolean on_popup_focus_out(GtkWidget *widget, GdkEvent *event, + gpointer user_data) { + gtk_widget_hide(widget); + return FALSE; +} + +static gboolean popup_menu_idle(gpointer user_data) { + popup_data *pd = (popup_data *)user_data; + + /* Destroy old popup if it exists. */ + if (popup_win) { + gtk_widget_destroy(popup_win); + popup_win = NULL; + } + + popup_win = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_type_hint(GTK_WINDOW(popup_win), + GDK_WINDOW_TYPE_HINT_POPUP_MENU); + gtk_window_set_decorated(GTK_WINDOW(popup_win), FALSE); + gtk_window_set_resizable(GTK_WINDOW(popup_win), FALSE); + gtk_window_set_skip_taskbar_hint(GTK_WINDOW(popup_win), TRUE); + gtk_window_set_skip_pager_hint(GTK_WINDOW(popup_win), TRUE); + gtk_window_set_keep_above(GTK_WINDOW(popup_win), TRUE); + + /* Close on focus loss. */ + g_signal_connect(popup_win, "focus-out-event", + G_CALLBACK(on_popup_focus_out), NULL); + + GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_container_add(GTK_CONTAINER(popup_win), vbox); + + for (int i = 0; i < pd->count; i++) { + xembed_menu_item *mi = &pd->items[i]; + + if (mi->is_separator) { + GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); + gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 2); + continue; + } + + if (mi->is_check) { + GtkWidget *chk = gtk_check_button_new_with_label( + mi->label ? mi->label : ""); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), mi->checked); + gtk_widget_set_sensitive(chk, mi->enabled); + g_signal_connect(chk, "toggled", + G_CALLBACK(on_check_toggled), + GINT_TO_POINTER(mi->id)); + gtk_box_pack_start(GTK_BOX(vbox), chk, FALSE, FALSE, 0); + } else { + GtkWidget *btn = gtk_button_new_with_label( + mi->label ? mi->label : ""); + gtk_widget_set_sensitive(btn, mi->enabled); + gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE); + /* Left-align label. */ + GtkWidget *label = gtk_bin_get_child(GTK_BIN(btn)); + if (label) + gtk_label_set_xalign(GTK_LABEL(label), 0.0); + g_signal_connect(btn, "clicked", + G_CALLBACK(on_button_clicked), + GINT_TO_POINTER(mi->id)); + gtk_box_pack_start(GTK_BOX(vbox), btn, FALSE, FALSE, 0); + } + } + + gtk_widget_show_all(popup_win); + + /* Position the window above the click point (menu grows upward from tray). */ + gint win_w, win_h; + gtk_window_get_size(GTK_WINDOW(popup_win), &win_w, &win_h); + int final_x = pd->x - win_w / 2; + int final_y = pd->y - win_h; + if (final_x < 0) final_x = 0; + if (final_y < 0) final_y = pd->y; /* fallback: below click */ + gtk_window_move(GTK_WINDOW(popup_win), final_x, final_y); + + /* Grab focus so focus-out-event works. */ + gtk_window_present(GTK_WINDOW(popup_win)); + + free_popup_data(pd); + return G_SOURCE_REMOVE; +} + +void xembed_show_popup_menu(xembed_menu_item *items, int count, + xembed_menu_click_cb cb, int x, int y) { + (void)cb; + popup_data *pd = (popup_data *)calloc(1, sizeof(popup_data)); + pd->items = (xembed_menu_item *)calloc(count, sizeof(xembed_menu_item)); + pd->count = count; + pd->x = x; + pd->y = y; + + for (int i = 0; i < count; i++) { + pd->items[i] = items[i]; + if (items[i].label) + pd->items[i].label = strdup(items[i].label); + } + + g_idle_add(popup_menu_idle, pd); +} diff --git a/client/ui-wails/xembed_tray_linux.h b/client/ui-wails/xembed_tray_linux.h new file mode 100644 index 000000000..1fcb151b8 --- /dev/null +++ b/client/ui-wails/xembed_tray_linux.h @@ -0,0 +1,71 @@ +#ifndef XEMBED_TRAY_H +#define XEMBED_TRAY_H + +#include + +// xembed_default_screen wraps the DefaultScreen macro for CGo. +static inline int xembed_default_screen(Display *dpy) { + return DefaultScreen(dpy); +} + +// xembed_find_tray returns the selection owner window for +// _NET_SYSTEM_TRAY_S{screen}, or 0 if no XEmbed tray manager exists. +Window xembed_find_tray(Display *dpy, int screen); + +// xembed_get_icon_size queries _NET_SYSTEM_TRAY_ICON_SIZE from the tray +// manager window. Returns the size in pixels, or 0 if not set. +int xembed_get_icon_size(Display *dpy, Window tray_mgr); + +// xembed_create_icon creates a tray icon window of the given size, +// sets _XEMBED_INFO, and returns the window ID. +// tray_mgr is the tray manager window; its _NET_SYSTEM_TRAY_VISUAL +// property is queried to obtain a 32-bit ARGB visual for transparency. +Window xembed_create_icon(Display *dpy, int screen, int size, Window tray_mgr); + +// xembed_dock sends _NET_SYSTEM_TRAY_OPCODE SYSTEM_TRAY_REQUEST_DOCK +// to the tray manager to embed our icon window. +int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win); + +// xembed_draw_icon draws ARGB pixel data onto the icon window. +// data is in [A,R,G,B] byte order per pixel (SNI IconPixmap format). +// img_w, img_h are the source image dimensions. +// win_size is the target window dimension (square). +void xembed_draw_icon(Display *dpy, Window icon_win, int win_size, + const unsigned char *data, int img_w, int img_h); + +// xembed_destroy_icon destroys the icon window. +void xembed_destroy_icon(Display *dpy, Window icon_win); + +// xembed_poll_event processes pending X11 events. Returns: +// 0 = no actionable event +// 1 = left button press (out_x, out_y filled) +// 2 = right button press (out_x, out_y filled) +// 3 = expose (needs redraw) +// 4 = configure (resize; out_x=width, out_y=height) +// -1 = DestroyNotify on icon window (tray died) +int xembed_poll_event(Display *dpy, Window icon_win, + int *out_x, int *out_y); + +// Callback type for menu item clicks. Called with the item's dbusmenu ID. +typedef void (*xembed_menu_click_cb)(int id); + +// xembed_popup_menu builds and shows a GTK3 popup menu. +// items is an array of menu item descriptors, count is the number of items. +// cb is called (from the GTK main thread) when an item is clicked. +// x, y are root coordinates for positioning the popup. +// This must be called from the GTK main thread (use g_idle_add). + +typedef struct { + int id; // dbusmenu item ID + const char *label; // display label (NULL for separator) + int enabled; // whether the item is clickable + int is_check; // whether this is a checkbox item + int checked; // checkbox state (0 or 1) + int is_separator;// 1 if this is a separator +} xembed_menu_item; + +// Schedule a GTK popup menu on the main thread. +void xembed_show_popup_menu(xembed_menu_item *items, int count, + xembed_menu_click_cb cb, int x, int y); + +#endif From 8b8f38de1b2a09df77171f791f3cbfbc30361c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 6 May 2026 16:55:52 +0200 Subject: [PATCH 53/80] [client/ui-wails] Show GUI and daemon versions in the About submenu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the legacy Fyne UI's two disabled "GUI: x.y.z" / "Daemon: a.b.c" entries under About so users (and support) can read the running versions from the tray. The GUI line is baked in at build time via version.NetbirdVersion() — the same -ldflags chain the rest of the repo uses. The daemon line starts as "—" and is rewritten in applyStatus on every Status snapshot whose DaemonVersion differs from the last one we recorded, so a daemon restart with a new build (e.g. after an enforced update) updates the menu automatically. Drive-by: rename the local variable that shadowed the version package in handleUpdate so the import resolves cleanly. --- client/ui-wails/tray.go | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/client/ui-wails/tray.go b/client/ui-wails/tray.go index cf7d99c8d..eb4c18586 100644 --- a/client/ui-wails/tray.go +++ b/client/ui-wails/tray.go @@ -16,6 +16,7 @@ import ( "github.com/wailsapp/wails/v3/pkg/services/notifications" "github.com/netbirdio/netbird/client/ui-wails/services" + "github.com/netbirdio/netbird/version" ) // User-facing strings exposed in the tray, OS notifications and the @@ -48,6 +49,12 @@ const ( // menuInstallVersionPrefix is rewritten with the target version when // the management server enforces the update. menuInstallVersionPrefix = "Install version " + // menuGUIVersionFmt and menuDaemonVersionFmt drive the disabled + // version-info entries under About. The daemon line is "—" until the + // first Status snapshot reports the daemon's version. + menuGUIVersionFmt = "GUI: %s" + menuDaemonVersionFmt = "Daemon: %s" + menuVersionUnknown = "—" // OS notifications. notifyUpdateTitle = "NetBird update available" @@ -96,12 +103,13 @@ type Tray struct { window *application.WebviewWindow svc TrayServices - statusItem *application.MenuItem - upItem *application.MenuItem - downItem *application.MenuItem - exitNodeItem *application.MenuItem - networksItem *application.MenuItem - updateItem *application.MenuItem + statusItem *application.MenuItem + upItem *application.MenuItem + downItem *application.MenuItem + exitNodeItem *application.MenuItem + networksItem *application.MenuItem + updateItem *application.MenuItem + daemonVersionItem *application.MenuItem mu sync.Mutex connected bool @@ -110,6 +118,7 @@ type Tray struct { updateEnforced bool exitNodes []string lastStatus string + lastDaemonVersion string notificationsEnabled bool activeProfile string activeUsername string @@ -208,6 +217,11 @@ func (t *Tray) buildMenu() *application.Menu { _ = t.app.Browser.OpenURL(urlGitHubRepo) }) about.Add(menuDocumentation).SetEnabled(false) + // Disabled informational entries: the GUI version is baked in at + // build time via -ldflags, the daemon version comes from the first + // Status snapshot and is updated in applyStatus. + about.Add(fmt.Sprintf(menuGUIVersionFmt, version.NetbirdVersion())).SetEnabled(false) + t.daemonVersionItem = about.Add(fmt.Sprintf(menuDaemonVersionFmt, menuVersionUnknown)).SetEnabled(false) // Hidden until the daemon emits EventUpdateAvailable. The label is // rewritten in onUpdateAvailable to match the legacy Fyne UI: // menuDownloadLatestVersion for opt-in, menuInstallVersionPrefix+version @@ -353,7 +367,7 @@ func (t *Tray) onUpdateAvailable(ev *application.CustomEvent) { func (t *Tray) handleUpdate() { t.mu.Lock() enforced := t.updateEnforced - version := t.updateVersion + updateVersion := t.updateVersion t.mu.Unlock() if !enforced { @@ -366,8 +380,8 @@ func (t *Tray) handleUpdate() { // RPC the /update page is polling. if t.window != nil { url := "/#/update" - if version != "" { - url += "?version=" + version + if updateVersion != "" { + url += "?version=" + updateVersion } t.window.SetURL(url) t.window.Show() @@ -419,8 +433,12 @@ func (t *Tray) applyStatus(st services.Status) { // flag in onSessionExpire. sessionExpiredEnter := strings.EqualFold(st.Status, statusSessionExpired) && !strings.EqualFold(t.lastStatus, statusSessionExpired) + daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion t.connected = connected t.lastStatus = st.Status + if daemonVersionChanged { + t.lastDaemonVersion = st.DaemonVersion + } exitNodes := exitNodesFromStatus(st) exitNodesChanged := !equalStrings(exitNodes, t.exitNodes) @@ -442,6 +460,9 @@ func (t *Tray) applyStatus(st services.Status) { if exitNodesChanged { t.rebuildExitNodes(exitNodes) } + if daemonVersionChanged && t.daemonVersionItem != nil { + t.daemonVersionItem.SetLabel(fmt.Sprintf(menuDaemonVersionFmt, st.DaemonVersion)) + } if sessionExpiredEnter { t.handleSessionExpired() } From f23aaa9ae7097c3f47e50efe0f418f40a90fd4d7 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 6 May 2026 17:14:11 +0200 Subject: [PATCH 54/80] [client] iOS: structured ResolvedIPs collection for domain routes (#6090) * [client] iOS: structured ResolvedIPs collection for domain routes Replace comma-joined ResolvedIPs string with a gomobile-friendly ResolvedIPs collection (Add/Get/Size), mirroring the Android bridge in client/android/network_domains.go. This allows the iOS app to match domain-route resolved IPs against connected peer routes without parsing CSV strings, fixing the route status indicator for dynamic (DNS) routes. * [client] iOS: align dynamic route exposure with Android bridge For dynamic (DNS) routes the Swift side previously received "invalid Prefix" as the Network value, forcing UI code to special-case that sentinel. The Android bridge uses Domains.SafeString() instead so peer.routes entries (which also derive from Domains.SafeString()) match directly. Mirror that here. Also fix the resolved IP lookup: resolvedDomains is keyed by the resolved domain (e.g. api.ipify.org), not the configured pattern (e.g. *.ipify.org). Group entries by ParentDomain like the daemon does in client/server/network.go, so wildcard route patterns get their resolved IPs populated. --- client/ios/NetBirdSDK/client.go | 43 ++++++++++++++++++++++----------- client/ios/NetBirdSDK/routes.go | 29 +++++++++++++++++++++- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 043673904..a616f9533 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -413,25 +413,40 @@ func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo) *RoutesSelectionDetails { var routeSelection []RoutesSelectionInfo for _, r := range routes { - domainList := make([]DomainInfo, 0) + // resolvedDomains is keyed by the resolved domain (e.g. api.ipify.org), + // not the configured pattern (e.g. *.ipify.org). Group entries whose + // ParentDomain belongs to this route, mirroring the daemon logic in + // client/server/network.go. + domainList := make([]DomainInfo, 0, len(r.Domains)) + domainIndex := make(map[domain.Domain]int, len(r.Domains)) for _, d := range r.Domains { - domainResp := DomainInfo{ - Domain: d.SafeString(), - } - - if info, exists := resolvedDomains[d]; exists { - var ipStrings []string - for _, prefix := range info.Prefixes { - ipStrings = append(ipStrings, prefix.Addr().String()) - } - domainResp.ResolvedIPs = strings.Join(ipStrings, ", ") - } - domainList = append(domainList, domainResp) + domainIndex[d] = len(domainList) + domainList = append(domainList, DomainInfo{Domain: d.SafeString()}) } + + for _, info := range resolvedDomains { + idx, ok := domainIndex[info.ParentDomain] + if !ok { + continue + } + for _, prefix := range info.Prefixes { + domainList[idx].AddResolvedIP(prefix.Addr().String()) + } + } + domainDetails := DomainDetails{items: domainList} + + // For dynamic (DNS) routes, expose the joined domain pattern as the + // Network value so it matches the peer.routes entries on the Swift + // side (mirroring the Android bridge in client/android/client.go). + netStr := r.Network.String() + if len(r.Domains) > 0 { + netStr = r.Domains.SafeString() + } + routeSelection = append(routeSelection, RoutesSelectionInfo{ ID: r.NetID, - Network: r.Network.String(), + Network: netStr, Domains: &domainDetails, Selected: r.Selected, }) diff --git a/client/ios/NetBirdSDK/routes.go b/client/ios/NetBirdSDK/routes.go index 7b84d6e1c..025313bfa 100644 --- a/client/ios/NetBirdSDK/routes.go +++ b/client/ios/NetBirdSDK/routes.go @@ -34,7 +34,34 @@ type DomainDetails struct { type DomainInfo struct { Domain string - ResolvedIPs string + resolvedIPs ResolvedIPs +} + +func (d *DomainInfo) AddResolvedIP(ipAddress string) { + d.resolvedIPs.Add(ipAddress) +} + +func (d *DomainInfo) GetResolvedIPs() *ResolvedIPs { + return &d.resolvedIPs +} + +type ResolvedIPs struct { + items []string +} + +func (r *ResolvedIPs) Add(ipAddress string) { + r.items = append(r.items, ipAddress) +} + +func (r *ResolvedIPs) Get(i int) string { + if i < 0 || i >= len(r.items) { + return "" + } + return r.items[i] +} + +func (r *ResolvedIPs) Size() int { + return len(r.items) } // Add new PeerInfo to the collection From 68c38247f1e724e3de3589453999a4371ae31a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 6 May 2026 17:17:54 +0200 Subject: [PATCH 55/80] [client/ui-wails] Add submenu support to the XEmbed tray popup Recursively walk dbusmenu children-display="submenu" entries when flattening the SNI menu so the GTK popup can render nested items. The C side renders submenu folders as labeled buttons that open a child popup window aligned to the anchor row, kept on-screen with horizontal flipping; the top-level popup no longer self-destructs when focus transfers to one of its own submenus. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/ui-wails/xembed_host_linux.go | 157 ++++++++++----- client/ui-wails/xembed_tray_linux.c | 279 +++++++++++++++++++++------ client/ui-wails/xembed_tray_linux.h | 6 +- 3 files changed, 336 insertions(+), 106 deletions(-) diff --git a/client/ui-wails/xembed_host_linux.go b/client/ui-wails/xembed_host_linux.go index 33d247b18..1565e0852 100644 --- a/client/ui-wails/xembed_host_linux.go +++ b/client/ui-wails/xembed_host_linux.go @@ -8,6 +8,7 @@ package main #include "xembed_tray_linux.h" #include #include +#include */ import "C" @@ -28,17 +29,30 @@ var ( activeMenuHostMu sync.Mutex ) -//export goMenuItemClicked -func goMenuItemClicked(id C.int) { - activeMenuHostMu.Lock() - h := activeMenuHost - activeMenuHostMu.Unlock() - - if h != nil { - go h.sendMenuEvent(int32(id)) - } +// menuItemInfo is the Go-side representation of one popup menu entry, +// flattened from a dbusMenuLayout tree before it is handed to the C +// popup builder. Submenus populate children; leaves leave it nil. +type menuItemInfo struct { + id int32 + label string + enabled bool + isCheck bool + checked bool + isSeparator bool + children []menuItemInfo } +// dbusMenuLayout mirrors the (ia{sv}av) result returned by +// com.canonical.dbusmenu.GetLayout. The Children variants each wrap a +// nested dbusMenuLayout; we decode them lazily in flattenMenu. +type dbusMenuLayout struct { + ID int32 + Properties map[string]dbus.Variant + Children []dbus.Variant +} + + + // xembedHost manages one XEmbed tray icon for an SNI item. type xembedHost struct { conn *dbus.Conn @@ -58,6 +72,23 @@ type xembedHost struct { stopCh chan struct{} } +// goMenuItemClicked is the C callback invoked from the GTK main thread +// when the user activates a popup-menu entry. C callbacks cannot carry +// Go pointers, so the active xembedHost is looked up through the +// activeMenuHost global instead. //export makes this symbol visible to +// the C side; the function must therefore live in package main. +// +//export goMenuItemClicked +func goMenuItemClicked(id C.int) { + activeMenuHostMu.Lock() + h := activeMenuHost + activeMenuHostMu.Unlock() + + if h != nil { + go h.sendMenuEvent(int32(id)) + } +} + // newXembedHost creates an XEmbed tray icon for the given SNI item. // Returns an error if no XEmbed tray manager is available (graceful fallback). func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) { @@ -253,25 +284,16 @@ func (h *xembedHost) contextMenu(x, y int32) { return } - // Build C menu item array. - cItems := make([]C.xembed_menu_item, len(items)) - cLabels := make([]*C.char, len(items)) // track for freeing - for i, mi := range items { - cItems[i].id = C.int(mi.id) - cItems[i].enabled = boolToInt(mi.enabled) - cItems[i].is_check = boolToInt(mi.isCheck) - cItems[i].checked = boolToInt(mi.checked) - cItems[i].is_separator = boolToInt(mi.isSeparator) - if mi.label != "" { - cLabels[i] = C.CString(mi.label) - cItems[i].label = cLabels[i] - } - } + // Build a C-allocated tree from the Go menu. xembed_show_popup_menu + // deep-copies into its own buffer (so it can outlive this stack + // frame), but it expects valid C strings + pointers in the caller's + // array — we still have to walk the items on the Go side and build + // matching C.xembed_menu_item nodes recursively. + var allocs []unsafe.Pointer + cItems := buildCItems(items, &allocs) defer func() { - for _, p := range cLabels { - if p != nil { - C.free(unsafe.Pointer(p)) - } + for _, p := range allocs { + C.free(p) } }() @@ -280,26 +302,10 @@ func (h *xembedHost) contextMenu(x, y int32) { activeMenuHost = h activeMenuHostMu.Unlock() - C.xembed_show_popup_menu(&cItems[0], C.int(len(cItems)), + C.xembed_show_popup_menu(cItems, C.int(len(items)), nil, C.int(x), C.int(y)) } -// dbusMenuLayout represents a com.canonical.dbusmenu layout item. -type dbusMenuLayout struct { - ID int32 - Properties map[string]dbus.Variant - Children []dbus.Variant -} - -type menuItemInfo struct { - id int32 - label string - enabled bool - isCheck bool - checked bool - isSeparator bool -} - func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo { var items []menuItemInfo @@ -352,6 +358,16 @@ func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo { } } + // Recurse into nested submenus. The dbusmenu spec marks a folder + // item with children-display=="submenu"; the children are already + // in child.Children because GetLayout was called with + // recursionDepth=-1 (all levels). + if v, ok := child.Properties["children-display"]; ok { + if s, ok := v.Value().(string); ok && s == "submenu" { + mi.children = h.flattenMenu(child) + } + } + items = append(items, mi) } @@ -369,13 +385,6 @@ func (h *xembedHost) sendMenuEvent(id int32) { } } -func boolToInt(b bool) C.int { - if b { - return 1 - } - return 0 -} - func (h *xembedHost) stop() { select { case <-h.stopCh: @@ -387,3 +396,49 @@ func (h *xembedHost) stop() { C.xembed_destroy_icon(h.dpy, h.iconWin) C.XCloseDisplay(h.dpy) } + +// buildCItems recursively translates Go menuItemInfo slices into a +// C-allocated array of xembed_menu_item suitable for passing across the +// Cgo boundary. The C side deep-copies the structure when it stages +// the popup, so any transient labels/children we allocate here can be +// released as soon as xembed_show_popup_menu returns. Every malloc is +// recorded in *allocs so the caller can free it via a single deferred +// loop. Returns nil for empty slices. +func buildCItems(items []menuItemInfo, allocs *[]unsafe.Pointer) *C.xembed_menu_item { + if len(items) == 0 { + return nil + } + size := C.size_t(len(items)) * C.size_t(unsafe.Sizeof(C.xembed_menu_item{})) + arr := C.malloc(size) + *allocs = append(*allocs, arr) + C.memset(arr, 0, size) + + slice := (*[1 << 16]C.xembed_menu_item)(arr)[:len(items):len(items)] + for i, mi := range items { + slice[i].id = C.int(mi.id) + slice[i].enabled = boolToInt(mi.enabled) + slice[i].is_check = boolToInt(mi.isCheck) + slice[i].checked = boolToInt(mi.checked) + slice[i].is_separator = boolToInt(mi.isSeparator) + if mi.label != "" { + cstr := C.CString(mi.label) + *allocs = append(*allocs, unsafe.Pointer(cstr)) + slice[i].label = cstr + } + if len(mi.children) > 0 { + slice[i].children = buildCItems(mi.children, allocs) + slice[i].child_count = C.int(len(mi.children)) + } + } + + return (*C.xembed_menu_item)(arr) +} + +// boolToInt converts a Go bool to the C int the dbusmenu C API uses +// for boolean flags. +func boolToInt(b bool) C.int { + if b { + return 1 + } + return 0 +} diff --git a/client/ui-wails/xembed_tray_linux.c b/client/ui-wails/xembed_tray_linux.c index 2db6a4e45..ac4344978 100644 --- a/client/ui-wails/xembed_tray_linux.c +++ b/client/ui-wails/xembed_tray_linux.c @@ -246,8 +246,11 @@ int xembed_poll_event(Display *dpy, Window icon_win, /* Implemented in Go via //export */ extern void goMenuItemClicked(int id); -/* The popup window, reused across invocations. */ +/* The top-level popup window, reused across invocations. Submenu + popups are tracked in a separate list so they all close when the + top-level closes. */ static GtkWidget *popup_win = NULL; +static GList *submenu_popups = NULL; /* list of GtkWidget* */ typedef struct { xembed_menu_item *items; @@ -255,38 +258,219 @@ typedef struct { int x, y; } popup_data; +/* Deep-free a heap-owned xembed_menu_item array (label + children). */ +static void free_items(xembed_menu_item *items, int count) { + if (!items) return; + for (int i = 0; i < count; i++) { + free((void *)items[i].label); + free_items(items[i].children, items[i].child_count); + } + free(items); +} + static void free_popup_data(popup_data *pd) { if (!pd) return; - for (int i = 0; i < pd->count; i++) - free((void *)pd->items[i].label); - free(pd->items); + free_items(pd->items, pd->count); free(pd); } -static void on_button_clicked(GtkButton *btn, gpointer user_data) { - int id = GPOINTER_TO_INT(user_data); - if (popup_win) +/* Close every popup window — top-level plus any open submenus. + Called when the user clicks an actionable item or focus leaves the + top-level window. */ +static void close_all_popups(void) { + for (GList *l = submenu_popups; l; l = l->next) { + gtk_widget_destroy(GTK_WIDGET(l->data)); + } + g_list_free(submenu_popups); + submenu_popups = NULL; + + if (popup_win) { gtk_widget_hide(popup_win); + } +} + +static void on_button_clicked(GtkButton *btn, gpointer user_data) { + (void)btn; + int id = GPOINTER_TO_INT(user_data); + close_all_popups(); goMenuItemClicked(id); } static void on_check_toggled(GtkToggleButton *btn, gpointer user_data) { + (void)btn; int id = GPOINTER_TO_INT(user_data); - if (popup_win) - gtk_widget_hide(popup_win); + close_all_popups(); goMenuItemClicked(id); } static gboolean on_popup_focus_out(GtkWidget *widget, GdkEvent *event, gpointer user_data) { - gtk_widget_hide(widget); + (void)widget; (void)event; (void)user_data; + /* Don't tear the menu down when the top-level loses focus to one of + its own submenu popups. close_all_popups() would destroy the + submenu we just opened. The submenu's own focus-out handler will + close it (and us) when focus leaves the popup tree. */ + if (submenu_popups != NULL) + return FALSE; + close_all_popups(); return FALSE; } +/* Forward declaration — submenu buttons need to schedule a child popup. */ +static GtkWidget *build_menu_box(xembed_menu_item *items, int count); + +typedef struct { + xembed_menu_item *items; + int count; + GtkWidget *anchor; /* the submenu button — used to position the popup */ +} submenu_open_data; + +/* Tear-down companion for submenu_open_data: detach from submenu_popups + and destroy. Used when the focus-out handler fires on a submenu. */ +static gboolean on_submenu_focus_out(GtkWidget *widget, GdkEvent *event, + gpointer user_data) { + (void)event; (void)user_data; + submenu_popups = g_list_remove(submenu_popups, widget); + gtk_widget_destroy(widget); + return FALSE; +} + +static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) { + submenu_open_data *sd = (submenu_open_data *)user_data; + + GtkWidget *win = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_type_hint(GTK_WINDOW(win), GDK_WINDOW_TYPE_HINT_POPUP_MENU); + gtk_window_set_decorated(GTK_WINDOW(win), FALSE); + gtk_window_set_resizable(GTK_WINDOW(win), FALSE); + gtk_window_set_skip_taskbar_hint(GTK_WINDOW(win), TRUE); + gtk_window_set_skip_pager_hint(GTK_WINDOW(win), TRUE); + gtk_window_set_keep_above(GTK_WINDOW(win), TRUE); + + g_signal_connect(win, "focus-out-event", + G_CALLBACK(on_submenu_focus_out), NULL); + + GtkWidget *vbox = build_menu_box(sd->items, sd->count); + gtk_container_add(GTK_CONTAINER(win), vbox); + + /* GtkButton has no native GdkWindow of its own — gtk_widget_get_window + returns the parent popup's window. To get the button's screen-space + position we read the popup origin (ox, oy) and add the button's + allocation within the popup. */ + gint ox, oy; + gdk_window_get_origin(gtk_widget_get_window(GTK_WIDGET(btn)), &ox, &oy); + GtkAllocation alloc; + gtk_widget_get_allocation(GTK_WIDGET(btn), &alloc); + int ax = ox + alloc.x; + int ay = oy + alloc.y; + + gtk_widget_show_all(win); + gint sw, sh; + gtk_window_get_size(GTK_WINDOW(win), &sw, &sh); + + /* The parent popup grows upward from the tray, so submenu items + sit closer to the bottom of the screen than to the top. Align + the submenu's BOTTOM to the anchor button's bottom: the popup + grows upward, level with the row that opened it. Don't clamp + to the monitor top — that would re-position the submenu next + to an unrelated sibling row above the anchor. */ + int final_x = ax + alloc.width; + int final_y = ay + alloc.height - sh; + + /* Horizontal flip against the monitor under the anchor button. */ + GdkDisplay *display = gtk_widget_get_display(win); + GdkMonitor *monitor = gdk_display_get_monitor_at_point(display, ax, ay); + if (monitor) { + GdkRectangle geom; + gdk_monitor_get_geometry(monitor, &geom); + if (final_x + sw > geom.x + geom.width) + final_x = ax - sw; /* flip to the left */ + } + + gtk_window_move(GTK_WINDOW(win), final_x, final_y); + gtk_window_present(GTK_WINDOW(win)); + + submenu_popups = g_list_prepend(submenu_popups, win); +} + +/* Build a vbox of GtkWidgets for the supplied items. Used for both the + top-level popup and each submenu popup. The submenu_open_data attached + to submenu buttons is freed when the submenu_popups list is cleared + (we use the button's "destroy" signal). */ +static void on_button_destroy_free_data(GtkWidget *widget, gpointer user_data) { + (void)widget; + free(user_data); +} + +static GtkWidget *build_menu_box(xembed_menu_item *items, int count) { + GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + + for (int i = 0; i < count; i++) { + xembed_menu_item *mi = &items[i]; + + if (mi->is_separator) { + GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); + gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 2); + continue; + } + + if (mi->is_check) { + GtkWidget *chk = gtk_check_button_new_with_label( + mi->label ? mi->label : ""); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), mi->checked); + gtk_widget_set_sensitive(chk, mi->enabled); + g_signal_connect(chk, "toggled", + G_CALLBACK(on_check_toggled), + GINT_TO_POINTER(mi->id)); + gtk_box_pack_start(GTK_BOX(vbox), chk, FALSE, FALSE, 0); + continue; + } + + /* Plain button (leaf) or submenu opener. Show "Label ▸" for + submenu folders so users see they're nested. */ + const char *label_text = mi->label ? mi->label : ""; + char *display_label = NULL; + if (mi->child_count > 0 && mi->children) { + /* Compose "label ▸" (BLACK RIGHT-POINTING SMALL TRIANGLE). */ + size_t n = strlen(label_text) + 8; /* ascii + " ▸" + NUL */ + display_label = (char *)malloc(n); + snprintf(display_label, n, "%s \xE2\x96\xB8", label_text); + label_text = display_label; + } + + GtkWidget *btn = gtk_button_new_with_label(label_text); + gtk_widget_set_sensitive(btn, mi->enabled); + gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE); + GtkWidget *lbl = gtk_bin_get_child(GTK_BIN(btn)); + if (lbl) gtk_label_set_xalign(GTK_LABEL(lbl), 0.0); + + free(display_label); + + if (mi->child_count > 0 && mi->children) { + submenu_open_data *sd = + (submenu_open_data *)calloc(1, sizeof(submenu_open_data)); + sd->items = mi->children; + sd->count = mi->child_count; + sd->anchor = btn; + g_signal_connect(btn, "clicked", + G_CALLBACK(on_submenu_button_clicked), sd); + g_signal_connect(btn, "destroy", + G_CALLBACK(on_button_destroy_free_data), sd); + } else { + g_signal_connect(btn, "clicked", + G_CALLBACK(on_button_clicked), + GINT_TO_POINTER(mi->id)); + } + gtk_box_pack_start(GTK_BOX(vbox), btn, FALSE, FALSE, 0); + } + + return vbox; +} + static gboolean popup_menu_idle(gpointer user_data) { popup_data *pd = (popup_data *)user_data; - /* Destroy old popup if it exists. */ + /* Destroy old top-level (and orphan submenus) before rebuilding. */ + close_all_popups(); if (popup_win) { gtk_widget_destroy(popup_win); popup_win = NULL; @@ -305,43 +489,9 @@ static gboolean popup_menu_idle(gpointer user_data) { g_signal_connect(popup_win, "focus-out-event", G_CALLBACK(on_popup_focus_out), NULL); - GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *vbox = build_menu_box(pd->items, pd->count); gtk_container_add(GTK_CONTAINER(popup_win), vbox); - for (int i = 0; i < pd->count; i++) { - xembed_menu_item *mi = &pd->items[i]; - - if (mi->is_separator) { - GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); - gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 2); - continue; - } - - if (mi->is_check) { - GtkWidget *chk = gtk_check_button_new_with_label( - mi->label ? mi->label : ""); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), mi->checked); - gtk_widget_set_sensitive(chk, mi->enabled); - g_signal_connect(chk, "toggled", - G_CALLBACK(on_check_toggled), - GINT_TO_POINTER(mi->id)); - gtk_box_pack_start(GTK_BOX(vbox), chk, FALSE, FALSE, 0); - } else { - GtkWidget *btn = gtk_button_new_with_label( - mi->label ? mi->label : ""); - gtk_widget_set_sensitive(btn, mi->enabled); - gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE); - /* Left-align label. */ - GtkWidget *label = gtk_bin_get_child(GTK_BIN(btn)); - if (label) - gtk_label_set_xalign(GTK_LABEL(label), 0.0); - g_signal_connect(btn, "clicked", - G_CALLBACK(on_button_clicked), - GINT_TO_POINTER(mi->id)); - gtk_box_pack_start(GTK_BOX(vbox), btn, FALSE, FALSE, 0); - } - } - gtk_widget_show_all(popup_win); /* Position the window above the click point (menu grows upward from tray). */ @@ -356,24 +506,45 @@ static gboolean popup_menu_idle(gpointer user_data) { /* Grab focus so focus-out-event works. */ gtk_window_present(GTK_WINDOW(popup_win)); - free_popup_data(pd); + /* The vbox+children retain pointers into pd->items (via submenu + click handlers). free_popup_data() walks the array recursively + to release labels and children buffers — but we need to keep + the items alive while the popup is open. Defer the free until + the popup window is destroyed. */ + g_object_set_data_full(G_OBJECT(popup_win), "popup_data", pd, + (GDestroyNotify)free_popup_data); return G_SOURCE_REMOVE; } +/* Recursively deep-copy a Go-supplied items array into freshly-allocated + C memory. Each label is strdup'd, each children array is calloc'd. */ +static xembed_menu_item *copy_items(xembed_menu_item *src, int count) { + if (count <= 0 || !src) return NULL; + xembed_menu_item *dst = + (xembed_menu_item *)calloc(count, sizeof(xembed_menu_item)); + for (int i = 0; i < count; i++) { + dst[i] = src[i]; + if (src[i].label) + dst[i].label = strdup(src[i].label); + if (src[i].child_count > 0 && src[i].children) { + dst[i].children = copy_items(src[i].children, src[i].child_count); + dst[i].child_count = src[i].child_count; + } else { + dst[i].children = NULL; + dst[i].child_count = 0; + } + } + return dst; +} + void xembed_show_popup_menu(xembed_menu_item *items, int count, xembed_menu_click_cb cb, int x, int y) { (void)cb; popup_data *pd = (popup_data *)calloc(1, sizeof(popup_data)); - pd->items = (xembed_menu_item *)calloc(count, sizeof(xembed_menu_item)); + pd->items = copy_items(items, count); pd->count = count; pd->x = x; pd->y = y; - for (int i = 0; i < count; i++) { - pd->items[i] = items[i]; - if (items[i].label) - pd->items[i].label = strdup(items[i].label); - } - g_idle_add(popup_menu_idle, pd); } diff --git a/client/ui-wails/xembed_tray_linux.h b/client/ui-wails/xembed_tray_linux.h index 1fcb151b8..4dd3691ab 100644 --- a/client/ui-wails/xembed_tray_linux.h +++ b/client/ui-wails/xembed_tray_linux.h @@ -55,13 +55,17 @@ typedef void (*xembed_menu_click_cb)(int id); // x, y are root coordinates for positioning the popup. // This must be called from the GTK main thread (use g_idle_add). -typedef struct { +typedef struct xembed_menu_item { int id; // dbusmenu item ID const char *label; // display label (NULL for separator) int enabled; // whether the item is clickable int is_check; // whether this is a checkbox item int checked; // checkbox state (0 or 1) int is_separator;// 1 if this is a separator + // children + child_count populate when this item is a submenu folder + // (dbusmenu's children-display=="submenu"). NULL/0 means leaf item. + struct xembed_menu_item *children; + int child_count; } xembed_menu_item; // Schedule a GTK popup menu on the main thread. From 91c745e5e83e88c96fcce9614562963283bbd5d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 6 May 2026 17:19:55 +0200 Subject: [PATCH 56/80] [client/ui-wails] Tear down the whole tray popup tree on focus loss Replace the per-submenu focus-out handler with a shared idle-deferred recheck: when any popup loses focus, ask after the next event-loop turn whether *any* of our popups still owns toplevel focus. If none does, the user clicked outside the menu tree, so close every popup at once instead of leaking the parent. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/ui-wails/xembed_tray_linux.c | 44 +++++++++++++++++------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/client/ui-wails/xembed_tray_linux.c b/client/ui-wails/xembed_tray_linux.c index ac4344978..ae34050f0 100644 --- a/client/ui-wails/xembed_tray_linux.c +++ b/client/ui-wails/xembed_tray_linux.c @@ -303,16 +303,34 @@ static void on_check_toggled(GtkToggleButton *btn, gpointer user_data) { goMenuItemClicked(id); } +/* When any popup loses focus we want to close the entire popup tree — + unless focus moved to another window we own (e.g. opening a submenu). + focus-out fires before the corresponding focus-in on the new window, + so we defer the check to an idle callback: by then any sibling popup + has had a chance to grab focus. If none of our windows still has + toplevel focus, the user clicked outside the menu tree → tear down. */ +static gboolean any_popup_has_focus(void) { + if (popup_win && gtk_window_has_toplevel_focus(GTK_WINDOW(popup_win))) + return TRUE; + for (GList *l = submenu_popups; l; l = l->next) { + if (gtk_window_has_toplevel_focus(GTK_WINDOW(l->data))) + return TRUE; + } + return FALSE; +} + +static gboolean focus_out_recheck(gpointer user_data) { + (void)user_data; + if (!any_popup_has_focus()) { + close_all_popups(); + } + return G_SOURCE_REMOVE; +} + static gboolean on_popup_focus_out(GtkWidget *widget, GdkEvent *event, gpointer user_data) { (void)widget; (void)event; (void)user_data; - /* Don't tear the menu down when the top-level loses focus to one of - its own submenu popups. close_all_popups() would destroy the - submenu we just opened. The submenu's own focus-out handler will - close it (and us) when focus leaves the popup tree. */ - if (submenu_popups != NULL) - return FALSE; - close_all_popups(); + g_idle_add(focus_out_recheck, NULL); return FALSE; } @@ -325,16 +343,6 @@ typedef struct { GtkWidget *anchor; /* the submenu button — used to position the popup */ } submenu_open_data; -/* Tear-down companion for submenu_open_data: detach from submenu_popups - and destroy. Used when the focus-out handler fires on a submenu. */ -static gboolean on_submenu_focus_out(GtkWidget *widget, GdkEvent *event, - gpointer user_data) { - (void)event; (void)user_data; - submenu_popups = g_list_remove(submenu_popups, widget); - gtk_widget_destroy(widget); - return FALSE; -} - static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) { submenu_open_data *sd = (submenu_open_data *)user_data; @@ -347,7 +355,7 @@ static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) { gtk_window_set_keep_above(GTK_WINDOW(win), TRUE); g_signal_connect(win, "focus-out-event", - G_CALLBACK(on_submenu_focus_out), NULL); + G_CALLBACK(on_popup_focus_out), NULL); GtkWidget *vbox = build_menu_box(sd->items, sd->count); gtk_container_add(GTK_CONTAINER(win), vbox); From bb2bf673a07d2ed41b3ef3d6ae86543856aa1ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 6 May 2026 17:48:47 +0200 Subject: [PATCH 57/80] [client/ui-wails] Wire up the SSO login flow end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the Fyne client's login path: the daemon Login RPC now defaults ProfileName/Username from GetActiveProfile + the OS user and sets IsUnixDesktopClient on Linux/FreeBSD so the daemon picks the SSO browser flow. A new OpenURL service launches the user's default browser via xdg-open / open / rundll32 (Fyne's openURL helper) — the embedded WebKit's window.open silently fails for external URLs. The frontend gains a Login page that drives the full Login → window.open via OpenURL → WaitSSOLogin → Up sequence with progress states. Status surfaces a Login button while the daemon reports NeedsLogin/SessionExpired, and the tray's status row stops being a purely-decorative label: it becomes a clickable Login entry whenever re-authentication is required. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/ui-wails/services/connection.ts | 11 ++ client/ui-wails/frontend/src/App.tsx | 4 +- client/ui-wails/frontend/src/pages/Login.tsx | 108 ++++++++++++++++++ .../ui-wails/frontend/src/pages/LoginUrl.tsx | 3 +- client/ui-wails/frontend/src/pages/Status.tsx | 21 +++- client/ui-wails/services/connection.go | 62 ++++++++-- client/ui-wails/tray.go | 19 ++- 7 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 client/ui-wails/frontend/src/pages/Login.tsx diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts index bff04759e..d4d2dd761 100644 --- a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts @@ -28,6 +28,17 @@ export function Logout(p: $models.LogoutParams): $CancellablePromise { return $Call.ByID(4028053230, p); } +/** + * OpenURL launches the user's preferred browser to display url. Mirrors the + * Fyne client's openURL helper so the SSO flow can pop the verification page + * the same way as the legacy UI — WebKitGTK's window.open is blocked by the + * embedded webview, and asking the user to copy/paste defeats the point of + * SSO. Honors $BROWSER first, then falls back to the platform default. + */ +export function OpenURL(url: string): $CancellablePromise { + return $Call.ByID(4267001345, url); +} + export function Up(p: $models.UpParams): $CancellablePromise { return $Call.ByID(1178388469, p); } diff --git a/client/ui-wails/frontend/src/App.tsx b/client/ui-wails/frontend/src/App.tsx index 6d8303ca5..2dcb4464a 100644 --- a/client/ui-wails/frontend/src/App.tsx +++ b/client/ui-wails/frontend/src/App.tsx @@ -9,13 +9,15 @@ import Debug from "./pages/Debug"; import Update from "./pages/Update"; import QuickActions from "./pages/QuickActions"; import LoginUrl from "./pages/LoginUrl"; +import Login from "./pages/Login"; export default function App() { return ( } /> - } /> + } /> + } /> } /> }> } /> diff --git a/client/ui-wails/frontend/src/pages/Login.tsx b/client/ui-wails/frontend/src/pages/Login.tsx new file mode 100644 index 000000000..bd2c09926 --- /dev/null +++ b/client/ui-wails/frontend/src/pages/Login.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ExternalLink, Loader2, AlertTriangle } from "lucide-react"; +import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; +import { Button } from "../components/Button"; + +type Phase = "starting" | "browser" | "connecting" | "error"; + +export default function Login() { + const navigate = useNavigate(); + const [phase, setPhase] = useState("starting"); + const [verificationUri, setVerificationUri] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + let cancelled = false; + (async () => { + try { + const result = await Connection.Login({ + profileName: "", + username: "", + managementUrl: "", + setupKey: "", + preSharedKey: "", + hostname: "", + hint: "", + }); + if (cancelled) return; + + if (result.needsSsoLogin) { + const uri = result.verificationUriComplete || result.verificationUri; + setVerificationUri(uri); + setPhase("browser"); + if (uri) Connection.OpenURL(uri).catch(console.error); + + await Connection.WaitSSOLogin({ + userCode: result.userCode, + hostname: "", + }); + if (cancelled) return; + } + + setPhase("connecting"); + await Connection.Up({ profileName: "", username: "" }); + if (cancelled) return; + + navigate("/", { replace: true }); + } catch (e) { + if (cancelled) return; + setErrorMsg(String(e)); + setPhase("error"); + } + })(); + + return () => { + cancelled = true; + }; + }, [navigate]); + + if (phase === "error") { + return ( +
+ +

Login failed

+

{errorMsg}

+ +
+ ); + } + + if (phase === "browser") { + return ( +
+

Continue in your browser

+

+ A browser tab should have opened. Sign in there — this window will + continue automatically once you're done. +

+ {verificationUri && ( + + )} +

+ {verificationUri} +

+
+ + Waiting for sign-in… +
+
+ ); + } + + const message = + phase === "connecting" ? "Bringing the connection up…" : "Starting login…"; + return ( +
+ +

{message}

+
+ ); +} diff --git a/client/ui-wails/frontend/src/pages/LoginUrl.tsx b/client/ui-wails/frontend/src/pages/LoginUrl.tsx index 71c8e88a1..6841b92a4 100644 --- a/client/ui-wails/frontend/src/pages/LoginUrl.tsx +++ b/client/ui-wails/frontend/src/pages/LoginUrl.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { ExternalLink } from "lucide-react"; +import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; import { Button } from "../components/Button"; export default function LoginUrl() { @@ -24,7 +25,7 @@ export default function LoginUrl() {

Open the following URL to finish signing in.

- diff --git a/client/ui-wails/frontend/src/pages/Status.tsx b/client/ui-wails/frontend/src/pages/Status.tsx index ec0533568..cd40dc58a 100644 --- a/client/ui-wails/frontend/src/pages/Status.tsx +++ b/client/ui-wails/frontend/src/pages/Status.tsx @@ -1,4 +1,5 @@ -import { CheckCircle2, Circle, Loader2, AlertTriangle, Power } from "lucide-react"; +import { CheckCircle2, Circle, Loader2, AlertTriangle, Power, LogIn } from "lucide-react"; +import { useNavigate } from "react-router-dom"; import { useStatus } from "../hooks/useStatus"; import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; import type { SystemEvent } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; @@ -8,11 +9,17 @@ import { cn } from "../lib/cn"; export default function Status() { const { status, error } = useStatus(); + const navigate = useNavigate(); const connState = status?.status ?? "Disconnected"; const connected = connState === "Connected"; const connecting = connState === "Connecting"; + // The daemon reports "NeedsLogin" on a fresh install or after a session + // expires. Show a Login button instead of the plain Connect button — Connect + // (Up) without a valid session would fail anyway. + const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired"; + const login = () => navigate("/login"); const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error); const disconnect = () => Connection.Down().catch(console.error); @@ -29,9 +36,15 @@ export default function Status() {
- + {needsLogin ? ( + + ) : ( + + )} diff --git a/client/ui-wails/services/connection.go b/client/ui-wails/services/connection.go index 282bd04f7..0af68028e 100644 --- a/client/ui-wails/services/connection.go +++ b/client/ui-wails/services/connection.go @@ -4,6 +4,11 @@ package services import ( "context" + "fmt" + "os" + "os/exec" + "os/user" + "runtime" "github.com/netbirdio/netbird/client/proto" ) @@ -59,16 +64,38 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err if err != nil { return LoginResult{}, err } + + // Mirror the Fyne client's defaulting: when the frontend doesn't supply + // profile / username, fall back to the daemon's active profile and the + // current OS user. The flag matches the Fyne ui's IsUnixDesktopClient + // condition so the daemon knows we can render an SSO browser flow. + profileName := p.ProfileName + username := p.Username + if profileName == "" { + if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil { + profileName = active.GetProfileName() + if username == "" { + username = active.GetUsername() + } + } + } + if username == "" { + if u, uerr := user.Current(); uerr == nil { + username = u.Username + } + } + req := &proto.LoginRequest{ - ManagementUrl: p.ManagementURL, - SetupKey: p.SetupKey, - Hostname: p.Hostname, + ManagementUrl: p.ManagementURL, + SetupKey: p.SetupKey, + Hostname: p.Hostname, + IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd", } - if p.ProfileName != "" { - req.ProfileName = ptrStr(p.ProfileName) + if profileName != "" { + req.ProfileName = ptrStr(profileName) } - if p.Username != "" { - req.Username = ptrStr(p.Username) + if username != "" { + req.Username = ptrStr(username) } if p.PreSharedKey != "" { req.OptionalPreSharedKey = ptrStr(p.PreSharedKey) @@ -129,6 +156,27 @@ func (s *Connection) Down(ctx context.Context) error { return err } +// OpenURL launches the user's preferred browser to display url. Mirrors the +// Fyne client's openURL helper so the SSO flow can pop the verification page +// the same way as the legacy UI — WebKitGTK's window.open is blocked by the +// embedded webview, and asking the user to copy/paste defeats the point of +// SSO. Honors $BROWSER first, then falls back to the platform default. +func (s *Connection) OpenURL(url string) error { + if browser := os.Getenv("BROWSER"); browser != "" { + return exec.Command(browser, url).Start() + } + switch runtime.GOOS { + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() + case "linux", "freebsd": + return exec.Command("xdg-open", url).Start() + default: + return fmt.Errorf("unsupported platform") + } +} + func (s *Connection) Logout(ctx context.Context, p LogoutParams) error { cli, err := s.conn.Client() if err != nil { diff --git a/client/ui-wails/tray.go b/client/ui-wails/tray.go index eb4c18586..063a4c5ee 100644 --- a/client/ui-wails/tray.go +++ b/client/ui-wails/tray.go @@ -75,6 +75,10 @@ const ( // Daemon status string for an SSO session that has expired and needs // re-authentication. Mirrors internal.StatusSessionExpired. statusSessionExpired = "SessionExpired" + // statusNeedsLogin is what the daemon publishes before the user has + // completed an SSO authentication on this profile. Mirrors + // internal.StatusNeedsLogin. + statusNeedsLogin = "NeedsLogin" // External URLs. urlGitHubRepo = "https://github.com/netbirdio/netbird" @@ -182,7 +186,13 @@ func (t *Tray) ShowWindow() { func (t *Tray) buildMenu() *application.Menu { menu := application.NewMenu() - t.statusItem = menu.Add(menuStatusDisconnected).SetEnabled(false) + // statusItem doubles as the "Login" entry once the daemon reports + // NeedsLogin/SessionExpired — applyStatus toggles its enabled state and + // label. The click handler is harmless while disabled, so we wire it + // up unconditionally rather than swapping items at runtime. + t.statusItem = menu.Add(menuStatusDisconnected). + OnClick(func(*application.Context) { t.openRoute("/login") }). + SetEnabled(false) menu.AddSeparator() // On Linux the tray icon's left-click handler is intentionally unbound @@ -447,11 +457,16 @@ func (t *Tray) applyStatus(st services.Status) { if iconChanged { t.applyIcon() + needsLogin := strings.EqualFold(st.Status, statusNeedsLogin) || + strings.EqualFold(st.Status, statusSessionExpired) if t.statusItem != nil { + // When the daemon needs re-authentication the status row turns + // into the actionable Login entry — Connect would only fail. t.statusItem.SetLabel(st.Status) + t.statusItem.SetEnabled(needsLogin) } if t.upItem != nil { - t.upItem.SetEnabled(!connected) + t.upItem.SetEnabled(!connected && !needsLogin) } if t.downItem != nil { t.downItem.SetEnabled(connected) From 05ee4e52b8927916aca57adf85ac96047b538047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 6 May 2026 17:59:50 +0200 Subject: [PATCH 58/80] [client/ui-wails] Make the SSO login flow recoverable from a stuck state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pending WaitSSOLogin parks the daemon on an OAuth UserCode forever once the user closes the browser without completing the flow. The frontend can't unblock that on its own — it needs the daemon to fire its own actCancel(). Three fixes work together: - Login() now issues a Down() before kicking off the new flow so a previously-stuck WaitSSOLogin is unwedged before we ask the daemon for fresh OAuth info. - The Login page's Cancel button calls Down() before navigating away, so abandoning the flow mid-browser actually settles the daemon's in-flight WaitSSOLogin instead of leaving it pinned. - Status keeps the Login button visible whenever we aren't Connected (including Connecting), so a UI restart that finds the daemon stuck in Connecting still has a one-click recovery path. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/ui-wails/frontend/src/pages/Login.tsx | 75 ++++++++++++++++--- client/ui-wails/frontend/src/pages/Status.tsx | 13 +++- client/ui-wails/services/connection.go | 11 +++ 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/client/ui-wails/frontend/src/pages/Login.tsx b/client/ui-wails/frontend/src/pages/Login.tsx index bd2c09926..439096f47 100644 --- a/client/ui-wails/frontend/src/pages/Login.tsx +++ b/client/ui-wails/frontend/src/pages/Login.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { ExternalLink, Loader2, AlertTriangle } from "lucide-react"; +import { ExternalLink, Loader2, AlertTriangle, X, RotateCcw } from "lucide-react"; import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; import { Button } from "../components/Button"; @@ -11,13 +11,17 @@ export default function Login() { const [phase, setPhase] = useState("starting"); const [verificationUri, setVerificationUri] = useState(""); const [errorMsg, setErrorMsg] = useState(""); - const startedRef = useRef(false); + // attempt is bumped every time the user asks for a fresh start, which + // re-arms the useEffect below so the daemon's Login RPC is dialed again. + const [attempt, setAttempt] = useState(0); + const cancelledRef = useRef(false); useEffect(() => { - if (startedRef.current) return; - startedRef.current = true; + cancelledRef.current = false; + setPhase("starting"); + setVerificationUri(""); + setErrorMsg(""); - let cancelled = false; (async () => { try { const result = await Connection.Login({ @@ -29,7 +33,7 @@ export default function Login() { hostname: "", hint: "", }); - if (cancelled) return; + if (cancelledRef.current) return; if (result.needsSsoLogin) { const uri = result.verificationUriComplete || result.verificationUri; @@ -41,24 +45,53 @@ export default function Login() { userCode: result.userCode, hostname: "", }); - if (cancelled) return; + if (cancelledRef.current) return; } setPhase("connecting"); await Connection.Up({ profileName: "", username: "" }); - if (cancelled) return; + if (cancelledRef.current) return; navigate("/", { replace: true }); } catch (e) { - if (cancelled) return; + if (cancelledRef.current) return; setErrorMsg(String(e)); setPhase("error"); } })(); return () => { - cancelled = true; + cancelledRef.current = true; }; + }, [navigate, attempt]); + + // restart aborts any in-flight wait by toggling the cancellation flag, + // tells the daemon to drop whatever it's holding (a stale WaitSSOLogin + // can wedge the daemon for a previous UserCode), and then bumps attempt + // so the effect re-runs with a clean slate. + const restart = useCallback(async () => { + cancelledRef.current = true; + try { + await Connection.Down(); + } catch (e) { + console.error(e); + } + setAttempt((n) => n + 1); + }, []); + + // Cancel must also tell the daemon to abandon the in-flight WaitSSOLogin. + // Without Down(), the daemon stays parked on the OAuth flow's UserCode + // forever; subsequent Login calls re-use the cached flow but the user has + // no way out. Down() triggers the daemon's actCancel(), which unblocks + // WaitSSOLogin with a context-canceled error so our promise settles. + const cancel = useCallback(async () => { + cancelledRef.current = true; + try { + await Connection.Down(); + } catch (e) { + console.error(e); + } + navigate("/", { replace: true }); }, [navigate]); if (phase === "error") { @@ -67,7 +100,14 @@ export default function Login() {

Login failed

{errorMsg}

- +
+ + +
); } @@ -93,6 +133,14 @@ export default function Login() { Waiting for sign-in… +
+ + +
); } @@ -103,6 +151,9 @@ export default function Login() {

{message}

+
); } diff --git a/client/ui-wails/frontend/src/pages/Status.tsx b/client/ui-wails/frontend/src/pages/Status.tsx index cd40dc58a..abeae0476 100644 --- a/client/ui-wails/frontend/src/pages/Status.tsx +++ b/client/ui-wails/frontend/src/pages/Status.tsx @@ -15,9 +15,13 @@ export default function Status() { const connected = connState === "Connected"; const connecting = connState === "Connecting"; // The daemon reports "NeedsLogin" on a fresh install or after a session - // expires. Show a Login button instead of the plain Connect button — Connect - // (Up) without a valid session would fail anyway. + // expires; "SessionExpired" once a previously good session lapses. In both + // cases Connect would fail without a fresh SSO login. const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired"; + // Always offer Login while we aren't Connected — including Connecting, + // because a stuck Login on the daemon leaves us in Connecting forever and + // the user has no other way out. Disconnect is the manual unstick path. + const showLogin = !connected; const login = () => navigate("/login"); const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error); @@ -45,6 +49,11 @@ export default function Status() { Connect )} + {showLogin && !needsLogin && ( + + )} diff --git a/client/ui-wails/services/connection.go b/client/ui-wails/services/connection.go index 0af68028e..6be9e153f 100644 --- a/client/ui-wails/services/connection.go +++ b/client/ui-wails/services/connection.go @@ -65,6 +65,17 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err return LoginResult{}, err } + // Reset the daemon's connection loop before kicking off a new login. + // If a previous Login left a WaitSSOLogin pending (user closed the + // browser without completing the flow), the daemon stays parked on the + // old UserCode and replies with "invalid setup-key or no sso information + // provided" to a fresh Login. Calling Down first dislodges that state; + // we ignore the error since Down on an already-idle daemon is a no-op. + if _, derr := cli.Down(ctx, &proto.DownRequest{}); derr != nil { + // Down failed — likely because the daemon is already idle. Continue. + _ = derr + } + // Mirror the Fyne client's defaulting: when the frontend doesn't supply // profile / username, fall back to the daemon's active profile and the // current OS user. The flag matches the Fyne ui's IsUnixDesktopClient From cce80f82766af3a22071287a0366ec95373564a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 6 May 2026 18:00:51 +0200 Subject: [PATCH 59/80] [client/ui-wails] Drop dead freebsd branches in services/connection.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file's build constraint excludes freebsd, so the freebsd cases in IsUnixDesktopClient and OpenURL were unreachable — staticcheck (SA4032) fails the pre-push lint. Linux is the only Unix-desktop GOOS this package compiles for, so collapse both checks accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/ui-wails/services/connection.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ui-wails/services/connection.go b/client/ui-wails/services/connection.go index 6be9e153f..84b4652ba 100644 --- a/client/ui-wails/services/connection.go +++ b/client/ui-wails/services/connection.go @@ -100,7 +100,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err ManagementUrl: p.ManagementURL, SetupKey: p.SetupKey, Hostname: p.Hostname, - IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd", + IsUnixDesktopClient: runtime.GOOS == "linux", } if profileName != "" { req.ProfileName = ptrStr(profileName) @@ -181,7 +181,7 @@ func (s *Connection) OpenURL(url string) error { return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() case "darwin": return exec.Command("open", url).Start() - case "linux", "freebsd": + case "linux": return exec.Command("xdg-open", url).Start() default: return fmt.Errorf("unsupported platform") From d324a5ff482a31eae8688fca91abb7d61cd1831e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Thu, 7 May 2026 10:23:02 +0200 Subject: [PATCH 60/80] [ci] Stub frontend/dist before lint so the Wails embed pattern matches client/ui-wails/main.go embeds all:frontend/dist, which is produced by the frontend build and gitignored. Lint runs don't build the frontend, so the directory is missing in CI and golangci-lint fails the typecheck. Create a placeholder file before linting so the embed has something to match. --- .github/workflows/golangci-lint.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 99181d8c9..2c386f246 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -51,6 +51,15 @@ jobs: - name: Install dependencies if: matrix.os == 'ubuntu-latest' run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev + - name: Stub Wails frontend bundle + # client/ui-wails/main.go has //go:embed all:frontend/dist. The + # directory is produced by `pnpm run build` and is gitignored, so + # lint-only runs (no frontend toolchain) need a placeholder file + # for the embed pattern to match. + shell: bash + run: | + mkdir -p client/ui-wails/frontend/dist + touch client/ui-wails/frontend/dist/.embed-placeholder - name: golangci-lint uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 with: From 5b70989e3eb32c982fa831417dddc420db3dafc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Thu, 7 May 2026 10:35:18 +0200 Subject: [PATCH 61/80] [client/ui-wails] Make /update page faithful to the legacy auto-update dialog Adds the missing info line ("Your client version is older than the auto-update version set in Management. Updating client to: .") and replaces the spinner with the legacy 1-second dot animation (Updating./.../...). Terminal-state wording now matches the Fyne UI exactly: 15 min timeout, canceled, and "Update failed: ". Ports mapInstallError from client/ui/update.go so daemon errors that embed "deadline exceeded" / "canceled" hit the right branch instead of falling through as a generic failure. Detects the daemon dropping mid-upgrade (the legacy success signal): if GetInstallerResult fails for 5s straight, call the new Update.Quit service method to exit, mirroring app.Quit() in showInstallerResult. --- .../client/ui-wails/services/update.ts | 11 ++ client/ui-wails/frontend/src/pages/Update.tsx | 136 ++++++++++++++---- client/ui-wails/services/update.go | 15 ++ 3 files changed, 131 insertions(+), 31 deletions(-) diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts index 2f4a04289..728d3aad0 100644 --- a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts @@ -20,6 +20,17 @@ export function GetInstallerResult(): $CancellablePromise<$models.UpdateResult> }); } +/** + * Quit asks the host application to exit. The /update page calls this once + * the daemon-side installer has reported success, mirroring the legacy + * Fyne UI's app.Quit() in showInstallerResult. Schedules the actual exit + * off the calling goroutine so the JS-side caller's response can return + * before the runtime tears down. + */ +export function Quit(): $CancellablePromise { + return $Call.ByID(27817640); +} + export function Trigger(): $CancellablePromise<$models.UpdateResult> { return $Call.ByID(2415339649).then(($result: any) => { return $$createType0($result); diff --git a/client/ui-wails/frontend/src/pages/Update.tsx b/client/ui-wails/frontend/src/pages/Update.tsx index 04d9eb245..6f5c55056 100644 --- a/client/ui-wails/frontend/src/pages/Update.tsx +++ b/client/ui-wails/frontend/src/pages/Update.tsx @@ -1,61 +1,135 @@ -import { useEffect, useState } from "react"; -import { Loader2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; const TIMEOUT_MS = 15 * 60 * 1000; +const POLL_INTERVAL_MS = 2000; +// How long the daemon is allowed to be unreachable before we treat it as +// "daemon went down for the upgrade, treat as success and quit". Mirrors +// the legacy Fyne UI's branch in client/ui/update.go where a connection +// failure during polling is taken as the success signal. +const DAEMON_DOWN_GRACE_MS = 5000; + +type Phase = + | { kind: "running"; dots: number } + | { kind: "timeout" } + | { kind: "canceled" } + | { kind: "failed"; message: string }; export default function Update() { - const [done, setDone] = useState(false); - const [error, setError] = useState(null); + const [phase, setPhase] = useState({ kind: "running", dots: 1 }); + const phaseRef = useRef(phase); + phaseRef.current = phase; + + const version = new URLSearchParams( + window.location.hash.split("?")[1] ?? "", + ).get("version"); useEffect(() => { let cancelled = false; - UpdateSvc.Trigger().catch((e) => !cancelled && setError(String(e))); - const start = Date.now(); - const timer = setInterval(async () => { + let firstUnreachableAt: number | null = null; + + UpdateSvc.Trigger().catch(() => { + // The daemon may already be down (installer launched, daemon shutting + // down). Don't treat as failure here; the poll loop's daemon-down + // detection handles it. + }); + + const dotTimer = setInterval(() => { + if (cancelled) return; + setPhase((p) => + p.kind === "running" ? { kind: "running", dots: (p.dots % 3) + 1 } : p, + ); + }, 1000); + + const pollTimer = setInterval(async () => { + if (cancelled) return; + if (phaseRef.current.kind !== "running") return; + if (Date.now() - start > TIMEOUT_MS) { - setError("Update timed out."); - clearInterval(timer); + clearInterval(pollTimer); + clearInterval(dotTimer); + setPhase({ kind: "timeout" }); return; } + try { const r = await UpdateSvc.GetInstallerResult(); + firstUnreachableAt = null; if (r.success) { - setDone(true); - clearInterval(timer); - } else if (r.errorMsg) { - setError(r.errorMsg); - clearInterval(timer); + clearInterval(pollTimer); + clearInterval(dotTimer); + UpdateSvc.Quit(); + return; + } + if (r.errorMsg) { + clearInterval(pollTimer); + clearInterval(dotTimer); + setPhase(mapInstallError(r.errorMsg)); } } catch { - // installer not finished yet + // RPC failed. The daemon often goes away mid-upgrade — treat a + // sustained outage as success and quit, matching the legacy UI. + const now = Date.now(); + if (firstUnreachableAt === null) { + firstUnreachableAt = now; + } else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) { + clearInterval(pollTimer); + clearInterval(dotTimer); + UpdateSvc.Quit(); + } } - }, 2000); + }, POLL_INTERVAL_MS); return () => { cancelled = true; - clearInterval(timer); + clearInterval(dotTimer); + clearInterval(pollTimer); }; }, []); + const versionLine = version + ? `Updating client to: ${version}.` + : "Updating client."; + return (
-
- {done ? ( -

Update complete

- ) : error ? ( -

{error}

- ) : ( - <> - -

Updating…

-

- Please don't close this window. -

- - )} +
+

+ {`Your client version is older than the auto-update version set in Management.\n${versionLine}`} +

+

{statusText(phase)}

); } + +function statusText(p: Phase): string { + switch (p.kind) { + case "running": + return "Updating" + ".".repeat(p.dots); + case "timeout": + return "Update timed out. Please try again."; + case "canceled": + return "Update canceled."; + case "failed": + return "Update failed: " + p.message; + } +} + +// Mirrors mapInstallError in client/ui/update.go. The daemon's installer +// surfaces error strings rather than typed errors, so the UI sniffs the +// message to decide whether to show the timeout/canceled wording. +function mapInstallError(msg: string): Phase { + const m = msg.trim().toLowerCase(); + if (m === "") { + return { kind: "failed", message: "unknown update error" }; + } + if (m.includes("deadline exceeded") || m.includes("timeout")) { + return { kind: "timeout" }; + } + if (m.includes("canceled") || m.includes("cancelled")) { + return { kind: "canceled" }; + } + return { kind: "failed", message: msg }; +} diff --git a/client/ui-wails/services/update.go b/client/ui-wails/services/update.go index e7f9ad9c9..6e66e1112 100644 --- a/client/ui-wails/services/update.go +++ b/client/ui-wails/services/update.go @@ -4,6 +4,9 @@ package services import ( "context" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" "github.com/netbirdio/netbird/client/proto" ) @@ -24,6 +27,18 @@ func NewUpdate(conn DaemonConn) *Update { return &Update{conn: conn} } +// Quit asks the host application to exit. The /update page calls this once +// the daemon-side installer has reported success, mirroring the legacy +// Fyne UI's app.Quit() in showInstallerResult. Schedules the actual exit +// off the calling goroutine so the JS-side caller's response can return +// before the runtime tears down. +func (s *Update) Quit() { + go func() { + time.Sleep(100 * time.Millisecond) + application.Get().Quit() + }() +} + func (s *Update) Trigger(ctx context.Context) (UpdateResult, error) { cli, err := s.conn.Client() if err != nil { From 205ebcfda28d08ba692d21fb3dbbc0788310c736 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 7 May 2026 18:33:37 +0900 Subject: [PATCH 62/80] [management, client] Add IPv6 overlay support (#5631) --- .gitignore | 1 + client/android/client.go | 96 +- client/android/peer_notifier.go | 1 + client/android/preferences.go | 18 + client/android/route_command.go | 7 +- client/anonymize/anonymize.go | 17 +- client/anonymize/anonymize_test.go | 24 +- client/cmd/ssh.go | 6 +- client/cmd/ssh_test.go | 4 +- client/cmd/status.go | 14 +- client/cmd/system.go | 5 + client/cmd/up.go | 12 + client/embed/embed.go | 3 + client/firewall/iptables/acl_linux.go | 33 +- client/firewall/iptables/manager_linux.go | 275 ++- client/firewall/iptables/router_linux.go | 104 +- client/firewall/iptables/rule.go | 1 + client/firewall/iptables/state_linux.go | 30 + client/firewall/manager/firewall.go | 15 +- client/firewall/manager/routerpair.go | 18 + client/firewall/nftables/acl_linux.go | 44 +- client/firewall/nftables/addr_family_linux.go | 81 + ...al_chain_monitor_integration_linux_test.go | 76 + .../nftables/external_chain_monitor_linux.go | 199 +++ .../external_chain_monitor_linux_test.go | 137 ++ client/firewall/nftables/manager_linux.go | 394 ++++- .../firewall/nftables/manager_linux_test.go | 128 ++ client/firewall/nftables/router_linux.go | 325 ++-- client/firewall/nftables/router_linux_test.go | 189 +- .../uspfilter/allow_netbird_windows.go | 53 +- client/firewall/uspfilter/conntrack/common.go | 7 +- .../uspfilter/conntrack/common_test.go | 48 + client/firewall/uspfilter/conntrack/icmp.go | 92 +- .../firewall/uspfilter/conntrack/icmp_test.go | 36 + client/firewall/uspfilter/filter.go | 325 +++- .../firewall/uspfilter/filter_bench_test.go | 9 +- .../firewall/uspfilter/filter_filter_test.go | 345 +++- .../uspfilter/filter_routeacl_test.go | 18 +- client/firewall/uspfilter/filter_test.go | 75 +- .../firewall/uspfilter/forwarder/endpoint.go | 22 +- .../firewall/uspfilter/forwarder/forwarder.go | 293 +++- .../uspfilter/forwarder/forwarder_test.go | 162 ++ client/firewall/uspfilter/forwarder/icmp.go | 218 ++- client/firewall/uspfilter/forwarder/tcp.go | 18 +- client/firewall/uspfilter/forwarder/udp.go | 17 +- client/firewall/uspfilter/hooks_filter.go | 1 - client/firewall/uspfilter/localip.go | 135 +- .../firewall/uspfilter/localip_bench_test.go | 72 + client/firewall/uspfilter/localip_test.go | 124 +- client/firewall/uspfilter/nat.go | 185 +- client/firewall/uspfilter/nat_bench_test.go | 22 +- client/firewall/uspfilter/nat_test.go | 11 +- client/firewall/uspfilter/tracer.go | 106 +- client/iface/configurer/usp.go | 2 +- client/iface/device/adapter.go | 2 +- client/iface/device/device_android.go | 2 +- client/iface/device/device_darwin.go | 33 +- client/iface/device/device_ios.go | 7 +- client/iface/device/device_kernel_unix.go | 2 +- client/iface/device/device_netstack.go | 9 +- client/iface/device/device_usp_unix.go | 32 +- client/iface/device/device_windows.go | 33 +- client/iface/device/kernel_module.go | 8 - client/iface/device/kernel_module_freebsd.go | 18 - client/iface/device/kernel_module_nonlinux.go | 13 + client/iface/device/wg_link_freebsd.go | 27 +- client/iface/device/wg_link_linux.go | 41 +- client/iface/iface.go | 11 +- .../{iface_new_windows.go => iface_new.go} | 19 +- client/iface/iface_new_android.go | 12 +- client/iface/iface_new_darwin.go | 35 - client/iface/iface_new_freebsd.go | 41 - client/iface/iface_new_ios.go | 10 +- client/iface/iface_new_js.go | 8 +- client/iface/iface_new_linux.go | 46 +- client/iface/iface_test.go | 21 +- client/iface/netstack/tun.go | 8 +- client/iface/wgaddr/address.go | 64 +- client/iface/wgaddr/address_test_helpers.go | 10 + client/iface/wgproxy/bind/proxy.go | 25 +- client/internal/acl/manager.go | 57 +- client/internal/auth/auth.go | 1 + client/internal/connect.go | 18 +- client/internal/connect_android_default.go | 4 + client/internal/debug/debug.go | 31 +- client/internal/debug/debug_test.go | 74 +- client/internal/dns.go | 79 +- client/internal/dns/host_darwin.go | 1 + client/internal/dns/local/local.go | 13 +- client/internal/dns/network_manager_unix.go | 25 +- client/internal/dns/server.go | 2 +- client/internal/dns/server_test.go | 6 +- client/internal/dns/service.go | 4 +- client/internal/dns/service_listener.go | 11 +- client/internal/dns/systemd_linux.go | 6 +- client/internal/dns/upstream.go | 7 + client/internal/dns/upstream_android.go | 2 +- client/internal/dns/upstream_general.go | 2 +- client/internal/dns/upstream_ios.go | 44 +- client/internal/dns_test.go | 138 ++ client/internal/dnsfwd/manager.go | 1 + client/internal/ebpf/ebpf/dns_fwd_linux.go | 15 +- client/internal/ebpf/manager/manager.go | 4 +- client/internal/engine.go | 142 +- client/internal/engine_ssh.go | 40 +- client/internal/engine_test.go | 242 ++- client/internal/iface_common.go | 2 +- .../lazyconn/activity/listener_bind.go | 23 +- client/internal/listener/network_change.go | 1 + .../internal/netflow/conntrack/conntrack.go | 23 +- client/internal/netflow/logger/logger.go | 12 +- client/internal/netflow/logger/logger_test.go | 2 +- client/internal/netflow/manager.go | 7 +- client/internal/netflow/types/types.go | 3 + client/internal/peer/status.go | 10 +- client/internal/peer/status_test.go | 11 +- client/internal/profilemanager/config.go | 12 +- client/internal/relay/relay.go | 3 +- client/internal/rosenpass/manager.go | 9 +- client/internal/rosenpass/manager_test.go | 14 + client/internal/routemanager/client/client.go | 5 +- .../routemanager/client/client_bench_test.go | 2 +- .../routemanager/dnsinterceptor/handler.go | 2 +- client/internal/routemanager/dynamic/route.go | 4 +- .../routemanager/dynamic/route_ios.go | 46 +- client/internal/routemanager/fakeip/fakeip.go | 144 +- .../routemanager/fakeip/fakeip_test.go | 169 +- .../routemanager/ipfwdstate/ipfwdstate.go | 6 +- client/internal/routemanager/manager.go | 18 +- client/internal/routemanager/manager_test.go | 3 +- .../routemanager/notifier/notifier_android.go | 25 +- .../routemanager/notifier/notifier_ios.go | 13 +- .../routemanager/notifier/notifier_other.go | 2 +- client/internal/routemanager/server/server.go | 17 +- .../routemanager/systemops/systemops.go | 10 +- .../systemops/systemops_generic.go | 70 +- .../systemops/systemops_generic_test.go | 3 +- .../routemanager/systemops/systemops_linux.go | 23 +- client/ios/NetBirdSDK/client.go | 73 +- client/ios/NetBirdSDK/peer_notifier.go | 12 + client/ios/NetBirdSDK/preferences.go | 18 + client/proto/daemon.pb.go | 67 +- client/proto/daemon.proto | 6 + client/server/network.go | 41 +- client/server/server.go | 3 + client/server/setconfig_test.go | 5 + client/server/trace.go | 74 +- client/ssh/config/manager.go | 11 +- client/ssh/config/manager_test.go | 13 +- client/ssh/proxy/proxy.go | 2 +- client/ssh/server/port_forwarding.go | 29 +- client/ssh/server/server.go | 68 +- client/status/status.go | 25 + client/status/status_test.go | 11 + client/system/info.go | 4 +- client/ui/client_ui.go | 32 +- client/ui/event/event.go | 10 +- client/ui/network.go | 8 +- client/wasm/cmd/main.go | 122 +- client/wasm/internal/rdp/rdcleanpath.go | 2 +- client/wasm/internal/ssh/client.go | 18 +- combined/cmd/config.go | 2 +- .../service/manager/l4_port_test.go | 5 +- .../reverseproxy/service/manager/manager.go | 5 +- .../service/manager/manager_test.go | 11 +- .../internals/shared/grpc/conversion.go | 91 +- management/internals/shared/grpc/server.go | 12 +- management/server/account.go | 479 ++++- management/server/account/manager.go | 1 + management/server/account/manager_mock.go | 12 + management/server/account_test.go | 217 ++- management/server/activity/codes.go | 7 + management/server/group.go | 71 +- management/server/group_ipv6_test.go | 125 ++ management/server/group_test.go | 9 +- .../handlers/accounts/accounts_handler.go | 82 +- .../accounts/accounts_handler_test.go | 30 + .../http/handlers/dns/nameservers_handler.go | 21 +- .../handlers/dns/nameservers_handler_test.go | 34 + .../handlers/groups/groups_handler_test.go | 6 +- .../http/handlers/peers/peers_handler.go | 38 + .../http/handlers/peers/peers_handler_test.go | 17 +- .../http/testing/testing_tools/tools.go | 4 +- management/server/mock_server/account_mock.go | 8 + management/server/peer.go | 76 +- management/server/peer/peer.go | 56 +- management/server/peer/peer_test.go | 23 + management/server/peer_test.go | 115 +- management/server/policy_test.go | 46 +- management/server/route_test.go | 125 +- management/server/settings/manager.go | 29 + management/server/settings/manager_mock.go | 17 + management/server/store/sql_store.go | 89 +- .../store/sql_store_get_account_test.go | 11 +- management/server/store/sql_store_test.go | 84 +- .../server/store/sqlstore_bench_test.go | 3 +- management/server/store/store.go | 3 +- management/server/store/store_mock.go | 19 +- management/server/types/account.go | 119 +- management/server/types/account_components.go | 17 +- management/server/types/account_test.go | 59 +- management/server/types/firewall_rule.go | 57 +- management/server/types/firewall_rule_test.go | 197 +++ management/server/types/ipv6_endtoend_test.go | 156 ++ management/server/types/ipv6_groups_test.go | 234 +++ management/server/types/network.go | 151 +- management/server/types/network_test.go | 151 +- .../server/types/networkmap_components.go | 166 +- .../networkmap_components_correctness_test.go | 4 +- .../types/networkmap_components_test.go | 8 +- management/server/types/settings.go | 10 + management/server/user.go | 6 + proxy/cmd/proxy/cmd/debug.go | 21 +- proxy/internal/debug/client.go | 22 +- proxy/internal/debug/handler.go | 18 +- relay/test/benchmark_test.go | 2 +- relay/testec2/turn_allocator.go | 2 +- route/route.go | 61 + route/route_test.go | 108 ++ shared/management/client/grpc.go | 14 + shared/management/http/api/openapi.yml | 26 +- shared/management/http/api/types.gen.go | 20 +- shared/management/proto/management.pb.go | 1545 +++++++++-------- shared/management/proto/management.proto | 27 +- shared/netiputil/compact.go | 78 + shared/netiputil/compact_test.go | 175 ++ shared/relay/client/dialer/quic/quic.go | 2 +- upload-server/server/s3_test.go | 3 +- util/capture/text.go | 22 +- 229 files changed, 10155 insertions(+), 2816 deletions(-) create mode 100644 client/firewall/nftables/addr_family_linux.go create mode 100644 client/firewall/nftables/external_chain_monitor_integration_linux_test.go create mode 100644 client/firewall/nftables/external_chain_monitor_linux.go create mode 100644 client/firewall/nftables/external_chain_monitor_linux_test.go create mode 100644 client/firewall/uspfilter/forwarder/forwarder_test.go create mode 100644 client/firewall/uspfilter/localip_bench_test.go delete mode 100644 client/iface/device/kernel_module.go delete mode 100644 client/iface/device/kernel_module_freebsd.go create mode 100644 client/iface/device/kernel_module_nonlinux.go rename client/iface/{iface_new_windows.go => iface_new.go} (50%) delete mode 100644 client/iface/iface_new_darwin.go delete mode 100644 client/iface/iface_new_freebsd.go create mode 100644 client/iface/wgaddr/address_test_helpers.go create mode 100644 client/internal/dns_test.go create mode 100644 client/internal/rosenpass/manager_test.go create mode 100644 management/server/group_ipv6_test.go create mode 100644 management/server/types/firewall_rule_test.go create mode 100644 management/server/types/ipv6_endtoend_test.go create mode 100644 management/server/types/ipv6_groups_test.go create mode 100644 route/route_test.go create mode 100644 shared/netiputil/compact.go create mode 100644 shared/netiputil/compact_test.go diff --git a/.gitignore b/.gitignore index a0f128933..783fe77f3 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ infrastructure_files/setup-*.env vendor/ /netbird client/netbird-electron/ +management/server/types/testdata/ diff --git a/client/android/client.go b/client/android/client.go index 37e17a363..99ccdf393 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -301,10 +301,11 @@ func (c *Client) PeersList() *PeerInfoArray { peerInfos := make([]PeerInfo, len(fullStatus.Peers)) for n, p := range fullStatus.Peers { pi := PeerInfo{ - p.IP, - p.FQDN, - int(p.ConnStatus), - PeerRoutes{routes: maps.Keys(p.GetRoutes())}, + IP: p.IP, + IPv6: p.IPv6, + FQDN: p.FQDN, + ConnStatus: int(p.ConnStatus), + Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())}, } peerInfos[n] = pi } @@ -336,43 +337,84 @@ func (c *Client) Networks() *NetworkArray { return nil } + routesMap := routeManager.GetClientRoutesWithNetID() + v6Merged := route.V6ExitMergeSet(routesMap) + resolvedDomains := c.recorder.GetResolvedDomainsStates() + networkArray := &NetworkArray{ items: make([]Network, 0), } - resolvedDomains := c.recorder.GetResolvedDomainsStates() - - for id, routes := range routeManager.GetClientRoutesWithNetID() { + for id, routes := range routesMap { if len(routes) == 0 { continue } - - r := routes[0] - domains := c.getNetworkDomainsFromRoute(r, resolvedDomains) - netStr := r.Network.String() - - if r.IsDynamic() { - netStr = r.Domains.SafeString() - } - - routePeer, err := c.recorder.GetPeer(routes[0].Peer) - if err != nil { - log.Errorf("could not get peer info for %s: %v", routes[0].Peer, err) + if _, skip := v6Merged[id]; skip { continue } - network := Network{ - Name: string(id), - Network: netStr, - Peer: routePeer.FQDN, - Status: routePeer.ConnStatus.String(), - IsSelected: routeSelector.IsSelected(id), - Domains: domains, + + network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged) + if network == nil { + continue } - networkArray.Add(network) + networkArray.Add(*network) } return networkArray } +func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}) *Network { + r := routes[0] + netStr := r.Network.String() + if r.IsDynamic() { + netStr = r.Domains.SafeString() + } + + routePeer, err := c.findBestRoutePeer(routes) + if err != nil { + log.Errorf("could not get peer info for route %s: %v", id, err) + return nil + } + + network := &Network{ + Name: string(id), + Network: netStr, + Peer: routePeer.FQDN, + Status: routePeer.ConnStatus.String(), + IsSelected: selected, + Domains: c.getNetworkDomainsFromRoute(r, resolvedDomains), + } + + if route.IsV4DefaultRoute(r.Network) && route.HasV6ExitPair(id, v6Merged) { + network.Network = "0.0.0.0/0, ::/0" + } + + return network +} + +// findBestRoutePeer returns the peer actively routing traffic for the given +// HA route group. Falls back to the first connected peer, then the first peer. +func (c *Client) findBestRoutePeer(routes []*route.Route) (peer.State, error) { + netStr := routes[0].Network.String() + + fullStatus := c.recorder.GetFullStatus() + for _, p := range fullStatus.Peers { + if _, ok := p.GetRoutes()[netStr]; ok { + return p, nil + } + } + + for _, r := range routes { + p, err := c.recorder.GetPeer(r.Peer) + if err != nil { + continue + } + if p.ConnStatus == peer.StatusConnected { + return p, nil + } + } + return c.recorder.GetPeer(routes[0].Peer) +} + // OnUpdatedHostDNS update the DNS servers addresses for root zones func (c *Client) OnUpdatedHostDNS(list *DNSList) error { dnsServer, err := dns.GetServerDns() diff --git a/client/android/peer_notifier.go b/client/android/peer_notifier.go index 4ec22f3ab..c2595e574 100644 --- a/client/android/peer_notifier.go +++ b/client/android/peer_notifier.go @@ -14,6 +14,7 @@ const ( // PeerInfo describe information about the peers. It designed for the UI usage type PeerInfo struct { IP string + IPv6 string FQDN string ConnStatus int Routes PeerRoutes diff --git a/client/android/preferences.go b/client/android/preferences.go index c3c8eb3fb..066477293 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -307,6 +307,24 @@ func (p *Preferences) SetBlockInbound(block bool) { p.configInput.BlockInbound = &block } +// GetDisableIPv6 reads disable IPv6 setting from config file +func (p *Preferences) GetDisableIPv6() (bool, error) { + if p.configInput.DisableIPv6 != nil { + return *p.configInput.DisableIPv6, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + return cfg.DisableIPv6, err +} + +// SetDisableIPv6 stores the given value and waits for commit +func (p *Preferences) SetDisableIPv6(disable bool) { + p.configInput.DisableIPv6 = &disable +} + // Commit writes out the changes to the config file func (p *Preferences) Commit() error { _, err := profilemanager.UpdateOrCreateConfig(p.configInput) diff --git a/client/android/route_command.go b/client/android/route_command.go index b47d5ca6c..5e7357335 100644 --- a/client/android/route_command.go +++ b/client/android/route_command.go @@ -18,9 +18,12 @@ func executeRouteToggle(id string, manager routemanager.Manager, netID := route.NetID(id) routes := []route.NetID{netID} - log.Debugf("%s with id: %s", operationName, id) + routesMap := manager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) - if err := routeOperation(routes, maps.Keys(manager.GetClientRoutesWithNetID())); err != nil { + log.Debugf("%s with ids: %v", operationName, routes) + + if err := routeOperation(routes, maps.Keys(routesMap)); err != nil { log.Debugf("error when %s: %s", operationName, err) return fmt.Errorf("error %s: %w", operationName, err) } diff --git a/client/anonymize/anonymize.go b/client/anonymize/anonymize.go index 89e653300..c140cef89 100644 --- a/client/anonymize/anonymize.go +++ b/client/anonymize/anonymize.go @@ -9,6 +9,7 @@ import ( "net/url" "regexp" "slices" + "strconv" "strings" ) @@ -26,8 +27,9 @@ type Anonymizer struct { } func DefaultAddresses() (netip.Addr, netip.Addr) { - // 198.51.100.0, 100:: - return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.AddrFrom16([16]byte{0x01}) + // 198.51.100.0 (RFC 5737 TEST-NET-2), 2001:db8:ffff:: (RFC 3849 documentation, last /48) + // The old start 100:: (discard, RFC 6666) is now used for fake IPs on Android. + return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::") } func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer { @@ -48,7 +50,7 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr { ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || - ip.IsPrivate() || + (ip.Is4() && ip.IsPrivate()) || ip.IsUnspecified() || ip.IsMulticast() || isWellKnown(ip) || @@ -96,6 +98,11 @@ func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool { } func (a *Anonymizer) AnonymizeIPString(ip string) string { + // Handle CIDR notation (e.g. "2001:db8::/32") + if prefix, err := netip.ParsePrefix(ip); err == nil { + return a.AnonymizeIP(prefix.Addr()).String() + "/" + strconv.Itoa(prefix.Bits()) + } + addr, err := netip.ParseAddr(ip) if err != nil { return ip @@ -150,7 +157,7 @@ func (a *Anonymizer) AnonymizeURI(uri string) string { if u.Opaque != "" { host, port, err := net.SplitHostPort(u.Opaque) if err == nil { - anonymizedHost = fmt.Sprintf("%s:%s", a.AnonymizeDomain(host), port) + anonymizedHost = net.JoinHostPort(a.AnonymizeDomain(host), port) } else { anonymizedHost = a.AnonymizeDomain(u.Opaque) } @@ -158,7 +165,7 @@ func (a *Anonymizer) AnonymizeURI(uri string) string { } else if u.Host != "" { host, port, err := net.SplitHostPort(u.Host) if err == nil { - anonymizedHost = fmt.Sprintf("%s:%s", a.AnonymizeDomain(host), port) + anonymizedHost = net.JoinHostPort(a.AnonymizeDomain(host), port) } else { anonymizedHost = a.AnonymizeDomain(u.Host) } diff --git a/client/anonymize/anonymize_test.go b/client/anonymize/anonymize_test.go index ff2e48869..852315fa1 100644 --- a/client/anonymize/anonymize_test.go +++ b/client/anonymize/anonymize_test.go @@ -13,7 +13,7 @@ import ( func TestAnonymizeIP(t *testing.T) { startIPv4 := netip.MustParseAddr("198.51.100.0") - startIPv6 := netip.MustParseAddr("100::") + startIPv6 := netip.MustParseAddr("2001:db8:ffff::") anonymizer := anonymize.NewAnonymizer(startIPv4, startIPv6) tests := []struct { @@ -26,9 +26,9 @@ func TestAnonymizeIP(t *testing.T) { {"Second Public IPv4", "4.3.2.1", "198.51.100.1"}, {"Repeated IPv4", "1.2.3.4", "198.51.100.0"}, {"Private IPv4", "192.168.1.1", "192.168.1.1"}, - {"First Public IPv6", "2607:f8b0:4005:805::200e", "100::"}, - {"Second Public IPv6", "a::b", "100::1"}, - {"Repeated IPv6", "2607:f8b0:4005:805::200e", "100::"}, + {"First Public IPv6", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"}, + {"Second Public IPv6", "a::b", "2001:db8:ffff::1"}, + {"Repeated IPv6", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"}, {"Private IPv6", "fe80::1", "fe80::1"}, {"In Range IPv4", "198.51.100.2", "198.51.100.2"}, } @@ -274,17 +274,27 @@ func TestAnonymizeString_IPAddresses(t *testing.T) { { name: "IPv6 Address", input: "Access attempted from 2001:db8::ff00:42", - expect: "Access attempted from 100::", + expect: "Access attempted from 2001:db8:ffff::", }, { name: "IPv6 Address with Port", input: "Access attempted from [2001:db8::ff00:42]:8080", - expect: "Access attempted from [100::]:8080", + expect: "Access attempted from [2001:db8:ffff::]:8080", }, { name: "Both IPv4 and IPv6", input: "IPv4: 142.108.0.1 and IPv6: 2001:db8::ff00:43", - expect: "IPv4: 198.51.100.1 and IPv6: 100::1", + expect: "IPv4: 198.51.100.1 and IPv6: 2001:db8:ffff::1", + }, + { + name: "STUN URI with IPv6", + input: "Connecting to stun:[2001:db8::ff00:42]:3478", + expect: "Connecting to stun:[2001:db8:ffff::]:3478", + }, + { + name: "HTTPS URI with IPv6", + input: "Visit https://[2001:db8::ff00:42]:443/path", + expect: "Visit https://[2001:db8:ffff::]:443/path", }, } diff --git a/client/cmd/ssh.go b/client/cmd/ssh.go index 0acf0b133..d6e052e08 100644 --- a/client/cmd/ssh.go +++ b/client/cmd/ssh.go @@ -523,7 +523,7 @@ func parseHostnameAndCommand(args []string) error { } func runSSH(ctx context.Context, addr string, cmd *cobra.Command) error { - target := fmt.Sprintf("%s:%d", addr, port) + target := net.JoinHostPort(strings.Trim(addr, "[]"), strconv.Itoa(port)) c, err := sshclient.Dial(ctx, target, username, sshclient.DialOptions{ KnownHostsFile: knownHostsFile, IdentityFile: identityFile, @@ -787,10 +787,10 @@ func isUnixSocket(path string) bool { return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "./") } -// normalizeLocalHost converts "*" to "0.0.0.0" for binding to all interfaces. +// normalizeLocalHost converts "*" to "" for binding to all interfaces (dual-stack). func normalizeLocalHost(host string) string { if host == "*" { - return "0.0.0.0" + return "" } return host } diff --git a/client/cmd/ssh_test.go b/client/cmd/ssh_test.go index 43291fa87..16ffadb90 100644 --- a/client/cmd/ssh_test.go +++ b/client/cmd/ssh_test.go @@ -527,10 +527,10 @@ func TestParsePortForward(t *testing.T) { { name: "wildcard bind all interfaces", spec: "*:8080:localhost:80", - expectedLocal: "0.0.0.0:8080", + expectedLocal: ":8080", expectedRemote: "localhost:80", expectError: false, - description: "Wildcard * should bind to all interfaces (0.0.0.0)", + description: "Wildcard * should bind to all interfaces (dual-stack)", }, { name: "wildcard for port only", diff --git a/client/cmd/status.go b/client/cmd/status.go index c35a06eb3..dae30e854 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -20,6 +20,7 @@ import ( var ( detailFlag bool ipv4Flag bool + ipv6Flag bool jsonFlag bool yamlFlag bool ipsFilter []string @@ -45,8 +46,9 @@ func init() { statusCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "display detailed status information in json format") statusCmd.PersistentFlags().BoolVar(&yamlFlag, "yaml", false, "display detailed status information in yaml format") statusCmd.PersistentFlags().BoolVar(&ipv4Flag, "ipv4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33") - statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4") - statusCmd.PersistentFlags().StringSliceVar(&ipsFilter, "filter-by-ips", []string{}, "filters the detailed output by a list of one or more IPs, e.g., --filter-by-ips 100.64.0.100,100.64.0.200") + statusCmd.PersistentFlags().BoolVar(&ipv6Flag, "ipv6", false, "display only NetBird IPv6 of this peer") + statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4", "ipv6") + statusCmd.PersistentFlags().StringSliceVar(&ipsFilter, "filter-by-ips", []string{}, "filters the detailed output by a list of one or more IPs (v4 or v6), e.g., --filter-by-ips 100.64.0.100,fd00::1") statusCmd.PersistentFlags().StringSliceVar(&prefixNamesFilter, "filter-by-names", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud") statusCmd.PersistentFlags().StringVar(&statusFilter, "filter-by-status", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected") statusCmd.PersistentFlags().StringVar(&connectionTypeFilter, "filter-by-connection-type", "", "filters the detailed output by connection type (P2P|Relayed), e.g., --filter-by-connection-type P2P") @@ -101,6 +103,14 @@ func statusFunc(cmd *cobra.Command, args []string) error { return nil } + if ipv6Flag { + ipv6 := resp.GetFullStatus().GetLocalPeerState().GetIpv6() + if ipv6 != "" { + cmd.Print(parseInterfaceIP(ipv6)) + } + return nil + } + pm := profilemanager.NewProfileManager() var profName string if activeProf, err := pm.GetActiveProfile(); err == nil { diff --git a/client/cmd/system.go b/client/cmd/system.go index f63432401..b386fe4ae 100644 --- a/client/cmd/system.go +++ b/client/cmd/system.go @@ -8,6 +8,7 @@ const ( disableFirewallFlag = "disable-firewall" blockLANAccessFlag = "block-lan-access" blockInboundFlag = "block-inbound" + disableIPv6Flag = "disable-ipv6" ) var ( @@ -17,6 +18,7 @@ var ( disableFirewall bool blockLANAccess bool blockInbound bool + disableIPv6 bool ) func init() { @@ -39,4 +41,7 @@ func init() { upCmd.PersistentFlags().BoolVar(&blockInbound, blockInboundFlag, false, "Block inbound connections. If enabled, the client will not allow any inbound connections to the local machine nor routed networks.\n"+ "This overrides any policies received from the management service.") + + upCmd.PersistentFlags().BoolVar(&disableIPv6, disableIPv6Flag, false, + "Disable IPv6 overlay. If enabled, the client won't request or use an IPv6 overlay address.") } diff --git a/client/cmd/up.go b/client/cmd/up.go index f4136cb23..cabd0aacf 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -435,6 +435,10 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro req.BlockInbound = &blockInbound } + if cmd.Flag(disableIPv6Flag).Changed { + req.DisableIpv6 = &disableIPv6 + } + if cmd.Flag(enableLazyConnectionFlag).Changed { req.LazyConnectionEnabled = &lazyConnEnabled } @@ -552,6 +556,10 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil ic.BlockInbound = &blockInbound } + if cmd.Flag(disableIPv6Flag).Changed { + ic.DisableIPv6 = &disableIPv6 + } + if cmd.Flag(enableLazyConnectionFlag).Changed { ic.LazyConnectionEnabled = &lazyConnEnabled } @@ -666,6 +674,10 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.BlockInbound = &blockInbound } + if cmd.Flag(disableIPv6Flag).Changed { + loginRequest.DisableIpv6 = &disableIPv6 + } + if cmd.Flag(enableLazyConnectionFlag).Changed { loginRequest.LazyConnectionEnabled = &lazyConnEnabled } diff --git a/client/embed/embed.go b/client/embed/embed.go index baa1d94d6..4b9445b97 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -80,6 +80,8 @@ type Options struct { StatePath string // DisableClientRoutes disables the client routes DisableClientRoutes bool + // DisableIPv6 disables IPv6 overlay addressing + DisableIPv6 bool // BlockInbound blocks all inbound connections from peers BlockInbound bool // WireguardPort is the port for the tunnel interface. Use 0 for a random port. @@ -171,6 +173,7 @@ func New(opts Options) (*Client, error) { PreSharedKey: &opts.PreSharedKey, DisableServerRoutes: &t, DisableClientRoutes: &opts.DisableClientRoutes, + DisableIPv6: &opts.DisableIPv6, BlockInbound: &opts.BlockInbound, WireguardPort: opts.WireguardPort, MTU: opts.MTU, diff --git a/client/firewall/iptables/acl_linux.go b/client/firewall/iptables/acl_linux.go index e629f7881..e5e19cec9 100644 --- a/client/firewall/iptables/acl_linux.go +++ b/client/firewall/iptables/acl_linux.go @@ -40,6 +40,7 @@ type aclManager struct { entries aclEntries optionalEntries map[string][]entry ipsetStore *ipsetStore + v6 bool stateManager *statemanager.Manager } @@ -51,6 +52,7 @@ func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*acl entries: make(map[string][][]string), optionalEntries: make(map[string][]entry), ipsetStore: newIpsetStore(), + v6: iptablesClient.Proto() == iptables.ProtocolIPv6, }, nil } @@ -85,7 +87,11 @@ func (m *aclManager) AddPeerFiltering( chain := chainNameInputRules ipsetName = transformIPsetName(ipsetName, sPort, dPort, action) - specs := filterRuleSpecs(ip, string(protocol), sPort, dPort, action, ipsetName) + if m.v6 && ipsetName != "" { + ipsetName += "-v6" + } + proto := protoForFamily(protocol, m.v6) + specs := filterRuleSpecs(ip, proto, sPort, dPort, action, ipsetName) mangleSpecs := slices.Clone(specs) mangleSpecs = append(mangleSpecs, @@ -109,6 +115,7 @@ func (m *aclManager) AddPeerFiltering( ip: ip.String(), chain: chain, specs: specs, + v6: m.v6, }}, nil } @@ -161,6 +168,7 @@ func (m *aclManager) AddPeerFiltering( ipsetName: ipsetName, ip: ip.String(), chain: chain, + v6: m.v6, } m.updateState() @@ -413,8 +421,13 @@ func (m *aclManager) updateState() { currentState.Lock() defer currentState.Unlock() - currentState.ACLEntries = m.entries - currentState.ACLIPsetStore = m.ipsetStore + if m.v6 { + currentState.ACLEntries6 = m.entries + currentState.ACLIPsetStore6 = m.ipsetStore + } else { + currentState.ACLEntries = m.entries + currentState.ACLIPsetStore = m.ipsetStore + } if err := m.stateManager.UpdateState(currentState); err != nil { log.Errorf("failed to update state: %v", err) @@ -422,13 +435,22 @@ func (m *aclManager) updateState() { } // filterRuleSpecs returns the specs of a filtering rule +// protoForFamily translates ICMP to ICMPv6 for ip6tables. +// ip6tables requires "ipv6-icmp" (or "icmpv6") instead of "icmp". +func protoForFamily(protocol firewall.Protocol, v6 bool) string { + if v6 && protocol == firewall.ProtocolICMP { + return "ipv6-icmp" + } + return string(protocol) +} + func filterRuleSpecs(ip net.IP, protocol string, sPort, dPort *firewall.Port, action firewall.Action, ipsetName string) (specs []string) { // don't use IP matching if IP is 0.0.0.0 matchByIP := !ip.IsUnspecified() if matchByIP { if ipsetName != "" { - specs = append(specs, "-m", "set", "--set", ipsetName, "src") + specs = append(specs, "-m", "set", "--match-set", ipsetName, "src") } else { specs = append(specs, "-s", ip.String()) } @@ -474,6 +496,9 @@ func (m *aclManager) createIPSet(name string) error { opts := ipset.CreateOptions{ Replace: true, } + if m.v6 { + opts.Family = ipset.FamilyIPV6 + } if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil { return fmt.Errorf("create ipset %s: %w", name, err) diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 7d8cd7f8c..696537dd8 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -18,6 +18,10 @@ import ( "github.com/netbirdio/netbird/client/internal/statemanager" ) +type resetter interface { + Reset() error +} + // Manager of iptables firewall type Manager struct { mutex sync.Mutex @@ -28,6 +32,11 @@ type Manager struct { aclMgr *aclManager router *router rawSupported bool + + // IPv6 counterparts, nil when no v6 overlay + ipv6Client *iptables.IPTables + aclMgr6 *aclManager + router6 *router } // iFaceMapper defines subset methods of interface required for manager @@ -58,9 +67,43 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) { return nil, fmt.Errorf("create acl manager: %w", err) } + if wgIface.Address().HasIPv6() { + if err := m.createIPv6Components(wgIface, mtu); err != nil { + return nil, fmt.Errorf("create IPv6 firewall: %w", err) + } + } + return m, nil } +func (m *Manager) createIPv6Components(wgIface iFaceMapper, mtu uint16) error { + ip6Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv6) + if err != nil { + return fmt.Errorf("init ip6tables: %w", err) + } + m.ipv6Client = ip6Client + + m.router6, err = newRouter(ip6Client, wgIface, mtu) + if err != nil { + return fmt.Errorf("create v6 router: %w", err) + } + + // Share the same IP forwarding state with the v4 router, since + // EnableIPForwarding controls both v4 and v6 sysctls. + m.router6.ipFwdState = m.router.ipFwdState + + m.aclMgr6, err = newAclManager(ip6Client, wgIface) + if err != nil { + return fmt.Errorf("create v6 acl manager: %w", err) + } + + return nil +} + +func (m *Manager) hasIPv6() bool { + return m.ipv6Client != nil +} + func (m *Manager) Init(stateManager *statemanager.Manager) error { state := &ShutdownState{ InterfaceState: &InterfaceState{ @@ -74,13 +117,8 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { log.Errorf("failed to update state: %v", err) } - if err := m.router.init(stateManager); err != nil { - return fmt.Errorf("router init: %w", err) - } - - if err := m.aclMgr.init(stateManager); err != nil { - // TODO: cleanup router - return fmt.Errorf("acl manager init: %w", err) + if err := m.initChains(stateManager); err != nil { + return err } if err := m.initNoTrackChain(); err != nil { @@ -103,6 +141,41 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { return nil } +// initChains initializes router and ACL chains for both address families, +// rolling back on failure. +func (m *Manager) initChains(stateManager *statemanager.Manager) error { + type initStep struct { + name string + init func(*statemanager.Manager) error + mgr resetter + } + + steps := []initStep{ + {"router", m.router.init, m.router}, + {"acl manager", m.aclMgr.init, m.aclMgr}, + } + if m.hasIPv6() { + steps = append(steps, + initStep{"v6 router", m.router6.init, m.router6}, + initStep{"v6 acl manager", m.aclMgr6.init, m.aclMgr6}, + ) + } + + var initialized []initStep + for _, s := range steps { + if err := s.init(stateManager); err != nil { + for i := len(initialized) - 1; i >= 0; i-- { + if rerr := initialized[i].mgr.Reset(); rerr != nil { + log.Warnf("rollback %s: %v", initialized[i].name, rerr) + } + } + return fmt.Errorf("%s init: %w", s.name, err) + } + initialized = append(initialized, s) + } + return nil +} + // AddPeerFiltering adds a rule to the firewall // // Comment will be ignored because some system this feature is not supported @@ -118,7 +191,13 @@ func (m *Manager) AddPeerFiltering( m.mutex.Lock() defer m.mutex.Unlock() - return m.aclMgr.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) + if ip.To4() != nil { + return m.aclMgr.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) + } + if !m.hasIPv6() { + return nil, fmt.Errorf("add peer filtering for %s: %w", ip, firewall.ErrIPv6NotInitialized) + } + return m.aclMgr6.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) } func (m *Manager) AddRouteFiltering( @@ -132,25 +211,48 @@ func (m *Manager) AddRouteFiltering( m.mutex.Lock() defer m.mutex.Unlock() - if destination.IsPrefix() && !destination.Prefix.Addr().Is4() { - return nil, fmt.Errorf("unsupported IP version: %s", destination.Prefix.Addr().String()) + if isIPv6RouteRule(sources, destination) { + if !m.hasIPv6() { + return nil, fmt.Errorf("add route filtering: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) } return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) } +func isIPv6RouteRule(sources []netip.Prefix, destination firewall.Network) bool { + if destination.IsPrefix() { + return destination.Prefix.Addr().Is6() + } + return len(sources) > 0 && sources[0].Addr().Is6() +} + // DeletePeerRule from the firewall by rule definition func (m *Manager) DeletePeerRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() + if m.hasIPv6() && isIPv6IptRule(rule) { + return m.aclMgr6.DeletePeerRule(rule) + } return m.aclMgr.DeletePeerRule(rule) } +func isIPv6IptRule(rule firewall.Rule) bool { + r, ok := rule.(*Rule) + return ok && r.v6 +} + +// DeleteRouteRule deletes a routing rule. +// Route rules are keyed by content hash. Check v4 first, try v6 if not found. func (m *Manager) DeleteRouteRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() + if m.hasIPv6() && !m.router.hasRule(rule.ID()) { + return m.router6.DeleteRouteRule(rule) + } return m.router.DeleteRouteRule(rule) } @@ -166,18 +268,65 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddNatRule(pair) + if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add NAT rule: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddNatRule(pair) + } + + if err := m.router.AddNatRule(pair); err != nil { + return err + } + + // Dynamic routes need NAT in both tables since resolved IPs can be + // either v4 or v6. This covers both DomainSet (modern) and the legacy + // wildcard 0.0.0.0/0 destination where the client resolves DNS. + if m.hasIPv6() && pair.Dynamic { + v6Pair := firewall.ToV6NatPair(pair) + if err := m.router6.AddNatRule(v6Pair); err != nil { + return fmt.Errorf("add v6 NAT rule: %w", err) + } + } + + return nil } func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveNatRule(pair) + if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() { + if !m.hasIPv6() { + return nil + } + return m.router6.RemoveNatRule(pair) + } + + var merr *multierror.Error + + if err := m.router.RemoveNatRule(pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove v4 NAT rule: %w", err)) + } + + if m.hasIPv6() && pair.Dynamic { + v6Pair := firewall.ToV6NatPair(pair) + if err := m.router6.RemoveNatRule(v6Pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove v6 NAT rule: %w", err)) + } + } + + return nberrors.FormatErrorOrNil(merr) } func (m *Manager) SetLegacyManagement(isLegacy bool) error { - return firewall.SetLegacyManagement(m.router, isLegacy) + if err := firewall.SetLegacyManagement(m.router, isLegacy); err != nil { + return err + } + if m.hasIPv6() { + return firewall.SetLegacyManagement(m.router6, isLegacy) + } + return nil } // Reset firewall to the default state @@ -191,6 +340,15 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { merr = multierror.Append(merr, fmt.Errorf("cleanup notrack chain: %w", err)) } + if m.hasIPv6() { + if err := m.aclMgr6.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset v6 acl manager: %w", err)) + } + if err := m.router6.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset v6 router: %w", err)) + } + } + if err := m.aclMgr.Reset(); err != nil { merr = multierror.Append(merr, fmt.Errorf("reset acl manager: %w", err)) } @@ -218,24 +376,21 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { // This is called when USPFilter wraps the native firewall, adding blanket accept // rules so that packet filtering is handled in userspace instead of by netfilter. func (m *Manager) AllowNetbird() error { - _, err := m.AddPeerFiltering( - nil, - net.IP{0, 0, 0, 0}, - firewall.ProtocolALL, - nil, - nil, - firewall.ActionAccept, - "", - ) - if err != nil { - return fmt.Errorf("allow netbird interface traffic: %w", err) + var merr *multierror.Error + if _, err := m.AddPeerFiltering(nil, net.IP{0, 0, 0, 0}, firewall.ProtocolALL, nil, nil, firewall.ActionAccept, ""); err != nil { + merr = multierror.Append(merr, fmt.Errorf("allow netbird v4 interface traffic: %w", err)) + } + if m.hasIPv6() { + if _, err := m.AddPeerFiltering(nil, net.IPv6zero, firewall.ProtocolALL, nil, nil, firewall.ActionAccept, ""); err != nil { + merr = multierror.Append(merr, fmt.Errorf("allow netbird v6 interface traffic: %w", err)) + } } if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { log.Warnf("failed to trust interface in firewalld: %v", err) } - return nil + return nberrors.FormatErrorOrNil(merr) } // Flush doesn't need to be implemented for this manager @@ -265,6 +420,12 @@ func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) m.mutex.Lock() defer m.mutex.Unlock() + if rule.TranslatedAddress.Is6() { + if !m.hasIPv6() { + return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddDNATRule(rule) + } return m.router.AddDNATRule(rule) } @@ -273,6 +434,9 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() + if m.hasIPv6() && !m.router.hasRule(rule.ID()+dnatSuffix) { + return m.router6.DeleteDNATRule(rule) + } return m.router.DeleteDNATRule(rule) } @@ -281,39 +445,82 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.UpdateSet(set, prefixes) + var v4Prefixes, v6Prefixes []netip.Prefix + for _, p := range prefixes { + if p.Addr().Is6() { + v6Prefixes = append(v6Prefixes, p) + } else { + v4Prefixes = append(v4Prefixes, p) + } + } + + if err := m.router.UpdateSet(set, v4Prefixes); err != nil { + return err + } + + if m.hasIPv6() && len(v6Prefixes) > 0 { + if err := m.router6.UpdateSet(set, v6Prefixes); err != nil { + return fmt.Errorf("update v6 set: %w", err) + } + } + + return nil } // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddInboundDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add inbound DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveInboundDNAT removes an inbound DNAT rule. -func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveInboundDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("remove inbound DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. -func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddOutputDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. -func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveOutputDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("remove output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } const ( diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go index a7c4f67dd..290e5da1e 100644 --- a/client/firewall/iptables/router_linux.go +++ b/client/firewall/iptables/router_linux.go @@ -54,8 +54,10 @@ const ( snatSuffix = "_snat" fwdSuffix = "_fwd" - // ipTCPHeaderMinSize represents minimum IP (20) + TCP (20) header size for MSS calculation - ipTCPHeaderMinSize = 40 + // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. + ipv4TCPHeaderSize = 40 + // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. + ipv6TCPHeaderSize = 60 ) type ruleInfo struct { @@ -86,6 +88,7 @@ type router struct { wgIface iFaceMapper legacyManagement bool mtu uint16 + v6 bool stateManager *statemanager.Manager ipFwdState *ipfwdstate.IPForwardingState @@ -97,6 +100,7 @@ func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint1 rules: make(map[string][]string), wgIface: wgIface, mtu: mtu, + v6: iptablesClient.Proto() == iptables.ProtocolIPv6, ipFwdState: ipfwdstate.NewIPForwardingState(), } @@ -186,6 +190,11 @@ func (r *router) AddRouteFiltering( return ruleKey, nil } +func (r *router) hasRule(id string) bool { + _, ok := r.rules[id] + return ok +} + func (r *router) DeleteRouteRule(rule firewall.Rule) error { ruleKey := rule.ID() @@ -392,9 +401,13 @@ func (r *router) cleanUpDefaultForwardRules() error { // Remove jump rules from built-in chains before deleting custom chains, // otherwise the chain deletion fails with "device or resource busy". - jumpRule := []string{"-j", chainNATOutput} - if err := r.iptablesClient.Delete(tableNat, "OUTPUT", jumpRule...); err != nil { - log.Debugf("clean OUTPUT jump rule: %v", err) + if ok, err := r.iptablesClient.ChainExists(tableNat, chainNATOutput); err != nil { + return fmt.Errorf("check chain %s: %w", chainNATOutput, err) + } else if ok { + jumpRule := []string{"-j", chainNATOutput} + if err := r.iptablesClient.Delete(tableNat, "OUTPUT", jumpRule...); err != nil { + log.Debugf("clean OUTPUT jump rule: %v", err) + } } for _, chainInfo := range []struct { @@ -434,6 +447,12 @@ func (r *router) createContainers() error { {chainRTRDR, tableNat}, {chainRTMSSCLAMP, tableMangle}, } { + // Fallback: clear chains that survived an unclean shutdown. + if ok, _ := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain); ok { + if err := r.iptablesClient.ClearAndDeleteChain(chainInfo.table, chainInfo.chain); err != nil { + log.Warnf("clear stale chain %s in %s: %v", chainInfo.chain, chainInfo.table, err) + } + } if err := r.iptablesClient.NewChain(chainInfo.table, chainInfo.chain); err != nil { return fmt.Errorf("create chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err) } @@ -540,9 +559,12 @@ func (r *router) addPostroutingRules() error { } // addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic. -// TODO: Add IPv6 support func (r *router) addMSSClampingRules() error { - mss := r.mtu - ipTCPHeaderMinSize + overhead := uint16(ipv4TCPHeaderSize) + if r.v6 { + overhead = ipv6TCPHeaderSize + } + mss := r.mtu - overhead // Add jump rule from FORWARD chain in mangle table to our custom chain jumpRule := []string{ @@ -727,8 +749,13 @@ func (r *router) updateState() { currentState.Lock() defer currentState.Unlock() - currentState.RouteRules = r.rules - currentState.RouteIPsetCounter = r.ipsetCounter + if r.v6 { + currentState.RouteRules6 = r.rules + currentState.RouteIPsetCounter6 = r.ipsetCounter + } else { + currentState.RouteRules = r.rules + currentState.RouteIPsetCounter = r.ipsetCounter + } if err := r.stateManager.UpdateState(currentState); err != nil { log.Errorf("failed to update state: %v", err) @@ -856,7 +883,7 @@ func (r *router) DeleteDNATRule(rule firewall.Rule) error { } if fwdRule, exists := r.rules[ruleKey+fwdSuffix]; exists { - if err := r.iptablesClient.Delete(tableFilter, chainRTFWDIN, fwdRule...); err != nil { + if err := r.iptablesClient.Delete(tableFilter, chainRTFWDOUT, fwdRule...); err != nil { merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err)) } delete(r.rules, ruleKey+fwdSuffix) @@ -883,7 +910,7 @@ func (r *router) genRouteRuleSpec(params routeFilteringRuleParams, sources []net rule = append(rule, destExp...) if params.Proto != firewall.ProtocolALL { - rule = append(rule, "-p", strings.ToLower(string(params.Proto))) + rule = append(rule, "-p", strings.ToLower(protoForFamily(params.Proto, r.v6))) rule = append(rule, applyPort("--sport", params.SPort)...) rule = append(rule, applyPort("--dport", params.DPort)...) } @@ -900,11 +927,12 @@ func (r *router) applyNetwork(flag string, network firewall.Network, prefixes [] } if network.IsSet() { - if _, err := r.ipsetCounter.Increment(network.Set.HashedName(), prefixes); err != nil { + name := r.ipsetName(network.Set.HashedName()) + if _, err := r.ipsetCounter.Increment(name, prefixes); err != nil { return nil, fmt.Errorf("create or get ipset: %w", err) } - return []string{"-m", "set", matchSet, network.Set.HashedName(), direction}, nil + return []string{"-m", "set", matchSet, name, direction}, nil } if network.IsPrefix() { return []string{flag, network.Prefix.String()}, nil @@ -915,27 +943,23 @@ func (r *router) applyNetwork(flag string, network firewall.Network, prefixes [] } func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { + name := r.ipsetName(set.HashedName()) var merr *multierror.Error for _, prefix := range prefixes { - // TODO: Implement IPv6 support - if prefix.Addr().Is6() { - log.Tracef("skipping IPv6 prefix %s: IPv6 support not yet implemented", prefix) - continue - } - if err := r.addPrefixToIPSet(set.HashedName(), prefix); err != nil { + if err := r.addPrefixToIPSet(name, prefix); err != nil { merr = multierror.Append(merr, fmt.Errorf("add prefix to ipset: %w", err)) } } if merr == nil { - log.Debugf("updated set %s with prefixes %v", set.HashedName(), prefixes) + log.Debugf("updated set %s with prefixes %v", name, prefixes) } return nberrors.FormatErrorOrNil(merr) } // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) +func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) if _, exists := r.rules[ruleID]; exists { return nil @@ -943,12 +967,12 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol dnatRule := []string{ "-i", r.wgIface.Name(), - "-p", strings.ToLower(string(protocol)), - "--dport", strconv.Itoa(int(sourcePort)), + "-p", strings.ToLower(protoForFamily(protocol, r.v6)), + "--dport", strconv.Itoa(int(originalPort)), "-d", localAddr.String(), "-m", "addrtype", "--dst-type", "LOCAL", "-j", "DNAT", - "--to-destination", ":" + strconv.Itoa(int(targetPort)), + "--to-destination", ":" + strconv.Itoa(int(translatedPort)), } ruleInfo := ruleInfo{ @@ -967,8 +991,8 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol } // RemoveInboundDNAT removes an inbound DNAT rule. -func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) +func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) if dnatRule, exists := r.rules[ruleID]; exists { if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil { @@ -1013,8 +1037,8 @@ func (r *router) ensureNATOutputChain() error { } // AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. -func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) +func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) if _, exists := r.rules[ruleID]; exists { return nil @@ -1025,11 +1049,11 @@ func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, } dnatRule := []string{ - "-p", strings.ToLower(string(protocol)), - "--dport", strconv.Itoa(int(sourcePort)), + "-p", strings.ToLower(protoForFamily(protocol, localAddr.Is6())), + "--dport", strconv.Itoa(int(originalPort)), "-d", localAddr.String(), "-j", "DNAT", - "--to-destination", ":" + strconv.Itoa(int(targetPort)), + "--to-destination", ":" + strconv.Itoa(int(translatedPort)), } if err := r.iptablesClient.Append(tableNat, chainNATOutput, dnatRule...); err != nil { @@ -1042,8 +1066,8 @@ func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, } // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. -func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) +func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) if dnatRule, exists := r.rules[ruleID]; exists { if err := r.iptablesClient.Delete(tableNat, chainNATOutput, dnatRule...); err != nil { @@ -1076,10 +1100,22 @@ func applyPort(flag string, port *firewall.Port) []string { return []string{flag, strconv.Itoa(int(port.Values[0]))} } +// ipsetName returns the ipset name, suffixed with "-v6" for the v6 router +// to avoid collisions since ipsets are global in the kernel. +func (r *router) ipsetName(name string) string { + if r.v6 { + return name + "-v6" + } + return name +} + func (r *router) createIPSet(name string) error { opts := ipset.CreateOptions{ Replace: true, } + if r.v6 { + opts.Family = ipset.FamilyIPV6 + } if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil { return fmt.Errorf("create ipset %s: %w", name, err) diff --git a/client/firewall/iptables/rule.go b/client/firewall/iptables/rule.go index aa4d2d079..4f4eab167 100644 --- a/client/firewall/iptables/rule.go +++ b/client/firewall/iptables/rule.go @@ -9,6 +9,7 @@ type Rule struct { mangleSpecs []string ip string chain string + v6 bool } // GetRuleID returns the rule id diff --git a/client/firewall/iptables/state_linux.go b/client/firewall/iptables/state_linux.go index 121c755e9..f4be37d01 100644 --- a/client/firewall/iptables/state_linux.go +++ b/client/firewall/iptables/state_linux.go @@ -4,6 +4,8 @@ import ( "fmt" "sync" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/wgaddr" ) @@ -32,6 +34,12 @@ type ShutdownState struct { ACLEntries aclEntries `json:"acl_entries,omitempty"` ACLIPsetStore *ipsetStore `json:"acl_ipset_store,omitempty"` + + // IPv6 counterparts + RouteRules6 routeRules `json:"route_rules_v6,omitempty"` + RouteIPsetCounter6 *ipsetCounter `json:"route_ipset_counter_v6,omitempty"` + ACLEntries6 aclEntries `json:"acl_entries_v6,omitempty"` + ACLIPsetStore6 *ipsetStore `json:"acl_ipset_store_v6,omitempty"` } func (s *ShutdownState) Name() string { @@ -62,6 +70,28 @@ func (s *ShutdownState) Cleanup() error { ipt.aclMgr.ipsetStore = s.ACLIPsetStore } + // Clean up v6 state even if the current run has no IPv6. + // The previous run may have left ip6tables rules behind. + if !ipt.hasIPv6() { + if err := ipt.createIPv6Components(s.InterfaceState, mtu); err != nil { + log.Warnf("failed to create v6 components for cleanup: %v", err) + } + } + if ipt.hasIPv6() { + if s.RouteRules6 != nil { + ipt.router6.rules = s.RouteRules6 + } + if s.RouteIPsetCounter6 != nil { + ipt.router6.ipsetCounter.LoadData(s.RouteIPsetCounter6) + } + if s.ACLEntries6 != nil { + ipt.aclMgr6.entries = s.ACLEntries6 + } + if s.ACLIPsetStore6 != nil { + ipt.aclMgr6.ipsetStore = s.ACLIPsetStore6 + } + } + if err := ipt.Close(nil); err != nil { return fmt.Errorf("reset iptables manager: %w", err) } diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index d65d717b3..149c6db83 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -1,6 +1,7 @@ package manager import ( + "errors" "fmt" "net" "net/netip" @@ -11,6 +12,10 @@ import ( "github.com/netbirdio/netbird/client/internal/statemanager" ) +// ErrIPv6NotInitialized is returned when an IPv6 address is passed to a firewall +// method but the IPv6 firewall components were not initialized. +var ErrIPv6NotInitialized = errors.New("IPv6 firewall not initialized") + const ( ForwardingFormatPrefix = "netbird-fwd-" ForwardingFormat = "netbird-fwd-%s-%t" @@ -164,18 +169,16 @@ type Manager interface { UpdateSet(hash Set, prefixes []netip.Prefix) error // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services - AddInboundDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error + AddInboundDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error // RemoveInboundDNAT removes inbound DNAT rule - RemoveInboundDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error + RemoveInboundDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error // AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. - // localAddr must be IPv4; the underlying iptables/nftables backends are IPv4-only. - AddOutputDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error + AddOutputDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. - // localAddr must be IPv4; the underlying iptables/nftables backends are IPv4-only. - RemoveOutputDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error + RemoveOutputDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error // SetupEBPFProxyNoTrack creates static notrack rules for eBPF proxy loopback traffic. // This prevents conntrack from interfering with WireGuard proxy communication. diff --git a/client/firewall/manager/routerpair.go b/client/firewall/manager/routerpair.go index 079c051d9..096f8b9bb 100644 --- a/client/firewall/manager/routerpair.go +++ b/client/firewall/manager/routerpair.go @@ -1,6 +1,8 @@ package manager import ( + "net/netip" + "github.com/netbirdio/netbird/route" ) @@ -10,6 +12,10 @@ type RouterPair struct { Destination Network Masquerade bool Inverse bool + // Dynamic indicates the route is domain-based. NAT rules for dynamic + // routes are duplicated to the v6 table so that resolved AAAA records + // are masqueraded correctly. + Dynamic bool } func GetInversePair(pair RouterPair) RouterPair { @@ -20,5 +26,17 @@ func GetInversePair(pair RouterPair) RouterPair { Destination: pair.Source, Masquerade: pair.Masquerade, Inverse: true, + Dynamic: pair.Dynamic, } } + +// ToV6NatPair creates a v6 counterpart of a v4 NAT pair with `::/0` source +// and, for prefix destinations, `::/0` destination. +func ToV6NatPair(pair RouterPair) RouterPair { + v6 := pair + v6.Source = Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + if v6.Destination.IsPrefix() { + v6.Destination = Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + } + return v6 +} diff --git a/client/firewall/nftables/acl_linux.go b/client/firewall/nftables/acl_linux.go index a9d066e2f..9d2ea7264 100644 --- a/client/firewall/nftables/acl_linux.go +++ b/client/firewall/nftables/acl_linux.go @@ -33,15 +33,12 @@ const ( const flushError = "flush: %w" -var ( - anyIP = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} -) - type AclManager struct { rConn *nftables.Conn sConn *nftables.Conn wgIface iFaceMapper routingFwChainName string + af addrFamily workTable *nftables.Table chainInputRules *nftables.Chain @@ -67,6 +64,7 @@ func newAclManager(table *nftables.Table, wgIface iFaceMapper, routingFwChainNam wgIface: wgIface, workTable: table, routingFwChainName: routingFwChainName, + af: familyForAddr(table.Family == nftables.TableFamilyIPv4), ipsetStore: newIpsetStore(), rules: make(map[string]*Rule), @@ -145,7 +143,7 @@ func (m *AclManager) DeletePeerRule(rule firewall.Rule) error { } if _, ok := ips[r.ip.String()]; ok { - err := m.sConn.SetDeleteElements(r.nftSet, []nftables.SetElement{{Key: r.ip.To4()}}) + err := m.sConn.SetDeleteElements(r.nftSet, []nftables.SetElement{{Key: ipToBytes(r.ip, m.af)}}) if err != nil { log.Errorf("delete elements for set %q: %v", r.nftSet.Name, err) } @@ -254,11 +252,11 @@ func (m *AclManager) addIOFiltering( expressions = append(expressions, &expr.Payload{ DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, - Offset: uint32(9), + Offset: m.af.protoOffset, Len: uint32(1), }) - protoData, err := protoToInt(proto) + protoData, err := m.af.protoNum(proto) if err != nil { return nil, fmt.Errorf("convert protocol to number: %v", err) } @@ -270,19 +268,16 @@ func (m *AclManager) addIOFiltering( }) } - rawIP := ip.To4() + rawIP := ipToBytes(ip, m.af) // check if rawIP contains zeroed IPv4 0.0.0.0 value // in that case not add IP match expression into the rule definition - if !bytes.HasPrefix(anyIP, rawIP) { - // source address position - addrOffset := uint32(12) - + if slices.ContainsFunc(rawIP, func(v byte) bool { return v != 0 }) { expressions = append(expressions, &expr.Payload{ DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, - Offset: addrOffset, - Len: 4, + Offset: m.af.srcAddrOffset, + Len: m.af.addrLen, }, ) // add individual IP for match if no ipset defined @@ -587,7 +582,7 @@ func (m *AclManager) addJumpRule(chain *nftables.Chain, to string, ifaceKey expr func (m *AclManager) addIpToSet(ipsetName string, ip net.IP) (*nftables.Set, error) { ipset, err := m.rConn.GetSetByName(m.workTable, ipsetName) - rawIP := ip.To4() + rawIP := ipToBytes(ip, m.af) if err != nil { if ipset, err = m.createSet(m.workTable, ipsetName); err != nil { return nil, fmt.Errorf("get set name: %v", err) @@ -619,7 +614,7 @@ func (m *AclManager) createSet(table *nftables.Table, name string) (*nftables.Se Name: name, Table: table, Dynamic: true, - KeyType: nftables.TypeIPAddr, + KeyType: m.af.setKeyType, } if err := m.rConn.AddSet(ipset, nil); err != nil { @@ -707,15 +702,12 @@ func ifname(n string) []byte { return b } -func protoToInt(protocol firewall.Protocol) (uint8, error) { - switch protocol { - case firewall.ProtocolTCP: - return unix.IPPROTO_TCP, nil - case firewall.ProtocolUDP: - return unix.IPPROTO_UDP, nil - case firewall.ProtocolICMP: - return unix.IPPROTO_ICMP, nil - } - return 0, fmt.Errorf("unsupported protocol: %s", protocol) +// ipToBytes converts net.IP to the correct byte length for the address family. +func ipToBytes(ip net.IP, af addrFamily) []byte { + if af.addrLen == 4 { + return ip.To4() + } + return ip.To16() } + diff --git a/client/firewall/nftables/addr_family_linux.go b/client/firewall/nftables/addr_family_linux.go new file mode 100644 index 000000000..0c90d704a --- /dev/null +++ b/client/firewall/nftables/addr_family_linux.go @@ -0,0 +1,81 @@ +package nftables + +import ( + "fmt" + "net" + + "github.com/google/nftables" + "golang.org/x/sys/unix" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +var ( + // afIPv4 defines IPv4 header layout and nftables types. + afIPv4 = addrFamily{ + protoOffset: 9, + srcAddrOffset: 12, + dstAddrOffset: 16, + addrLen: net.IPv4len, + totalBits: 8 * net.IPv4len, + setKeyType: nftables.TypeIPAddr, + tableFamily: nftables.TableFamilyIPv4, + icmpProto: unix.IPPROTO_ICMP, + } + // afIPv6 defines IPv6 header layout and nftables types. + afIPv6 = addrFamily{ + protoOffset: 6, + srcAddrOffset: 8, + dstAddrOffset: 24, + addrLen: net.IPv6len, + totalBits: 8 * net.IPv6len, + setKeyType: nftables.TypeIP6Addr, + tableFamily: nftables.TableFamilyIPv6, + icmpProto: unix.IPPROTO_ICMPV6, + } +) + +// addrFamily holds protocol-specific constants for nftables expression building. +type addrFamily struct { + // protoOffset is the IP header offset for the protocol/next-header field (9 for v4, 6 for v6) + protoOffset uint32 + // srcAddrOffset is the IP header offset for the source address (12 for v4, 8 for v6) + srcAddrOffset uint32 + // dstAddrOffset is the IP header offset for the destination address (16 for v4, 24 for v6) + dstAddrOffset uint32 + // addrLen is the byte length of addresses (4 for v4, 16 for v6) + addrLen uint32 + // totalBits is the address size in bits (32 for v4, 128 for v6) + totalBits int + // setKeyType is the nftables set data type for addresses + setKeyType nftables.SetDatatype + // tableFamily is the nftables table family + tableFamily nftables.TableFamily + // icmpProto is the ICMP protocol number for this family (1 for v4, 58 for v6) + icmpProto uint8 +} + +// familyForAddr returns the address family for the given IP. +func familyForAddr(is4 bool) addrFamily { + if is4 { + return afIPv4 + } + return afIPv6 +} + +// protoNum converts a firewall protocol to the IP protocol number, +// using the correct ICMP variant for the address family. +func (af addrFamily) protoNum(protocol firewall.Protocol) (uint8, error) { + switch protocol { + case firewall.ProtocolTCP: + return unix.IPPROTO_TCP, nil + case firewall.ProtocolUDP: + return unix.IPPROTO_UDP, nil + case firewall.ProtocolICMP: + return af.icmpProto, nil + case firewall.ProtocolALL: + return 0, nil + default: + return 0, fmt.Errorf("unsupported protocol: %s", protocol) + } +} diff --git a/client/firewall/nftables/external_chain_monitor_integration_linux_test.go b/client/firewall/nftables/external_chain_monitor_integration_linux_test.go new file mode 100644 index 000000000..3c4e3f44d --- /dev/null +++ b/client/firewall/nftables/external_chain_monitor_integration_linux_test.go @@ -0,0 +1,76 @@ +//go:build linux + +package nftables + +import ( + "os" + "sync/atomic" + "testing" + "time" + + "github.com/google/nftables" + "github.com/stretchr/testify/require" +) + +// TestExternalChainMonitorRootIntegration verifies that adding a new chain +// in an external (non-netbird) filter table triggers the reconciler. +// Requires CAP_NET_ADMIN; skip otherwise. +func TestExternalChainMonitorRootIntegration(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("root required") + } + + calls := make(chan struct{}, 8) + var count atomic.Int32 + rec := &countingReconciler{calls: calls, count: &count} + + m := newExternalChainMonitor(rec) + m.start() + t.Cleanup(m.stop) + + // Give the netlink subscription a moment to register. + time.Sleep(200 * time.Millisecond) + + conn := &nftables.Conn{} + table := conn.AddTable(&nftables.Table{ + Name: "nbmon_integration_test", + Family: nftables.TableFamilyINet, + }) + t.Cleanup(func() { + cleanup := &nftables.Conn{} + cleanup.DelTable(table) + _ = cleanup.Flush() + }) + + chain := conn.AddChain(&nftables.Chain{ + Name: "filter_INPUT", + Table: table, + Hooknum: nftables.ChainHookInput, + Priority: nftables.ChainPriorityFilter, + Type: nftables.ChainTypeFilter, + }) + _ = chain + require.NoError(t, conn.Flush(), "create external test chain") + + select { + case <-calls: + // success + case <-time.After(3 * time.Second): + t.Fatalf("reconcile was not invoked after creating an external chain") + } + require.GreaterOrEqual(t, count.Load(), int32(1)) +} + +type countingReconciler struct { + calls chan struct{} + count *atomic.Int32 +} + +func (c *countingReconciler) reconcileExternalChains() error { + c.count.Add(1) + select { + case c.calls <- struct{}{}: + default: + } + return nil +} diff --git a/client/firewall/nftables/external_chain_monitor_linux.go b/client/firewall/nftables/external_chain_monitor_linux.go new file mode 100644 index 000000000..2a2e04c09 --- /dev/null +++ b/client/firewall/nftables/external_chain_monitor_linux.go @@ -0,0 +1,199 @@ +package nftables + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/google/nftables" + log "github.com/sirupsen/logrus" +) + +const ( + externalMonitorReconcileDelay = 500 * time.Millisecond + externalMonitorInitInterval = 5 * time.Second + externalMonitorMaxInterval = 5 * time.Minute + externalMonitorRandomization = 0.5 +) + +// externalChainReconciler re-applies passthrough accept rules to external +// nftables chains. Implementations must be safe to call from the monitor +// goroutine; the Manager locks its mutex internally. +type externalChainReconciler interface { + reconcileExternalChains() error +} + +// externalChainMonitor watches nftables netlink events and triggers a +// reconcile when a new table or chain appears (e.g. after +// `firewall-cmd --reload`). Netlink errors trigger exponential-backoff +// reconnect. +type externalChainMonitor struct { + reconciler externalChainReconciler + + mu sync.Mutex + cancel context.CancelFunc + done chan struct{} +} + +func newExternalChainMonitor(r externalChainReconciler) *externalChainMonitor { + return &externalChainMonitor{reconciler: r} +} + +func (m *externalChainMonitor) start() { + m.mu.Lock() + defer m.mu.Unlock() + + if m.cancel != nil { + return + } + + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + m.done = make(chan struct{}) + + go m.run(ctx) +} + +func (m *externalChainMonitor) stop() { + m.mu.Lock() + cancel := m.cancel + done := m.done + m.cancel = nil + m.done = nil + m.mu.Unlock() + + if cancel == nil { + return + } + cancel() + <-done +} + +func (m *externalChainMonitor) run(ctx context.Context) { + defer close(m.done) + + bo := &backoff.ExponentialBackOff{ + InitialInterval: externalMonitorInitInterval, + RandomizationFactor: externalMonitorRandomization, + Multiplier: backoff.DefaultMultiplier, + MaxInterval: externalMonitorMaxInterval, + MaxElapsedTime: 0, + Clock: backoff.SystemClock, + } + bo.Reset() + + for ctx.Err() == nil { + err := m.watch(ctx) + if ctx.Err() != nil { + return + } + + delay := bo.NextBackOff() + log.Warnf("external chain monitor: %v, reconnecting in %s", err, delay) + select { + case <-ctx.Done(): + return + case <-time.After(delay): + } + } +} + +func (m *externalChainMonitor) watch(ctx context.Context) error { + events, closeMon, err := m.subscribe() + if err != nil { + return err + } + defer closeMon() + + debounce := time.NewTimer(time.Hour) + if !debounce.Stop() { + <-debounce.C + } + defer debounce.Stop() + + pending := false + for { + select { + case <-ctx.Done(): + return nil + case <-debounce.C: + pending = false + m.reconcile() + case ev, ok := <-events: + if !ok { + return errors.New("monitor channel closed") + } + if ev.Error != nil { + return fmt.Errorf("monitor event: %w", ev.Error) + } + if !isRelevantMonitorEvent(ev) { + continue + } + resetDebounce(debounce, pending) + pending = true + } + } +} + +func (m *externalChainMonitor) subscribe() (chan *nftables.MonitorEvent, func(), error) { + conn := &nftables.Conn{} + mon := nftables.NewMonitor( + nftables.WithMonitorAction(nftables.MonitorActionNew), + nftables.WithMonitorObject(nftables.MonitorObjectChains|nftables.MonitorObjectTables), + ) + events, err := conn.AddMonitor(mon) + if err != nil { + return nil, nil, fmt.Errorf("add netlink monitor: %w", err) + } + return events, func() { _ = mon.Close() }, nil +} + +// resetDebounce reschedules a pending debounce timer without leaking a stale +// fire on its channel. pending must reflect whether the timer is armed. +func resetDebounce(t *time.Timer, pending bool) { + if pending && !t.Stop() { + select { + case <-t.C: + default: + } + } + t.Reset(externalMonitorReconcileDelay) +} + +func (m *externalChainMonitor) reconcile() { + if err := m.reconciler.reconcileExternalChains(); err != nil { + log.Warnf("reconcile external chain rules: %v", err) + } +} + +// isRelevantMonitorEvent returns true for table/chain creation events on +// families we care about. The reconciler filters to actual external filter +// chains. +func isRelevantMonitorEvent(ev *nftables.MonitorEvent) bool { + switch ev.Type { + case nftables.MonitorEventTypeNewChain: + chain, ok := ev.Data.(*nftables.Chain) + if !ok || chain == nil || chain.Table == nil { + return false + } + return isMonitoredFamily(chain.Table.Family) + case nftables.MonitorEventTypeNewTable: + table, ok := ev.Data.(*nftables.Table) + if !ok || table == nil { + return false + } + return isMonitoredFamily(table.Family) + } + return false +} + +func isMonitoredFamily(family nftables.TableFamily) bool { + switch family { + case nftables.TableFamilyIPv4, nftables.TableFamilyIPv6, nftables.TableFamilyINet: + return true + } + return false +} diff --git a/client/firewall/nftables/external_chain_monitor_linux_test.go b/client/firewall/nftables/external_chain_monitor_linux_test.go new file mode 100644 index 000000000..1a37faca2 --- /dev/null +++ b/client/firewall/nftables/external_chain_monitor_linux_test.go @@ -0,0 +1,137 @@ +package nftables + +import ( + "testing" + + "github.com/google/nftables" + "github.com/stretchr/testify/assert" +) + +func TestIsMonitoredFamily(t *testing.T) { + tests := []struct { + family nftables.TableFamily + want bool + }{ + {nftables.TableFamilyIPv4, true}, + {nftables.TableFamilyIPv6, true}, + {nftables.TableFamilyINet, true}, + {nftables.TableFamilyARP, false}, + {nftables.TableFamilyBridge, false}, + {nftables.TableFamilyNetdev, false}, + {nftables.TableFamilyUnspecified, false}, + } + for _, tc := range tests { + assert.Equal(t, tc.want, isMonitoredFamily(tc.family), "family=%d", tc.family) + } +} + +func TestIsRelevantMonitorEvent(t *testing.T) { + inetTable := &nftables.Table{Name: "firewalld", Family: nftables.TableFamilyINet} + ipTable := &nftables.Table{Name: "filter", Family: nftables.TableFamilyIPv4} + arpTable := &nftables.Table{Name: "arp", Family: nftables.TableFamilyARP} + + tests := []struct { + name string + ev *nftables.MonitorEvent + want bool + }{ + { + name: "new chain in inet firewalld", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewChain, + Data: &nftables.Chain{Name: "filter_INPUT", Table: inetTable}, + }, + want: true, + }, + { + name: "new chain in ip filter", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewChain, + Data: &nftables.Chain{Name: "INPUT", Table: ipTable}, + }, + want: true, + }, + { + name: "new chain in unwatched arp family", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewChain, + Data: &nftables.Chain{Name: "x", Table: arpTable}, + }, + want: false, + }, + { + name: "new table inet", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewTable, + Data: inetTable, + }, + want: true, + }, + { + name: "del chain (we only act on new)", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeDelChain, + Data: &nftables.Chain{Name: "filter_INPUT", Table: inetTable}, + }, + want: false, + }, + { + name: "chain with nil table", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewChain, + Data: &nftables.Chain{Name: "x"}, + }, + want: false, + }, + { + name: "nil data", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewChain, + Data: (*nftables.Chain)(nil), + }, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, isRelevantMonitorEvent(tc.ev)) + }) + } +} + +// fakeReconciler records reconcile invocations for debounce tests. +type fakeReconciler struct { + calls chan struct{} +} + +func (f *fakeReconciler) reconcileExternalChains() error { + f.calls <- struct{}{} + return nil +} + +func TestExternalChainMonitorStopWithoutStart(t *testing.T) { + m := newExternalChainMonitor(&fakeReconciler{calls: make(chan struct{}, 1)}) + // Must not panic or block. + m.stop() +} + +func TestExternalChainMonitorDoubleStart(t *testing.T) { + // start() twice should be a no-op; stop() cleans up once. + // We avoid exercising the netlink watch loop here because it needs root. + m := newExternalChainMonitor(&fakeReconciler{calls: make(chan struct{}, 1)}) + + // Replace run with a stub that just waits for cancel, so start() stays + // deterministic without opening a netlink socket. + origDone := make(chan struct{}) + m.done = origDone + m.cancel = func() { close(origDone) } + + // Second start should be a no-op (cancel already set). + m.start() + assert.NotNil(t, m.cancel) + + m.stop() + assert.Nil(t, m.cancel) + assert.Nil(t, m.done) +} diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index 8cd5cc6b3..fdc7c2f3c 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -11,9 +11,11 @@ import ( "github.com/google/nftables" "github.com/google/nftables/binaryutil" "github.com/google/nftables/expr" + "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" + nberrors "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/firewall/firewalld" firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface/wgaddr" @@ -49,10 +51,17 @@ type Manager struct { rConn *nftables.Conn wgIface iFaceMapper - router *router - aclManager *AclManager + router *router + aclManager *AclManager + + // IPv6 counterparts, nil when no v6 overlay + router6 *router + aclManager6 *AclManager + notrackOutputChain *nftables.Chain notrackPreroutingChain *nftables.Chain + + extMonitor *externalChainMonitor } // Create nftables firewall manager @@ -62,7 +71,8 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) { wgIface: wgIface, } - workTable := &nftables.Table{Name: getTableName(), Family: nftables.TableFamilyIPv4} + tableName := getTableName() + workTable := &nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv4} var err error m.router, err = newRouter(workTable, wgIface, mtu) @@ -75,35 +85,137 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) { return nil, fmt.Errorf("create acl manager: %w", err) } + if wgIface.Address().HasIPv6() { + if err := m.createIPv6Components(tableName, wgIface, mtu); err != nil { + return nil, fmt.Errorf("create IPv6 firewall: %w", err) + } + } + + m.extMonitor = newExternalChainMonitor(m) + return m, nil } +func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mtu uint16) error { + workTable6 := &nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv6} + + var err error + m.router6, err = newRouter(workTable6, wgIface, mtu) + if err != nil { + return fmt.Errorf("create v6 router: %w", err) + } + + // Share the same IP forwarding state with the v4 router, since + // EnableIPForwarding controls both v4 and v6 sysctls. + m.router6.ipFwdState = m.router.ipFwdState + + m.aclManager6, err = newAclManager(workTable6, wgIface, chainNameRoutingFw) + if err != nil { + return fmt.Errorf("create v6 acl manager: %w", err) + } + + return nil +} + +// hasIPv6 reports whether the manager has IPv6 components initialized. +func (m *Manager) hasIPv6() bool { + return m.router6 != nil +} + +func (m *Manager) initIPv6() error { + workTable6, err := m.createWorkTableFamily(nftables.TableFamilyIPv6) + if err != nil { + return fmt.Errorf("create v6 work table: %w", err) + } + + if err := m.router6.init(workTable6); err != nil { + return fmt.Errorf("v6 router init: %w", err) + } + + if err := m.aclManager6.init(workTable6); err != nil { + return fmt.Errorf("v6 acl manager init: %w", err) + } + + return nil +} + // Init nftables firewall manager func (m *Manager) Init(stateManager *statemanager.Manager) error { + if err := m.initFirewall(); err != nil { + return err + } + + m.persistState(stateManager) + + // Start after initFirewall has installed the baseline external-chain + // accept rules. start() is idempotent across Init/Close/Init cycles. + m.extMonitor.start() + + return nil +} + +// reconcileExternalChains re-applies passthrough accept rules to external +// filter chains for both IPv4 and IPv6 routers. Called by the monitor when +// tables or chains appear (e.g. after firewalld reloads). +func (m *Manager) reconcileExternalChains() error { + m.mutex.Lock() + defer m.mutex.Unlock() + + var merr *multierror.Error + if m.router != nil { + if err := m.router.acceptExternalChainsRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("v4: %w", err)) + } + } + if m.hasIPv6() { + if err := m.router6.acceptExternalChainsRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("v6: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +func (m *Manager) initFirewall() (err error) { workTable, err := m.createWorkTable() if err != nil { return fmt.Errorf("create work table: %w", err) } + defer func() { + if err != nil { + m.rollbackInit() + } + }() + if err := m.router.init(workTable); err != nil { return fmt.Errorf("router init: %w", err) } if err := m.aclManager.init(workTable); err != nil { - // TODO: cleanup router return fmt.Errorf("acl manager init: %w", err) } + if m.hasIPv6() { + if err := m.initIPv6(); err != nil { + // Peer has a v6 address: v6 firewall MUST work or we risk fail-open. + return fmt.Errorf("init IPv6 firewall (required because peer has IPv6 address): %w", err) + } + } + if err := m.initNoTrackChains(workTable); err != nil { log.Warnf("raw priority chains not available, notrack rules will be disabled: %v", err) } + return nil +} + +// persistState saves the current interface state for potential recreation on restart. +// Unlike iptables, which requires tracking individual rules, nftables maintains +// a known state (our netbird table plus a few static rules). This allows for easy +// cleanup using Close() without needing to store specific rules. +func (m *Manager) persistState(stateManager *statemanager.Manager) { stateManager.RegisterState(&ShutdownState{}) - // We only need to record minimal interface state for potential recreation. - // Unlike iptables, which requires tracking individual rules, nftables maintains - // a known state (our netbird table plus a few static rules). This allows for easy - // cleanup using Close() without needing to store specific rules. if err := stateManager.UpdateState(&ShutdownState{ InterfaceState: &InterfaceState{ NameStr: m.wgIface.Name(), @@ -114,14 +226,29 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { log.Errorf("failed to update state: %v", err) } - // persist early go func() { if err := stateManager.PersistState(context.Background()); err != nil { log.Errorf("failed to persist state: %v", err) } }() +} - return nil +// rollbackInit performs best-effort cleanup of already-initialized state when Init fails partway through. +func (m *Manager) rollbackInit() { + if err := m.router.Reset(); err != nil { + log.Warnf("rollback router: %v", err) + } + if m.hasIPv6() { + if err := m.router6.Reset(); err != nil { + log.Warnf("rollback v6 router: %v", err) + } + } + if err := m.cleanupNetbirdTables(); err != nil { + log.Warnf("cleanup tables: %v", err) + } + if err := m.rConn.Flush(); err != nil { + log.Warnf("flush: %v", err) + } } // AddPeerFiltering rule to the firewall @@ -140,12 +267,14 @@ func (m *Manager) AddPeerFiltering( m.mutex.Lock() defer m.mutex.Unlock() - rawIP := ip.To4() - if rawIP == nil { - return nil, fmt.Errorf("unsupported IP version: %s", ip.String()) + if ip.To4() != nil { + return m.aclManager.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) } - return m.aclManager.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) + if !m.hasIPv6() { + return nil, fmt.Errorf("add peer filtering for %s: %w", ip, firewall.ErrIPv6NotInitialized) + } + return m.aclManager6.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) } func (m *Manager) AddRouteFiltering( @@ -159,8 +288,11 @@ func (m *Manager) AddRouteFiltering( m.mutex.Lock() defer m.mutex.Unlock() - if destination.IsPrefix() && !destination.Prefix.Addr().Is4() { - return nil, fmt.Errorf("unsupported IP version: %s", destination.Prefix.Addr().String()) + if isIPv6RouteRule(sources, destination) { + if !m.hasIPv6() { + return nil, fmt.Errorf("add route filtering: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) } return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) @@ -171,15 +303,66 @@ func (m *Manager) DeletePeerRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() + if m.hasIPv6() && isIPv6Rule(rule) { + return m.aclManager6.DeletePeerRule(rule) + } return m.aclManager.DeletePeerRule(rule) } -// DeleteRouteRule deletes a routing rule +func isIPv6Rule(rule firewall.Rule) bool { + r, ok := rule.(*Rule) + return ok && r.nftRule != nil && r.nftRule.Table != nil && r.nftRule.Table.Family == nftables.TableFamilyIPv6 +} + +// isIPv6RouteRule determines whether a route rule belongs to the v6 table. +// For static routes, the destination prefix determines the family. For dynamic +// routes (DomainSet), the sources determine the family since management +// duplicates dynamic rules per family. +func isIPv6RouteRule(sources []netip.Prefix, destination firewall.Network) bool { + if destination.IsPrefix() { + return destination.Prefix.Addr().Is6() + } + return len(sources) > 0 && sources[0].Addr().Is6() +} + +// DeleteRouteRule deletes a routing rule. Route rules live in exactly one +// router; the cached maps are normally authoritative, so the kernel is only +// consulted when neither map knows about the rule. func (m *Manager) DeleteRouteRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.DeleteRouteRule(rule) + id := rule.ID() + r, err := m.routerForRuleID(id, (*router).hasRule) + if err != nil { + return err + } + return r.DeleteRouteRule(rule) +} + +// routerForRuleID picks the router holding the rule with the given id, using +// the supplied lookup. If the cached maps disagree (or both miss), it refreshes +// from the kernel once and re-checks before falling back to the v4 router. +func (m *Manager) routerForRuleID(id string, has func(*router, string) bool) (*router, error) { + if has(m.router, id) { + return m.router, nil + } + if m.hasIPv6() && has(m.router6, id) { + return m.router6, nil + } + if !m.hasIPv6() { + return m.router, nil + } + if err := m.router.refreshRulesMap(); err != nil { + return nil, fmt.Errorf("refresh v4 rules: %w", err) + } + if err := m.router6.refreshRulesMap(); err != nil { + return nil, fmt.Errorf("refresh v6 rules: %w", err) + } + if has(m.router6, id) && !has(m.router, id) { + return m.router6, nil + } + return m.router, nil } func (m *Manager) IsServerRouteSupported() bool { @@ -194,19 +377,70 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddNatRule(pair) + if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add NAT rule: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddNatRule(pair) + } + + if err := m.router.AddNatRule(pair); err != nil { + return err + } + + // Dynamic routes need NAT in both tables since resolved IPs can be + // either v4 or v6. This covers both DomainSet (modern) and the legacy + // wildcard 0.0.0.0/0 destination where the client resolves DNS. + // On v6 failure we keep the v4 NAT rule rather than rolling back: half + // connectivity is better than none, and RemoveNatRule is content-keyed + // so the eventual cleanup still works. + if m.hasIPv6() && pair.Dynamic { + v6Pair := firewall.ToV6NatPair(pair) + if err := m.router6.AddNatRule(v6Pair); err != nil { + return fmt.Errorf("add v6 NAT rule: %w", err) + } + } + + return nil } func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveNatRule(pair) + if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() { + if !m.hasIPv6() { + return nil + } + return m.router6.RemoveNatRule(pair) + } + + var merr *multierror.Error + + if err := m.router.RemoveNatRule(pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove v4 NAT rule: %w", err)) + } + + if m.hasIPv6() && pair.Dynamic { + v6Pair := firewall.ToV6NatPair(pair) + if err := m.router6.RemoveNatRule(v6Pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove v6 NAT rule: %w", err)) + } + } + + return nberrors.FormatErrorOrNil(merr) } // AllowNetbird allows netbird interface traffic. // This is called when USPFilter wraps the native firewall, adding blanket accept // rules so that packet filtering is handled in userspace instead of by netfilter. +// +// TODO: In USP mode this only adds ACCEPT to the netbird table's own chains, +// which doesn't override DROP rules in external tables (e.g. firewalld). +// Should add passthrough rules to external chains (like the native mode router's +// addExternalChainsRules does) for both the netbird table family and inet tables. +// The netbird table itself is fine (routing chains already exist there), but +// non-netbird tables with INPUT/FORWARD hooks can still DROP our WG traffic. func (m *Manager) AllowNetbird() error { m.mutex.Lock() defer m.mutex.Unlock() @@ -214,6 +448,11 @@ func (m *Manager) AllowNetbird() error { if err := m.aclManager.createDefaultAllowRules(); err != nil { return fmt.Errorf("create default allow rules: %w", err) } + if m.hasIPv6() { + if err := m.aclManager6.createDefaultAllowRules(); err != nil { + return fmt.Errorf("create v6 default allow rules: %w", err) + } + } if err := m.rConn.Flush(); err != nil { return fmt.Errorf("flush allow input netbird rules: %w", err) } @@ -227,31 +466,47 @@ func (m *Manager) AllowNetbird() error { // SetLegacyManagement sets the route manager to use legacy management func (m *Manager) SetLegacyManagement(isLegacy bool) error { - return firewall.SetLegacyManagement(m.router, isLegacy) + if err := firewall.SetLegacyManagement(m.router, isLegacy); err != nil { + return err + } + if m.hasIPv6() { + return firewall.SetLegacyManagement(m.router6, isLegacy) + } + return nil } // Close closes the firewall manager func (m *Manager) Close(stateManager *statemanager.Manager) error { + m.extMonitor.stop() + m.mutex.Lock() defer m.mutex.Unlock() + var merr *multierror.Error + if err := m.router.Reset(); err != nil { - return fmt.Errorf("reset router: %v", err) + merr = multierror.Append(merr, fmt.Errorf("reset router: %v", err)) + } + + if m.hasIPv6() { + if err := m.router6.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset v6 router: %v", err)) + } } if err := m.cleanupNetbirdTables(); err != nil { - return fmt.Errorf("cleanup netbird tables: %v", err) + merr = multierror.Append(merr, fmt.Errorf("cleanup netbird tables: %v", err)) } if err := m.rConn.Flush(); err != nil { - return fmt.Errorf(flushError, err) + merr = multierror.Append(merr, fmt.Errorf(flushError, err)) } if err := stateManager.DeleteState(&ShutdownState{}); err != nil { - return fmt.Errorf("delete state: %v", err) + merr = multierror.Append(merr, fmt.Errorf("delete state: %v", err)) } - return nil + return nberrors.FormatErrorOrNil(merr) } func (m *Manager) cleanupNetbirdTables() error { @@ -300,6 +555,12 @@ func (m *Manager) Flush() error { return err } + if m.hasIPv6() { + if err := m.aclManager6.Flush(); err != nil { + return fmt.Errorf("flush v6 acl: %w", err) + } + } + if err := m.refreshNoTrackChains(); err != nil { log.Errorf("failed to refresh notrack chains: %v", err) } @@ -312,6 +573,12 @@ func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) m.mutex.Lock() defer m.mutex.Unlock() + if rule.TranslatedAddress.Is6() { + if !m.hasIPv6() { + return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddDNATRule(rule) + } return m.router.AddDNATRule(rule) } @@ -320,7 +587,11 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.DeleteDNATRule(rule) + r, err := m.routerForRuleID(rule.ID(), (*router).hasDNATRule) + if err != nil { + return err + } + return r.DeleteDNATRule(rule) } // UpdateSet updates the set with the given prefixes @@ -328,39 +599,82 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.UpdateSet(set, prefixes) + var v4Prefixes, v6Prefixes []netip.Prefix + for _, p := range prefixes { + if p.Addr().Is6() { + v6Prefixes = append(v6Prefixes, p) + } else { + v4Prefixes = append(v4Prefixes, p) + } + } + + if err := m.router.UpdateSet(set, v4Prefixes); err != nil { + return err + } + + if m.hasIPv6() && len(v6Prefixes) > 0 { + if err := m.router6.UpdateSet(set, v6Prefixes); err != nil { + return fmt.Errorf("update v6 set: %w", err) + } + } + + return nil } // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddInboundDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add inbound DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveInboundDNAT removes an inbound DNAT rule. -func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveInboundDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("remove inbound DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. -func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddOutputDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. -func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveOutputDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("remove output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } const ( @@ -534,7 +848,11 @@ func (m *Manager) refreshNoTrackChains() error { } func (m *Manager) createWorkTable() (*nftables.Table, error) { - tables, err := m.rConn.ListTablesOfFamily(nftables.TableFamilyIPv4) + return m.createWorkTableFamily(nftables.TableFamilyIPv4) +} + +func (m *Manager) createWorkTableFamily(family nftables.TableFamily) (*nftables.Table, error) { + tables, err := m.rConn.ListTablesOfFamily(family) if err != nil { return nil, fmt.Errorf("list of tables: %w", err) } @@ -546,7 +864,7 @@ func (m *Manager) createWorkTable() (*nftables.Table, error) { } } - table := m.rConn.AddTable(&nftables.Table{Name: getTableName(), Family: nftables.TableFamilyIPv4}) + table := m.rConn.AddTable(&nftables.Table{Name: tableName, Family: family}) err = m.rConn.Flush() return table, err } diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go index d48e4ba88..be4f65881 100644 --- a/client/firewall/nftables/manager_linux_test.go +++ b/client/firewall/nftables/manager_linux_test.go @@ -383,10 +383,138 @@ func TestNftablesManagerCompatibilityWithIptables(t *testing.T) { err = manager.AddNatRule(pair) require.NoError(t, err, "failed to add NAT rule") + dnatRule, err := manager.AddDNATRule(fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{8080}}, + TranslatedAddress: netip.MustParseAddr("100.96.0.2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + }) + require.NoError(t, err, "failed to add DNAT rule") + + t.Cleanup(func() { + require.NoError(t, manager.DeleteDNATRule(dnatRule), "failed to delete DNAT rule") + }) + stdout, stderr = runIptablesSave(t) verifyIptablesOutput(t, stdout, stderr) } +func TestNftablesManagerIPv6CompatibilityWithIp6tables(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + for _, bin := range []string{"ip6tables", "ip6tables-save", "iptables-save"} { + if _, err := exec.LookPath(bin); err != nil { + t.Skipf("%s not available on this system: %v", bin, err) + } + } + + // Seed ip6 tables in the nft backend. Docker may not create them. + seedIp6tables(t) + + ifaceMockV6 := &iFaceMock{ + NameFunc: func() string { return "wt-test" }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.96.0.1"), + Network: netip.MustParsePrefix("100.96.0.0/16"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } + + manager, err := Create(ifaceMockV6, iface.DefaultMTU) + require.NoError(t, err, "create manager") + require.NoError(t, manager.Init(nil)) + + t.Cleanup(func() { + require.NoError(t, manager.Close(nil), "close manager") + + stdout, stderr := runIp6tablesSave(t) + verifyIp6tablesOutput(t, stdout, stderr) + }) + + ip := netip.MustParseAddr("fd00::2") + _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "") + require.NoError(t, err, "add v6 peer filtering rule") + + _, err = manager.AddRouteFiltering( + nil, + []netip.Prefix{netip.MustParsePrefix("fd00:1::/64")}, + fw.Network{Prefix: netip.MustParsePrefix("2001:db8::/48")}, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err, "add v6 route filtering rule") + + err = manager.AddNatRule(fw.RouterPair{ + Source: fw.Network{Prefix: netip.MustParsePrefix("fd00::/64")}, + Destination: fw.Network{Prefix: netip.MustParsePrefix("2001:db8::/48")}, + Masquerade: true, + }) + require.NoError(t, err, "add v6 NAT rule") + + dnatRule, err := manager.AddDNATRule(fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{8080}}, + TranslatedAddress: netip.MustParseAddr("fd00::2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + }) + require.NoError(t, err, "add v6 DNAT rule") + + t.Cleanup(func() { + require.NoError(t, manager.DeleteDNATRule(dnatRule), "delete v6 DNAT rule") + }) + + stdout, stderr := runIptablesSave(t) + verifyIptablesOutput(t, stdout, stderr) + + stdout, stderr = runIp6tablesSave(t) + verifyIp6tablesOutput(t, stdout, stderr) +} + +func seedIp6tables(t *testing.T) { + t.Helper() + for _, tc := range []struct{ table, chain string }{ + {"filter", "FORWARD"}, + {"nat", "POSTROUTING"}, + {"mangle", "FORWARD"}, + } { + add := exec.Command("ip6tables", "-t", tc.table, "-A", tc.chain, "-j", "ACCEPT") + require.NoError(t, add.Run(), "seed ip6tables -t %s", tc.table) + del := exec.Command("ip6tables", "-t", tc.table, "-D", tc.chain, "-j", "ACCEPT") + require.NoError(t, del.Run(), "unseed ip6tables -t %s", tc.table) + } +} + +func runIp6tablesSave(t *testing.T) (string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command("ip6tables-save") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), "ip6tables-save failed") + return stdout.String(), stderr.String() +} + +func verifyIp6tablesOutput(t *testing.T, stdout, stderr string) { + t.Helper() + for _, msg := range []string{ + "Table `nat' is incompatible", + "Table `mangle' is incompatible", + "Table `filter' is incompatible", + } { + require.NotContains(t, stdout, msg, + "ip6tables-save stdout reports incompatibility: %s", stdout) + require.NotContains(t, stderr, msg, + "ip6tables-save stderr reports incompatibility: %s", stderr) + } +} + func TestNftablesManagerCompatibilityWithIptablesFor6kPrefixes(t *testing.T) { if check() != NFTABLES { t.Skip("nftables not supported on this system") diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go index 8cc0d2792..4214455a9 100644 --- a/client/firewall/nftables/router_linux.go +++ b/client/firewall/nftables/router_linux.go @@ -50,8 +50,10 @@ const ( dnatSuffix = "_dnat" snatSuffix = "_snat" - // ipTCPHeaderMinSize represents minimum IP (20) + TCP (20) header size for MSS calculation - ipTCPHeaderMinSize = 40 + // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. + ipv4TCPHeaderSize = 40 + // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. + ipv6TCPHeaderSize = 60 // maxPrefixesSet 1638 prefixes start to fail, taking some margin maxPrefixesSet = 1500 @@ -76,6 +78,7 @@ type router struct { rules map[string]*nftables.Rule ipsetCounter *refcounter.Counter[string, setInput, *nftables.Set] + af addrFamily wgIface iFaceMapper ipFwdState *ipfwdstate.IPForwardingState legacyManagement bool @@ -88,6 +91,7 @@ func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*rou workTable: workTable, chains: make(map[string]*nftables.Chain), rules: make(map[string]*nftables.Rule), + af: familyForAddr(workTable.Family == nftables.TableFamilyIPv4), wgIface: wgIface, ipFwdState: ipfwdstate.NewIPForwardingState(), mtu: mtu, @@ -150,7 +154,7 @@ func (r *router) Reset() error { func (r *router) removeNatPreroutingRules() error { table := &nftables.Table{ Name: tableNat, - Family: nftables.TableFamilyIPv4, + Family: r.af.tableFamily, } chain := &nftables.Chain{ Name: chainNameNatPrerouting, @@ -183,7 +187,7 @@ func (r *router) removeNatPreroutingRules() error { } func (r *router) loadFilterTable() (*nftables.Table, error) { - tables, err := r.conn.ListTablesOfFamily(nftables.TableFamilyIPv4) + tables, err := r.conn.ListTablesOfFamily(r.af.tableFamily) if err != nil { return nil, fmt.Errorf("list tables: %w", err) } @@ -419,7 +423,7 @@ func (r *router) AddRouteFiltering( // Handle protocol if proto != firewall.ProtocolALL { - protoNum, err := protoToInt(proto) + protoNum, err := r.af.protoNum(proto) if err != nil { return nil, fmt.Errorf("convert protocol to number: %w", err) } @@ -479,7 +483,24 @@ func (r *router) getIpSet(set firewall.Set, prefixes []netip.Prefix, isSource bo return nil, fmt.Errorf("create or get ipset: %w", err) } - return getIpSetExprs(ref, isSource) + return r.getIpSetExprs(ref, isSource) +} + +func (r *router) iptablesProto() iptables.Protocol { + if r.af.tableFamily == nftables.TableFamilyIPv6 { + return iptables.ProtocolIPv6 + } + return iptables.ProtocolIPv4 +} + +func (r *router) hasRule(id string) bool { + _, ok := r.rules[id] + return ok +} + +func (r *router) hasDNATRule(id string) bool { + _, ok := r.rules[id+dnatSuffix] + return ok } func (r *router) DeleteRouteRule(rule firewall.Rule) error { @@ -528,10 +549,10 @@ func (r *router) createIpSet(setName string, input setInput) (*nftables.Set, err Table: r.workTable, // required for prefixes Interval: true, - KeyType: nftables.TypeIPAddr, + KeyType: r.af.setKeyType, } - elements := convertPrefixesToSet(prefixes) + elements := r.convertPrefixesToSet(prefixes) nElements := len(elements) maxElements := maxPrefixesSet * 2 @@ -564,23 +585,17 @@ func (r *router) createIpSet(setName string, input setInput) (*nftables.Set, err return nfset, nil } -func convertPrefixesToSet(prefixes []netip.Prefix) []nftables.SetElement { +func (r *router) convertPrefixesToSet(prefixes []netip.Prefix) []nftables.SetElement { var elements []nftables.SetElement for _, prefix := range prefixes { - // TODO: Implement IPv6 support - if prefix.Addr().Is6() { - log.Tracef("skipping IPv6 prefix %s: IPv6 support not yet implemented", prefix) - continue - } - // nftables needs half-open intervals [firstIP, lastIP) for prefixes // e.g. 10.0.0.0/24 becomes [10.0.0.0, 10.0.1.0), 10.1.1.1/32 becomes [10.1.1.1, 10.1.1.2) etc firstIP := prefix.Addr() lastIP := calculateLastIP(prefix).Next() elements = append(elements, - // the nft tool also adds a line like this, see https://github.com/google/nftables/issues/247 - // nftables.SetElement{Key: []byte{0, 0, 0, 0}, IntervalEnd: true}, + // the nft tool also adds a zero-address IntervalEnd element, see https://github.com/google/nftables/issues/247 + // nftables.SetElement{Key: make([]byte, r.af.addrLen), IntervalEnd: true}, nftables.SetElement{Key: firstIP.AsSlice()}, nftables.SetElement{Key: lastIP.AsSlice(), IntervalEnd: true}, ) @@ -590,10 +605,20 @@ func convertPrefixesToSet(prefixes []netip.Prefix) []nftables.SetElement { // calculateLastIP determines the last IP in a given prefix. func calculateLastIP(prefix netip.Prefix) netip.Addr { - hostMask := ^uint32(0) >> prefix.Masked().Bits() - lastIP := uint32FromNetipAddr(prefix.Addr()) | hostMask + masked := prefix.Masked() + if masked.Addr().Is4() { + hostMask := ^uint32(0) >> masked.Bits() + lastIP := uint32FromNetipAddr(masked.Addr()) | hostMask + return netip.AddrFrom4(uint32ToBytes(lastIP)) + } - return netip.AddrFrom4(uint32ToBytes(lastIP)) + // IPv6: set host bits to all 1s + b := masked.Addr().As16() + bits := masked.Bits() + for i := bits; i < 128; i++ { + b[i/8] |= 1 << (7 - i%8) + } + return netip.AddrFrom16(b) } // Utility function to convert netip.Addr to uint32. @@ -845,9 +870,16 @@ func (r *router) addPostroutingRules() { } // addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic. -// TODO: Add IPv6 support func (r *router) addMSSClampingRules() error { - mss := r.mtu - ipTCPHeaderMinSize + overhead := uint16(ipv4TCPHeaderSize) + if r.af.tableFamily == nftables.TableFamilyIPv6 { + overhead = ipv6TCPHeaderSize + } + if r.mtu <= overhead { + log.Debugf("MTU %d too small for MSS clamping (overhead %d), skipping", r.mtu, overhead) + return nil + } + mss := r.mtu - overhead exprsOut := []expr.Any{ &expr.Meta{ @@ -1054,17 +1086,22 @@ func (r *router) acceptFilterTableRules() error { log.Debugf("Used %s to add accept forward and input rules", fw) }() - // Try iptables first and fallback to nftables if iptables is not available - ipt, err := iptables.New() + // Try iptables first and fallback to nftables if iptables is not available. + // Use the correct protocol (iptables vs ip6tables) for the address family. + ipt, err := iptables.NewWithProtocol(r.iptablesProto()) if err != nil { - // iptables is not available but the filter table exists log.Warnf("Will use nftables to manipulate the filter table because iptables is not available: %v", err) fw = "nftables" return r.acceptFilterRulesNftables(r.filterTable) } - return r.acceptFilterRulesIptables(ipt) + if err := r.acceptFilterRulesIptables(ipt); err != nil { + log.Warnf("iptables failed (table may be incompatible), falling back to nftables: %v", err) + fw = "nftables" + return r.acceptFilterRulesNftables(r.filterTable) + } + return nil } func (r *router) acceptFilterRulesIptables(ipt *iptables.IPTables) error { @@ -1135,83 +1172,122 @@ func (r *router) acceptExternalChainsRules() error { } intf := ifname(r.wgIface.Name()) - for _, chain := range chains { - if chain.Hooknum == nil { - log.Debugf("skipping external chain %s/%s: hooknum is nil", chain.Table.Name, chain.Name) - continue - } - - log.Debugf("adding accept rules to external %s chain: %s %s/%s", - hookName(chain.Hooknum), familyName(chain.Table.Family), chain.Table.Name, chain.Name) - - switch *chain.Hooknum { - case *nftables.ChainHookForward: - r.insertForwardAcceptRules(chain, intf) - case *nftables.ChainHookInput: - r.insertInputAcceptRule(chain, intf) - } + r.applyExternalChainAccept(chain, intf) } if err := r.conn.Flush(); err != nil { return fmt.Errorf("flush external chain rules: %w", err) } - return nil } +func (r *router) applyExternalChainAccept(chain *nftables.Chain, intf []byte) { + if chain.Hooknum == nil { + log.Debugf("skipping external chain %s/%s: hooknum is nil", chain.Table.Name, chain.Name) + return + } + + log.Debugf("adding accept rules to external %s chain: %s %s/%s", + hookName(chain.Hooknum), familyName(chain.Table.Family), chain.Table.Name, chain.Name) + + switch *chain.Hooknum { + case *nftables.ChainHookForward: + r.insertForwardAcceptRules(chain, intf) + case *nftables.ChainHookInput: + r.insertInputAcceptRule(chain, intf) + } +} + func (r *router) insertForwardAcceptRules(chain *nftables.Chain, intf []byte) { - iifRule := &nftables.Rule{ + existing, err := r.existingNetbirdRulesInChain(chain) + if err != nil { + log.Warnf("skip forward accept rules in %s/%s: %v", chain.Table.Name, chain.Name, err) + return + } + r.insertForwardIifRule(chain, intf, existing) + r.insertForwardOifEstablishedRule(chain, intf, existing) +} + +func (r *router) insertForwardIifRule(chain *nftables.Chain, intf []byte, existing map[string]bool) { + if existing[userDataAcceptForwardRuleIif] { + return + } + r.conn.InsertRule(&nftables.Rule{ Table: chain.Table, Chain: chain, Exprs: []expr.Any{ &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: intf, - }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, &expr.Counter{}, &expr.Verdict{Kind: expr.VerdictAccept}, }, UserData: []byte(userDataAcceptForwardRuleIif), - } - r.conn.InsertRule(iifRule) + }) +} - oifExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: intf, - }, +func (r *router) insertForwardOifEstablishedRule(chain *nftables.Chain, intf []byte, existing map[string]bool) { + if existing[userDataAcceptForwardRuleOif] { + return } - oifRule := &nftables.Rule{ + exprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, + } + r.conn.InsertRule(&nftables.Rule{ Table: chain.Table, Chain: chain, - Exprs: append(oifExprs, getEstablishedExprs(2)...), + Exprs: append(exprs, getEstablishedExprs(2)...), UserData: []byte(userDataAcceptForwardRuleOif), - } - r.conn.InsertRule(oifRule) + }) } func (r *router) insertInputAcceptRule(chain *nftables.Chain, intf []byte) { - inputRule := &nftables.Rule{ + existing, err := r.existingNetbirdRulesInChain(chain) + if err != nil { + log.Warnf("skip input accept rule in %s/%s: %v", chain.Table.Name, chain.Name, err) + return + } + if existing[userDataAcceptInputRule] { + return + } + r.conn.InsertRule(&nftables.Rule{ Table: chain.Table, Chain: chain, Exprs: []expr.Any{ &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: intf, - }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, &expr.Counter{}, &expr.Verdict{Kind: expr.VerdictAccept}, }, UserData: []byte(userDataAcceptInputRule), + }) +} + +// existingNetbirdRulesInChain returns the set of netbird-owned UserData tags present in a chain; callers must bail on error since InsertRule is additive. +func (r *router) existingNetbirdRulesInChain(chain *nftables.Chain) (map[string]bool, error) { + rules, err := r.conn.GetRules(chain.Table, chain) + if err != nil { + return nil, fmt.Errorf("list rules: %w", err) } - r.conn.InsertRule(inputRule) + present := map[string]bool{} + for _, rule := range rules { + if !isNetbirdAcceptRuleTag(rule.UserData) { + continue + } + present[string(rule.UserData)] = true + } + return present, nil +} + +func isNetbirdAcceptRuleTag(userData []byte) bool { + switch string(userData) { + case userDataAcceptForwardRuleIif, + userDataAcceptForwardRuleOif, + userDataAcceptInputRule: + return true + } + return false } func (r *router) removeAcceptFilterRules() error { @@ -1233,13 +1309,17 @@ func (r *router) removeFilterTableRules() error { return nil } - ipt, err := iptables.New() + ipt, err := iptables.NewWithProtocol(r.iptablesProto()) if err != nil { log.Debugf("iptables not available, using nftables to remove filter rules: %v", err) return r.removeAcceptRulesFromTable(r.filterTable) } - return r.removeAcceptFilterRulesIptables(ipt) + if err := r.removeAcceptFilterRulesIptables(ipt); err != nil { + log.Debugf("iptables removal failed (table may be incompatible), falling back to nftables: %v", err) + return r.removeAcceptRulesFromTable(r.filterTable) + } + return nil } func (r *router) removeAcceptRulesFromTable(table *nftables.Table) error { @@ -1306,7 +1386,7 @@ func (r *router) removeExternalChainsRules() error { func (r *router) findExternalChains() []*nftables.Chain { var chains []*nftables.Chain - families := []nftables.TableFamily{nftables.TableFamilyIPv4, nftables.TableFamilyINet} + families := []nftables.TableFamily{r.af.tableFamily, nftables.TableFamilyINet} for _, family := range families { allChains, err := r.conn.ListChainsOfTableFamily(family) @@ -1337,8 +1417,8 @@ func (r *router) isExternalChain(chain *nftables.Chain) bool { return false } - // Skip all iptables-managed tables in the ip family - if chain.Table.Family == nftables.TableFamilyIPv4 && isIptablesTable(chain.Table.Name) { + // Skip iptables/ip6tables-managed tables (adding nft-native rules breaks iptables-save compat) + if (chain.Table.Family == nftables.TableFamilyIPv4 || chain.Table.Family == nftables.TableFamilyIPv6) && isIptablesTable(chain.Table.Name) { return false } @@ -1479,7 +1559,7 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { return rule, nil } - protoNum, err := protoToInt(rule.Protocol) + protoNum, err := r.af.protoNum(rule.Protocol) if err != nil { return nil, fmt.Errorf("convert protocol to number: %w", err) } @@ -1542,7 +1622,7 @@ func (r *router) addDnatRedirect(rule firewall.ForwardRule, protoNum uint8, rule dnatExprs = append(dnatExprs, &expr.NAT{ Type: expr.NATTypeDestNAT, - Family: uint32(nftables.TableFamilyIPv4), + Family: uint32(r.af.tableFamily), RegAddrMin: 1, RegProtoMin: regProtoMin, RegProtoMax: regProtoMax, @@ -1635,14 +1715,15 @@ func (r *router) addXTablesRedirect(dnatExprs []expr.Any, ruleKey string, rule f }, ) + natTable := &nftables.Table{ + Name: tableNat, + Family: r.af.tableFamily, + } dnatRule := &nftables.Rule{ - Table: &nftables.Table{ - Name: tableNat, - Family: nftables.TableFamilyIPv4, - }, + Table: natTable, Chain: &nftables.Chain{ Name: chainNameNatPrerouting, - Table: r.filterTable, + Table: natTable, Type: nftables.ChainTypeNAT, Hooknum: nftables.ChainHookPrerouting, Priority: nftables.ChainPriorityNATDest, @@ -1673,8 +1754,8 @@ func (r *router) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleKey &expr.Payload{ DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, - Offset: 16, - Len: 4, + Offset: r.af.dstAddrOffset, + Len: r.af.addrLen, }, &expr.Cmp{ Op: expr.CmpOpEq, @@ -1752,7 +1833,7 @@ func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { return fmt.Errorf("get set %s: %w", set.HashedName(), err) } - elements := convertPrefixesToSet(prefixes) + elements := r.convertPrefixesToSet(prefixes) if err := r.conn.SetAddElements(nfset, elements); err != nil { return fmt.Errorf("add elements to set %s: %w", set.HashedName(), err) } @@ -1767,14 +1848,14 @@ func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { } // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) +func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) if _, exists := r.rules[ruleID]; exists { return nil } - protoNum, err := protoToInt(protocol) + protoNum, err := r.af.protoNum(protocol) if err != nil { return fmt.Errorf("convert protocol to number: %w", err) } @@ -1801,11 +1882,15 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol &expr.Cmp{ Op: expr.CmpOpEq, Register: 3, - Data: binaryutil.BigEndian.PutUint16(sourcePort), + Data: binaryutil.BigEndian.PutUint16(originalPort), }, } - exprs = append(exprs, applyPrefix(netip.PrefixFrom(localAddr, 32), false)...) + bits := 32 + if localAddr.Is6() { + bits = 128 + } + exprs = append(exprs, r.applyPrefix(netip.PrefixFrom(localAddr, bits), false)...) exprs = append(exprs, &expr.Immediate{ @@ -1814,11 +1899,11 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol }, &expr.Immediate{ Register: 2, - Data: binaryutil.BigEndian.PutUint16(targetPort), + Data: binaryutil.BigEndian.PutUint16(translatedPort), }, &expr.NAT{ Type: expr.NATTypeDestNAT, - Family: uint32(nftables.TableFamilyIPv4), + Family: uint32(r.af.tableFamily), RegAddrMin: 1, RegProtoMin: 2, RegProtoMax: 0, @@ -1843,12 +1928,12 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol } // RemoveInboundDNAT removes an inbound DNAT rule. -func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { if err := r.refreshRulesMap(); err != nil { return fmt.Errorf(refreshRulesMapError, err) } - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) + ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) rule, exists := r.rules[ruleID] if !exists { @@ -1894,8 +1979,8 @@ func (r *router) ensureNATOutputChain() error { } // AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. -func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) +func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) if _, exists := r.rules[ruleID]; exists { return nil @@ -1905,7 +1990,7 @@ func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, return err } - protoNum, err := protoToInt(protocol) + protoNum, err := r.af.protoNum(protocol) if err != nil { return fmt.Errorf("convert protocol to number: %w", err) } @@ -1926,11 +2011,15 @@ func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, &expr.Cmp{ Op: expr.CmpOpEq, Register: 2, - Data: binaryutil.BigEndian.PutUint16(sourcePort), + Data: binaryutil.BigEndian.PutUint16(originalPort), }, } - exprs = append(exprs, applyPrefix(netip.PrefixFrom(localAddr, 32), false)...) + bits := 32 + if localAddr.Is6() { + bits = 128 + } + exprs = append(exprs, r.applyPrefix(netip.PrefixFrom(localAddr, bits), false)...) exprs = append(exprs, &expr.Immediate{ @@ -1939,11 +2028,11 @@ func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, }, &expr.Immediate{ Register: 2, - Data: binaryutil.BigEndian.PutUint16(targetPort), + Data: binaryutil.BigEndian.PutUint16(translatedPort), }, &expr.NAT{ Type: expr.NATTypeDestNAT, - Family: uint32(nftables.TableFamilyIPv4), + Family: uint32(r.af.tableFamily), RegAddrMin: 1, RegProtoMin: 2, }, @@ -1967,12 +2056,12 @@ func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, } // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. -func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { if err := r.refreshRulesMap(); err != nil { return fmt.Errorf(refreshRulesMapError, err) } - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) + ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) rule, exists := r.rules[ruleID] if !exists { @@ -2011,45 +2100,44 @@ func (r *router) applyNetwork( } if network.IsPrefix() { - return applyPrefix(network.Prefix, isSource), nil + return r.applyPrefix(network.Prefix, isSource), nil } return nil, nil } // applyPrefix generates nftables expressions for a CIDR prefix -func applyPrefix(prefix netip.Prefix, isSource bool) []expr.Any { - // dst offset - offset := uint32(16) +func (r *router) applyPrefix(prefix netip.Prefix, isSource bool) []expr.Any { + // dst offset by default + offset := r.af.dstAddrOffset if isSource { // src offset - offset = 12 + offset = r.af.srcAddrOffset } ones := prefix.Bits() - // 0.0.0.0/0 doesn't need extra expressions + // unspecified address (/0) doesn't need extra expressions if ones == 0 { return nil } - mask := net.CIDRMask(ones, 32) + mask := net.CIDRMask(ones, r.af.totalBits) + xor := make([]byte, r.af.addrLen) return []expr.Any{ &expr.Payload{ DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset, - Len: 4, + Len: r.af.addrLen, }, - // netmask &expr.Bitwise{ DestRegister: 1, SourceRegister: 1, - Len: 4, + Len: r.af.addrLen, Mask: mask, - Xor: []byte{0, 0, 0, 0}, + Xor: xor, }, - // net address &expr.Cmp{ Op: expr.CmpOpEq, Register: 1, @@ -2132,13 +2220,12 @@ func getCtNewExprs() []expr.Any { } } -func getIpSetExprs(ref refcounter.Ref[*nftables.Set], isSource bool) ([]expr.Any, error) { - - // dst offset - offset := uint32(16) +func (r *router) getIpSetExprs(ref refcounter.Ref[*nftables.Set], isSource bool) ([]expr.Any, error) { + // dst offset by default + offset := r.af.dstAddrOffset if isSource { // src offset - offset = 12 + offset = r.af.srcAddrOffset } return []expr.Any{ @@ -2146,7 +2233,7 @@ func getIpSetExprs(ref refcounter.Ref[*nftables.Set], isSource bool) ([]expr.Any DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset, - Len: 4, + Len: r.af.addrLen, }, &expr.Lookup{ SourceRegister: 1, diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go index f0e34d211..c5d6729d9 100644 --- a/client/firewall/nftables/router_linux_test.go +++ b/client/firewall/nftables/router_linux_test.go @@ -90,8 +90,9 @@ func TestNftablesManager_AddNatRule(t *testing.T) { } // Build CIDR matching expressions - sourceExp := applyPrefix(testCase.InputPair.Source.Prefix, true) - destExp := applyPrefix(testCase.InputPair.Destination.Prefix, false) + testRouter := &router{af: afIPv4} + sourceExp := testRouter.applyPrefix(testCase.InputPair.Source.Prefix, true) + destExp := testRouter.applyPrefix(testCase.InputPair.Destination.Prefix, false) // Combine all expressions in the correct order // nolint:gocritic @@ -508,6 +509,136 @@ func TestNftablesCreateIpSet(t *testing.T) { } } +func TestNftablesCreateIpSet_IPv6(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + workTable, err := createWorkTableIPv6() + require.NoError(t, err, "Failed to create v6 work table") + defer deleteWorkTableIPv6() + + r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) + require.NoError(t, err, "Failed to create router") + require.NoError(t, r.init(workTable)) + defer func() { + require.NoError(t, r.Reset(), "Failed to reset router") + }() + + tests := []struct { + name string + sources []netip.Prefix + expected []netip.Prefix + }{ + { + name: "Single IPv6", + sources: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/128")}, + }, + { + name: "Multiple IPv6 Subnets", + sources: []netip.Prefix{ + netip.MustParsePrefix("fd00::/64"), + netip.MustParsePrefix("2001:db8::/48"), + netip.MustParsePrefix("fe80::/10"), + }, + }, + { + name: "Overlapping IPv6", + sources: []netip.Prefix{ + netip.MustParsePrefix("fd00::/48"), + netip.MustParsePrefix("fd00::/64"), + netip.MustParsePrefix("fd00::1/128"), + }, + expected: []netip.Prefix{ + netip.MustParsePrefix("fd00::/48"), + }, + }, + { + name: "Mixed prefix lengths", + sources: []netip.Prefix{ + netip.MustParsePrefix("2001:db8:1::/48"), + netip.MustParsePrefix("2001:db8:2::1/128"), + netip.MustParsePrefix("fd00:abcd::/32"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setName := firewall.NewPrefixSet(tt.sources).HashedName() + set, err := r.createIpSet(setName, setInput{prefixes: tt.sources}) + require.NoError(t, err, "Failed to create IPv6 set") + require.NotNil(t, set) + + assert.Equal(t, setName, set.Name) + assert.True(t, set.Interval) + assert.Equal(t, nftables.TypeIP6Addr, set.KeyType) + + fetchedSet, err := r.conn.GetSetByName(r.workTable, setName) + require.NoError(t, err, "Failed to fetch created set") + + elements, err := r.conn.GetSetElements(fetchedSet) + require.NoError(t, err, "Failed to get set elements") + + uniquePrefixes := make(map[string]bool) + for _, elem := range elements { + if !elem.IntervalEnd && len(elem.Key) == 16 { + ip := netip.AddrFrom16([16]byte(elem.Key)) + uniquePrefixes[ip.String()] = true + } + } + + expectedCount := len(tt.expected) + if expectedCount == 0 { + expectedCount = len(tt.sources) + } + assert.Equal(t, expectedCount, len(uniquePrefixes), "unique prefix count mismatch") + + r.conn.DelSet(set) + require.NoError(t, r.conn.Flush()) + }) + } +} + +func createWorkTableIPv6() (*nftables.Table, error) { + sConn, err := nftables.New(nftables.AsLasting()) + if err != nil { + return nil, err + } + + tables, err := sConn.ListTablesOfFamily(nftables.TableFamilyIPv6) + if err != nil { + return nil, err + } + for _, t := range tables { + if t.Name == tableNameNetbird { + sConn.DelTable(t) + } + } + + table := sConn.AddTable(&nftables.Table{Name: tableNameNetbird, Family: nftables.TableFamilyIPv6}) + err = sConn.Flush() + return table, err +} + +func deleteWorkTableIPv6() { + sConn, err := nftables.New(nftables.AsLasting()) + if err != nil { + return + } + + tables, err := sConn.ListTablesOfFamily(nftables.TableFamilyIPv6) + if err != nil { + return + } + for _, t := range tables { + if t.Name == tableNameNetbird { + sConn.DelTable(t) + _ = sConn.Flush() + } + } +} + func verifyRule(t *testing.T, rule *nftables.Rule, sources []netip.Prefix, destination netip.Prefix, proto firewall.Protocol, sPort, dPort *firewall.Port, direction firewall.RuleDirection, action firewall.Action, expectSet bool) { t.Helper() @@ -627,7 +758,7 @@ func containsPort(exprs []expr.Any, port *firewall.Port, isSource bool) bool { func containsProtocol(exprs []expr.Any, proto firewall.Protocol) bool { var metaFound, cmpFound bool - expectedProto, _ := protoToInt(proto) + expectedProto, _ := afIPv4.protoNum(proto) for _, e := range exprs { switch ex := e.(type) { case *expr.Meta: @@ -854,3 +985,55 @@ func TestRouter_AddNatRule_WithStaleEntry(t *testing.T) { } assert.Equal(t, 1, found, "NAT rule should exist in kernel") } + +func TestCalculateLastIP(t *testing.T) { + tests := []struct { + prefix string + want string + }{ + {"10.0.0.0/24", "10.0.0.255"}, + {"10.0.0.0/32", "10.0.0.0"}, + {"0.0.0.0/0", "255.255.255.255"}, + {"192.168.1.0/28", "192.168.1.15"}, + {"fd00::/64", "fd00::ffff:ffff:ffff:ffff"}, + {"fd00::/128", "fd00::"}, + {"2001:db8::/48", "2001:db8:0:ffff:ffff:ffff:ffff:ffff"}, + {"::/0", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"}, + } + for _, tt := range tests { + t.Run(tt.prefix, func(t *testing.T) { + prefix := netip.MustParsePrefix(tt.prefix) + got := calculateLastIP(prefix) + assert.Equal(t, tt.want, got.String()) + }) + } +} + +func TestConvertPrefixesToSet_IPv6(t *testing.T) { + r := &router{af: afIPv6} + prefixes := []netip.Prefix{ + netip.MustParsePrefix("fd00::/64"), + netip.MustParsePrefix("2001:db8::1/128"), + } + + elements := r.convertPrefixesToSet(prefixes) + + // Each prefix produces 2 elements (start + end) + require.Len(t, elements, 4) + + // fd00::/64 start + assert.Equal(t, netip.MustParseAddr("fd00::").As16(), [16]byte(elements[0].Key)) + assert.False(t, elements[0].IntervalEnd) + + // fd00::/64 end (fd00:0:0:1::, one past the last) + assert.Equal(t, netip.MustParseAddr("fd00:0:0:1::").As16(), [16]byte(elements[1].Key)) + assert.True(t, elements[1].IntervalEnd) + + // 2001:db8::1/128 start + assert.Equal(t, netip.MustParseAddr("2001:db8::1").As16(), [16]byte(elements[2].Key)) + assert.False(t, elements[2].IntervalEnd) + + // 2001:db8::1/128 end (2001:db8::2) + assert.Equal(t, netip.MustParseAddr("2001:db8::2").As16(), [16]byte(elements[3].Key)) + assert.True(t, elements[3].IntervalEnd) +} diff --git a/client/firewall/uspfilter/allow_netbird_windows.go b/client/firewall/uspfilter/allow_netbird_windows.go index 6aef2ecfd..10a2b9116 100644 --- a/client/firewall/uspfilter/allow_netbird_windows.go +++ b/client/firewall/uspfilter/allow_netbird_windows.go @@ -5,8 +5,10 @@ import ( "os/exec" "syscall" + "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" + nberrors "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/internal/statemanager" ) @@ -29,15 +31,20 @@ func (m *Manager) Close(*statemanager.Manager) error { return nil } - if !isFirewallRuleActive(firewallRuleName) { - return nil + var merr *multierror.Error + if isFirewallRuleActive(firewallRuleName) { + if err := manageFirewallRule(firewallRuleName, deleteRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove windows firewall rule: %w", err)) + } } - if err := manageFirewallRule(firewallRuleName, deleteRule); err != nil { - return fmt.Errorf("couldn't remove windows firewall: %w", err) + if isFirewallRuleActive(firewallRuleName + "-v6") { + if err := manageFirewallRule(firewallRuleName+"-v6", deleteRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove windows v6 firewall rule: %w", err)) + } } - return nil + return nberrors.FormatErrorOrNil(merr) } // AllowNetbird allows netbird interface traffic @@ -46,17 +53,33 @@ func (m *Manager) AllowNetbird() error { return nil } - if isFirewallRuleActive(firewallRuleName) { - return nil + if !isFirewallRuleActive(firewallRuleName) { + if err := manageFirewallRule(firewallRuleName, + addRule, + "dir=in", + "enable=yes", + "action=allow", + "profile=any", + "localip="+m.wgIface.Address().IP.String(), + ); err != nil { + return err + } } - return manageFirewallRule(firewallRuleName, - addRule, - "dir=in", - "enable=yes", - "action=allow", - "profile=any", - "localip="+m.wgIface.Address().IP.String(), - ) + + if v6 := m.wgIface.Address().IPv6; v6.IsValid() && !isFirewallRuleActive(firewallRuleName+"-v6") { + if err := manageFirewallRule(firewallRuleName+"-v6", + addRule, + "dir=in", + "enable=yes", + "action=allow", + "profile=any", + "localip="+v6.String(), + ); err != nil { + return err + } + } + + return nil } func manageFirewallRule(ruleName string, action action, extraArgs ...string) error { diff --git a/client/firewall/uspfilter/conntrack/common.go b/client/firewall/uspfilter/conntrack/common.go index 7be0dd78f..88e90317c 100644 --- a/client/firewall/uspfilter/conntrack/common.go +++ b/client/firewall/uspfilter/conntrack/common.go @@ -1,8 +1,9 @@ package conntrack import ( - "fmt" + "net" "net/netip" + "strconv" "sync/atomic" "time" @@ -64,5 +65,7 @@ type ConnKey struct { } func (c ConnKey) String() string { - return fmt.Sprintf("%s:%d → %s:%d", c.SrcIP.Unmap(), c.SrcPort, c.DstIP.Unmap(), c.DstPort) + return net.JoinHostPort(c.SrcIP.Unmap().String(), strconv.Itoa(int(c.SrcPort))) + + " → " + + net.JoinHostPort(c.DstIP.Unmap().String(), strconv.Itoa(int(c.DstPort))) } diff --git a/client/firewall/uspfilter/conntrack/common_test.go b/client/firewall/uspfilter/conntrack/common_test.go index d868dd1fb..7e67b98fa 100644 --- a/client/firewall/uspfilter/conntrack/common_test.go +++ b/client/firewall/uspfilter/conntrack/common_test.go @@ -13,6 +13,54 @@ import ( var logger = log.NewFromLogrus(logrus.StandardLogger()) var flowLogger = netflow.NewManager(nil, []byte{}, nil).GetLogger() +func TestConnKey_String(t *testing.T) { + tests := []struct { + name string + key ConnKey + expect string + }{ + { + name: "IPv4", + key: ConnKey{ + SrcIP: netip.MustParseAddr("192.168.1.1"), + DstIP: netip.MustParseAddr("10.0.0.1"), + SrcPort: 12345, + DstPort: 80, + }, + expect: "192.168.1.1:12345 → 10.0.0.1:80", + }, + { + name: "IPv6", + key: ConnKey{ + SrcIP: netip.MustParseAddr("2001:db8::1"), + DstIP: netip.MustParseAddr("2001:db8::2"), + SrcPort: 54321, + DstPort: 443, + }, + expect: "[2001:db8::1]:54321 → [2001:db8::2]:443", + }, + { + name: "IPv4-mapped IPv6 unmaps", + key: ConnKey{ + SrcIP: netip.MustParseAddr("::ffff:10.0.0.1"), + DstIP: netip.MustParseAddr("::ffff:10.0.0.2"), + SrcPort: 1000, + DstPort: 2000, + }, + expect: "10.0.0.1:1000 → 10.0.0.2:2000", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.key.String() + if got != tc.expect { + t.Errorf("got %q, want %q", got, tc.expect) + } + }) + } +} + // Memory pressure tests func BenchmarkMemoryPressure(b *testing.B) { b.Run("TCPHighLoad", func(b *testing.B) { diff --git a/client/firewall/uspfilter/conntrack/icmp.go b/client/firewall/uspfilter/conntrack/icmp.go index 50b663642..a48215ca9 100644 --- a/client/firewall/uspfilter/conntrack/icmp.go +++ b/client/firewall/uspfilter/conntrack/icmp.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/netip" + "strconv" "sync" "time" @@ -21,9 +22,14 @@ const ( // ICMPCleanupInterval is how often we check for stale ICMP connections ICMPCleanupInterval = 15 * time.Second - // MaxICMPPayloadLength is the maximum length of ICMP payload we consider for original packet info, - // which includes the IP header (20 bytes) and transport header (8 bytes) - MaxICMPPayloadLength = 28 + // MaxICMPPayloadLength is the maximum length of ICMP payload we consider for original packet info. + // IPv4: 20-byte header + 8-byte transport = 28 bytes. + // IPv6: 40-byte header + 8-byte transport = 48 bytes. + MaxICMPPayloadLength = 48 + // minICMPPayloadIPv4 is the minimum embedded packet length for IPv4 ICMP errors. + minICMPPayloadIPv4 = 28 + // minICMPPayloadIPv6 is the minimum embedded packet length for IPv6 ICMP errors. + minICMPPayloadIPv6 = 48 ) // ICMPConnKey uniquely identifies an ICMP connection @@ -65,7 +71,7 @@ type ICMPInfo struct { // String implements fmt.Stringer for lazy evaluation in log messages func (info ICMPInfo) String() string { - if info.isErrorMessage() && info.PayloadLen >= MaxICMPPayloadLength { + if info.isErrorMessage() && info.PayloadLen >= minICMPPayloadIPv4 { if origInfo := info.parseOriginalPacket(); origInfo != "" { return fmt.Sprintf("%s (original: %s)", info.TypeCode, origInfo) } @@ -74,42 +80,72 @@ func (info ICMPInfo) String() string { return info.TypeCode.String() } -// isErrorMessage returns true if this ICMP type carries original packet info +// isErrorMessage returns true if this ICMP type carries original packet info. +// Covers both ICMPv4 and ICMPv6 error types. Without a family field we match +// both sets; type 3 overlaps (v4 DestUnreachable / v6 TimeExceeded) so it's +// kept as a literal. func (info ICMPInfo) isErrorMessage() bool { typ := info.TypeCode.Type() - return typ == 3 || // Destination Unreachable - typ == 5 || // Redirect - typ == 11 || // Time Exceeded - typ == 12 // Parameter Problem + // ICMPv4 error types + if typ == layers.ICMPv4TypeDestinationUnreachable || + typ == layers.ICMPv4TypeRedirect || + typ == layers.ICMPv4TypeTimeExceeded || + typ == layers.ICMPv4TypeParameterProblem { + return true + } + // ICMPv6 error types (type 3 already matched above as v4 DestUnreachable) + if typ == layers.ICMPv6TypeDestinationUnreachable || + typ == layers.ICMPv6TypePacketTooBig || + typ == layers.ICMPv6TypeParameterProblem { + return true + } + return false } // parseOriginalPacket extracts info about the original packet from ICMP payload func (info ICMPInfo) parseOriginalPacket() string { - if info.PayloadLen < MaxICMPPayloadLength { + if info.PayloadLen == 0 { return "" } - // TODO: handle IPv6 - if version := (info.PayloadData[0] >> 4) & 0xF; version != 4 { + version := (info.PayloadData[0] >> 4) & 0xF + + var protocol uint8 + var srcIP, dstIP net.IP + var transportData []byte + + switch version { + case 4: + if info.PayloadLen < minICMPPayloadIPv4 { + return "" + } + protocol = info.PayloadData[9] + srcIP = net.IP(info.PayloadData[12:16]) + dstIP = net.IP(info.PayloadData[16:20]) + transportData = info.PayloadData[20:] + case 6: + if info.PayloadLen < minICMPPayloadIPv6 { + return "" + } + // Next Header field in IPv6 header + protocol = info.PayloadData[6] + srcIP = net.IP(info.PayloadData[8:24]) + dstIP = net.IP(info.PayloadData[24:40]) + transportData = info.PayloadData[40:] + default: return "" } - protocol := info.PayloadData[9] - srcIP := net.IP(info.PayloadData[12:16]) - dstIP := net.IP(info.PayloadData[16:20]) - - transportData := info.PayloadData[20:] - switch nftypes.Protocol(protocol) { case nftypes.TCP: srcPort := uint16(transportData[0])<<8 | uint16(transportData[1]) dstPort := uint16(transportData[2])<<8 | uint16(transportData[3]) - return fmt.Sprintf("TCP %s:%d → %s:%d", srcIP, srcPort, dstIP, dstPort) + return "TCP " + net.JoinHostPort(srcIP.String(), strconv.Itoa(int(srcPort))) + " → " + net.JoinHostPort(dstIP.String(), strconv.Itoa(int(dstPort))) case nftypes.UDP: srcPort := uint16(transportData[0])<<8 | uint16(transportData[1]) dstPort := uint16(transportData[2])<<8 | uint16(transportData[3]) - return fmt.Sprintf("UDP %s:%d → %s:%d", srcIP, srcPort, dstIP, dstPort) + return "UDP " + net.JoinHostPort(srcIP.String(), strconv.Itoa(int(srcPort))) + " → " + net.JoinHostPort(dstIP.String(), strconv.Itoa(int(dstPort))) case nftypes.ICMP: icmpType := transportData[0] @@ -247,9 +283,10 @@ func (t *ICMPTracker) track( t.sendEvent(nftypes.TypeStart, conn, ruleId) } -// IsValidInbound checks if an inbound ICMP Echo Reply matches a tracked request +// IsValidInbound checks if an inbound ICMP Echo Reply matches a tracked request. +// Accepts both ICMPv4 (type 0) and ICMPv6 (type 129) echo replies. func (t *ICMPTracker) IsValidInbound(srcIP netip.Addr, dstIP netip.Addr, id uint16, icmpType uint8, size int) bool { - if icmpType != uint8(layers.ICMPv4TypeEchoReply) { + if icmpType != uint8(layers.ICMPv4TypeEchoReply) && icmpType != uint8(layers.ICMPv6TypeEchoReply) { return false } @@ -301,6 +338,13 @@ func (t *ICMPTracker) cleanup() { } } +func icmpProtocolForAddr(ip netip.Addr) nftypes.Protocol { + if ip.Is6() { + return nftypes.ICMPv6 + } + return nftypes.ICMP +} + // Close stops the cleanup routine and releases resources func (t *ICMPTracker) Close() { t.tickerCancel() @@ -316,7 +360,7 @@ func (t *ICMPTracker) sendEvent(typ nftypes.Type, conn *ICMPConnTrack, ruleID [] Type: typ, RuleID: ruleID, Direction: conn.Direction, - Protocol: nftypes.ICMP, // TODO: adjust for IPv6/icmpv6 + Protocol: icmpProtocolForAddr(conn.SourceIP), SourceIP: conn.SourceIP, DestIP: conn.DestIP, ICMPType: conn.ICMPType, @@ -334,7 +378,7 @@ func (t *ICMPTracker) sendStartEvent(direction nftypes.Direction, srcIP netip.Ad Type: nftypes.TypeStart, RuleID: ruleID, Direction: direction, - Protocol: nftypes.ICMP, + Protocol: icmpProtocolForAddr(srcIP), SourceIP: srcIP, DestIP: dstIP, ICMPType: typ, diff --git a/client/firewall/uspfilter/conntrack/icmp_test.go b/client/firewall/uspfilter/conntrack/icmp_test.go index b15b42cf0..6d1f87162 100644 --- a/client/firewall/uspfilter/conntrack/icmp_test.go +++ b/client/firewall/uspfilter/conntrack/icmp_test.go @@ -5,6 +5,42 @@ import ( "testing" ) +func TestICMPConnKey_String(t *testing.T) { + tests := []struct { + name string + key ICMPConnKey + expect string + }{ + { + name: "IPv4", + key: ICMPConnKey{ + SrcIP: netip.MustParseAddr("192.168.1.1"), + DstIP: netip.MustParseAddr("10.0.0.1"), + ID: 1234, + }, + expect: "192.168.1.1 → 10.0.0.1 (id 1234)", + }, + { + name: "IPv6", + key: ICMPConnKey{ + SrcIP: netip.MustParseAddr("2001:db8::1"), + DstIP: netip.MustParseAddr("2001:db8::2"), + ID: 5678, + }, + expect: "2001:db8::1 → 2001:db8::2 (id 5678)", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.key.String() + if got != tc.expect { + t.Errorf("got %q, want %q", got, tc.expect) + } + }) + } +} + func BenchmarkICMPTracker(b *testing.B) { b.Run("TrackOutbound", func(b *testing.B) { tracker := NewICMPTracker(DefaultICMPTimeout, logger, flowLogger) diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index 3787e63a8..5ecd08abf 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -18,9 +18,10 @@ import ( "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/google/uuid" + "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" + nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/firewall/uspfilter/common" "github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack" @@ -35,8 +36,10 @@ import ( const ( layerTypeAll = 255 - // ipTCPHeaderMinSize represents minimum IP (20) + TCP (20) header size for MSS calculation - ipTCPHeaderMinSize = 40 + // ipv4TCPHeaderMinSize represents minimum IPv4 (20) + TCP (20) header size for MSS calculation + ipv4TCPHeaderMinSize = 40 + // ipv6TCPHeaderMinSize represents minimum IPv6 (40) + TCP (20) header size for MSS calculation + ipv6TCPHeaderMinSize = 60 ) // serviceKey represents a protocol/port combination for netstack service registry @@ -123,7 +126,7 @@ type Manager struct { logger *nblog.Logger flowLogger nftypes.FlowLogger - blockRule firewall.Rule + blockRules []firewall.Rule // Internal 1:1 DNAT dnatEnabled atomic.Bool @@ -138,9 +141,10 @@ type Manager struct { netstackServices map[serviceKey]struct{} netstackServiceMutex sync.RWMutex - mtu uint16 - mssClampValue uint16 - mssClampEnabled bool + mtu uint16 + mssClampValueIPv4 uint16 + mssClampValueIPv6 uint16 + mssClampEnabled bool // Only one hook per protocol is supported. Outbound direction only. udpHookOut atomic.Pointer[common.PacketHook] @@ -157,11 +161,28 @@ type decoder struct { icmp4 layers.ICMPv4 icmp6 layers.ICMPv6 decoded []gopacket.LayerType - parser *gopacket.DecodingLayerParser + parser4 *gopacket.DecodingLayerParser + parser6 *gopacket.DecodingLayerParser dnatOrigPort uint16 } +// decodePacket decodes packet data using the appropriate parser based on IP version. +func (d *decoder) decodePacket(data []byte) error { + if len(data) == 0 { + return errors.New("empty packet") + } + version := data[0] >> 4 + switch version { + case 4: + return d.parser4.DecodeLayers(data, &d.decoded) + case 6: + return d.parser6.DecodeLayers(data, &d.decoded) + default: + return fmt.Errorf("unknown IP version %d", version) + } +} + // Create userspace firewall manager constructor func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) { return create(iface, nil, disableServerRoutes, flowLogger, mtu) @@ -219,11 +240,17 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe d := &decoder{ decoded: []gopacket.LayerType{}, } - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true + d.parser4.IgnoreUnsupported = true + + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true return d }, }, @@ -249,7 +276,12 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe if !disableMSSClamping { m.mssClampEnabled = true - m.mssClampValue = mtu - ipTCPHeaderMinSize + if mtu > ipv4TCPHeaderMinSize { + m.mssClampValueIPv4 = mtu - ipv4TCPHeaderMinSize + } + if mtu > ipv6TCPHeaderMinSize { + m.mssClampValueIPv6 = mtu - ipv6TCPHeaderMinSize + } } if err := m.localipmanager.UpdateLocalIPs(iface); err != nil { return nil, fmt.Errorf("update local IPs: %w", err) @@ -272,13 +304,25 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe return m, nil } -func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) (firewall.Rule, error) { +// blockInvalidRouted installs drop rules for traffic to the wg overlay that +// arrives via the routing path. v4 and v6 are independent: a v6 install +// failure leaves v4 protection in place (and vice versa) so the returned +// slice always contains whatever was successfully installed, even on error. +// Callers must persist the slice so DisableRouting can clean partial state. +func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) ([]firewall.Rule, error) { wgPrefix := iface.Address().Network log.Debugf("blocking invalid routed traffic for %s", wgPrefix) - rule, err := m.addRouteFiltering( + sources := []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + v6Net := iface.Address().IPv6Net + if v6Net.IsValid() { + sources = append(sources, netip.PrefixFrom(netip.IPv6Unspecified(), 0)) + } + + var rules []firewall.Rule + v4Rule, err := m.addRouteFiltering( nil, - []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)}, + sources, firewall.Network{Prefix: wgPrefix}, firewall.ProtocolALL, nil, @@ -286,12 +330,30 @@ func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) (firewall.Rule, e firewall.ActionDrop, ) if err != nil { - return nil, fmt.Errorf("block wg nte : %w", err) + return rules, fmt.Errorf("block wg v4 net: %w", err) + } + rules = append(rules, v4Rule) + + if v6Net.IsValid() { + log.Debugf("blocking invalid routed traffic for %s", v6Net) + v6Rule, err := m.addRouteFiltering( + nil, + sources, + firewall.Network{Prefix: v6Net}, + firewall.ProtocolALL, + nil, + nil, + firewall.ActionDrop, + ) + if err != nil { + return rules, fmt.Errorf("block wg v6 net: %w", err) + } + rules = append(rules, v6Rule) } // TODO: Block networks that we're a client of - return rule, nil + return rules, nil } func (m *Manager) determineRouting() error { @@ -521,7 +583,7 @@ func (m *Manager) addRouteFiltering( mgmtId: id, sources: sources, dstSet: destination.Set, - protoLayer: protoToLayer(proto, layers.LayerTypeIPv4), + protoLayer: protoToLayer(proto, ipLayerFromPrefix(destination.Prefix)), srcPort: sPort, dstPort: dPort, action: action, @@ -612,10 +674,10 @@ func (m *Manager) Flush() error { return nil } // resetState clears all firewall rules and closes connection trackers. // Must be called with m.mutex held. func (m *Manager) resetState() { - maps.Clear(m.outgoingRules) - maps.Clear(m.incomingDenyRules) - maps.Clear(m.incomingRules) - maps.Clear(m.routeRulesMap) + clear(m.outgoingRules) + clear(m.incomingDenyRules) + clear(m.incomingRules) + clear(m.routeRulesMap) m.routeRules = m.routeRules[:0] m.udpHookOut.Store(nil) m.tcpHookOut.Store(nil) @@ -676,11 +738,7 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { } destinations := matches[0].destinations - for _, prefix := range prefixes { - if prefix.Addr().Is4() { - destinations = append(destinations, prefix) - } - } + destinations = append(destinations, prefixes...) slices.SortFunc(destinations, func(a, b netip.Prefix) int { cmp := a.Addr().Compare(b.Addr()) @@ -719,7 +777,7 @@ func (m *Manager) filterOutbound(packetData []byte, size int) bool { d := m.decoders.Get().(*decoder) defer m.decoders.Put(d) - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { return false } @@ -803,12 +861,32 @@ func (m *Manager) clampTCPMSS(packetData []byte, d *decoder) bool { return false } + var mssClampValue uint16 + var ipHeaderSize int + switch d.decoded[0] { + case layers.LayerTypeIPv4: + mssClampValue = m.mssClampValueIPv4 + ipHeaderSize = int(d.ip4.IHL) * 4 + if ipHeaderSize < 20 { + return false + } + case layers.LayerTypeIPv6: + mssClampValue = m.mssClampValueIPv6 + ipHeaderSize = 40 + default: + return false + } + + if mssClampValue == 0 { + return false + } + mssOptionIndex := -1 var currentMSS uint16 for i, opt := range d.tcp.Options { if opt.OptionType == layers.TCPOptionKindMSS && len(opt.OptionData) == 2 { currentMSS = binary.BigEndian.Uint16(opt.OptionData) - if currentMSS > m.mssClampValue { + if currentMSS > mssClampValue { mssOptionIndex = i break } @@ -819,20 +897,15 @@ func (m *Manager) clampTCPMSS(packetData []byte, d *decoder) bool { return false } - ipHeaderSize := int(d.ip4.IHL) * 4 - if ipHeaderSize < 20 { + if !m.updateMSSOption(packetData, d, mssOptionIndex, mssClampValue, ipHeaderSize) { return false } - if !m.updateMSSOption(packetData, d, mssOptionIndex, ipHeaderSize) { - return false - } - - m.logger.Trace2("Clamped TCP MSS from %d to %d", currentMSS, m.mssClampValue) + m.logger.Trace2("Clamped TCP MSS from %d to %d", currentMSS, mssClampValue) return true } -func (m *Manager) updateMSSOption(packetData []byte, d *decoder, mssOptionIndex, ipHeaderSize int) bool { +func (m *Manager) updateMSSOption(packetData []byte, d *decoder, mssOptionIndex int, mssClampValue uint16, ipHeaderSize int) bool { tcpHeaderStart := ipHeaderSize tcpOptionsStart := tcpHeaderStart + 20 @@ -847,7 +920,7 @@ func (m *Manager) updateMSSOption(packetData []byte, d *decoder, mssOptionIndex, } mssValueOffset := optOffset + 2 - binary.BigEndian.PutUint16(packetData[mssValueOffset:mssValueOffset+2], m.mssClampValue) + binary.BigEndian.PutUint16(packetData[mssValueOffset:mssValueOffset+2], mssClampValue) m.recalculateTCPChecksum(packetData, d, tcpHeaderStart) return true @@ -857,18 +930,32 @@ func (m *Manager) recalculateTCPChecksum(packetData []byte, d *decoder, tcpHeade tcpLayer := packetData[tcpHeaderStart:] tcpLength := len(packetData) - tcpHeaderStart + // Zero out existing checksum tcpLayer[16] = 0 tcpLayer[17] = 0 + // Build pseudo-header checksum based on IP version var pseudoSum uint32 - pseudoSum += uint32(d.ip4.SrcIP[0])<<8 | uint32(d.ip4.SrcIP[1]) - pseudoSum += uint32(d.ip4.SrcIP[2])<<8 | uint32(d.ip4.SrcIP[3]) - pseudoSum += uint32(d.ip4.DstIP[0])<<8 | uint32(d.ip4.DstIP[1]) - pseudoSum += uint32(d.ip4.DstIP[2])<<8 | uint32(d.ip4.DstIP[3]) - pseudoSum += uint32(d.ip4.Protocol) - pseudoSum += uint32(tcpLength) + switch d.decoded[0] { + case layers.LayerTypeIPv4: + pseudoSum += uint32(d.ip4.SrcIP[0])<<8 | uint32(d.ip4.SrcIP[1]) + pseudoSum += uint32(d.ip4.SrcIP[2])<<8 | uint32(d.ip4.SrcIP[3]) + pseudoSum += uint32(d.ip4.DstIP[0])<<8 | uint32(d.ip4.DstIP[1]) + pseudoSum += uint32(d.ip4.DstIP[2])<<8 | uint32(d.ip4.DstIP[3]) + pseudoSum += uint32(d.ip4.Protocol) + pseudoSum += uint32(tcpLength) + case layers.LayerTypeIPv6: + for i := 0; i < 16; i += 2 { + pseudoSum += uint32(d.ip6.SrcIP[i])<<8 | uint32(d.ip6.SrcIP[i+1]) + } + for i := 0; i < 16; i += 2 { + pseudoSum += uint32(d.ip6.DstIP[i])<<8 | uint32(d.ip6.DstIP[i+1]) + } + pseudoSum += uint32(tcpLength) + pseudoSum += uint32(layers.IPProtocolTCP) + } - var sum = pseudoSum + sum := pseudoSum for i := 0; i < tcpLength-1; i += 2 { sum += uint32(tcpLayer[i])<<8 | uint32(tcpLayer[i+1]) } @@ -906,6 +993,9 @@ func (m *Manager) trackOutbound(d *decoder, srcIP, dstIP netip.Addr, packetData } case layers.LayerTypeICMPv4: m.icmpTracker.TrackOutbound(srcIP, dstIP, d.icmp4.Id, d.icmp4.TypeCode, d.icmp4.Payload, size) + case layers.LayerTypeICMPv6: + id, tc := icmpv6EchoFields(d) + m.icmpTracker.TrackOutbound(srcIP, dstIP, id, tc, d.icmp6.Payload, size) } } @@ -919,6 +1009,9 @@ func (m *Manager) trackInbound(d *decoder, srcIP, dstIP netip.Addr, ruleID []byt m.tcpTracker.TrackInbound(srcIP, dstIP, uint16(d.tcp.SrcPort), uint16(d.tcp.DstPort), flags, ruleID, size, d.dnatOrigPort) case layers.LayerTypeICMPv4: m.icmpTracker.TrackInbound(srcIP, dstIP, d.icmp4.Id, d.icmp4.TypeCode, ruleID, d.icmp4.Payload, size) + case layers.LayerTypeICMPv6: + id, tc := icmpv6EchoFields(d) + m.icmpTracker.TrackInbound(srcIP, dstIP, id, tc, ruleID, d.icmp6.Payload, size) } d.dnatOrigPort = 0 @@ -951,15 +1044,19 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { // TODO: pass fragments of routed packets to forwarder if fragment { - m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v", - srcIP, dstIP, d.ip4.Id, d.ip4.Flags) + if d.decoded[0] == layers.LayerTypeIPv4 { + m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v", + srcIP, dstIP, d.ip4.Id, d.ip4.Flags) + } else { + m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP) + } return false } // TODO: optimize port DNAT by caching matched rules in conntrack if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated { // Re-decode after port DNAT translation to update port information - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { m.logger.Error1("failed to re-decode packet after port DNAT: %v", err) return true } @@ -968,7 +1065,7 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { if translated := m.translateInboundReverse(packetData, d); translated { // Re-decode after translation to get original addresses - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { m.logger.Error1("failed to re-decode packet after reverse DNAT: %v", err) return true } @@ -1100,6 +1197,48 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe return true } +// icmpv6EchoFields extracts the echo identifier from an ICMPv6 packet and maps +// the ICMPv6 type code to an ICMPv4TypeCode so the ICMP conntrack can handle +// both families uniformly. The echo ID is in the first two payload bytes. +func icmpv6EchoFields(d *decoder) (id uint16, tc layers.ICMPv4TypeCode) { + if len(d.icmp6.Payload) >= 2 { + id = uint16(d.icmp6.Payload[0])<<8 | uint16(d.icmp6.Payload[1]) + } + // Map ICMPv6 echo types to ICMPv4 equivalents for unified tracking. + switch d.icmp6.TypeCode.Type() { + case layers.ICMPv6TypeEchoRequest: + tc = layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0) + case layers.ICMPv6TypeEchoReply: + tc = layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoReply, 0) + default: + tc = layers.CreateICMPv4TypeCode(d.icmp6.TypeCode.Type(), d.icmp6.TypeCode.Code()) + } + return id, tc +} + +// protoLayerMatches checks if a packet's protocol layer matches a rule's expected +// protocol layer. ICMPv4 and ICMPv6 are treated as equivalent when matching +// ICMP rules since management sends a single ICMP rule for both families. +func protoLayerMatches(ruleLayer, packetLayer gopacket.LayerType) bool { + if ruleLayer == packetLayer { + return true + } + if ruleLayer == layers.LayerTypeICMPv4 && packetLayer == layers.LayerTypeICMPv6 { + return true + } + if ruleLayer == layers.LayerTypeICMPv6 && packetLayer == layers.LayerTypeICMPv4 { + return true + } + return false +} + +func ipLayerFromPrefix(p netip.Prefix) gopacket.LayerType { + if p.Addr().Is6() { + return layers.LayerTypeIPv6 + } + return layers.LayerTypeIPv4 +} + func protoToLayer(proto firewall.Protocol, ipLayer gopacket.LayerType) gopacket.LayerType { switch proto { case firewall.ProtocolTCP: @@ -1123,8 +1262,10 @@ func getProtocolFromPacket(d *decoder) nftypes.Protocol { return nftypes.TCP case layers.LayerTypeUDP: return nftypes.UDP - case layers.LayerTypeICMPv4, layers.LayerTypeICMPv6: + case layers.LayerTypeICMPv4: return nftypes.ICMP + case layers.LayerTypeICMPv6: + return nftypes.ICMPv6 default: return nftypes.ProtocolUnknown } @@ -1145,7 +1286,7 @@ func getPortsFromPacket(d *decoder) (srcPort, dstPort uint16) { // It returns true, false if the packet is valid and not a fragment. // It returns true, true if the packet is a fragment and valid. func (m *Manager) isValidPacket(d *decoder, packetData []byte) (bool, bool) { - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { m.logger.Trace1("couldn't decode packet, err: %s", err) return false, false } @@ -1158,10 +1299,21 @@ func (m *Manager) isValidPacket(d *decoder, packetData []byte) (bool, bool) { } // Fragments are also valid - if l == 1 && d.decoded[0] == layers.LayerTypeIPv4 { - ip4 := d.ip4 - if ip4.Flags&layers.IPv4MoreFragments != 0 || ip4.FragOffset != 0 { - return true, true + if l == 1 { + switch d.decoded[0] { + case layers.LayerTypeIPv4: + if d.ip4.Flags&layers.IPv4MoreFragments != 0 || d.ip4.FragOffset != 0 { + return true, true + } + case layers.LayerTypeIPv6: + // IPv6 uses Fragment extension header (NextHeader=44). If gopacket + // only decoded the IPv6 layer, the transport is in a fragment. + // TODO: handle non-Fragment extension headers (HopByHop, Routing, + // DestOpts) by walking the chain. gopacket's parser does not + // support them as DecodingLayers; today we drop such packets. + if d.ip6.NextHeader == layers.IPProtocolIPv6Fragment { + return true, true + } } } @@ -1199,21 +1351,35 @@ func (m *Manager) isValidTrackedConnection(d *decoder, srcIP, dstIP netip.Addr, size, ) - // TODO: ICMPv6 + case layers.LayerTypeICMPv6: + id, _ := icmpv6EchoFields(d) + return m.icmpTracker.IsValidInbound( + srcIP, + dstIP, + id, + d.icmp6.TypeCode.Type(), + size, + ) } return false } -// isSpecialICMP returns true if the packet is a special ICMP packet that should be allowed +// isSpecialICMP returns true if the packet is a special ICMP error packet that should be allowed. func (m *Manager) isSpecialICMP(d *decoder) bool { - if d.decoded[1] != layers.LayerTypeICMPv4 { - return false + switch d.decoded[1] { + case layers.LayerTypeICMPv4: + icmpType := d.icmp4.TypeCode.Type() + return icmpType == layers.ICMPv4TypeDestinationUnreachable || + icmpType == layers.ICMPv4TypeTimeExceeded + case layers.LayerTypeICMPv6: + icmpType := d.icmp6.TypeCode.Type() + return icmpType == layers.ICMPv6TypeDestinationUnreachable || + icmpType == layers.ICMPv6TypePacketTooBig || + icmpType == layers.ICMPv6TypeTimeExceeded || + icmpType == layers.ICMPv6TypeParameterProblem } - - icmpType := d.icmp4.TypeCode.Type() - return icmpType == layers.ICMPv4TypeDestinationUnreachable || - icmpType == layers.ICMPv4TypeTimeExceeded + return false } func (m *Manager) peerACLsBlock(srcIP netip.Addr, d *decoder, packetData []byte) ([]byte, bool) { @@ -1270,7 +1436,7 @@ func validateRule(ip netip.Addr, packetData []byte, rules map[string]PeerRule, d return rule.mgmtId, rule.drop, true } - if payloadLayer != rule.protoLayer { + if !protoLayerMatches(rule.protoLayer, payloadLayer) { continue } @@ -1305,8 +1471,7 @@ func (m *Manager) routeACLsPass(srcIP, dstIP netip.Addr, protoLayer gopacket.Lay } func (m *Manager) ruleMatches(rule *RouteRule, srcAddr, dstAddr netip.Addr, protoLayer gopacket.LayerType, srcPort, dstPort uint16) bool { - // TODO: handle ipv6 vs ipv4 icmp rules - if rule.protoLayer != layerTypeAll && rule.protoLayer != protoLayer { + if rule.protoLayer != layerTypeAll && !protoLayerMatches(rule.protoLayer, protoLayer) { return false } @@ -1367,13 +1532,14 @@ func (m *Manager) EnableRouting() error { return nil } - rule, err := m.blockInvalidRouted(m.wgIface) + rules, err := m.blockInvalidRouted(m.wgIface) + // Persist whatever was installed even on partial failure, so DisableRouting + // can clean it up later. + m.blockRules = rules if err != nil { return fmt.Errorf("block invalid routed: %w", err) } - m.blockRule = rule - return nil } @@ -1389,9 +1555,16 @@ func (m *Manager) DisableRouting() error { m.routingEnabled.Store(false) m.nativeRouter.Store(false) - // don't stop forwarder if in use by netstack + var merr *multierror.Error + for _, rule := range m.blockRules { + if err := m.deleteRouteRule(rule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete block rule: %w", err)) + } + } + m.blockRules = nil + if m.netstack && m.localForwarding { - return nil + return nberrors.FormatErrorOrNil(merr) } fwder.Stop() @@ -1399,14 +1572,7 @@ func (m *Manager) DisableRouting() error { log.Debug("forwarder stopped") - if m.blockRule != nil { - if err := m.deleteRouteRule(m.blockRule); err != nil { - return fmt.Errorf("delete block rule: %w", err) - } - m.blockRule = nil - } - - return nil + return nberrors.FormatErrorOrNil(merr) } // RegisterNetstackService registers a service as listening on the netstack for the given protocol and port @@ -1460,7 +1626,8 @@ func (m *Manager) shouldForward(d *decoder, dstIP netip.Addr) bool { } // traffic to our other local interfaces (not NetBird IP) - always forward - if dstIP != m.wgIface.Address().IP { + addr := m.wgIface.Address() + if dstIP != addr.IP && (!addr.IPv6.IsValid() || dstIP != addr.IPv6) { return true } diff --git a/client/firewall/uspfilter/filter_bench_test.go b/client/firewall/uspfilter/filter_bench_test.go index 10ff62ed3..4dccb0f65 100644 --- a/client/firewall/uspfilter/filter_bench_test.go +++ b/client/firewall/uspfilter/filter_bench_test.go @@ -1023,7 +1023,8 @@ func BenchmarkMSSClamping(b *testing.B) { }() manager.mssClampEnabled = true - manager.mssClampValue = 1240 + manager.mssClampValueIPv4 = 1240 + manager.mssClampValueIPv6 = 1220 srcIP := net.ParseIP("100.64.0.2") dstIP := net.ParseIP("8.8.8.8") @@ -1088,7 +1089,8 @@ func BenchmarkMSSClampingOverhead(b *testing.B) { manager.mssClampEnabled = sc.enabled if sc.enabled { - manager.mssClampValue = 1240 + manager.mssClampValueIPv4 = 1240 + manager.mssClampValueIPv6 = 1220 } srcIP := net.ParseIP("100.64.0.2") @@ -1141,7 +1143,8 @@ func BenchmarkMSSClampingMemory(b *testing.B) { }() manager.mssClampEnabled = true - manager.mssClampValue = 1240 + manager.mssClampValueIPv4 = 1240 + manager.mssClampValueIPv6 = 1220 srcIP := net.ParseIP("100.64.0.2") dstIP := net.ParseIP("8.8.8.8") diff --git a/client/firewall/uspfilter/filter_filter_test.go b/client/firewall/uspfilter/filter_filter_test.go index a8efbac1c..a64c83138 100644 --- a/client/firewall/uspfilter/filter_filter_test.go +++ b/client/firewall/uspfilter/filter_filter_test.go @@ -539,53 +539,236 @@ func TestPeerACLFiltering(t *testing.T) { } } +func TestPeerACLFilteringIPv6(t *testing.T) { + localIP := netip.MustParseAddr("100.10.0.100") + localIPv6 := netip.MustParseAddr("fd00::100") + wgNet := netip.MustParsePrefix("100.10.0.0/16") + wgNetV6 := netip.MustParsePrefix("fd00::/64") + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: localIP, + Network: wgNet, + IPv6: localIPv6, + IPv6Net: wgNetV6, + } + }, + } + + manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, manager.Close(nil)) }) + + err = manager.UpdateLocalIPs() + require.NoError(t, err) + + testCases := []struct { + name string + srcIP string + dstIP string + proto fw.Protocol + srcPort uint16 + dstPort uint16 + ruleIP string + ruleProto fw.Protocol + ruleDstPort *fw.Port + ruleAction fw.Action + shouldBeBlocked bool + }{ + { + name: "IPv6: allow TCP from peer", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolTCP, + ruleDstPort: &fw.Port{Values: []uint16{443}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "IPv6: allow UDP from peer", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolUDP, + srcPort: 12345, + dstPort: 53, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolUDP, + ruleDstPort: &fw.Port{Values: []uint16{53}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "IPv6: allow ICMPv6 from peer", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolICMP, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolICMP, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "IPv6: block TCP without rule", + srcIP: "fd00::2", + dstIP: "fd00::100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolTCP, + ruleDstPort: &fw.Port{Values: []uint16{443}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: true, + }, + { + name: "IPv6: drop rule", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 22, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolTCP, + ruleDstPort: &fw.Port{Values: []uint16{22}}, + ruleAction: fw.ActionDrop, + shouldBeBlocked: true, + }, + { + name: "IPv6: allow all protocols", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolUDP, + srcPort: 12345, + dstPort: 9999, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolALL, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "IPv6: v4 wildcard ICMP rule matches ICMPv6 via protoLayerMatches", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolICMP, + ruleIP: "0.0.0.0", + ruleProto: fw.ProtocolICMP, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + } + + t.Run("IPv6 implicit DROP (no rules)", func(t *testing.T) { + packet := createTestPacket(t, "fd00::1", "fd00::100", fw.ProtocolTCP, 12345, 443) + isDropped := manager.FilterInbound(packet, 0) + require.True(t, isDropped, "IPv6 packet should be dropped when no rules exist") + }) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.ruleAction == fw.ActionDrop { + rules, err := manager.AddPeerFiltering(nil, net.ParseIP(tc.ruleIP), fw.ProtocolALL, nil, nil, fw.ActionAccept, "") + require.NoError(t, err) + t.Cleanup(func() { + for _, rule := range rules { + require.NoError(t, manager.DeletePeerRule(rule)) + } + }) + } + + rules, err := manager.AddPeerFiltering(nil, net.ParseIP(tc.ruleIP), tc.ruleProto, nil, tc.ruleDstPort, tc.ruleAction, "") + require.NoError(t, err) + require.NotEmpty(t, rules) + t.Cleanup(func() { + for _, rule := range rules { + require.NoError(t, manager.DeletePeerRule(rule)) + } + }) + + packet := createTestPacket(t, tc.srcIP, tc.dstIP, tc.proto, tc.srcPort, tc.dstPort) + isDropped := manager.FilterInbound(packet, 0) + require.Equal(t, tc.shouldBeBlocked, isDropped, "packet filter result mismatch") + }) + } +} + func createTestPacket(t *testing.T, srcIP, dstIP string, proto fw.Protocol, srcPort, dstPort uint16) []byte { t.Helper() + src := net.ParseIP(srcIP) + dst := net.ParseIP(dstIP) + buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } - ipLayer := &layers.IPv4{ - Version: 4, - TTL: 64, - SrcIP: net.ParseIP(srcIP), - DstIP: net.ParseIP(dstIP), - } + // Detect address family + isV6 := src.To4() == nil var err error - switch proto { - case fw.ProtocolTCP: - ipLayer.Protocol = layers.IPProtocolTCP - tcp := &layers.TCP{ - SrcPort: layers.TCPPort(srcPort), - DstPort: layers.TCPPort(dstPort), - } - err = tcp.SetNetworkLayerForChecksum(ipLayer) - require.NoError(t, err) - err = gopacket.SerializeLayers(buf, opts, ipLayer, tcp) - case fw.ProtocolUDP: - ipLayer.Protocol = layers.IPProtocolUDP - udp := &layers.UDP{ - SrcPort: layers.UDPPort(srcPort), - DstPort: layers.UDPPort(dstPort), + if isV6 { + ip6 := &layers.IPv6{ + Version: 6, + HopLimit: 64, + SrcIP: src, + DstIP: dst, } - err = udp.SetNetworkLayerForChecksum(ipLayer) - require.NoError(t, err) - err = gopacket.SerializeLayers(buf, opts, ipLayer, udp) - case fw.ProtocolICMP: - ipLayer.Protocol = layers.IPProtocolICMPv4 - icmp := &layers.ICMPv4{ - TypeCode: layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0), + switch proto { + case fw.ProtocolTCP: + ip6.NextHeader = layers.IPProtocolTCP + tcp := &layers.TCP{SrcPort: layers.TCPPort(srcPort), DstPort: layers.TCPPort(dstPort)} + _ = tcp.SetNetworkLayerForChecksum(ip6) + err = gopacket.SerializeLayers(buf, opts, ip6, tcp) + case fw.ProtocolUDP: + ip6.NextHeader = layers.IPProtocolUDP + udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)} + _ = udp.SetNetworkLayerForChecksum(ip6) + err = gopacket.SerializeLayers(buf, opts, ip6, udp) + case fw.ProtocolICMP: + ip6.NextHeader = layers.IPProtocolICMPv6 + icmp := &layers.ICMPv6{ + TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeEchoRequest, 0), + } + _ = icmp.SetNetworkLayerForChecksum(ip6) + err = gopacket.SerializeLayers(buf, opts, ip6, icmp) + default: + err = gopacket.SerializeLayers(buf, opts, ip6) + } + } else { + ip4 := &layers.IPv4{ + Version: 4, + TTL: 64, + SrcIP: src, + DstIP: dst, } - err = gopacket.SerializeLayers(buf, opts, ipLayer, icmp) - default: - err = gopacket.SerializeLayers(buf, opts, ipLayer) + switch proto { + case fw.ProtocolTCP: + ip4.Protocol = layers.IPProtocolTCP + tcp := &layers.TCP{SrcPort: layers.TCPPort(srcPort), DstPort: layers.TCPPort(dstPort)} + _ = tcp.SetNetworkLayerForChecksum(ip4) + err = gopacket.SerializeLayers(buf, opts, ip4, tcp) + case fw.ProtocolUDP: + ip4.Protocol = layers.IPProtocolUDP + udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)} + _ = udp.SetNetworkLayerForChecksum(ip4) + err = gopacket.SerializeLayers(buf, opts, ip4, udp) + case fw.ProtocolICMP: + ip4.Protocol = layers.IPProtocolICMPv4 + icmp := &layers.ICMPv4{TypeCode: layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0)} + err = gopacket.SerializeLayers(buf, opts, ip4, icmp) + default: + err = gopacket.SerializeLayers(buf, opts, ip4) + } } require.NoError(t, err) @@ -1498,3 +1681,103 @@ func TestRouteACLSet(t *testing.T) { _, isAllowed = manager.routeACLsPass(srcIP, dstIP, protoToLayer(fw.ProtocolTCP, layers.LayerTypeIPv4), 12345, 80) require.True(t, isAllowed, "After set update, traffic to the added network should be allowed") } + +// TestRouteACLFilteringIPv6 tests IPv6 route ACL matching directly via routeACLsPass. +// Note: full FilterInbound for routed IPv6 traffic drops at the forwarder stage (IPv4-only) +// but the ACL decision itself is correct. +func TestRouteACLFilteringIPv6(t *testing.T) { + manager := setupRoutedManager(t, "10.10.0.100/16") + + v6Dst := netip.MustParsePrefix("fd00:dead:beef::/48") + _, err := manager.AddRouteFiltering( + nil, + []netip.Prefix{netip.MustParsePrefix("fd00::/16")}, + fw.Network{Prefix: v6Dst}, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{80}}, + fw.ActionAccept, + ) + require.NoError(t, err) + + _, err = manager.AddRouteFiltering( + nil, + []netip.Prefix{netip.MustParsePrefix("fd00::/16")}, + fw.Network{Prefix: netip.MustParsePrefix("fd00:dead:beef:1::/64")}, + fw.ProtocolALL, + nil, + nil, + fw.ActionDrop, + ) + require.NoError(t, err) + + tests := []struct { + name string + srcIP netip.Addr + dstIP netip.Addr + proto gopacket.LayerType + srcPort uint16 + dstPort uint16 + allowed bool + }{ + { + name: "IPv6 TCP to allowed dest", + srcIP: netip.MustParseAddr("fd00::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef::80"), + proto: layers.LayerTypeTCP, + srcPort: 12345, + dstPort: 80, + allowed: true, + }, + { + name: "IPv6 TCP wrong port", + srcIP: netip.MustParseAddr("fd00::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef::80"), + proto: layers.LayerTypeTCP, + srcPort: 12345, + dstPort: 443, + allowed: false, + }, + { + name: "IPv6 UDP not matched by TCP rule", + srcIP: netip.MustParseAddr("fd00::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef::80"), + proto: layers.LayerTypeUDP, + srcPort: 12345, + dstPort: 80, + allowed: false, + }, + { + name: "IPv6 ICMPv6 matches ICMP rule via protoLayerMatches", + srcIP: netip.MustParseAddr("fd00::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef::80"), + proto: layers.LayerTypeICMPv6, + allowed: false, + }, + { + name: "IPv6 to denied subnet", + srcIP: netip.MustParseAddr("fd00::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef:1::1"), + proto: layers.LayerTypeTCP, + srcPort: 12345, + dstPort: 80, + allowed: false, + }, + { + name: "IPv6 source outside allowed range", + srcIP: netip.MustParseAddr("fe80::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef::80"), + proto: layers.LayerTypeTCP, + srcPort: 12345, + dstPort: 80, + allowed: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, pass := manager.routeACLsPass(tc.srcIP, tc.dstIP, tc.proto, tc.srcPort, tc.dstPort) + require.Equal(t, tc.allowed, pass, "route ACL result mismatch") + }) + } +} diff --git a/client/firewall/uspfilter/filter_routeacl_test.go b/client/firewall/uspfilter/filter_routeacl_test.go index 68572a01c..449554d8b 100644 --- a/client/firewall/uspfilter/filter_routeacl_test.go +++ b/client/firewall/uspfilter/filter_routeacl_test.go @@ -189,21 +189,21 @@ func TestBlockInvalidRoutedIdempotent(t *testing.T) { }) // Call blockInvalidRouted directly multiple times - rule1, err := manager.blockInvalidRouted(ifaceMock) + rules1, err := manager.blockInvalidRouted(ifaceMock) require.NoError(t, err) - require.NotNil(t, rule1) + require.NotEmpty(t, rules1) - rule2, err := manager.blockInvalidRouted(ifaceMock) + rules2, err := manager.blockInvalidRouted(ifaceMock) require.NoError(t, err) - require.NotNil(t, rule2) + require.NotEmpty(t, rules2) - rule3, err := manager.blockInvalidRouted(ifaceMock) + rules3, err := manager.blockInvalidRouted(ifaceMock) require.NoError(t, err) - require.NotNil(t, rule3) + require.NotEmpty(t, rules3) - // All should return the same rule - assert.Equal(t, rule1.ID(), rule2.ID(), "Second call should return same rule") - assert.Equal(t, rule2.ID(), rule3.ID(), "Third call should return same rule") + // All calls should return the same v4 block rule (idempotent install). + assert.Equal(t, rules1[0].ID(), rules2[0].ID(), "Second call should return same v4 rule") + assert.Equal(t, rules2[0].ID(), rules3[0].ID(), "Third call should return same v4 rule") // Should have exactly 1 route rule manager.mutex.RLock() diff --git a/client/firewall/uspfilter/filter_test.go b/client/firewall/uspfilter/filter_test.go index 5fb9fef0e..f19c4bb56 100644 --- a/client/firewall/uspfilter/filter_test.go +++ b/client/firewall/uspfilter/filter_test.go @@ -535,11 +535,16 @@ func TestProcessOutgoingHooks(t *testing.T) { d := &decoder{ decoded: []gopacket.LayerType{}, } - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true return d }, } @@ -638,11 +643,16 @@ func TestStatefulFirewall_UDPTracking(t *testing.T) { d := &decoder{ decoded: []gopacket.LayerType{}, } - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true return d }, } @@ -1048,8 +1058,8 @@ func TestMSSClamping(t *testing.T) { }() require.True(t, manager.mssClampEnabled, "MSS clamping should be enabled by default") - expectedMSSValue := uint16(1280 - ipTCPHeaderMinSize) - require.Equal(t, expectedMSSValue, manager.mssClampValue, "MSS clamp value should be MTU - 40") + require.Equal(t, uint16(1280-ipv4TCPHeaderMinSize), manager.mssClampValueIPv4, "IPv4 MSS clamp value should be MTU - 40") + require.Equal(t, uint16(1280-ipv6TCPHeaderMinSize), manager.mssClampValueIPv6, "IPv6 MSS clamp value should be MTU - 60") err = manager.UpdateLocalIPs() require.NoError(t, err) @@ -1067,7 +1077,7 @@ func TestMSSClamping(t *testing.T) { require.Len(t, d.tcp.Options, 1, "Should have MSS option") require.Equal(t, uint8(layers.TCPOptionKindMSS), uint8(d.tcp.Options[0].OptionType)) actualMSS := binary.BigEndian.Uint16(d.tcp.Options[0].OptionData) - require.Equal(t, expectedMSSValue, actualMSS, "MSS should be clamped to MTU - 40") + require.Equal(t, manager.mssClampValueIPv4, actualMSS, "MSS should be clamped to MTU - 40") }) t.Run("SYN packet with low MSS unchanged", func(t *testing.T) { @@ -1091,7 +1101,7 @@ func TestMSSClamping(t *testing.T) { d := parsePacket(t, packet) require.Len(t, d.tcp.Options, 1, "Should have MSS option") actualMSS := binary.BigEndian.Uint16(d.tcp.Options[0].OptionData) - require.Equal(t, expectedMSSValue, actualMSS, "MSS in SYN-ACK should be clamped") + require.Equal(t, manager.mssClampValueIPv4, actualMSS, "MSS in SYN-ACK should be clamped") }) t.Run("Non-SYN packet unchanged", func(t *testing.T) { @@ -1263,13 +1273,18 @@ func TestShouldForward(t *testing.T) { d := &decoder{ decoded: []gopacket.LayerType{}, } - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true - err = d.parser.DecodeLayers(buf.Bytes(), &d.decoded) + err = d.decodePacket(buf.Bytes()) require.NoError(t, err) return d @@ -1329,6 +1344,44 @@ func TestShouldForward(t *testing.T) { }, } + // Add IPv6 to the interface and test dual-stack cases + wgIPv6 := netip.MustParseAddr("fd00::1") + otherIPv6 := netip.MustParseAddr("fd00::2") + ifaceMock.AddressFunc = func() wgaddr.Address { + return wgaddr.Address{ + IP: wgIP, + Network: netip.PrefixFrom(wgIP, 24), + IPv6: wgIPv6, + IPv6Net: netip.PrefixFrom(wgIPv6, 64), + } + } + + // Re-create manager to pick up the new address with IPv6 + require.NoError(t, manager.Close(nil)) + manager, err = Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + require.NoError(t, err) + + v6Cases := []struct { + name string + dstIP netip.Addr + expected bool + description string + }{ + {"v6 traffic to other address", otherIPv6, true, "should forward v6 traffic not destined to our v6 address"}, + {"v6 traffic to our v6 IP", wgIPv6, false, "should not forward traffic destined to our v6 address"}, + {"v4 traffic to other with v6 configured", otherIP, true, "should forward v4 traffic when v6 configured"}, + {"v4 traffic to our v4 IP with v6 configured", wgIP, false, "should not forward traffic to our v4 address"}, + } + for _, tt := range v6Cases { + t.Run(tt.name, func(t *testing.T) { + manager.localForwarding = true + manager.netstack = false + decoder := createTCPDecoder(8080) + result := manager.shouldForward(decoder, tt.dstIP) + require.Equal(t, tt.expected, result, tt.description) + }) + } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Configure manager diff --git a/client/firewall/uspfilter/forwarder/endpoint.go b/client/firewall/uspfilter/forwarder/endpoint.go index 96ab89af8..fab776f2a 100644 --- a/client/firewall/uspfilter/forwarder/endpoint.go +++ b/client/firewall/uspfilter/forwarder/endpoint.go @@ -1,7 +1,8 @@ package forwarder import ( - "fmt" + "net" + "strconv" "sync/atomic" wgdevice "golang.zx2c4.com/wireguard/device" @@ -54,16 +55,23 @@ func (e *endpoint) LinkAddress() tcpip.LinkAddress { func (e *endpoint) WritePackets(pkts stack.PacketBufferList) (int, tcpip.Error) { var written int for _, pkt := range pkts.AsSlice() { - netHeader := header.IPv4(pkt.NetworkHeader().View().AsSlice()) - data := stack.PayloadSince(pkt.NetworkHeader()) if data == nil { continue } - pktBytes := data.AsSlice() + raw := pkt.NetworkHeader().View().AsSlice() + if len(raw) == 0 { + continue + } + var address tcpip.Address + if raw[0]>>4 == 6 { + address = header.IPv6(raw).DestinationAddress() + } else { + address = header.IPv4(raw).DestinationAddress() + } - address := netHeader.DestinationAddress() + pktBytes := data.AsSlice() if err := e.device.CreateOutboundPacket(pktBytes, address.AsSlice()); err != nil { e.logger.Error1("CreateOutboundPacket: %v", err) continue @@ -114,5 +122,7 @@ type epID stack.TransportEndpointID func (i epID) String() string { // src and remote is swapped - return fmt.Sprintf("%s:%d → %s:%d", i.RemoteAddress, i.RemotePort, i.LocalAddress, i.LocalPort) + return net.JoinHostPort(i.RemoteAddress.String(), strconv.Itoa(int(i.RemotePort))) + + " → " + + net.JoinHostPort(i.LocalAddress.String(), strconv.Itoa(int(i.LocalPort))) } diff --git a/client/firewall/uspfilter/forwarder/forwarder.go b/client/firewall/uspfilter/forwarder/forwarder.go index 925273f24..6291eb285 100644 --- a/client/firewall/uspfilter/forwarder/forwarder.go +++ b/client/firewall/uspfilter/forwarder/forwarder.go @@ -14,6 +14,7 @@ import ( "gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip/header" "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" + "gvisor.dev/gvisor/pkg/tcpip/network/ipv6" "gvisor.dev/gvisor/pkg/tcpip/stack" "gvisor.dev/gvisor/pkg/tcpip/transport/icmp" "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" @@ -36,25 +37,31 @@ type Forwarder struct { logger *nblog.Logger flowLogger nftypes.FlowLogger // ruleIdMap is used to store the rule ID for a given connection - ruleIdMap sync.Map - stack *stack.Stack - endpoint *endpoint - udpForwarder *udpForwarder - ctx context.Context - cancel context.CancelFunc - ip tcpip.Address - netstack bool - hasRawICMPAccess bool - pingSemaphore chan struct{} + ruleIdMap sync.Map + stack *stack.Stack + endpoint *endpoint + udpForwarder *udpForwarder + ctx context.Context + cancel context.CancelFunc + ip tcpip.Address + ipv6 tcpip.Address + netstack bool + hasRawICMPAccess bool + hasRawICMPv6Access bool + pingSemaphore chan struct{} } func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.FlowLogger, netstack bool, mtu uint16) (*Forwarder, error) { s := stack.New(stack.Options{ - NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol}, + NetworkProtocols: []stack.NetworkProtocolFactory{ + ipv4.NewProtocol, + ipv6.NewProtocol, + }, TransportProtocols: []stack.TransportProtocolFactory{ tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol4, + icmp.NewProtocol6, }, HandleLocal: false, }) @@ -73,7 +80,7 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow protoAddr := tcpip.ProtocolAddress{ Protocol: ipv4.ProtocolNumber, AddressWithPrefix: tcpip.AddressWithPrefix{ - Address: tcpip.AddrFromSlice(iface.Address().IP.AsSlice()), + Address: tcpip.AddrFrom4(iface.Address().IP.As4()), PrefixLen: iface.Address().Network.Bits(), }, } @@ -82,6 +89,19 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow return nil, fmt.Errorf("failed to add protocol address: %s", err) } + if v6 := iface.Address().IPv6; v6.IsValid() { + v6Addr := tcpip.ProtocolAddress{ + Protocol: ipv6.ProtocolNumber, + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: tcpip.AddrFrom16(v6.As16()), + PrefixLen: iface.Address().IPv6Net.Bits(), + }, + } + if err := s.AddProtocolAddress(nicID, v6Addr, stack.AddressProperties{}); err != nil { + return nil, fmt.Errorf("add IPv6 protocol address: %s", err) + } + } + defaultSubnet, err := tcpip.NewSubnet( tcpip.AddrFrom4([4]byte{0, 0, 0, 0}), tcpip.MaskFromBytes([]byte{0, 0, 0, 0}), @@ -90,6 +110,14 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow return nil, fmt.Errorf("creating default subnet: %w", err) } + defaultSubnetV6, err := tcpip.NewSubnet( + tcpip.AddrFrom16([16]byte{}), + tcpip.MaskFromBytes(make([]byte, 16)), + ) + if err != nil { + return nil, fmt.Errorf("creating default v6 subnet: %w", err) + } + if err := s.SetPromiscuousMode(nicID, true); err != nil { return nil, fmt.Errorf("set promiscuous mode: %s", err) } @@ -98,10 +126,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow } s.SetRouteTable([]tcpip.Route{ - { - Destination: defaultSubnet, - NIC: nicID, - }, + {Destination: defaultSubnet, NIC: nicID}, + {Destination: defaultSubnetV6, NIC: nicID}, }) ctx, cancel := context.WithCancel(context.Background()) @@ -114,7 +140,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow ctx: ctx, cancel: cancel, netstack: netstack, - ip: tcpip.AddrFromSlice(iface.Address().IP.AsSlice()), + ip: tcpip.AddrFrom4(iface.Address().IP.As4()), + ipv6: addrFromNetipAddr(iface.Address().IPv6), pingSemaphore: make(chan struct{}, 3), } @@ -131,7 +158,10 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow udpForwarder := udp.NewForwarder(s, f.handleUDP) s.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) - s.SetTransportProtocolHandler(icmp.ProtocolNumber4, f.handleICMP) + // ICMP is handled directly in InjectIncomingPacket, bypassing gVisor's + // network layer. This avoids duplicate echo replies (v4) and the v6 + // auto-reply bug where gVisor responds at the network layer before + // our transport handler fires. f.checkICMPCapability() @@ -150,8 +180,30 @@ func (f *Forwarder) SetCapture(pc PacketCapture) { } func (f *Forwarder) InjectIncomingPacket(payload []byte) error { - if len(payload) < header.IPv4MinimumSize { - return fmt.Errorf("packet too small: %d bytes", len(payload)) + if len(payload) == 0 { + return fmt.Errorf("empty packet") + } + + var protoNum tcpip.NetworkProtocolNumber + switch payload[0] >> 4 { + case 4: + if len(payload) < header.IPv4MinimumSize { + return fmt.Errorf("IPv4 packet too small: %d bytes", len(payload)) + } + if f.handleICMPDirect(payload) { + return nil + } + protoNum = ipv4.ProtocolNumber + case 6: + if len(payload) < header.IPv6MinimumSize { + return fmt.Errorf("IPv6 packet too small: %d bytes", len(payload)) + } + if f.handleICMPDirect(payload) { + return nil + } + protoNum = ipv6.ProtocolNumber + default: + return fmt.Errorf("unknown IP version: %d", payload[0]>>4) } pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{ @@ -160,11 +212,160 @@ func (f *Forwarder) InjectIncomingPacket(payload []byte) error { defer pkt.DecRef() if f.endpoint.dispatcher != nil { - f.endpoint.dispatcher.DeliverNetworkPacket(ipv4.ProtocolNumber, pkt) + f.endpoint.dispatcher.DeliverNetworkPacket(protoNum, pkt) } return nil } +// handleICMPDirect intercepts ICMP packets from raw IP payloads before they +// enter gVisor. It synthesizes the TransportEndpointID and PacketBuffer that +// the existing handlers expect, then dispatches to handleICMP/handleICMPv6. +// This bypasses gVisor's network layer which causes duplicate v4 echo replies +// and auto-replies to all v6 echo requests in promiscuous mode. +// +// Unlike gVisor's network layer, this does not validate ICMP checksums or +// reassemble IP fragments. Fragmented ICMP packets fall through to gVisor. +func parseICMPv4(payload []byte) (ipHdrLen, icmpLen int, src, dst tcpip.Address, ok bool) { + if len(payload) < header.IPv4MinimumSize { + return 0, 0, src, dst, false + } + ip := header.IPv4(payload) + if ip.Protocol() != uint8(header.ICMPv4ProtocolNumber) { + return 0, 0, src, dst, false + } + if ip.FragmentOffset() != 0 || ip.Flags()&header.IPv4FlagMoreFragments != 0 { + return 0, 0, src, dst, false + } + ipHdrLen = int(ip.HeaderLength()) + totalLen := int(ip.TotalLength()) + if ipHdrLen < header.IPv4MinimumSize || ipHdrLen > totalLen || totalLen > len(payload) { + return 0, 0, src, dst, false + } + icmpLen = totalLen - ipHdrLen + if icmpLen < header.ICMPv4MinimumSize { + return 0, 0, src, dst, false + } + return ipHdrLen, icmpLen, ip.SourceAddress(), ip.DestinationAddress(), true +} + +func parseICMPv6(payload []byte) (ipHdrLen, icmpLen int, src, dst tcpip.Address, ok bool) { + if len(payload) < header.IPv6MinimumSize { + return 0, 0, src, dst, false + } + ip := header.IPv6(payload) + declaredLen := int(ip.PayloadLength()) + hdrEnd := header.IPv6MinimumSize + declaredLen + if hdrEnd > len(payload) { + return 0, 0, src, dst, false + } + icmpStart, ok := skipIPv6ExtensionsToICMPv6(payload, ip.NextHeader(), hdrEnd) + if !ok { + return 0, 0, src, dst, false + } + icmpLen = hdrEnd - icmpStart + if icmpLen < header.ICMPv6MinimumSize { + return 0, 0, src, dst, false + } + return icmpStart, icmpLen, ip.SourceAddress(), ip.DestinationAddress(), true +} + +// skipIPv6ExtensionsToICMPv6 walks the IPv6 extension-header chain starting +// after the fixed header. It advances past Hop-by-Hop, Routing, and +// Destination Options headers (which share the NextHeader+ExtLen+6+ExtLen*8 +// layout) and returns the offset of the ICMPv6 payload. Fragment, ESP, AH, +// and unknown identifiers are reported as not handleable so the caller can +// defer to gVisor. +func skipIPv6ExtensionsToICMPv6(payload []byte, next uint8, hdrEnd int) (int, bool) { + off := header.IPv6MinimumSize + for { + if next == uint8(header.ICMPv6ProtocolNumber) { + return off, true + } + if !isWalkableIPv6ExtHdr(next) { + return 0, false + } + newOff, newNext, ok := advanceIPv6ExtHdr(payload, off, hdrEnd) + if !ok { + return 0, false + } + off = newOff + next = newNext + } +} + +func isWalkableIPv6ExtHdr(id uint8) bool { + switch id { + case uint8(header.IPv6HopByHopOptionsExtHdrIdentifier), + uint8(header.IPv6RoutingExtHdrIdentifier), + uint8(header.IPv6DestinationOptionsExtHdrIdentifier): + return true + } + return false +} + +func advanceIPv6ExtHdr(payload []byte, off, hdrEnd int) (int, uint8, bool) { + if off+8 > hdrEnd { + return 0, 0, false + } + extLen := (int(payload[off+1]) + 1) * 8 + if off+extLen > hdrEnd { + return 0, 0, false + } + return off + extLen, payload[off], true +} + +func (f *Forwarder) handleICMPDirect(payload []byte) bool { + if len(payload) == 0 { + return false + } + var ( + ipHdrLen int + icmpLen int + srcAddr tcpip.Address + dstAddr tcpip.Address + ok bool + ) + version := payload[0] >> 4 + switch version { + case 4: + ipHdrLen, icmpLen, srcAddr, dstAddr, ok = parseICMPv4(payload) + case 6: + ipHdrLen, icmpLen, srcAddr, dstAddr, ok = parseICMPv6(payload) + } + if !ok { + return false + } + + // Let gVisor handle ICMP destined for our own addresses natively. + // Its network-layer auto-reply is correct and efficient for local traffic. + if f.ip.Equal(dstAddr) || f.ipv6.Equal(dstAddr) { + return false + } + + id := stack.TransportEndpointID{ + LocalAddress: dstAddr, + RemoteAddress: srcAddr, + } + + // Trim the buffer to the IP-declared length so gVisor doesn't see padding. + pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(payload[:ipHdrLen+icmpLen]), + }) + defer pkt.DecRef() + + if _, ok := pkt.NetworkHeader().Consume(ipHdrLen); !ok { + return false + } + if _, ok := pkt.TransportHeader().Consume(icmpLen); !ok { + return false + } + + if version == 6 { + return f.handleICMPv6(id, pkt) + } + return f.handleICMP(id, pkt) +} + // Stop gracefully shuts down the forwarder func (f *Forwarder) Stop() { f.cancel() @@ -177,11 +378,14 @@ func (f *Forwarder) Stop() { f.stack.Wait() } -func (f *Forwarder) determineDialAddr(addr tcpip.Address) net.IP { +func (f *Forwarder) determineDialAddr(addr tcpip.Address) netip.Addr { if f.netstack && f.ip.Equal(addr) { - return net.IPv4(127, 0, 0, 1) + return netip.AddrFrom4([4]byte{127, 0, 0, 1}) } - return addr.AsSlice() + if f.netstack && f.ipv6.Equal(addr) { + return netip.IPv6Loopback() + } + return addrToNetipAddr(addr) } func (f *Forwarder) RegisterRuleID(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, ruleID []byte) { @@ -215,23 +419,50 @@ func buildKey(srcIP, dstIP netip.Addr, srcPort, dstPort uint16) conntrack.ConnKe } } +// addrFromNetipAddr converts a netip.Addr to a gvisor tcpip.Address without allocating. +func addrFromNetipAddr(addr netip.Addr) tcpip.Address { + if !addr.IsValid() { + return tcpip.Address{} + } + if addr.Is4() { + return tcpip.AddrFrom4(addr.As4()) + } + return tcpip.AddrFrom16(addr.As16()) +} + +// addrToNetipAddr converts a gvisor tcpip.Address to netip.Addr without allocating. +func addrToNetipAddr(addr tcpip.Address) netip.Addr { + switch addr.Len() { + case 4: + return netip.AddrFrom4(addr.As4()) + case 16: + return netip.AddrFrom16(addr.As16()) + default: + return netip.Addr{} + } +} + // checkICMPCapability tests whether we have raw ICMP socket access at startup. func (f *Forwarder) checkICMPCapability() { + f.hasRawICMPAccess = probeRawICMP("ip4:icmp", "0.0.0.0", f.logger) + f.hasRawICMPv6Access = probeRawICMP("ip6:ipv6-icmp", "::", f.logger) +} + +func probeRawICMP(network, addr string, logger *nblog.Logger) bool { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() lc := net.ListenConfig{} - conn, err := lc.ListenPacket(ctx, "ip4:icmp", "0.0.0.0") + conn, err := lc.ListenPacket(ctx, network, addr) if err != nil { - f.hasRawICMPAccess = false - f.logger.Debug("forwarder: No raw ICMP socket access, will use ping binary fallback") - return + logger.Debug1("forwarder: no raw %s socket access, will use ping binary fallback", network) + return false } if err := conn.Close(); err != nil { - f.logger.Debug1("forwarder: Failed to close ICMP capability test socket: %v", err) + logger.Debug2("forwarder: failed to close %s capability test socket: %v", network, err) } - f.hasRawICMPAccess = true - f.logger.Debug("forwarder: Raw ICMP socket access available") + logger.Debug1("forwarder: raw %s socket access available", network) + return true } diff --git a/client/firewall/uspfilter/forwarder/forwarder_test.go b/client/firewall/uspfilter/forwarder/forwarder_test.go new file mode 100644 index 000000000..ad74e8493 --- /dev/null +++ b/client/firewall/uspfilter/forwarder/forwarder_test.go @@ -0,0 +1,162 @@ +package forwarder + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/header" +) + +const echoRequestSize = 8 + +func makeIPv6(t *testing.T, src, dst netip.Addr, nextHdr uint8, payload []byte) []byte { + t.Helper() + buf := make([]byte, header.IPv6MinimumSize+len(payload)) + ip := header.IPv6(buf) + ip.Encode(&header.IPv6Fields{ + PayloadLength: uint16(len(payload)), + TransportProtocol: 0, // overwritten below to allow any value + HopLimit: 64, + SrcAddr: tcpipAddrFromNetip(src), + DstAddr: tcpipAddrFromNetip(dst), + }) + buf[6] = nextHdr + copy(buf[header.IPv6MinimumSize:], payload) + return buf +} + +func tcpipAddrFromNetip(a netip.Addr) tcpip.Address { + b := a.As16() + return tcpip.AddrFrom16(b) +} + +func echoRequest() []byte { + icmp := make([]byte, echoRequestSize) + icmp[0] = uint8(header.ICMPv6EchoRequest) + return icmp +} + +// extHdr builds a generic IPv6 extension header (HBH/Routing/DestOpts) of the +// given total octet length (must be multiple of 8, >= 8) with the given next +// header. +func extHdr(t *testing.T, next uint8, totalLen int) []byte { + t.Helper() + require.GreaterOrEqual(t, totalLen, 8) + require.Equal(t, 0, totalLen%8) + buf := make([]byte, totalLen) + buf[0] = next + buf[1] = uint8(totalLen/8 - 1) + return buf +} + +func TestParseICMPv6_NoExtensions(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + pkt := makeIPv6(t, src, dst, uint8(header.ICMPv6ProtocolNumber), echoRequest()) + + off, icmpLen, _, _, ok := parseICMPv6(pkt) + require.True(t, ok) + assert.Equal(t, header.IPv6MinimumSize, off) + assert.Equal(t, echoRequestSize, icmpLen) +} + +func TestParseICMPv6_SingleExtension(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + hbh := extHdr(t, uint8(header.ICMPv6ProtocolNumber), 8) + payload := append([]byte{}, hbh...) + payload = append(payload, echoRequest()...) + pkt := makeIPv6(t, src, dst, uint8(header.IPv6HopByHopOptionsExtHdrIdentifier), payload) + + off, icmpLen, _, _, ok := parseICMPv6(pkt) + require.True(t, ok) + assert.Equal(t, header.IPv6MinimumSize+8, off) + assert.Equal(t, echoRequestSize, icmpLen) +} + +func TestParseICMPv6_ChainedExtensions(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + dest := extHdr(t, uint8(header.ICMPv6ProtocolNumber), 16) + rt := extHdr(t, uint8(header.IPv6DestinationOptionsExtHdrIdentifier), 8) + hbh := extHdr(t, uint8(header.IPv6RoutingExtHdrIdentifier), 8) + payload := append(append(append([]byte{}, hbh...), rt...), dest...) + payload = append(payload, echoRequest()...) + pkt := makeIPv6(t, src, dst, uint8(header.IPv6HopByHopOptionsExtHdrIdentifier), payload) + + off, icmpLen, _, _, ok := parseICMPv6(pkt) + require.True(t, ok) + assert.Equal(t, header.IPv6MinimumSize+8+8+16, off) + assert.Equal(t, echoRequestSize, icmpLen) +} + +func TestParseICMPv6_FragmentDefersToGVisor(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + pkt := makeIPv6(t, src, dst, uint8(header.IPv6FragmentExtHdrIdentifier), make([]byte, 8)) + + _, _, _, _, ok := parseICMPv6(pkt) + assert.False(t, ok) +} + +func TestParseICMPv6_TruncatedExtension(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + // Extension claims 16 bytes but only 8 remain after the IP header. + hbh := []byte{uint8(header.ICMPv6ProtocolNumber), 1, 0, 0, 0, 0, 0, 0} + pkt := makeIPv6(t, src, dst, uint8(header.IPv6HopByHopOptionsExtHdrIdentifier), hbh) + + _, _, _, _, ok := parseICMPv6(pkt) + assert.False(t, ok) +} + +func TestParseICMPv6_TruncatedICMPPayload(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + // PayloadLength claims 8 bytes of ICMPv6 but the buffer only holds 4. + pkt := makeIPv6(t, src, dst, uint8(header.ICMPv6ProtocolNumber), make([]byte, 8)) + pkt = pkt[:header.IPv6MinimumSize+4] + + _, _, _, _, ok := parseICMPv6(pkt) + assert.False(t, ok) +} + +func TestParseICMPv4_RejectsShortIHL(t *testing.T) { + pkt := make([]byte, 28) + pkt[0] = 0x44 // version 4, IHL 4 (16 bytes - below minimum) + pkt[9] = uint8(header.ICMPv4ProtocolNumber) + header.IPv4(pkt).SetTotalLength(28) + + _, _, _, _, ok := parseICMPv4(pkt) + assert.False(t, ok) +} + +func TestParseICMPv4_RejectsTotalLenOverBuffer(t *testing.T) { + pkt := make([]byte, header.IPv4MinimumSize+header.ICMPv4MinimumSize) + ip := header.IPv4(pkt) + ip.Encode(&header.IPv4Fields{ + TotalLength: uint16(len(pkt) + 16), + Protocol: uint8(header.ICMPv4ProtocolNumber), + TTL: 64, + }) + + _, _, _, _, ok := parseICMPv4(pkt) + assert.False(t, ok) +} + +func TestParseICMPv4_RejectsFragment(t *testing.T) { + pkt := make([]byte, header.IPv4MinimumSize+header.ICMPv4MinimumSize) + ip := header.IPv4(pkt) + ip.Encode(&header.IPv4Fields{ + TotalLength: uint16(len(pkt)), + Protocol: uint8(header.ICMPv4ProtocolNumber), + TTL: 64, + Flags: header.IPv4FlagMoreFragments, + }) + + _, _, _, _, ok := parseICMPv4(pkt) + assert.False(t, ok) +} diff --git a/client/firewall/uspfilter/forwarder/icmp.go b/client/firewall/uspfilter/forwarder/icmp.go index 217423901..3922c2052 100644 --- a/client/firewall/uspfilter/forwarder/icmp.go +++ b/client/firewall/uspfilter/forwarder/icmp.go @@ -35,7 +35,7 @@ func (f *Forwarder) handleICMP(id stack.TransportEndpointID, pkt *stack.PacketBu } icmpData := stack.PayloadSince(pkt.TransportHeader()).AsSlice() - conn, err := f.forwardICMPPacket(id, icmpData, uint8(icmpHdr.Type()), uint8(icmpHdr.Code()), 100*time.Millisecond) + conn, err := f.forwardICMPPacket(id, icmpData, uint8(icmpHdr.Type()), uint8(icmpHdr.Code()), false, 100*time.Millisecond) if err != nil { f.logger.Error2("forwarder: Failed to forward ICMP packet for %v: %v", epID(id), err) return true @@ -58,7 +58,7 @@ func (f *Forwarder) handleICMPEcho(flowID uuid.UUID, id stack.TransportEndpointI defer func() { <-f.pingSemaphore }() if f.hasRawICMPAccess { - f.handleICMPViaSocket(flowID, id, icmpType, icmpCode, icmpData, rxBytes) + f.handleICMPViaSocket(flowID, id, icmpType, icmpCode, icmpData, rxBytes, false) } else { f.handleICMPViaPing(flowID, id, icmpType, icmpCode, icmpData, rxBytes) } @@ -72,18 +72,23 @@ func (f *Forwarder) handleICMPEcho(flowID uuid.UUID, id stack.TransportEndpointI // forwardICMPPacket creates a raw ICMP socket and sends the packet, returning the connection. // The caller is responsible for closing the returned connection. -func (f *Forwarder) forwardICMPPacket(id stack.TransportEndpointID, payload []byte, icmpType, icmpCode uint8, timeout time.Duration) (net.PacketConn, error) { +func (f *Forwarder) forwardICMPPacket(id stack.TransportEndpointID, payload []byte, icmpType, icmpCode uint8, v6 bool, timeout time.Duration) (net.PacketConn, error) { ctx, cancel := context.WithTimeout(f.ctx, timeout) defer cancel() + network, listenAddr := "ip4:icmp", "0.0.0.0" + if v6 { + network, listenAddr = "ip6:ipv6-icmp", "::" + } + lc := net.ListenConfig{} - conn, err := lc.ListenPacket(ctx, "ip4:icmp", "0.0.0.0") + conn, err := lc.ListenPacket(ctx, network, listenAddr) if err != nil { return nil, fmt.Errorf("create ICMP socket: %w", err) } dstIP := f.determineDialAddr(id.LocalAddress) - dst := &net.IPAddr{IP: dstIP} + dst := &net.IPAddr{IP: dstIP.AsSlice()} if _, err = conn.WriteTo(payload, dst); err != nil { if closeErr := conn.Close(); closeErr != nil { @@ -98,11 +103,11 @@ func (f *Forwarder) forwardICMPPacket(id stack.TransportEndpointID, payload []by return conn, nil } -// handleICMPViaSocket handles ICMP echo requests using raw sockets. -func (f *Forwarder) handleICMPViaSocket(flowID uuid.UUID, id stack.TransportEndpointID, icmpType, icmpCode uint8, icmpData []byte, rxBytes int) { +// handleICMPViaSocket handles ICMP echo requests using raw sockets for both v4 and v6. +func (f *Forwarder) handleICMPViaSocket(flowID uuid.UUID, id stack.TransportEndpointID, icmpType, icmpCode uint8, icmpData []byte, rxBytes int, v6 bool) { sendTime := time.Now() - conn, err := f.forwardICMPPacket(id, icmpData, icmpType, icmpCode, 5*time.Second) + conn, err := f.forwardICMPPacket(id, icmpData, icmpType, icmpCode, v6, 5*time.Second) if err != nil { f.logger.Error2("forwarder: Failed to send ICMP packet for %v: %v", epID(id), err) return @@ -113,16 +118,20 @@ func (f *Forwarder) handleICMPViaSocket(flowID uuid.UUID, id stack.TransportEndp } }() - txBytes := f.handleEchoResponse(conn, id) + txBytes := f.handleEchoResponse(conn, id, v6) rtt := time.Since(sendTime).Round(10 * time.Microsecond) - f.logger.Trace4("forwarder: Forwarded ICMP echo reply %v type %v code %v (rtt=%v, raw socket)", - epID(id), icmpType, icmpCode, rtt) + proto := "ICMP" + if v6 { + proto = "ICMPv6" + } + f.logger.Trace5("forwarder: Forwarded %s echo reply %v type %v code %v (rtt=%v, raw socket)", + proto, epID(id), icmpType, icmpCode, rtt) f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes)) } -func (f *Forwarder) handleEchoResponse(conn net.PacketConn, id stack.TransportEndpointID) int { +func (f *Forwarder) handleEchoResponse(conn net.PacketConn, id stack.TransportEndpointID, v6 bool) int { if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { f.logger.Error1("forwarder: Failed to set read deadline for ICMP response: %v", err) return 0 @@ -137,6 +146,19 @@ func (f *Forwarder) handleEchoResponse(conn net.PacketConn, id stack.TransportEn return 0 } + if v6 { + // Recompute checksum: the raw socket response has a checksum computed + // over the real endpoint addresses, but we inject with overlay addresses. + icmpHdr := header.ICMPv6(response[:n]) + icmpHdr.SetChecksum(0) + icmpHdr.SetChecksum(header.ICMPv6Checksum(header.ICMPv6ChecksumParams{ + Header: icmpHdr, + Src: id.LocalAddress, + Dst: id.RemoteAddress, + })) + return f.injectICMPv6Reply(id, response[:n]) + } + return f.injectICMPReply(id, response[:n]) } @@ -150,19 +172,23 @@ func (f *Forwarder) sendICMPEvent(typ nftypes.Type, flowID uuid.UUID, id stack.T txPackets = 1 } - srcIp := netip.AddrFrom4(id.RemoteAddress.As4()) - dstIp := netip.AddrFrom4(id.LocalAddress.As4()) + srcIp := addrToNetipAddr(id.RemoteAddress) + dstIp := addrToNetipAddr(id.LocalAddress) + + proto := nftypes.ICMP + if srcIp.Is6() { + proto = nftypes.ICMPv6 + } fields := nftypes.EventFields{ FlowID: flowID, Type: typ, Direction: nftypes.Ingress, - Protocol: nftypes.ICMP, - // TODO: handle ipv6 - SourceIP: srcIp, - DestIP: dstIp, - ICMPType: icmpType, - ICMPCode: icmpCode, + Protocol: proto, + SourceIP: srcIp, + DestIP: dstIp, + ICMPType: icmpType, + ICMPCode: icmpCode, RxBytes: rxBytes, TxBytes: txBytes, @@ -209,26 +235,164 @@ func (f *Forwarder) handleICMPViaPing(flowID uuid.UUID, id stack.TransportEndpoi f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes)) } +// handleICMPv6 handles ICMPv6 packets from the network stack. +func (f *Forwarder) handleICMPv6(id stack.TransportEndpointID, pkt *stack.PacketBuffer) bool { + icmpHdr := header.ICMPv6(pkt.TransportHeader().View().AsSlice()) + + flowID := uuid.New() + f.sendICMPEvent(nftypes.TypeStart, flowID, id, uint8(icmpHdr.Type()), uint8(icmpHdr.Code()), 0, 0) + + if icmpHdr.Type() == header.ICMPv6EchoRequest { + return f.handleICMPv6Echo(flowID, id, pkt, uint8(icmpHdr.Type()), uint8(icmpHdr.Code())) + } + + // For non-echo types (Destination Unreachable, Packet Too Big, etc), forward without waiting + if !f.hasRawICMPv6Access { + f.logger.Debug2("forwarder: Cannot handle ICMPv6 type %v without raw socket access for %v", icmpHdr.Type(), epID(id)) + return false + } + + icmpData := stack.PayloadSince(pkt.TransportHeader()).AsSlice() + conn, err := f.forwardICMPPacket(id, icmpData, uint8(icmpHdr.Type()), uint8(icmpHdr.Code()), true, 100*time.Millisecond) + if err != nil { + f.logger.Error2("forwarder: Failed to forward ICMPv6 packet for %v: %v", epID(id), err) + return true + } + if err := conn.Close(); err != nil { + f.logger.Debug1("forwarder: Failed to close ICMPv6 socket: %v", err) + } + + return true +} + +// handleICMPv6Echo handles ICMPv6 echo requests via raw socket or ping binary fallback. +func (f *Forwarder) handleICMPv6Echo(flowID uuid.UUID, id stack.TransportEndpointID, pkt *stack.PacketBuffer, icmpType, icmpCode uint8) bool { + select { + case f.pingSemaphore <- struct{}{}: + icmpData := stack.PayloadSince(pkt.TransportHeader()).ToSlice() + rxBytes := pkt.Size() + + go func() { + defer func() { <-f.pingSemaphore }() + + if f.hasRawICMPv6Access { + f.handleICMPViaSocket(flowID, id, icmpType, icmpCode, icmpData, rxBytes, true) + } else { + f.handleICMPv6ViaPing(flowID, id, icmpType, icmpCode, icmpData, rxBytes) + } + }() + default: + f.logger.Debug3("forwarder: ICMPv6 rate limit exceeded for %v type %v code %v", epID(id), icmpType, icmpCode) + } + return true +} + +// handleICMPv6ViaPing uses the system ping6 binary for ICMPv6 echo. +func (f *Forwarder) handleICMPv6ViaPing(flowID uuid.UUID, id stack.TransportEndpointID, icmpType, icmpCode uint8, icmpData []byte, rxBytes int) { + ctx, cancel := context.WithTimeout(f.ctx, 5*time.Second) + defer cancel() + + dstIP := f.determineDialAddr(id.LocalAddress) + cmd := buildPingCommand(ctx, dstIP, 5*time.Second) + + pingStart := time.Now() + if err := cmd.Run(); err != nil { + f.logger.Warn4("forwarder: Ping6 failed for %v type %v code %v: %v", epID(id), icmpType, icmpCode, err) + return + } + rtt := time.Since(pingStart).Round(10 * time.Microsecond) + + f.logger.Trace3("forwarder: Forwarded ICMPv6 echo request %v type %v code %v", + epID(id), icmpType, icmpCode) + + txBytes := f.synthesizeICMPv6EchoReply(id, icmpData) + + f.logger.Trace4("forwarder: Forwarded ICMPv6 echo reply %v type %v code %v (rtt=%v, ping binary)", + epID(id), icmpType, icmpCode, rtt) + + f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes)) +} + +// synthesizeICMPv6EchoReply creates an ICMPv6 echo reply and injects it back. +func (f *Forwarder) synthesizeICMPv6EchoReply(id stack.TransportEndpointID, icmpData []byte) int { + replyICMP := make([]byte, len(icmpData)) + copy(replyICMP, icmpData) + + replyHdr := header.ICMPv6(replyICMP) + replyHdr.SetType(header.ICMPv6EchoReply) + replyHdr.SetChecksum(0) + // ICMPv6Checksum computes the pseudo-header internally from Src/Dst. + // Header contains the full ICMP message, so PayloadCsum/PayloadLen are zero. + replyHdr.SetChecksum(header.ICMPv6Checksum(header.ICMPv6ChecksumParams{ + Header: replyHdr, + Src: id.LocalAddress, + Dst: id.RemoteAddress, + })) + + return f.injectICMPv6Reply(id, replyICMP) +} + +// injectICMPv6Reply wraps an ICMPv6 payload in an IPv6 header and sends to the peer. +func (f *Forwarder) injectICMPv6Reply(id stack.TransportEndpointID, icmpPayload []byte) int { + ipHdr := make([]byte, header.IPv6MinimumSize) + ip := header.IPv6(ipHdr) + ip.Encode(&header.IPv6Fields{ + PayloadLength: uint16(len(icmpPayload)), + TransportProtocol: header.ICMPv6ProtocolNumber, + HopLimit: 64, + SrcAddr: id.LocalAddress, + DstAddr: id.RemoteAddress, + }) + + fullPacket := make([]byte, 0, len(ipHdr)+len(icmpPayload)) + fullPacket = append(fullPacket, ipHdr...) + fullPacket = append(fullPacket, icmpPayload...) + + if err := f.endpoint.device.CreateOutboundPacket(fullPacket, id.RemoteAddress.AsSlice()); err != nil { + f.logger.Error1("forwarder: Failed to send ICMPv6 reply to peer: %v", err) + return 0 + } + + return len(fullPacket) +} + +const ( + pingBin = "ping" + ping6Bin = "ping6" +) + // buildPingCommand creates a platform-specific ping command. -func buildPingCommand(ctx context.Context, target net.IP, timeout time.Duration) *exec.Cmd { +// Most platforms auto-detect IPv6 from raw addresses. macOS/iOS/OpenBSD require ping6. +func buildPingCommand(ctx context.Context, target netip.Addr, timeout time.Duration) *exec.Cmd { timeoutSec := int(timeout.Seconds()) if timeoutSec < 1 { timeoutSec = 1 } + isV6 := target.Is6() + timeoutStr := fmt.Sprintf("%d", timeoutSec) + switch runtime.GOOS { case "linux", "android": - return exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", timeoutSec), "-q", target.String()) + return exec.CommandContext(ctx, pingBin, "-c", "1", "-W", timeoutStr, "-q", target.String()) case "darwin", "ios": - return exec.CommandContext(ctx, "ping", "-c", "1", "-t", fmt.Sprintf("%d", timeoutSec), "-q", target.String()) + bin := pingBin + if isV6 { + bin = ping6Bin + } + return exec.CommandContext(ctx, bin, "-c", "1", "-t", timeoutStr, "-q", target.String()) case "freebsd": - return exec.CommandContext(ctx, "ping", "-c", "1", "-t", fmt.Sprintf("%d", timeoutSec), target.String()) + return exec.CommandContext(ctx, pingBin, "-c", "1", "-t", timeoutStr, target.String()) case "openbsd", "netbsd": - return exec.CommandContext(ctx, "ping", "-c", "1", "-w", fmt.Sprintf("%d", timeoutSec), target.String()) + bin := pingBin + if isV6 { + bin = ping6Bin + } + return exec.CommandContext(ctx, bin, "-c", "1", "-w", timeoutStr, target.String()) case "windows": - return exec.CommandContext(ctx, "ping", "-n", "1", "-w", fmt.Sprintf("%d", timeoutSec*1000), target.String()) + return exec.CommandContext(ctx, pingBin, "-n", "1", "-w", fmt.Sprintf("%d", timeoutSec*1000), target.String()) default: - return exec.CommandContext(ctx, "ping", "-c", "1", target.String()) + return exec.CommandContext(ctx, pingBin, "-c", "1", target.String()) } } diff --git a/client/firewall/uspfilter/forwarder/tcp.go b/client/firewall/uspfilter/forwarder/tcp.go index aef420061..8844463f5 100644 --- a/client/firewall/uspfilter/forwarder/tcp.go +++ b/client/firewall/uspfilter/forwarder/tcp.go @@ -2,10 +2,9 @@ package forwarder import ( "context" - "fmt" "io" "net" - "net/netip" + "strconv" "sync" "github.com/google/uuid" @@ -33,7 +32,7 @@ func (f *Forwarder) handleTCP(r *tcp.ForwarderRequest) { } }() - dialAddr := fmt.Sprintf("%s:%d", f.determineDialAddr(id.LocalAddress), id.LocalPort) + dialAddr := net.JoinHostPort(f.determineDialAddr(id.LocalAddress).String(), strconv.Itoa(int(id.LocalPort))) outConn, err := (&net.Dialer{}).DialContext(f.ctx, "tcp", dialAddr) if err != nil { @@ -133,15 +132,14 @@ func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn } func (f *Forwarder) sendTCPEvent(typ nftypes.Type, flowID uuid.UUID, id stack.TransportEndpointID, rxBytes, txBytes, rxPackets, txPackets uint64) { - srcIp := netip.AddrFrom4(id.RemoteAddress.As4()) - dstIp := netip.AddrFrom4(id.LocalAddress.As4()) + srcIp := addrToNetipAddr(id.RemoteAddress) + dstIp := addrToNetipAddr(id.LocalAddress) fields := nftypes.EventFields{ - FlowID: flowID, - Type: typ, - Direction: nftypes.Ingress, - Protocol: nftypes.TCP, - // TODO: handle ipv6 + FlowID: flowID, + Type: typ, + Direction: nftypes.Ingress, + Protocol: nftypes.TCP, SourceIP: srcIp, DestIP: dstIp, SourcePort: id.RemotePort, diff --git a/client/firewall/uspfilter/forwarder/udp.go b/client/firewall/uspfilter/forwarder/udp.go index f175e275b..c92fa1f32 100644 --- a/client/firewall/uspfilter/forwarder/udp.go +++ b/client/firewall/uspfilter/forwarder/udp.go @@ -6,7 +6,7 @@ import ( "fmt" "io" "net" - "net/netip" + "strconv" "sync" "sync/atomic" "time" @@ -158,7 +158,7 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) bool { } }() - dstAddr := fmt.Sprintf("%s:%d", f.determineDialAddr(id.LocalAddress), id.LocalPort) + dstAddr := net.JoinHostPort(f.determineDialAddr(id.LocalAddress).String(), strconv.Itoa(int(id.LocalPort))) outConn, err := (&net.Dialer{}).DialContext(f.ctx, "udp", dstAddr) if err != nil { f.logger.Debug2("forwarder: UDP dial error for %v: %v", epID(id), err) @@ -276,15 +276,14 @@ func (f *Forwarder) proxyUDP(ctx context.Context, pConn *udpPacketConn, id stack // sendUDPEvent stores flow events for UDP connections func (f *Forwarder) sendUDPEvent(typ nftypes.Type, flowID uuid.UUID, id stack.TransportEndpointID, rxBytes, txBytes, rxPackets, txPackets uint64) { - srcIp := netip.AddrFrom4(id.RemoteAddress.As4()) - dstIp := netip.AddrFrom4(id.LocalAddress.As4()) + srcIp := addrToNetipAddr(id.RemoteAddress) + dstIp := addrToNetipAddr(id.LocalAddress) fields := nftypes.EventFields{ - FlowID: flowID, - Type: typ, - Direction: nftypes.Ingress, - Protocol: nftypes.UDP, - // TODO: handle ipv6 + FlowID: flowID, + Type: typ, + Direction: nftypes.Ingress, + Protocol: nftypes.UDP, SourceIP: srcIp, DestIP: dstIp, SourcePort: id.RemotePort, diff --git a/client/firewall/uspfilter/hooks_filter.go b/client/firewall/uspfilter/hooks_filter.go index 8d3cc0f5c..f3adf5f8b 100644 --- a/client/firewall/uspfilter/hooks_filter.go +++ b/client/firewall/uspfilter/hooks_filter.go @@ -13,7 +13,6 @@ const ( ipv4HeaderMinLen = 20 ipv4ProtoOffset = 9 ipv4FlagsOffset = 6 - ipv4DstOffset = 16 ipProtoUDP = 17 ipProtoTCP = 6 ipv4FragOffMask = 0x1fff diff --git a/client/firewall/uspfilter/localip.go b/client/firewall/uspfilter/localip.go index f63fe3e45..b35be56c6 100644 --- a/client/firewall/uspfilter/localip.go +++ b/client/firewall/uspfilter/localip.go @@ -4,89 +4,32 @@ import ( "fmt" "net" "net/netip" - "sync" + "sync/atomic" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/firewall/uspfilter/common" ) -type localIPManager struct { - mu sync.RWMutex - - // fixed-size high array for upper byte of a IPv4 address - ipv4Bitmap [256]*ipv4LowBitmap +// localIPSnapshot is an immutable snapshot of local IP addresses, swapped +// atomically so reads are lock-free. +type localIPSnapshot struct { + ips map[netip.Addr]struct{} } -// ipv4LowBitmap is a map for the low 16 bits of a IPv4 address -type ipv4LowBitmap struct { - bitmap [8192]uint32 +type localIPManager struct { + snapshot atomic.Pointer[localIPSnapshot] } func newLocalIPManager() *localIPManager { - return &localIPManager{} + m := &localIPManager{} + m.snapshot.Store(&localIPSnapshot{ + ips: make(map[netip.Addr]struct{}), + }) + return m } -func (m *localIPManager) setBitmapBit(ip net.IP) { - ipv4 := ip.To4() - if ipv4 == nil { - return - } - high := uint16(ipv4[0]) - low := (uint16(ipv4[1]) << 8) | (uint16(ipv4[2]) << 4) | uint16(ipv4[3]) - - index := low / 32 - bit := low % 32 - - if m.ipv4Bitmap[high] == nil { - m.ipv4Bitmap[high] = &ipv4LowBitmap{} - } - - m.ipv4Bitmap[high].bitmap[index] |= 1 << bit -} - -func (m *localIPManager) setBitInBitmap(ip netip.Addr, bitmap *[256]*ipv4LowBitmap, ipv4Set map[netip.Addr]struct{}, ipv4Addresses *[]netip.Addr) { - if !ip.Is4() { - return - } - ipv4 := ip.AsSlice() - - high := uint16(ipv4[0]) - low := (uint16(ipv4[1]) << 8) | (uint16(ipv4[2]) << 4) | uint16(ipv4[3]) - - if bitmap[high] == nil { - bitmap[high] = &ipv4LowBitmap{} - } - - index := low / 32 - bit := low % 32 - bitmap[high].bitmap[index] |= 1 << bit - - if _, exists := ipv4Set[ip]; !exists { - ipv4Set[ip] = struct{}{} - *ipv4Addresses = append(*ipv4Addresses, ip) - } -} - -func (m *localIPManager) checkBitmapBit(ip []byte) bool { - high := uint16(ip[0]) - low := (uint16(ip[1]) << 8) | (uint16(ip[2]) << 4) | uint16(ip[3]) - - if m.ipv4Bitmap[high] == nil { - return false - } - - index := low / 32 - bit := low % 32 - return (m.ipv4Bitmap[high].bitmap[index] & (1 << bit)) != 0 -} - -func (m *localIPManager) processIP(ip netip.Addr, bitmap *[256]*ipv4LowBitmap, ipv4Set map[netip.Addr]struct{}, ipv4Addresses *[]netip.Addr) error { - m.setBitInBitmap(ip, bitmap, ipv4Set, ipv4Addresses) - return nil -} - -func (m *localIPManager) processInterface(iface net.Interface, bitmap *[256]*ipv4LowBitmap, ipv4Set map[netip.Addr]struct{}, ipv4Addresses *[]netip.Addr) { +func processInterface(iface net.Interface, ips map[netip.Addr]struct{}, addresses *[]netip.Addr) { addrs, err := iface.Addrs() if err != nil { log.Debugf("get addresses for interface %s failed: %v", iface.Name, err) @@ -104,18 +47,19 @@ func (m *localIPManager) processInterface(iface net.Interface, bitmap *[256]*ipv continue } - addr, ok := netip.AddrFromSlice(ip) + parsed, ok := netip.AddrFromSlice(ip) if !ok { log.Warnf("invalid IP address %s in interface %s", ip.String(), iface.Name) continue } - if err := m.processIP(addr.Unmap(), bitmap, ipv4Set, ipv4Addresses); err != nil { - log.Debugf("process IP failed: %v", err) - } + parsed = parsed.Unmap() + ips[parsed] = struct{}{} + *addresses = append(*addresses, parsed) } } +// UpdateLocalIPs rebuilds the local IP snapshot and swaps it in atomically. func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) { defer func() { if r := recover(); r != nil { @@ -123,20 +67,20 @@ func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) { } }() - var newIPv4Bitmap [256]*ipv4LowBitmap - ipv4Set := make(map[netip.Addr]struct{}) - var ipv4Addresses []netip.Addr + ips := make(map[netip.Addr]struct{}) + var addresses []netip.Addr - // 127.0.0.0/8 - newIPv4Bitmap[127] = &ipv4LowBitmap{} - for i := 0; i < 8192; i++ { - // #nosec G602 -- bitmap is defined as [8192]uint32, loop range is correct - newIPv4Bitmap[127].bitmap[i] = 0xFFFFFFFF - } + // loopback + ips[netip.AddrFrom4([4]byte{127, 0, 0, 1})] = struct{}{} + ips[netip.IPv6Loopback()] = struct{}{} if iface != nil { - if err := m.processIP(iface.Address().IP, &newIPv4Bitmap, ipv4Set, &ipv4Addresses); err != nil { - return err + ip := iface.Address().IP + ips[ip] = struct{}{} + addresses = append(addresses, ip) + if v6 := iface.Address().IPv6; v6.IsValid() { + ips[v6] = struct{}{} + addresses = append(addresses, v6) } } @@ -147,25 +91,24 @@ func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) { // TODO: filter out down interfaces (net.FlagUp). Also handle the reverse // case where an interface comes up between refreshes. for _, intf := range interfaces { - m.processInterface(intf, &newIPv4Bitmap, ipv4Set, &ipv4Addresses) + processInterface(intf, ips, &addresses) } } - m.mu.Lock() - m.ipv4Bitmap = newIPv4Bitmap - m.mu.Unlock() + m.snapshot.Store(&localIPSnapshot{ips: ips}) - log.Debugf("Local IPv4 addresses: %v", ipv4Addresses) + log.Debugf("Local IP addresses: %v", addresses) return nil } +// IsLocalIP checks if the given IP is a local address. Lock-free on the read path. func (m *localIPManager) IsLocalIP(ip netip.Addr) bool { - if !ip.Is4() { - return false + s := m.snapshot.Load() + + if ip.Is4() && ip.As4()[0] == 127 { + return true } - m.mu.RLock() - defer m.mu.RUnlock() - - return m.checkBitmapBit(ip.AsSlice()) + _, found := s.ips[ip] + return found } diff --git a/client/firewall/uspfilter/localip_bench_test.go b/client/firewall/uspfilter/localip_bench_test.go new file mode 100644 index 000000000..14e12bd08 --- /dev/null +++ b/client/firewall/uspfilter/localip_bench_test.go @@ -0,0 +1,72 @@ +package uspfilter + +import ( + "net/netip" + "testing" + + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +func setupManager(b *testing.B) *localIPManager { + b.Helper() + m := newLocalIPManager() + mock := &IFaceMock{ + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } + if err := m.UpdateLocalIPs(mock); err != nil { + b.Fatalf("UpdateLocalIPs: %v", err) + } + return m +} + +func BenchmarkIsLocalIP_v4_hit(b *testing.B) { + m := setupManager(b) + ip := netip.MustParseAddr("100.64.0.1") + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.IsLocalIP(ip) + } +} + +func BenchmarkIsLocalIP_v4_miss(b *testing.B) { + m := setupManager(b) + ip := netip.MustParseAddr("8.8.8.8") + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.IsLocalIP(ip) + } +} + +func BenchmarkIsLocalIP_v6_hit(b *testing.B) { + m := setupManager(b) + ip := netip.MustParseAddr("fd00::1") + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.IsLocalIP(ip) + } +} + +func BenchmarkIsLocalIP_v6_miss(b *testing.B) { + m := setupManager(b) + ip := netip.MustParseAddr("2001:db8::1") + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.IsLocalIP(ip) + } +} + +func BenchmarkIsLocalIP_loopback(b *testing.B) { + m := setupManager(b) + ip := netip.MustParseAddr("127.0.0.1") + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.IsLocalIP(ip) + } +} diff --git a/client/firewall/uspfilter/localip_test.go b/client/firewall/uspfilter/localip_test.go index 6653947fa..0dc524c41 100644 --- a/client/firewall/uspfilter/localip_test.go +++ b/client/firewall/uspfilter/localip_test.go @@ -72,14 +72,45 @@ func TestLocalIPManager(t *testing.T) { expected: false, }, { - name: "IPv6 address", + name: "IPv6 address matches", setupAddr: wgaddr.Address{ - IP: netip.MustParseAddr("fe80::1"), + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + }, + testIP: netip.MustParseAddr("fd00::1"), + expected: true, + }, + { + name: "IPv6 address does not match", + setupAddr: wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + }, + testIP: netip.MustParseAddr("fd00::99"), + expected: false, + }, + { + name: "No aliasing between similar IPs", + setupAddr: wgaddr.Address{ + IP: netip.MustParseAddr("192.168.1.1"), Network: netip.MustParsePrefix("192.168.1.0/24"), }, - testIP: netip.MustParseAddr("fe80::1"), + testIP: netip.MustParseAddr("192.168.0.17"), expected: false, }, + { + name: "IPv6 loopback", + setupAddr: wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + }, + testIP: netip.MustParseAddr("::1"), + expected: true, + }, } for _, tt := range tests { @@ -171,90 +202,3 @@ func TestLocalIPManager_AllInterfaces(t *testing.T) { }) } } - -// MapImplementation is a version using map[string]struct{} -type MapImplementation struct { - localIPs map[string]struct{} -} - -func BenchmarkIPChecks(b *testing.B) { - interfaces := make([]net.IP, 16) - for i := range interfaces { - interfaces[i] = net.IPv4(10, 0, byte(i>>8), byte(i)) - } - - // Setup bitmap - bitmapManager := newLocalIPManager() - for _, ip := range interfaces[:8] { // Add half of IPs - bitmapManager.setBitmapBit(ip) - } - - // Setup map version - mapManager := &MapImplementation{ - localIPs: make(map[string]struct{}), - } - for _, ip := range interfaces[:8] { - mapManager.localIPs[ip.String()] = struct{}{} - } - - b.Run("Bitmap_Hit", func(b *testing.B) { - ip := interfaces[4] - b.ResetTimer() - for i := 0; i < b.N; i++ { - bitmapManager.checkBitmapBit(ip) - } - }) - - b.Run("Bitmap_Miss", func(b *testing.B) { - ip := interfaces[12] - b.ResetTimer() - for i := 0; i < b.N; i++ { - bitmapManager.checkBitmapBit(ip) - } - }) - - b.Run("Map_Hit", func(b *testing.B) { - ip := interfaces[4] - b.ResetTimer() - for i := 0; i < b.N; i++ { - // nolint:gosimple - _ = mapManager.localIPs[ip.String()] - } - }) - - b.Run("Map_Miss", func(b *testing.B) { - ip := interfaces[12] - b.ResetTimer() - for i := 0; i < b.N; i++ { - // nolint:gosimple - _ = mapManager.localIPs[ip.String()] - } - }) -} - -func BenchmarkWGPosition(b *testing.B) { - wgIP := net.ParseIP("10.10.0.1") - - // Create two managers - one checks WG IP first, other checks it last - b.Run("WG_First", func(b *testing.B) { - bm := newLocalIPManager() - bm.setBitmapBit(wgIP) - b.ResetTimer() - for i := 0; i < b.N; i++ { - bm.checkBitmapBit(wgIP) - } - }) - - b.Run("WG_Last", func(b *testing.B) { - bm := newLocalIPManager() - // Fill with other IPs first - for i := 0; i < 15; i++ { - bm.setBitmapBit(net.IPv4(10, 0, byte(i>>8), byte(i))) - } - bm.setBitmapBit(wgIP) // Add WG IP last - b.ResetTimer() - for i := 0; i < b.N; i++ { - bm.checkBitmapBit(wgIP) - } - }) -} diff --git a/client/firewall/uspfilter/nat.go b/client/firewall/uspfilter/nat.go index 8ed32eb5e..0d411c21e 100644 --- a/client/firewall/uspfilter/nat.go +++ b/client/firewall/uspfilter/nat.go @@ -13,8 +13,6 @@ import ( firewall "github.com/netbirdio/netbird/client/firewall/manager" ) -var ErrIPv4Only = errors.New("only IPv4 is supported for DNAT") - var ( errInvalidIPHeaderLength = errors.New("invalid IP header length") ) @@ -25,10 +23,33 @@ const ( destinationPortOffset = 2 // IP address offsets in IPv4 header - sourceIPOffset = 12 - destinationIPOffset = 16 + ipv4SrcOffset = 12 + ipv4DstOffset = 16 + + // IP address offsets in IPv6 header + ipv6SrcOffset = 8 + ipv6DstOffset = 24 + + // IPv6 fixed header length + ipv6HeaderLen = 40 ) +// ipHeaderLen returns the IP header length based on the decoded layer type. +func ipHeaderLen(d *decoder) (int, error) { + switch d.decoded[0] { + case layers.LayerTypeIPv4: + n := int(d.ip4.IHL) * 4 + if n < 20 { + return 0, errInvalidIPHeaderLength + } + return n, nil + case layers.LayerTypeIPv6: + return ipv6HeaderLen, nil + default: + return 0, fmt.Errorf("unknown IP layer: %v", d.decoded[0]) + } +} + // ipv4Checksum calculates IPv4 header checksum. func ipv4Checksum(header []byte) uint16 { if len(header) < 20 { @@ -234,14 +255,13 @@ func (m *Manager) translateOutboundDNAT(packetData []byte, d *decoder) bool { return false } - dstIP := netip.AddrFrom4([4]byte{packetData[16], packetData[17], packetData[18], packetData[19]}) - + _, dstIP := extractPacketIPs(packetData, d) translatedIP, exists := m.getDNATTranslation(dstIP) if !exists { return false } - if err := m.rewritePacketIP(packetData, d, translatedIP, destinationIPOffset); err != nil { + if err := m.rewritePacketIP(packetData, d, translatedIP, false); err != nil { m.logger.Error1("failed to rewrite packet destination: %v", err) return false } @@ -256,14 +276,13 @@ func (m *Manager) translateInboundReverse(packetData []byte, d *decoder) bool { return false } - srcIP := netip.AddrFrom4([4]byte{packetData[12], packetData[13], packetData[14], packetData[15]}) - + srcIP, _ := extractPacketIPs(packetData, d) originalIP, exists := m.findReverseDNATMapping(srcIP) if !exists { return false } - if err := m.rewritePacketIP(packetData, d, originalIP, sourceIPOffset); err != nil { + if err := m.rewritePacketIP(packetData, d, originalIP, true); err != nil { m.logger.Error1("failed to rewrite packet source: %v", err) return false } @@ -272,38 +291,96 @@ func (m *Manager) translateInboundReverse(packetData []byte, d *decoder) bool { return true } -// rewritePacketIP replaces an IP address (source or destination) in the packet and updates checksums. -func (m *Manager) rewritePacketIP(packetData []byte, d *decoder, newIP netip.Addr, ipOffset int) error { +// extractPacketIPs extracts src and dst IP addresses directly from raw packet bytes. +func extractPacketIPs(packetData []byte, d *decoder) (src, dst netip.Addr) { + switch d.decoded[0] { + case layers.LayerTypeIPv4: + src = netip.AddrFrom4([4]byte{packetData[ipv4SrcOffset], packetData[ipv4SrcOffset+1], packetData[ipv4SrcOffset+2], packetData[ipv4SrcOffset+3]}) + dst = netip.AddrFrom4([4]byte{packetData[ipv4DstOffset], packetData[ipv4DstOffset+1], packetData[ipv4DstOffset+2], packetData[ipv4DstOffset+3]}) + case layers.LayerTypeIPv6: + src = netip.AddrFrom16([16]byte(packetData[ipv6SrcOffset : ipv6SrcOffset+16])) + dst = netip.AddrFrom16([16]byte(packetData[ipv6DstOffset : ipv6DstOffset+16])) + } + return src, dst +} + +// rewritePacketIP replaces a source (isSource=true) or destination IP address in the packet and updates checksums. +func (m *Manager) rewritePacketIP(packetData []byte, d *decoder, newIP netip.Addr, isSource bool) error { + hdrLen, err := ipHeaderLen(d) + if err != nil { + return err + } + + switch d.decoded[0] { + case layers.LayerTypeIPv4: + return m.rewriteIPv4(packetData, d, newIP, hdrLen, isSource) + case layers.LayerTypeIPv6: + return m.rewriteIPv6(packetData, d, newIP, hdrLen, isSource) + default: + return fmt.Errorf("unknown IP layer: %v", d.decoded[0]) + } +} + +func (m *Manager) rewriteIPv4(packetData []byte, d *decoder, newIP netip.Addr, hdrLen int, isSource bool) error { if !newIP.Is4() { - return ErrIPv4Only + return fmt.Errorf("cannot write IPv6 address into IPv4 packet") + } + + offset := ipv4DstOffset + if isSource { + offset = ipv4SrcOffset } var oldIP [4]byte - copy(oldIP[:], packetData[ipOffset:ipOffset+4]) + copy(oldIP[:], packetData[offset:offset+4]) newIPBytes := newIP.As4() + copy(packetData[offset:offset+4], newIPBytes[:]) - copy(packetData[ipOffset:ipOffset+4], newIPBytes[:]) - - ipHeaderLen := int(d.ip4.IHL) * 4 - if ipHeaderLen < 20 || ipHeaderLen > len(packetData) { - return errInvalidIPHeaderLength - } - + // Recalculate IPv4 header checksum binary.BigEndian.PutUint16(packetData[10:12], 0) - ipChecksum := ipv4Checksum(packetData[:ipHeaderLen]) - binary.BigEndian.PutUint16(packetData[10:12], ipChecksum) + binary.BigEndian.PutUint16(packetData[10:12], ipv4Checksum(packetData[:hdrLen])) + // Update transport checksums incrementally if len(d.decoded) > 1 { switch d.decoded[1] { case layers.LayerTypeTCP: - m.updateTCPChecksum(packetData, ipHeaderLen, oldIP[:], newIPBytes[:]) + m.updateTCPChecksum(packetData, hdrLen, oldIP[:], newIPBytes[:]) case layers.LayerTypeUDP: - m.updateUDPChecksum(packetData, ipHeaderLen, oldIP[:], newIPBytes[:]) + m.updateUDPChecksum(packetData, hdrLen, oldIP[:], newIPBytes[:]) case layers.LayerTypeICMPv4: - m.updateICMPChecksum(packetData, ipHeaderLen) + m.updateICMPChecksum(packetData, hdrLen) } } + return nil +} +func (m *Manager) rewriteIPv6(packetData []byte, d *decoder, newIP netip.Addr, hdrLen int, isSource bool) error { + if !newIP.Is6() { + return fmt.Errorf("cannot write IPv4 address into IPv6 packet") + } + + offset := ipv6DstOffset + if isSource { + offset = ipv6SrcOffset + } + + var oldIP [16]byte + copy(oldIP[:], packetData[offset:offset+16]) + newIPBytes := newIP.As16() + copy(packetData[offset:offset+16], newIPBytes[:]) + + // IPv6 has no header checksum, only update transport checksums + if len(d.decoded) > 1 { + switch d.decoded[1] { + case layers.LayerTypeTCP: + m.updateTCPChecksum(packetData, hdrLen, oldIP[:], newIPBytes[:]) + case layers.LayerTypeUDP: + m.updateUDPChecksum(packetData, hdrLen, oldIP[:], newIPBytes[:]) + case layers.LayerTypeICMPv6: + // ICMPv6 checksum includes pseudo-header with addresses, use incremental update + m.updateICMPv6Checksum(packetData, hdrLen, oldIP[:], newIPBytes[:]) + } + } return nil } @@ -351,6 +428,20 @@ func (m *Manager) updateICMPChecksum(packetData []byte, ipHeaderLen int) { binary.BigEndian.PutUint16(icmpData[2:4], checksum) } +// updateICMPv6Checksum updates ICMPv6 checksum after address change. +// ICMPv6 uses a pseudo-header (like TCP/UDP), so incremental update applies. +func (m *Manager) updateICMPv6Checksum(packetData []byte, ipHeaderLen int, oldIP, newIP []byte) { + icmpStart := ipHeaderLen + if len(packetData) < icmpStart+4 { + return + } + + checksumOffset := icmpStart + 2 + oldChecksum := binary.BigEndian.Uint16(packetData[checksumOffset : checksumOffset+2]) + newChecksum := incrementalUpdate(oldChecksum, oldIP, newIP) + binary.BigEndian.PutUint16(packetData[checksumOffset:checksumOffset+2], newChecksum) +} + // incrementalUpdate performs incremental checksum update per RFC 1624. func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 { sum := uint32(^oldChecksum) @@ -403,14 +494,14 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { } // addPortRedirection adds a port redirection rule. -func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, sourcePort, targetPort uint16) error { +func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, originalPort, translatedPort uint16) error { m.portDNATMutex.Lock() defer m.portDNATMutex.Unlock() rule := portDNATRule{ protocol: protocol, - origPort: sourcePort, - targetPort: targetPort, + origPort: originalPort, + targetPort: translatedPort, targetIP: targetIP, } @@ -422,7 +513,7 @@ func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.Laye // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. // TODO: also delegate to nativeFirewall when available for kernel WG mode -func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { var layerType gopacket.LayerType switch protocol { case firewall.ProtocolTCP: @@ -433,16 +524,16 @@ func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protoco return fmt.Errorf("unsupported protocol: %s", protocol) } - return m.addPortRedirection(localAddr, layerType, sourcePort, targetPort) + return m.addPortRedirection(localAddr, layerType, originalPort, translatedPort) } // removePortRedirection removes a port redirection rule. -func (m *Manager) removePortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, sourcePort, targetPort uint16) error { +func (m *Manager) removePortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, originalPort, translatedPort uint16) error { m.portDNATMutex.Lock() defer m.portDNATMutex.Unlock() m.portDNATRules = slices.DeleteFunc(m.portDNATRules, func(rule portDNATRule) bool { - return rule.protocol == protocol && rule.origPort == sourcePort && rule.targetPort == targetPort && rule.targetIP.Compare(targetIP) == 0 + return rule.protocol == protocol && rule.origPort == originalPort && rule.targetPort == translatedPort && rule.targetIP.Compare(targetIP) == 0 }) if len(m.portDNATRules) == 0 { @@ -453,7 +544,7 @@ func (m *Manager) removePortRedirection(targetIP netip.Addr, protocol gopacket.L } // RemoveInboundDNAT removes an inbound DNAT rule. -func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { var layerType gopacket.LayerType switch protocol { case firewall.ProtocolTCP: @@ -464,23 +555,23 @@ func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Prot return fmt.Errorf("unsupported protocol: %s", protocol) } - return m.removePortRedirection(localAddr, layerType, sourcePort, targetPort) + return m.removePortRedirection(localAddr, layerType, originalPort, translatedPort) } // AddOutputDNAT delegates to the native firewall if available. -func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { if m.nativeFirewall == nil { return fmt.Errorf("output DNAT not supported without native firewall") } - return m.nativeFirewall.AddOutputDNAT(localAddr, protocol, sourcePort, targetPort) + return m.nativeFirewall.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveOutputDNAT delegates to the native firewall if available. -func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { if m.nativeFirewall == nil { return nil } - return m.nativeFirewall.RemoveOutputDNAT(localAddr, protocol, sourcePort, targetPort) + return m.nativeFirewall.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } // translateInboundPortDNAT applies port-specific DNAT translation to inbound packets. @@ -532,12 +623,12 @@ func (m *Manager) applyPortRule(packetData []byte, d *decoder, srcIP, dstIP neti // rewriteTCPPort rewrites a TCP port (source or destination) and updates checksum. func (m *Manager) rewriteTCPPort(packetData []byte, d *decoder, newPort uint16, portOffset int) error { - ipHeaderLen := int(d.ip4.IHL) * 4 - if ipHeaderLen < 20 || ipHeaderLen > len(packetData) { - return errInvalidIPHeaderLength + hdrLen, err := ipHeaderLen(d) + if err != nil { + return err } - tcpStart := ipHeaderLen + tcpStart := hdrLen if len(packetData) < tcpStart+4 { return fmt.Errorf("packet too short for TCP header") } @@ -563,12 +654,12 @@ func (m *Manager) rewriteTCPPort(packetData []byte, d *decoder, newPort uint16, // rewriteUDPPort rewrites a UDP port (source or destination) and updates checksum. func (m *Manager) rewriteUDPPort(packetData []byte, d *decoder, newPort uint16, portOffset int) error { - ipHeaderLen := int(d.ip4.IHL) * 4 - if ipHeaderLen < 20 || ipHeaderLen > len(packetData) { - return errInvalidIPHeaderLength + hdrLen, err := ipHeaderLen(d) + if err != nil { + return err } - udpStart := ipHeaderLen + udpStart := hdrLen if len(packetData) < udpStart+8 { return fmt.Errorf("packet too short for UDP header") } diff --git a/client/firewall/uspfilter/nat_bench_test.go b/client/firewall/uspfilter/nat_bench_test.go index d2599e577..1e15c8c0c 100644 --- a/client/firewall/uspfilter/nat_bench_test.go +++ b/client/firewall/uspfilter/nat_bench_test.go @@ -342,12 +342,17 @@ func BenchmarkDNATMemoryAllocations(b *testing.B) { // Parse the packet fresh each time to get a clean decoder d := &decoder{decoded: []gopacket.LayerType{}} - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true - err = d.parser.DecodeLayers(testPacket, &d.decoded) + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true + err = d.decodePacket(testPacket) assert.NoError(b, err) manager.translateOutboundDNAT(testPacket, d) @@ -371,12 +376,17 @@ func BenchmarkDirectIPExtraction(b *testing.B) { b.Run("decoder_extraction", func(b *testing.B) { // Create decoder once for comparison d := &decoder{decoded: []gopacket.LayerType{}} - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true - err := d.parser.DecodeLayers(packet, &d.decoded) + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true + err := d.decodePacket(packet) assert.NoError(b, err) for i := 0; i < b.N; i++ { diff --git a/client/firewall/uspfilter/nat_test.go b/client/firewall/uspfilter/nat_test.go index 50743d006..4598c3901 100644 --- a/client/firewall/uspfilter/nat_test.go +++ b/client/firewall/uspfilter/nat_test.go @@ -86,13 +86,18 @@ func parsePacket(t testing.TB, packetData []byte) *decoder { d := &decoder{ decoded: []gopacket.LayerType{}, } - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true - err := d.parser.DecodeLayers(packetData, &d.decoded) + err := d.decodePacket(packetData) require.NoError(t, err) return d } diff --git a/client/firewall/uspfilter/tracer.go b/client/firewall/uspfilter/tracer.go index 69c2519bf..696489e95 100644 --- a/client/firewall/uspfilter/tracer.go +++ b/client/firewall/uspfilter/tracer.go @@ -2,7 +2,9 @@ package uspfilter import ( "fmt" + "net" "net/netip" + "strconv" "time" "github.com/google/gopacket" @@ -112,10 +114,13 @@ func (t *PacketTrace) AddResultWithForwarder(stage PacketStage, message string, } func (p *PacketBuilder) Build() ([]byte, error) { - ip := p.buildIPLayer() - pktLayers := []gopacket.SerializableLayer{ip} + ipLayer, err := p.buildIPLayer() + if err != nil { + return nil, err + } + pktLayers := []gopacket.SerializableLayer{ipLayer} - transportLayer, err := p.buildTransportLayer(ip) + transportLayer, err := p.buildTransportLayer(ipLayer) if err != nil { return nil, err } @@ -129,30 +134,43 @@ func (p *PacketBuilder) Build() ([]byte, error) { return serializePacket(pktLayers) } -func (p *PacketBuilder) buildIPLayer() *layers.IPv4 { +func (p *PacketBuilder) buildIPLayer() (gopacket.SerializableLayer, error) { + if p.SrcIP.Is4() != p.DstIP.Is4() { + return nil, fmt.Errorf("mixed address families: src=%s dst=%s", p.SrcIP, p.DstIP) + } + proto := getIPProtocolNumber(p.Protocol, p.SrcIP.Is6()) + if p.SrcIP.Is6() { + return &layers.IPv6{ + Version: 6, + HopLimit: 64, + NextHeader: proto, + SrcIP: p.SrcIP.AsSlice(), + DstIP: p.DstIP.AsSlice(), + }, nil + } return &layers.IPv4{ Version: 4, TTL: 64, - Protocol: layers.IPProtocol(getIPProtocolNumber(p.Protocol)), + Protocol: proto, SrcIP: p.SrcIP.AsSlice(), DstIP: p.DstIP.AsSlice(), - } + }, nil } -func (p *PacketBuilder) buildTransportLayer(ip *layers.IPv4) ([]gopacket.SerializableLayer, error) { +func (p *PacketBuilder) buildTransportLayer(ipLayer gopacket.SerializableLayer) ([]gopacket.SerializableLayer, error) { switch p.Protocol { case "tcp": - return p.buildTCPLayer(ip) + return p.buildTCPLayer(ipLayer) case "udp": - return p.buildUDPLayer(ip) + return p.buildUDPLayer(ipLayer) case "icmp": - return p.buildICMPLayer() + return p.buildICMPLayer(ipLayer) default: return nil, fmt.Errorf("unsupported protocol: %s", p.Protocol) } } -func (p *PacketBuilder) buildTCPLayer(ip *layers.IPv4) ([]gopacket.SerializableLayer, error) { +func (p *PacketBuilder) buildTCPLayer(ipLayer gopacket.SerializableLayer) ([]gopacket.SerializableLayer, error) { tcp := &layers.TCP{ SrcPort: layers.TCPPort(p.SrcPort), DstPort: layers.TCPPort(p.DstPort), @@ -164,24 +182,44 @@ func (p *PacketBuilder) buildTCPLayer(ip *layers.IPv4) ([]gopacket.SerializableL PSH: p.TCPState != nil && p.TCPState.PSH, URG: p.TCPState != nil && p.TCPState.URG, } - if err := tcp.SetNetworkLayerForChecksum(ip); err != nil { - return nil, fmt.Errorf("set network layer for TCP checksum: %w", err) + if nl, ok := ipLayer.(gopacket.NetworkLayer); ok { + if err := tcp.SetNetworkLayerForChecksum(nl); err != nil { + return nil, fmt.Errorf("set network layer for TCP checksum: %w", err) + } } return []gopacket.SerializableLayer{tcp}, nil } -func (p *PacketBuilder) buildUDPLayer(ip *layers.IPv4) ([]gopacket.SerializableLayer, error) { +func (p *PacketBuilder) buildUDPLayer(ipLayer gopacket.SerializableLayer) ([]gopacket.SerializableLayer, error) { udp := &layers.UDP{ SrcPort: layers.UDPPort(p.SrcPort), DstPort: layers.UDPPort(p.DstPort), } - if err := udp.SetNetworkLayerForChecksum(ip); err != nil { - return nil, fmt.Errorf("set network layer for UDP checksum: %w", err) + if nl, ok := ipLayer.(gopacket.NetworkLayer); ok { + if err := udp.SetNetworkLayerForChecksum(nl); err != nil { + return nil, fmt.Errorf("set network layer for UDP checksum: %w", err) + } } return []gopacket.SerializableLayer{udp}, nil } -func (p *PacketBuilder) buildICMPLayer() ([]gopacket.SerializableLayer, error) { +func (p *PacketBuilder) buildICMPLayer(ipLayer gopacket.SerializableLayer) ([]gopacket.SerializableLayer, error) { + if p.SrcIP.Is6() || p.DstIP.Is6() { + icmp := &layers.ICMPv6{ + TypeCode: layers.CreateICMPv6TypeCode(p.ICMPType, p.ICMPCode), + } + if nl, ok := ipLayer.(gopacket.NetworkLayer); ok { + _ = icmp.SetNetworkLayerForChecksum(nl) + } + if p.ICMPType == layers.ICMPv6TypeEchoRequest || p.ICMPType == layers.ICMPv6TypeEchoReply { + echo := &layers.ICMPv6Echo{ + Identifier: 1, + SeqNumber: 1, + } + return []gopacket.SerializableLayer{icmp, echo}, nil + } + return []gopacket.SerializableLayer{icmp}, nil + } icmp := &layers.ICMPv4{ TypeCode: layers.CreateICMPv4TypeCode(p.ICMPType, p.ICMPCode), } @@ -204,14 +242,17 @@ func serializePacket(layers []gopacket.SerializableLayer) ([]byte, error) { return buf.Bytes(), nil } -func getIPProtocolNumber(protocol fw.Protocol) int { +func getIPProtocolNumber(protocol fw.Protocol, isV6 bool) layers.IPProtocol { switch protocol { case fw.ProtocolTCP: - return int(layers.IPProtocolTCP) + return layers.IPProtocolTCP case fw.ProtocolUDP: - return int(layers.IPProtocolUDP) + return layers.IPProtocolUDP case fw.ProtocolICMP: - return int(layers.IPProtocolICMPv4) + if isV6 { + return layers.IPProtocolICMPv6 + } + return layers.IPProtocolICMPv4 default: return 0 } @@ -234,7 +275,7 @@ func (m *Manager) TracePacket(packetData []byte, direction fw.RuleDirection) *Pa trace := &PacketTrace{Direction: direction} // Initial packet decoding - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { trace.AddResult(StageReceived, fmt.Sprintf("Failed to decode packet: %v", err), false) return trace } @@ -256,6 +297,8 @@ func (m *Manager) TracePacket(packetData []byte, direction fw.RuleDirection) *Pa trace.DestinationPort = uint16(d.udp.DstPort) case layers.LayerTypeICMPv4: trace.Protocol = "ICMP" + case layers.LayerTypeICMPv6: + trace.Protocol = "ICMPv6" } trace.AddResult(StageReceived, fmt.Sprintf("Received %s packet: %s:%d -> %s:%d", @@ -319,6 +362,13 @@ func (m *Manager) buildConntrackStateMessage(d *decoder) string { flags&conntrack.TCPFin != 0) case layers.LayerTypeICMPv4: msg += fmt.Sprintf(" (ICMP ID=%d, Seq=%d)", d.icmp4.Id, d.icmp4.Seq) + case layers.LayerTypeICMPv6: + var id, seq uint16 + if len(d.icmp6.Payload) >= 4 { + id = uint16(d.icmp6.Payload[0])<<8 | uint16(d.icmp6.Payload[1]) + seq = uint16(d.icmp6.Payload[2])<<8 | uint16(d.icmp6.Payload[3]) + } + msg += fmt.Sprintf(" (ICMPv6 ID=%d, Seq=%d)", id, seq) } return msg } @@ -395,7 +445,7 @@ func (m *Manager) handleRouteACLs(trace *PacketTrace, d *decoder, srcIP, dstIP n trace.AddResult(StageRouteACL, msg, allowed) if allowed && m.forwarder.Load() != nil { - m.addForwardingResult(trace, "proxy-remote", fmt.Sprintf("%s:%d", dstIP, dstPort), true) + m.addForwardingResult(trace, "proxy-remote", net.JoinHostPort(dstIP.String(), strconv.Itoa(int(dstPort))), true) } trace.AddResult(StageCompleted, msgProcessingCompleted, allowed) @@ -415,7 +465,7 @@ func (m *Manager) traceOutbound(packetData []byte, trace *PacketTrace) *PacketTr d := m.decoders.Get().(*decoder) defer m.decoders.Put(d) - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { trace.AddResult(StageCompleted, "Packet dropped - decode error", false) return trace } @@ -434,7 +484,7 @@ func (m *Manager) traceOutbound(packetData []byte, trace *PacketTrace) *PacketTr func (m *Manager) handleInboundDNAT(trace *PacketTrace, packetData []byte, d *decoder, srcIP, dstIP *netip.Addr) bool { portDNATApplied := m.traceInboundPortDNAT(trace, packetData, d) if portDNATApplied { - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { trace.AddResult(StageInboundPortDNAT, "Failed to re-decode after port DNAT", false) return true } @@ -444,7 +494,7 @@ func (m *Manager) handleInboundDNAT(trace *PacketTrace, packetData []byte, d *de nat1to1Applied := m.traceInbound1to1NAT(trace, packetData, d) if nat1to1Applied { - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { trace.AddResult(StageInbound1to1NAT, "Failed to re-decode after 1:1 NAT", false) return true } @@ -509,7 +559,7 @@ func (m *Manager) traceInbound1to1NAT(trace *PacketTrace, packetData []byte, d * return false } - srcIP := netip.AddrFrom4([4]byte{packetData[12], packetData[13], packetData[14], packetData[15]}) + srcIP, _ := extractPacketIPs(packetData, d) translated := m.translateInboundReverse(packetData, d) if translated { @@ -539,7 +589,7 @@ func (m *Manager) traceOutbound1to1NAT(trace *PacketTrace, packetData []byte, d return false } - dstIP := netip.AddrFrom4([4]byte{packetData[16], packetData[17], packetData[18], packetData[19]}) + _, dstIP := extractPacketIPs(packetData, d) translated := m.translateOutboundDNAT(packetData, d) if translated { diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index e3a96590c..9b070aab8 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -119,7 +119,7 @@ func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, if err != nil { return fmt.Errorf("failed to parse endpoint address: %w", err) } - addrPort := netip.AddrPortFrom(addr, uint16(endpoint.Port)) + addrPort := netip.AddrPortFrom(addr.Unmap(), uint16(endpoint.Port)) c.activityRecorder.UpsertAddress(peerKey, addrPort) } return nil diff --git a/client/iface/device/adapter.go b/client/iface/device/adapter.go index 6ebc05390..e3caaf930 100644 --- a/client/iface/device/adapter.go +++ b/client/iface/device/adapter.go @@ -2,7 +2,7 @@ package device // TunAdapter is an interface for create tun device from external service type TunAdapter interface { - ConfigureInterface(address string, mtu int, dns string, searchDomains string, routes string) (int, error) + ConfigureInterface(address string, addressV6 string, mtu int, dns string, searchDomains string, routes string) (int, error) UpdateAddr(address string) error ProtectSocket(fd int32) bool } diff --git a/client/iface/device/device_android.go b/client/iface/device/device_android.go index 198343fbd..cbe88c10c 100644 --- a/client/iface/device/device_android.go +++ b/client/iface/device/device_android.go @@ -63,7 +63,7 @@ func (t *WGTunDevice) Create(routes []string, dns string, searchDomains []string searchDomainsToString = "" } - fd, err := t.tunAdapter.ConfigureInterface(t.address.String(), int(t.mtu), dns, searchDomainsToString, routesString) + fd, err := t.tunAdapter.ConfigureInterface(t.address.String(), t.address.IPv6String(), int(t.mtu), dns, searchDomainsToString, routesString) if err != nil { log.Errorf("failed to create Android interface: %s", err) return nil, err diff --git a/client/iface/device/device_darwin.go b/client/iface/device/device_darwin.go index acd5f6f11..ac8f8a51b 100644 --- a/client/iface/device/device_darwin.go +++ b/client/iface/device/device_darwin.go @@ -131,23 +131,32 @@ func (t *TunDevice) Device() *device.Device { // assignAddr Adds IP address to the tunnel interface and network route based on the range provided func (t *TunDevice) assignAddr() error { - cmd := exec.Command("ifconfig", t.name, "inet", t.address.IP.String(), t.address.IP.String()) - if out, err := cmd.CombinedOutput(); err != nil { - log.Errorf("adding address command '%v' failed with output: %s", cmd.String(), out) - return err + if out, err := exec.Command("ifconfig", t.name, "inet", t.address.IP.String(), t.address.IP.String()).CombinedOutput(); err != nil { + return fmt.Errorf("add v4 address: %s: %w", string(out), err) } - // dummy ipv6 so routing works - cmd = exec.Command("ifconfig", t.name, "inet6", "fe80::/64") - if out, err := cmd.CombinedOutput(); err != nil { - log.Debugf("adding address command '%v' failed with output: %s", cmd.String(), out) + // Assign a dummy link-local so macOS enables IPv6 on the tun device. + // When a real overlay v6 is present, use that instead. + v6Addr := "fe80::/64" + if t.address.HasIPv6() { + v6Addr = t.address.IPv6String() + } + if out, err := exec.Command("ifconfig", t.name, "inet6", v6Addr).CombinedOutput(); err != nil { + log.Warnf("failed to assign IPv6 address %s, continuing v4-only: %s: %v", v6Addr, string(out), err) + t.address.ClearIPv6() } - routeCmd := exec.Command("route", "add", "-net", t.address.Network.String(), "-interface", t.name) - if out, err := routeCmd.CombinedOutput(); err != nil { - log.Errorf("adding route command '%v' failed with output: %s", routeCmd.String(), out) - return err + if out, err := exec.Command("route", "add", "-net", t.address.Network.String(), "-interface", t.name).CombinedOutput(); err != nil { + return fmt.Errorf("add route %s via %s: %s: %w", t.address.Network, t.name, string(out), err) } + + if t.address.HasIPv6() { + if out, err := exec.Command("route", "add", "-inet6", "-net", t.address.IPv6Net.String(), "-interface", t.name).CombinedOutput(); err != nil { + log.Warnf("failed to add route %s via %s, continuing v4-only: %s: %v", t.address.IPv6Net, t.name, string(out), err) + t.address.ClearIPv6() + } + } + return nil } diff --git a/client/iface/device/device_ios.go b/client/iface/device/device_ios.go index aa77cee45..8368c8dce 100644 --- a/client/iface/device/device_ios.go +++ b/client/iface/device/device_ios.go @@ -151,8 +151,11 @@ func (t *TunDevice) MTU() uint16 { return t.mtu } -func (t *TunDevice) UpdateAddr(_ wgaddr.Address) error { - // todo implement +// UpdateAddr updates the device address. On iOS the tunnel is managed by the +// NetworkExtension, so we only store the new value. The extension picks up the +// change on the next tunnel reconfiguration. +func (t *TunDevice) UpdateAddr(addr wgaddr.Address) error { + t.address = addr return nil } diff --git a/client/iface/device/device_kernel_unix.go b/client/iface/device/device_kernel_unix.go index 2a836f846..25c4148a6 100644 --- a/client/iface/device/device_kernel_unix.go +++ b/client/iface/device/device_kernel_unix.go @@ -173,7 +173,7 @@ func (t *TunKernelDevice) FilteredDevice() *FilteredDevice { // assignAddr Adds IP address to the tunnel interface func (t *TunKernelDevice) assignAddr() error { - return t.link.assignAddr(t.address) + return t.link.assignAddr(&t.address) } func (t *TunKernelDevice) GetNet() *netstack.Net { diff --git a/client/iface/device/device_netstack.go b/client/iface/device/device_netstack.go index 1a92b148f..b3bce3925 100644 --- a/client/iface/device/device_netstack.go +++ b/client/iface/device/device_netstack.go @@ -3,6 +3,7 @@ package device import ( "errors" "fmt" + "net/netip" log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/conn" @@ -63,8 +64,12 @@ func (t *TunNetstackDevice) create() (WGConfigurer, error) { return nil, fmt.Errorf("last ip: %w", err) } - log.Debugf("netstack using address: %s", t.address.IP) - t.nsTun = nbnetstack.NewNetStackTun(t.listenAddress, t.address.IP, dnsAddr, int(t.mtu)) + addresses := []netip.Addr{t.address.IP} + if t.address.HasIPv6() { + addresses = append(addresses, t.address.IPv6) + } + log.Debugf("netstack using addresses: %v", addresses) + t.nsTun = nbnetstack.NewNetStackTun(t.listenAddress, addresses, dnsAddr, int(t.mtu)) log.Debugf("netstack using dns address: %s", dnsAddr) tunIface, net, err := t.nsTun.Create() if err != nil { diff --git a/client/iface/device/device_usp_unix.go b/client/iface/device/device_usp_unix.go index 24654fc03..04c265c49 100644 --- a/client/iface/device/device_usp_unix.go +++ b/client/iface/device/device_usp_unix.go @@ -16,7 +16,7 @@ import ( "github.com/netbirdio/netbird/client/iface/wgaddr" ) -type USPDevice struct { +type TunDevice struct { name string address wgaddr.Address port int @@ -30,10 +30,10 @@ type USPDevice struct { configurer WGConfigurer } -func NewUSPDevice(name string, address wgaddr.Address, port int, key string, mtu uint16, iceBind *bind.ICEBind) *USPDevice { +func NewTunDevice(name string, address wgaddr.Address, port int, key string, mtu uint16, iceBind *bind.ICEBind) *TunDevice { log.Infof("using userspace bind mode") - return &USPDevice{ + return &TunDevice{ name: name, address: address, port: port, @@ -43,7 +43,7 @@ func NewUSPDevice(name string, address wgaddr.Address, port int, key string, mtu } } -func (t *USPDevice) Create() (WGConfigurer, error) { +func (t *TunDevice) Create() (WGConfigurer, error) { log.Info("create tun interface") tunIface, err := tun.CreateTUN(t.name, int(t.mtu)) if err != nil { @@ -75,7 +75,7 @@ func (t *USPDevice) Create() (WGConfigurer, error) { return t.configurer, nil } -func (t *USPDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { +func (t *TunDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { if t.device == nil { return nil, fmt.Errorf("device is not ready yet") } @@ -95,12 +95,12 @@ func (t *USPDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { return udpMux, nil } -func (t *USPDevice) UpdateAddr(address wgaddr.Address) error { +func (t *TunDevice) UpdateAddr(address wgaddr.Address) error { t.address = address return t.assignAddr() } -func (t *USPDevice) Close() error { +func (t *TunDevice) Close() error { if t.configurer != nil { t.configurer.Close() } @@ -115,39 +115,39 @@ func (t *USPDevice) Close() error { return nil } -func (t *USPDevice) WgAddress() wgaddr.Address { +func (t *TunDevice) WgAddress() wgaddr.Address { return t.address } -func (t *USPDevice) MTU() uint16 { +func (t *TunDevice) MTU() uint16 { return t.mtu } -func (t *USPDevice) DeviceName() string { +func (t *TunDevice) DeviceName() string { return t.name } -func (t *USPDevice) FilteredDevice() *FilteredDevice { +func (t *TunDevice) FilteredDevice() *FilteredDevice { return t.filteredDevice } // Device returns the wireguard device -func (t *USPDevice) Device() *device.Device { +func (t *TunDevice) Device() *device.Device { return t.device } // assignAddr Adds IP address to the tunnel interface -func (t *USPDevice) assignAddr() error { +func (t *TunDevice) assignAddr() error { link := newWGLink(t.name) - return link.assignAddr(t.address) + return link.assignAddr(&t.address) } -func (t *USPDevice) GetNet() *netstack.Net { +func (t *TunDevice) GetNet() *netstack.Net { return nil } // GetICEBind returns the ICEBind instance -func (t *USPDevice) GetICEBind() EndpointManager { +func (t *TunDevice) GetICEBind() EndpointManager { return t.iceBind } diff --git a/client/iface/device/device_windows.go b/client/iface/device/device_windows.go index 96350df8a..f52392fa2 100644 --- a/client/iface/device/device_windows.go +++ b/client/iface/device/device_windows.go @@ -87,7 +87,21 @@ func (t *TunDevice) Create() (WGConfigurer, error) { err = nbiface.Set() if err != nil { t.device.Close() - return nil, fmt.Errorf("got error when getting setting the interface mtu: %s", err) + return nil, fmt.Errorf("set IPv4 interface MTU: %s", err) + } + + if t.address.HasIPv6() { + nbiface6, err := luid.IPInterface(windows.AF_INET6) + if err != nil { + log.Warnf("failed to get IPv6 interface for MTU, continuing v4-only: %v", err) + t.address.ClearIPv6() + } else { + nbiface6.NLMTU = uint32(t.mtu) + if err := nbiface6.Set(); err != nil { + log.Warnf("failed to set IPv6 interface MTU, continuing v4-only: %v", err) + t.address.ClearIPv6() + } + } } err = t.assignAddr() if err != nil { @@ -178,8 +192,21 @@ func (t *TunDevice) GetInterfaceGUIDString() (string, error) { // assignAddr Adds IP address to the tunnel interface and network route based on the range provided func (t *TunDevice) assignAddr() error { luid := winipcfg.LUID(t.nativeTunDevice.LUID()) - log.Debugf("adding address %s to interface: %s", t.address.IP, t.name) - return luid.SetIPAddresses([]netip.Prefix{netip.MustParsePrefix(t.address.String())}) + + v4Prefix := t.address.Prefix() + if t.address.HasIPv6() { + v6Prefix := t.address.IPv6Prefix() + log.Debugf("adding addresses %s, %s to interface: %s", v4Prefix, v6Prefix, t.name) + if err := luid.SetIPAddresses([]netip.Prefix{v4Prefix, v6Prefix}); err != nil { + log.Warnf("failed to assign dual-stack addresses, retrying v4-only: %v", err) + t.address.ClearIPv6() + return luid.SetIPAddresses([]netip.Prefix{v4Prefix}) + } + return nil + } + + log.Debugf("adding address %s to interface: %s", v4Prefix, t.name) + return luid.SetIPAddresses([]netip.Prefix{v4Prefix}) } func (t *TunDevice) GetNet() *netstack.Net { diff --git a/client/iface/device/kernel_module.go b/client/iface/device/kernel_module.go deleted file mode 100644 index 1bdd6f7c6..000000000 --- a/client/iface/device/kernel_module.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build (!linux && !freebsd) || android - -package device - -// WireGuardModuleIsLoaded check if we can load WireGuard mod (linux only) -func WireGuardModuleIsLoaded() bool { - return false -} diff --git a/client/iface/device/kernel_module_freebsd.go b/client/iface/device/kernel_module_freebsd.go deleted file mode 100644 index dd6c8b408..000000000 --- a/client/iface/device/kernel_module_freebsd.go +++ /dev/null @@ -1,18 +0,0 @@ -package device - -// WireGuardModuleIsLoaded check if kernel support wireguard -func WireGuardModuleIsLoaded() bool { - // Despite the fact FreeBSD natively support Wireguard (https://github.com/WireGuard/wireguard-freebsd) - // we are currently do not use it, since it is required to add wireguard kernel support to - // - https://github.com/netbirdio/netbird/tree/main/sharedsock - // - https://github.com/mdlayher/socket - // TODO: implement kernel space - return false -} - -// ModuleTunIsLoaded check if tun module exist, if is not attempt to load it -func ModuleTunIsLoaded() bool { - // Assume tun supported by freebsd kernel by default - // TODO: implement check for module loaded in kernel or build-it - return true -} diff --git a/client/iface/device/kernel_module_nonlinux.go b/client/iface/device/kernel_module_nonlinux.go new file mode 100644 index 000000000..58d97080b --- /dev/null +++ b/client/iface/device/kernel_module_nonlinux.go @@ -0,0 +1,13 @@ +//go:build !linux || android + +package device + +// WireGuardModuleIsLoaded reports whether the kernel WireGuard module is available. +func WireGuardModuleIsLoaded() bool { + return false +} + +// ModuleTunIsLoaded reports whether the tun device is available. +func ModuleTunIsLoaded() bool { + return true +} diff --git a/client/iface/device/wg_link_freebsd.go b/client/iface/device/wg_link_freebsd.go index 1b06e0e15..87df89183 100644 --- a/client/iface/device/wg_link_freebsd.go +++ b/client/iface/device/wg_link_freebsd.go @@ -2,6 +2,7 @@ package device import ( "fmt" + "os/exec" log "github.com/sirupsen/logrus" @@ -57,32 +58,32 @@ func (l *wgLink) up() error { return nil } -func (l *wgLink) assignAddr(address wgaddr.Address) error { +func (l *wgLink) assignAddr(address *wgaddr.Address) error { link, err := freebsd.LinkByName(l.name) if err != nil { return fmt.Errorf("link by name: %w", err) } - ip := address.IP.String() - - // Convert prefix length to hex netmask prefixLen := address.Network.Bits() - if !address.IP.Is4() { - return fmt.Errorf("IPv6 not supported for interface assignment") - } - maskBits := uint32(0xffffffff) << (32 - prefixLen) mask := fmt.Sprintf("0x%08x", maskBits) - log.Infof("assign addr %s mask %s to %s interface", ip, mask, l.name) + log.Infof("assign addr %s mask %s to %s interface", address.IP, mask, l.name) - err = link.AssignAddr(ip, mask) - if err != nil { + if err := link.AssignAddr(address.IP.String(), mask); err != nil { return fmt.Errorf("assign addr: %w", err) } - err = link.Up() - if err != nil { + if address.HasIPv6() { + log.Infof("assign IPv6 addr %s to %s interface", address.IPv6String(), l.name) + cmd := exec.Command("ifconfig", l.name, "inet6", address.IPv6String()) + if out, err := cmd.CombinedOutput(); err != nil { + log.Warnf("failed to assign IPv6 address %s to %s, continuing v4-only: %s: %v", address.IPv6String(), l.name, string(out), err) + address.ClearIPv6() + } + } + + if err := link.Up(); err != nil { return fmt.Errorf("up: %w", err) } diff --git a/client/iface/device/wg_link_linux.go b/client/iface/device/wg_link_linux.go index d941cd022..6a02cb356 100644 --- a/client/iface/device/wg_link_linux.go +++ b/client/iface/device/wg_link_linux.go @@ -4,6 +4,8 @@ package device import ( "fmt" + "net" + "net/netip" "os" log "github.com/sirupsen/logrus" @@ -92,7 +94,7 @@ func (l *wgLink) up() error { return nil } -func (l *wgLink) assignAddr(address wgaddr.Address) error { +func (l *wgLink) assignAddr(address *wgaddr.Address) error { //delete existing addresses list, err := netlink.AddrList(l, 0) if err != nil { @@ -110,20 +112,16 @@ func (l *wgLink) assignAddr(address wgaddr.Address) error { } name := l.attrs.Name - addrStr := address.String() - log.Debugf("adding address %s to interface: %s", addrStr, name) - - addr, err := netlink.ParseAddr(addrStr) - if err != nil { - return fmt.Errorf("parse addr: %w", err) + if err := l.addAddr(name, address.Prefix()); err != nil { + return err } - err = netlink.AddrAdd(l, addr) - if os.IsExist(err) { - log.Infof("interface %s already has the address: %s", name, addrStr) - } else if err != nil { - return fmt.Errorf("add addr: %w", err) + if address.HasIPv6() { + if err := l.addAddr(name, address.IPv6Prefix()); err != nil { + log.Warnf("failed to assign IPv6 address %s to %s, continuing v4-only: %v", address.IPv6Prefix(), name, err) + address.ClearIPv6() + } } // On linux, the link must be brought up @@ -133,3 +131,22 @@ func (l *wgLink) assignAddr(address wgaddr.Address) error { return nil } + +func (l *wgLink) addAddr(ifaceName string, prefix netip.Prefix) error { + log.Debugf("adding address %s to interface: %s", prefix, ifaceName) + + addr := &netlink.Addr{ + IPNet: &net.IPNet{ + IP: prefix.Addr().AsSlice(), + Mask: net.CIDRMask(prefix.Bits(), prefix.Addr().BitLen()), + }, + } + + if err := netlink.AddrAdd(l, addr); os.IsExist(err) { + log.Infof("interface %s already has the address: %s", ifaceName, prefix) + } else if err != nil { + return fmt.Errorf("add addr %s: %w", prefix, err) + } + + return nil +} diff --git a/client/iface/iface.go b/client/iface/iface.go index 655dd1682..78c5080e7 100644 --- a/client/iface/iface.go +++ b/client/iface/iface.go @@ -57,7 +57,7 @@ type wgProxyFactory interface { type WGIFaceOpts struct { IFaceName string - Address string + Address wgaddr.Address WGPort int WGPrivKey string MTU uint16 @@ -141,16 +141,11 @@ func (w *WGIface) Up() (*udpmux.UniversalUDPMuxDefault, error) { } // UpdateAddr updates address of the interface -func (w *WGIface) UpdateAddr(newAddr string) error { +func (w *WGIface) UpdateAddr(newAddr wgaddr.Address) error { w.mu.Lock() defer w.mu.Unlock() - addr, err := wgaddr.ParseWGAddress(newAddr) - if err != nil { - return err - } - - return w.tun.UpdateAddr(addr) + return w.tun.UpdateAddr(newAddr) } // UpdatePeer updates existing Wireguard Peer or creates a new one if doesn't exist diff --git a/client/iface/iface_new_windows.go b/client/iface/iface_new.go similarity index 50% rename from client/iface/iface_new_windows.go rename to client/iface/iface_new.go index dfd9028e7..28f350e3f 100644 --- a/client/iface/iface_new_windows.go +++ b/client/iface/iface_new.go @@ -1,33 +1,28 @@ +//go:build !linux && !ios && !android && !js + package iface import ( "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" - wgaddr "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" ) // NewWGIFace Creates a new WireGuard interface instance func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) + iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU) var tun WGTunDevice if netstack.IsEnabled() { - tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()) + tun = device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()) } else { - tun = device.NewTunDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind) + tun = device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind) } - wgIFace := &WGIface{ + return &WGIface{ userspaceBind: true, tun: tun, wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), - } - return wgIFace, nil - + }, nil } diff --git a/client/iface/iface_new_android.go b/client/iface/iface_new_android.go index 3b68f63f2..e28dcc0de 100644 --- a/client/iface/iface_new_android.go +++ b/client/iface/iface_new_android.go @@ -4,23 +4,17 @@ import ( "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" - "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" ) // NewWGIFace Creates a new WireGuard interface instance func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) + iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU) if netstack.IsEnabled() { wgIFace := &WGIface{ userspaceBind: true, - tun: device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()), + tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()), wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), } return wgIFace, nil @@ -28,7 +22,7 @@ func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { wgIFace := &WGIface{ userspaceBind: true, - tun: device.NewTunDevice(wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunAdapter, opts.DisableDNS), + tun: device.NewTunDevice(opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunAdapter, opts.DisableDNS), wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), } return wgIFace, nil diff --git a/client/iface/iface_new_darwin.go b/client/iface/iface_new_darwin.go deleted file mode 100644 index 9f21ec950..000000000 --- a/client/iface/iface_new_darwin.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build !ios - -package iface - -import ( - "github.com/netbirdio/netbird/client/iface/bind" - "github.com/netbirdio/netbird/client/iface/device" - "github.com/netbirdio/netbird/client/iface/netstack" - "github.com/netbirdio/netbird/client/iface/wgaddr" - "github.com/netbirdio/netbird/client/iface/wgproxy" -) - -// NewWGIFace Creates a new WireGuard interface instance -func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) - - var tun WGTunDevice - if netstack.IsEnabled() { - tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()) - } else { - tun = device.NewTunDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind) - } - - wgIFace := &WGIface{ - userspaceBind: true, - tun: tun, - wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), - } - return wgIFace, nil -} diff --git a/client/iface/iface_new_freebsd.go b/client/iface/iface_new_freebsd.go deleted file mode 100644 index a342bd579..000000000 --- a/client/iface/iface_new_freebsd.go +++ /dev/null @@ -1,41 +0,0 @@ -//go:build freebsd - -package iface - -import ( - "fmt" - - "github.com/netbirdio/netbird/client/iface/bind" - "github.com/netbirdio/netbird/client/iface/device" - "github.com/netbirdio/netbird/client/iface/netstack" - "github.com/netbirdio/netbird/client/iface/wgaddr" - "github.com/netbirdio/netbird/client/iface/wgproxy" -) - -// NewWGIFace Creates a new WireGuard interface instance -func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - - wgIFace := &WGIface{} - - if netstack.IsEnabled() { - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) - wgIFace.tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()) - wgIFace.userspaceBind = true - wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU) - return wgIFace, nil - } - - if device.ModuleTunIsLoaded() { - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) - wgIFace.tun = device.NewUSPDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind) - wgIFace.userspaceBind = true - wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU) - return wgIFace, nil - } - - return nil, fmt.Errorf("couldn't check or load tun module") -} diff --git a/client/iface/iface_new_ios.go b/client/iface/iface_new_ios.go index 5d6a32e39..41e0022b2 100644 --- a/client/iface/iface_new_ios.go +++ b/client/iface/iface_new_ios.go @@ -5,21 +5,15 @@ package iface import ( "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" - "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" ) // NewWGIFace Creates a new WireGuard interface instance func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) + iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU) wgIFace := &WGIface{ - tun: device.NewTunDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunFd), + tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunFd), userspaceBind: true, wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), } diff --git a/client/iface/iface_new_js.go b/client/iface/iface_new_js.go index ad913ab04..9f7a3ba62 100644 --- a/client/iface/iface_new_js.go +++ b/client/iface/iface_new_js.go @@ -4,21 +4,15 @@ import ( "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" - "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" ) // NewWGIFace creates a new WireGuard interface for WASM (always uses netstack mode) func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - relayBind := bind.NewRelayBindJS() wgIface := &WGIface{ - tun: device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, relayBind, netstack.ListenAddr()), + tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, relayBind, netstack.ListenAddr()), userspaceBind: true, wgProxyFactory: wgproxy.NewUSPFactory(relayBind, opts.MTU), } diff --git a/client/iface/iface_new_linux.go b/client/iface/iface_new_linux.go index d84035403..65ce67e88 100644 --- a/client/iface/iface_new_linux.go +++ b/client/iface/iface_new_linux.go @@ -3,44 +3,40 @@ package iface import ( - "fmt" + "errors" "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" - "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" ) // NewWGIFace Creates a new WireGuard interface instance func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - - wgIFace := &WGIface{} - if netstack.IsEnabled() { - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) - wgIFace.tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()) - wgIFace.userspaceBind = true - wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU) - return wgIFace, nil + iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU) + return &WGIface{ + tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()), + userspaceBind: true, + wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), + }, nil } if device.WireGuardModuleIsLoaded() { - wgIFace.tun = device.NewKernelDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, opts.TransportNet) - wgIFace.wgProxyFactory = wgproxy.NewKernelFactory(opts.WGPort, opts.MTU) - return wgIFace, nil - } - if device.ModuleTunIsLoaded() { - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) - wgIFace.tun = device.NewUSPDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind) - wgIFace.userspaceBind = true - wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU) - return wgIFace, nil + return &WGIface{ + tun: device.NewKernelDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, opts.TransportNet), + wgProxyFactory: wgproxy.NewKernelFactory(opts.WGPort, opts.MTU), + }, nil } - return nil, fmt.Errorf("couldn't check or load tun module") + if device.ModuleTunIsLoaded() { + iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU) + return &WGIface{ + tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind), + userspaceBind: true, + wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), + }, nil + } + + return nil, errors.New("tun module not available") } diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 6bbfeaa63..dbeb69bc6 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -16,6 +16,7 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/stdnet" ) @@ -48,7 +49,7 @@ func TestWGIface_UpdateAddr(t *testing.T) { opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: addr, + Address: wgaddr.MustParseWGAddress(addr), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -84,7 +85,7 @@ func TestWGIface_UpdateAddr(t *testing.T) { //update WireGuard address addr = "100.64.0.2/8" - err = iface.UpdateAddr(addr) + err = iface.UpdateAddr(wgaddr.MustParseWGAddress(addr)) if err != nil { t.Fatal(err) } @@ -130,7 +131,7 @@ func Test_CreateInterface(t *testing.T) { } opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: 33100, WGPrivKey: key, MTU: DefaultMTU, @@ -174,7 +175,7 @@ func Test_Close(t *testing.T) { opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -219,7 +220,7 @@ func TestRecreation(t *testing.T) { opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -291,7 +292,7 @@ func Test_ConfigureInterface(t *testing.T) { } opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -347,7 +348,7 @@ func Test_UpdatePeer(t *testing.T) { opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: 33100, WGPrivKey: key, MTU: DefaultMTU, @@ -417,7 +418,7 @@ func Test_RemovePeer(t *testing.T) { opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: 33100, WGPrivKey: key, MTU: DefaultMTU, @@ -482,7 +483,7 @@ func Test_ConnectPeers(t *testing.T) { optsPeer1 := WGIFaceOpts{ IFaceName: peer1ifaceName, - Address: peer1wgIP.String(), + Address: wgaddr.MustParseWGAddress(peer1wgIP.String()), WGPort: peer1wgPort, WGPrivKey: peer1Key.String(), MTU: DefaultMTU, @@ -522,7 +523,7 @@ func Test_ConnectPeers(t *testing.T) { optsPeer2 := WGIFaceOpts{ IFaceName: peer2ifaceName, - Address: peer2wgIP.String(), + Address: wgaddr.MustParseWGAddress(peer2wgIP.String()), WGPort: peer2wgPort, WGPrivKey: peer2Key.String(), MTU: DefaultMTU, diff --git a/client/iface/netstack/tun.go b/client/iface/netstack/tun.go index 346ae29ec..8c7526bbb 100644 --- a/client/iface/netstack/tun.go +++ b/client/iface/netstack/tun.go @@ -13,7 +13,7 @@ import ( const EnvSkipProxy = "NB_NETSTACK_SKIP_PROXY" type NetStackTun struct { //nolint:revive - address netip.Addr + addresses []netip.Addr dnsAddress netip.Addr mtu int listenAddress string @@ -22,9 +22,9 @@ type NetStackTun struct { //nolint:revive tundev tun.Device } -func NewNetStackTun(listenAddress string, address netip.Addr, dnsAddress netip.Addr, mtu int) *NetStackTun { +func NewNetStackTun(listenAddress string, addresses []netip.Addr, dnsAddress netip.Addr, mtu int) *NetStackTun { return &NetStackTun{ - address: address, + addresses: addresses, dnsAddress: dnsAddress, mtu: mtu, listenAddress: listenAddress, @@ -33,7 +33,7 @@ func NewNetStackTun(listenAddress string, address netip.Addr, dnsAddress netip.A func (t *NetStackTun) Create() (tun.Device, *netstack.Net, error) { nsTunDev, tunNet, err := netstack.CreateNetTUN( - []netip.Addr{t.address}, + t.addresses, []netip.Addr{t.dnsAddress}, t.mtu) if err != nil { diff --git a/client/iface/wgaddr/address.go b/client/iface/wgaddr/address.go index 078f8be95..43d1ec9aa 100644 --- a/client/iface/wgaddr/address.go +++ b/client/iface/wgaddr/address.go @@ -3,12 +3,18 @@ package wgaddr import ( "fmt" "net/netip" + + "github.com/netbirdio/netbird/shared/netiputil" ) // Address WireGuard parsed address type Address struct { IP netip.Addr Network netip.Prefix + + // IPv6 overlay address, if assigned. + IPv6 netip.Addr + IPv6Net netip.Prefix } // ParseWGAddress parse a string ("1.2.3.4/24") address to WG Address @@ -23,6 +29,60 @@ func ParseWGAddress(address string) (Address, error) { }, nil } -func (addr Address) String() string { - return fmt.Sprintf("%s/%d", addr.IP.String(), addr.Network.Bits()) +// HasIPv6 reports whether a v6 overlay address is assigned. +func (addr Address) HasIPv6() bool { + return addr.IPv6.IsValid() +} + +func (addr Address) String() string { + return addr.Prefix().String() +} + +// IPv6String returns the v6 address in CIDR notation, or empty string if none. +func (addr Address) IPv6String() string { + if !addr.HasIPv6() { + return "" + } + return addr.IPv6Prefix().String() +} + +// Prefix returns the v4 host address with its network prefix length (e.g. 100.64.0.1/16). +func (addr Address) Prefix() netip.Prefix { + return netip.PrefixFrom(addr.IP, addr.Network.Bits()) +} + +// IPv6Prefix returns the v6 host address with its network prefix length, or a zero prefix if none. +func (addr Address) IPv6Prefix() netip.Prefix { + if !addr.HasIPv6() { + return netip.Prefix{} + } + return netip.PrefixFrom(addr.IPv6, addr.IPv6Net.Bits()) +} + +// SetIPv6FromCompact decodes a compact prefix (5 or 17 bytes) and sets the IPv6 fields. +// Returns an error if the bytes are invalid. A nil or empty input is a no-op. +// +//nolint:recvcheck +func (addr *Address) SetIPv6FromCompact(raw []byte) error { + if len(raw) == 0 { + return nil + } + prefix, err := netiputil.DecodePrefix(raw) + if err != nil { + return fmt.Errorf("decode v6 overlay address: %w", err) + } + if !prefix.Addr().Is6() { + return fmt.Errorf("expected IPv6 address, got %s", prefix.Addr()) + } + addr.IPv6 = prefix.Addr() + addr.IPv6Net = prefix.Masked() + return nil +} + +// ClearIPv6 removes the IPv6 overlay address, leaving only v4. +// +//nolint:recvcheck +func (addr *Address) ClearIPv6() { + addr.IPv6 = netip.Addr{} + addr.IPv6Net = netip.Prefix{} } diff --git a/client/iface/wgaddr/address_test_helpers.go b/client/iface/wgaddr/address_test_helpers.go new file mode 100644 index 000000000..87403e789 --- /dev/null +++ b/client/iface/wgaddr/address_test_helpers.go @@ -0,0 +1,10 @@ +package wgaddr + +// MustParseWGAddress parses and returns a WG Address, panicking on error. +func MustParseWGAddress(address string) Address { + a, err := ParseWGAddress(address) + if err != nil { + panic(err) + } + return a +} diff --git a/client/iface/wgproxy/bind/proxy.go b/client/iface/wgproxy/bind/proxy.go index 9ac3ea6df..be6f3806e 100644 --- a/client/iface/wgproxy/bind/proxy.go +++ b/client/iface/wgproxy/bind/proxy.go @@ -6,7 +6,7 @@ import ( "fmt" "net" "net/netip" - "strings" + "sync" log "github.com/sirupsen/logrus" @@ -196,18 +196,25 @@ func (p *ProxyBind) proxyToLocal(ctx context.Context) { } } -// fakeAddress returns a fake address that is used to as an identifier for the peer. -// The fake address is in the format of 127.1.x.x where x.x is the last two octets of the peer address. +// fakeAddress returns a fake address that is used as an identifier for the peer. +// The fake address is in the format of 127.1.x.x where x.x is derived from the +// last two bytes of the peer address (works for both IPv4 and IPv6). func fakeAddress(peerAddress *net.UDPAddr) (*netip.AddrPort, error) { - octets := strings.Split(peerAddress.IP.String(), ".") - if len(octets) != 4 { - return nil, fmt.Errorf("invalid IP format") + if peerAddress == nil { + return nil, fmt.Errorf("nil peer address") + } + if peerAddress.Port < 0 || peerAddress.Port > 65535 { + return nil, fmt.Errorf("invalid UDP port: %d", peerAddress.Port) } - fakeIP, err := netip.ParseAddr(fmt.Sprintf("127.1.%s.%s", octets[2], octets[3])) - if err != nil { - return nil, fmt.Errorf("parse new IP: %w", err) + addr, ok := netip.AddrFromSlice(peerAddress.IP) + if !ok { + return nil, fmt.Errorf("invalid IP format") } + addr = addr.Unmap() + + raw := addr.As16() + fakeIP := netip.AddrFrom4([4]byte{127, 1, raw[14], raw[15]}) netipAddr := netip.AddrPortFrom(fakeIP, uint16(peerAddress.Port)) return &netipAddr, nil diff --git a/client/internal/acl/manager.go b/client/internal/acl/manager.go index dd6f9479a..c54a3e897 100644 --- a/client/internal/acl/manager.go +++ b/client/internal/acl/manager.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "errors" "fmt" - "net" "net/netip" "strconv" "sync" @@ -19,6 +18,7 @@ import ( "github.com/netbirdio/netbird/client/internal/acl/id" "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" ) var ErrSourceRangesEmpty = errors.New("sources range is empty") @@ -105,6 +105,10 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { newRulePairs := make(map[id.RuleID][]firewall.Rule) ipsetByRuleSelectors := make(map[string]string) + // TODO: deny rules should be fatal: if a deny rule fails to apply, we must + // roll back all allow rules to avoid a fail-open where allowed traffic bypasses + // the missing deny. Currently we accumulate errors and continue. + var merr *multierror.Error for _, r := range rules { // if this rule is member of rule selection with more than DefaultIPsCountForSet // it's IP address can be used in the ipset for firewall manager which supports it @@ -117,9 +121,8 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { } pairID, rulePair, err := d.protoRuleToFirewallRule(r, ipsetName) if err != nil { - log.Errorf("failed to apply firewall rule: %+v, %v", r, err) - d.rollBack(newRulePairs) - break + merr = multierror.Append(merr, fmt.Errorf("apply firewall rule: %w", err)) + continue } if len(rulePair) > 0 { d.peerRulesPairs[pairID] = rulePair @@ -127,6 +130,10 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { } } + if merr != nil { + log.Errorf("failed to apply %d peer ACL rule(s): %v", merr.Len(), nberrors.FormatErrorOrNil(merr)) + } + for pairID, rules := range d.peerRulesPairs { if _, ok := newRulePairs[pairID]; !ok { for _, rule := range rules { @@ -216,9 +223,9 @@ func (d *DefaultManager) protoRuleToFirewallRule( r *mgmProto.FirewallRule, ipsetName string, ) (id.RuleID, []firewall.Rule, error) { - ip := net.ParseIP(r.PeerIP) - if ip == nil { - return "", nil, fmt.Errorf("invalid IP address, skipping firewall rule") + ip, err := extractRuleIP(r) + if err != nil { + return "", nil, err } protocol, err := convertToFirewallProtocol(r.Protocol) @@ -289,13 +296,13 @@ func portInfoEmpty(portInfo *mgmProto.PortInfo) bool { func (d *DefaultManager) addInRules( id []byte, - ip net.IP, + ip netip.Addr, protocol firewall.Protocol, port *firewall.Port, action firewall.Action, ipsetName string, ) ([]firewall.Rule, error) { - rule, err := d.firewall.AddPeerFiltering(id, ip, protocol, nil, port, action, ipsetName) + rule, err := d.firewall.AddPeerFiltering(id, ip.AsSlice(), protocol, nil, port, action, ipsetName) if err != nil { return nil, fmt.Errorf("add firewall rule: %w", err) } @@ -305,7 +312,7 @@ func (d *DefaultManager) addInRules( func (d *DefaultManager) addOutRules( id []byte, - ip net.IP, + ip netip.Addr, protocol firewall.Protocol, port *firewall.Port, action firewall.Action, @@ -315,7 +322,7 @@ func (d *DefaultManager) addOutRules( return nil, nil } - rule, err := d.firewall.AddPeerFiltering(id, ip, protocol, port, nil, action, ipsetName) + rule, err := d.firewall.AddPeerFiltering(id, ip.AsSlice(), protocol, port, nil, action, ipsetName) if err != nil { return nil, fmt.Errorf("add firewall rule: %w", err) } @@ -323,9 +330,9 @@ func (d *DefaultManager) addOutRules( return rule, nil } -// getPeerRuleID() returns unique ID for the rule based on its parameters. +// getPeerRuleID returns unique ID for the rule based on its parameters. func (d *DefaultManager) getPeerRuleID( - ip net.IP, + ip netip.Addr, proto firewall.Protocol, direction int, port *firewall.Port, @@ -344,15 +351,25 @@ func (d *DefaultManager) getRuleGroupingSelector(rule *mgmProto.FirewallRule) st return fmt.Sprintf("%v:%v:%v:%s:%v", strconv.Itoa(int(rule.Direction)), rule.Action, rule.Protocol, rule.Port, rule.PortInfo) } -func (d *DefaultManager) rollBack(newRulePairs map[id.RuleID][]firewall.Rule) { - log.Debugf("rollback ACL to previous state") - for _, rules := range newRulePairs { - for _, rule := range rules { - if err := d.firewall.DeletePeerRule(rule); err != nil { - log.Errorf("failed to delete new firewall rule (id: %v) during rollback: %v", rule.ID(), err) - } + +// extractRuleIP extracts the peer IP from a firewall rule. +// If sourcePrefixes is populated (new management), decode the first entry and use its address. +// Otherwise fall back to the deprecated PeerIP string field (old management). +func extractRuleIP(r *mgmProto.FirewallRule) (netip.Addr, error) { + if len(r.SourcePrefixes) > 0 { + addr, err := netiputil.DecodeAddr(r.SourcePrefixes[0]) + if err != nil { + return netip.Addr{}, fmt.Errorf("decode source prefix: %w", err) } + return addr.Unmap(), nil } + + //nolint:staticcheck // PeerIP used for backward compatibility with old management + addr, err := netip.ParseAddr(r.PeerIP) + if err != nil { + return netip.Addr{}, fmt.Errorf("invalid IP address, skipping firewall rule") + } + return addr.Unmap(), nil } func convertToFirewallProtocol(protocol mgmProto.RuleProtocol) (firewall.Protocol, error) { diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index bdfd07430..afc8ee77f 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -321,6 +321,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) { a.config.DisableFirewall, a.config.BlockLANAccess, a.config.BlockInbound, + a.config.DisableIPv6, a.config.LazyConnectionEnabled, a.config.EnableSSHRoot, a.config.EnableSSHSFTP, diff --git a/client/internal/connect.go b/client/internal/connect.go index 72e096a80..8c0e9b1ba 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -14,10 +14,13 @@ import ( "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" @@ -536,9 +539,20 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf if config.NetworkMonitor != nil { nm = *config.NetworkMonitor } + wgAddr, err := wgaddr.ParseWGAddress(peerConfig.Address) + if err != nil { + return nil, fmt.Errorf("parse overlay address %q: %w", peerConfig.Address, err) + } + + if !config.DisableIPv6 { + if err := wgAddr.SetIPv6FromCompact(peerConfig.GetAddressV6()); err != nil { + log.Warn(err) + } + } + engineConf := &EngineConfig{ WgIfaceName: config.WgIface, - WgAddr: peerConfig.Address, + WgAddr: wgAddr, IFaceBlackList: config.IFaceBlackList, DisableIPv6Discovery: config.DisableIPv6Discovery, WgPrivateKey: key, @@ -563,6 +577,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf DisableFirewall: config.DisableFirewall, BlockLANAccess: config.BlockLANAccess, BlockInbound: config.BlockInbound, + DisableIPv6: config.DisableIPv6, LazyConnectionEnabled: config.LazyConnectionEnabled, @@ -637,6 +652,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config.DisableFirewall, config.BlockLANAccess, config.BlockInbound, + config.DisableIPv6, config.LazyConnectionEnabled, config.EnableSSHRoot, config.EnableSSHSFTP, diff --git a/client/internal/connect_android_default.go b/client/internal/connect_android_default.go index 190341c4a..b05e91fec 100644 --- a/client/internal/connect_android_default.go +++ b/client/internal/connect_android_default.go @@ -40,6 +40,10 @@ func (noopNetworkChangeListener) SetInterfaceIP(string) { // network stack, not by OS-level interface configuration. } +func (noopNetworkChangeListener) SetInterfaceIPv6(string) { + // No-op: same as SetInterfaceIP, IPv6 overlay is managed by userspace stack. +} + // noopDnsReadyListener is a stub for embed.Client on Android. // DNS readiness notifications are not needed in netstack/embed mode // since system DNS is disabled and DNS resolution happens externally. diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 0a12a5326..9c50f02b3 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -31,6 +31,7 @@ import ( "github.com/netbirdio/netbird/client/internal/updater/installer" nbstatus "github.com/netbirdio/netbird/client/status" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" ) const readmeContent = `Netbird debug bundle @@ -624,6 +625,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("DisableFirewall: %v\n", g.internalConfig.DisableFirewall)) configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess)) configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound)) + configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6)) if g.internalConfig.DisableNotifications != nil { configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications)) @@ -1294,6 +1296,21 @@ func anonymizePeerConfig(config *mgmProto.PeerConfig, anonymizer *anonymize.Anon config.Address = anonymizer.AnonymizeIP(addr).String() } + if len(config.GetAddressV6()) > 0 { + v6Prefix, err := netiputil.DecodePrefix(config.GetAddressV6()) + if err != nil { + config.AddressV6 = nil + } else { + anonV6 := anonymizer.AnonymizeIP(v6Prefix.Addr()) + b, err := netiputil.EncodePrefix(netip.PrefixFrom(anonV6, v6Prefix.Bits())) + if err != nil { + config.AddressV6 = nil + } else { + config.AddressV6 = b + } + } + } + anonymizeSSHConfig(config.SshConfig) config.Dns = anonymizer.AnonymizeString(config.Dns) @@ -1396,8 +1413,20 @@ func anonymizeFirewallRule(rule *mgmProto.FirewallRule, anonymizer *anonymize.An return } + //nolint:staticcheck // PeerIP used for backward compatibility if addr, err := netip.ParseAddr(rule.PeerIP); err == nil { - rule.PeerIP = anonymizer.AnonymizeIP(addr).String() + rule.PeerIP = anonymizer.AnonymizeIP(addr).String() //nolint:staticcheck + } + + for i, raw := range rule.GetSourcePrefixes() { + p, err := netiputil.DecodePrefix(raw) + if err != nil { + continue + } + anonAddr := anonymizer.AnonymizeIP(p.Addr()) + if b, err := netiputil.EncodePrefix(netip.PrefixFrom(anonAddr, p.Bits())); err == nil { + rule.SourcePrefixes[i] = b + } } } diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 05d51e593..39b972244 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "net" + "net/netip" "net/url" "os" "path/filepath" @@ -21,8 +22,16 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" ) +func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte { + t.Helper() + b, err := netiputil.EncodePrefix(p) + require.NoError(t, err) + return b +} + func TestAnonymizeStateFile(t *testing.T) { testState := map[string]json.RawMessage{ "null_state": json.RawMessage("null"), @@ -173,7 +182,7 @@ func TestAnonymizeStateFile(t *testing.T) { assert.Equal(t, "100.64.0.1", state["protected_ip"]) // Protected IP unchanged assert.Equal(t, "8.8.8.8", state["well_known_ip"]) // Well-known IP unchanged assert.NotEqual(t, "2001:db8::1", state["ipv6_addr"]) - assert.Equal(t, "fd00::1", state["private_ipv6"]) // Private IPv6 unchanged + assert.NotEqual(t, "fd00::1", state["private_ipv6"]) // ULA IPv6 anonymized (global ID is a fingerprint) assert.NotEqual(t, "test.example.com", state["domain"]) assert.True(t, strings.HasSuffix(state["domain"].(string), ".domain")) assert.Equal(t, "device.netbird.cloud", state["netbird_domain"]) // Netbird domain unchanged @@ -277,11 +286,13 @@ func mustMarshal(v any) json.RawMessage { } func TestAnonymizeNetworkMap(t *testing.T) { + origV6Prefix := netip.MustParsePrefix("2001:db8:abcd::5/64") networkMap := &mgmProto.NetworkMap{ PeerConfig: &mgmProto.PeerConfig{ - Address: "203.0.113.5", - Dns: "1.2.3.4", - Fqdn: "peer1.corp.example.com", + Address: "203.0.113.5", + AddressV6: mustEncodePrefix(t, origV6Prefix), + Dns: "1.2.3.4", + Fqdn: "peer1.corp.example.com", SshConfig: &mgmProto.SSHConfig{ SshPubKey: []byte("ssh-rsa AAAAB3NzaC1..."), }, @@ -355,6 +366,12 @@ func TestAnonymizeNetworkMap(t *testing.T) { require.NotEqual(t, "peer1.corp.example.com", peerCfg.Fqdn) require.True(t, strings.HasSuffix(peerCfg.Fqdn, ".domain")) + // Verify AddressV6 is anonymized but preserves prefix length + anonV6Prefix, err := netiputil.DecodePrefix(peerCfg.AddressV6) + require.NoError(t, err) + assert.Equal(t, origV6Prefix.Bits(), anonV6Prefix.Bits(), "prefix length must be preserved") + assert.NotEqual(t, origV6Prefix.Addr(), anonV6Prefix.Addr(), "IPv6 address must be anonymized") + // Verify SSH key is replaced require.Equal(t, []byte("ssh-placeholder-key"), peerCfg.SshConfig.SshPubKey) @@ -660,8 +677,6 @@ func isInCGNATRange(ip net.IP) bool { } func TestAnonymizeFirewallRules(t *testing.T) { - // TODO: Add ipv6 - // Example iptables-save output iptablesSave := `# Generated by iptables-save v1.8.7 on Thu Dec 19 10:00:00 2024 *filter @@ -697,17 +712,31 @@ Chain FORWARD (policy ACCEPT 0 packets, 0 bytes) Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes) pkts bytes target prot opt in out source destination` - // Example nftables output + // Example ip6tables-save output + ip6tablesSave := `# Generated by ip6tables-save v1.8.7 on Thu Dec 19 10:00:00 2024 +*filter +:INPUT ACCEPT [0:0] +:FORWARD ACCEPT [0:0] +:OUTPUT ACCEPT [0:0] +-A INPUT -s fd00:1234::1/128 -j ACCEPT +-A INPUT -s 2607:f8b0:4005::1/128 -j DROP +-A FORWARD -s 2001:db8::/32 -d 2607:f8b0:4005::200e/128 -j ACCEPT +COMMIT` + + // Example nftables output with IPv6 nftablesRules := `table inet filter { chain input { type filter hook input priority filter; policy accept; ip saddr 192.168.1.1 accept ip saddr 44.192.140.1 drop + ip6 saddr 2607:f8b0:4005::1 drop + ip6 saddr fd00:1234::1 accept } chain forward { type filter hook forward priority filter; policy accept; ip saddr 10.0.0.0/8 drop ip saddr 44.192.140.0/24 ip daddr 52.84.12.34/24 accept + ip6 saddr 2001:db8::/32 ip6 daddr 2607:f8b0:4005::200e/128 accept } }` @@ -770,6 +799,37 @@ Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes) assert.Contains(t, anonNftables, "table inet filter {") assert.Contains(t, anonNftables, "chain input {") assert.Contains(t, anonNftables, "type filter hook input priority filter; policy accept;") + + // IPv6 public addresses in nftables should be anonymized + assert.NotContains(t, anonNftables, "2607:f8b0:4005::1") + assert.NotContains(t, anonNftables, "2607:f8b0:4005::200e") + assert.NotContains(t, anonNftables, "2001:db8::") + assert.Contains(t, anonNftables, "2001:db8:ffff::") // Default anonymous v6 range + + // ULA addresses in nftables should be anonymized (global ID is a fingerprint) + assert.NotContains(t, anonNftables, "fd00:1234::1") + + // IPv6 nftables structure preserved + assert.Contains(t, anonNftables, "ip6 saddr") + assert.Contains(t, anonNftables, "ip6 daddr") + + // Test ip6tables-save anonymization + anonIp6tablesSave := anonymizer.AnonymizeString(ip6tablesSave) + + // ULA IPv6 should be anonymized (global ID is a fingerprint) + assert.NotContains(t, anonIp6tablesSave, "fd00:1234::1/128") + + // Public IPv6 addresses should be anonymized + assert.NotContains(t, anonIp6tablesSave, "2607:f8b0:4005::1") + assert.NotContains(t, anonIp6tablesSave, "2607:f8b0:4005::200e") + assert.NotContains(t, anonIp6tablesSave, "2001:db8::") + assert.Contains(t, anonIp6tablesSave, "2001:db8:ffff::") // Default anonymous v6 range + + // Structure should be preserved + assert.Contains(t, anonIp6tablesSave, "*filter") + assert.Contains(t, anonIp6tablesSave, "COMMIT") + assert.Contains(t, anonIp6tablesSave, "-j DROP") + assert.Contains(t, anonIp6tablesSave, "-j ACCEPT") } // TestAddConfig_AllFieldsCovered uses reflection to ensure every field in diff --git a/client/internal/dns.go b/client/internal/dns.go index f5040ee49..a6604810f 100644 --- a/client/internal/dns.go +++ b/client/internal/dns.go @@ -12,52 +12,83 @@ import ( nbdns "github.com/netbirdio/netbird/dns" ) -func createPTRRecord(aRecord nbdns.SimpleRecord, prefix netip.Prefix) (nbdns.SimpleRecord, bool) { - ip, err := netip.ParseAddr(aRecord.RData) +func createPTRRecord(record nbdns.SimpleRecord, prefix netip.Prefix) (nbdns.SimpleRecord, bool) { + ip, err := netip.ParseAddr(record.RData) if err != nil { - log.Warnf("failed to parse IP address %s: %v", aRecord.RData, err) + log.Warnf("failed to parse IP address %s: %v", record.RData, err) return nbdns.SimpleRecord{}, false } + ip = ip.Unmap() if !prefix.Contains(ip) { return nbdns.SimpleRecord{}, false } - ipOctets := strings.Split(ip.String(), ".") - slices.Reverse(ipOctets) - rdnsName := dns.Fqdn(strings.Join(ipOctets, ".") + ".in-addr.arpa") + var rdnsName string + if ip.Is4() { + octets := strings.Split(ip.String(), ".") + slices.Reverse(octets) + rdnsName = dns.Fqdn(strings.Join(octets, ".") + ".in-addr.arpa") + } else { + // Expand to full 32 nibbles in reverse order (LSB first) per RFC 3596. + raw := ip.As16() + nibbles := make([]string, 32) + for i := 0; i < 16; i++ { + nibbles[31-i*2] = fmt.Sprintf("%x", raw[i]>>4) + nibbles[31-i*2-1] = fmt.Sprintf("%x", raw[i]&0x0f) + } + rdnsName = dns.Fqdn(strings.Join(nibbles, ".") + ".ip6.arpa") + } return nbdns.SimpleRecord{ Name: rdnsName, Type: int(dns.TypePTR), - Class: aRecord.Class, - TTL: aRecord.TTL, - RData: dns.Fqdn(aRecord.Name), + Class: record.Class, + TTL: record.TTL, + RData: dns.Fqdn(record.Name), }, true } -// generateReverseZoneName creates the reverse DNS zone name for a given network +// generateReverseZoneName creates the reverse DNS zone name for a given network. +// For IPv4 it produces an in-addr.arpa name, for IPv6 an ip6.arpa name. func generateReverseZoneName(network netip.Prefix) (string, error) { - networkIP := network.Masked().Addr() + networkIP := network.Masked().Addr().Unmap() + bits := network.Bits() - if !networkIP.Is4() { - return "", fmt.Errorf("reverse DNS is only supported for IPv4 networks, got: %s", networkIP) + if networkIP.Is4() { + // Round up to nearest byte. + octetsToUse := (bits + 7) / 8 + + octets := strings.Split(networkIP.String(), ".") + if octetsToUse > len(octets) { + return "", fmt.Errorf("invalid network mask size for reverse DNS: %d", bits) + } + + reverseOctets := make([]string, octetsToUse) + for i := 0; i < octetsToUse; i++ { + reverseOctets[octetsToUse-1-i] = octets[i] + } + + return dns.Fqdn(strings.Join(reverseOctets, ".") + ".in-addr.arpa"), nil } - // round up to nearest byte - octetsToUse := (network.Bits() + 7) / 8 + // IPv6: round up to nearest nibble (4-bit boundary). + nibblesToUse := (bits + 3) / 4 - octets := strings.Split(networkIP.String(), ".") - if octetsToUse > len(octets) { - return "", fmt.Errorf("invalid network mask size for reverse DNS: %d", network.Bits()) + raw := networkIP.As16() + allNibbles := make([]string, 32) + for i := 0; i < 16; i++ { + allNibbles[i*2] = fmt.Sprintf("%x", raw[i]>>4) + allNibbles[i*2+1] = fmt.Sprintf("%x", raw[i]&0x0f) } - reverseOctets := make([]string, octetsToUse) - for i := 0; i < octetsToUse; i++ { - reverseOctets[octetsToUse-1-i] = octets[i] + // Take the first nibblesToUse nibbles (network portion), reverse them. + used := make([]string, nibblesToUse) + for i := 0; i < nibblesToUse; i++ { + used[nibblesToUse-1-i] = allNibbles[i] } - return dns.Fqdn(strings.Join(reverseOctets, ".") + ".in-addr.arpa"), nil + return dns.Fqdn(strings.Join(used, ".") + ".ip6.arpa"), nil } // zoneExists checks if a zone with the given name already exists in the configuration @@ -71,7 +102,7 @@ func zoneExists(config *nbdns.Config, zoneName string) bool { return false } -// collectPTRRecords gathers all PTR records for the given network from A records +// collectPTRRecords gathers all PTR records for the given network from A and AAAA records. func collectPTRRecords(config *nbdns.Config, prefix netip.Prefix) []nbdns.SimpleRecord { var records []nbdns.SimpleRecord @@ -80,7 +111,7 @@ func collectPTRRecords(config *nbdns.Config, prefix netip.Prefix) []nbdns.Simple continue } for _, record := range zone.Records { - if record.Type != int(dns.TypeA) { + if record.Type != int(dns.TypeA) && record.Type != int(dns.TypeAAAA) { continue } diff --git a/client/internal/dns/host_darwin.go b/client/internal/dns/host_darwin.go index b3908f163..0f4eb6bf8 100644 --- a/client/internal/dns/host_darwin.go +++ b/client/internal/dns/host_darwin.go @@ -298,6 +298,7 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { if ip, err := netip.ParseAddr(address); err == nil && !ip.IsUnspecified() { ip = ip.Unmap() serverAddresses = append(serverAddresses, ip) + // Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4. if !dnsSettings.ServerIP.IsValid() && ip.Is4() { dnsSettings.ServerIP = ip } diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go index a67a23945..e9d310f00 100644 --- a/client/internal/dns/local/local.go +++ b/client/internal/dns/local/local.go @@ -13,7 +13,6 @@ import ( "github.com/miekg/dns" log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/client/internal/dns/types" @@ -67,9 +66,9 @@ func (d *Resolver) Stop() { d.mu.Lock() defer d.mu.Unlock() - maps.Clear(d.records) - maps.Clear(d.domains) - maps.Clear(d.zones) + clear(d.records) + clear(d.domains) + clear(d.zones) } // ID returns the unique handler ID @@ -444,9 +443,9 @@ func (d *Resolver) Update(customZones []nbdns.CustomZone) { d.mu.Lock() defer d.mu.Unlock() - maps.Clear(d.records) - maps.Clear(d.domains) - maps.Clear(d.zones) + clear(d.records) + clear(d.domains) + clear(d.zones) for _, zone := range customZones { zoneDomain := domain.Domain(strings.ToLower(dns.Fqdn(zone.Domain))) diff --git a/client/internal/dns/network_manager_unix.go b/client/internal/dns/network_manager_unix.go index e4ccc8cbd..66d82dcd7 100644 --- a/client/internal/dns/network_manager_unix.go +++ b/client/internal/dns/network_manager_unix.go @@ -110,8 +110,25 @@ func (n *networkManagerDbusConfigurator) applyDNSConfig(config HostDNSConfig, st connSettings.cleanDeprecatedSettings() - convDNSIP := binary.LittleEndian.Uint32(config.ServerIP.AsSlice()) - connSettings[networkManagerDbusIPv4Key][networkManagerDbusDNSKey] = dbus.MakeVariant([]uint32{convDNSIP}) + ipKey := networkManagerDbusIPv4Key + staleKey := networkManagerDbusIPv6Key + if config.ServerIP.Is6() { + ipKey = networkManagerDbusIPv6Key + staleKey = networkManagerDbusIPv4Key + raw := config.ServerIP.As16() + connSettings[ipKey][networkManagerDbusDNSKey] = dbus.MakeVariant([][]byte{raw[:]}) + } else { + convDNSIP := binary.LittleEndian.Uint32(config.ServerIP.AsSlice()) + connSettings[ipKey][networkManagerDbusDNSKey] = dbus.MakeVariant([]uint32{convDNSIP}) + } + + // Clear stale DNS settings from the opposite address family to avoid + // leftover entries if the server IP family changed. + if staleSettings, ok := connSettings[staleKey]; ok { + delete(staleSettings, networkManagerDbusDNSKey) + delete(staleSettings, networkManagerDbusDNSPriorityKey) + delete(staleSettings, networkManagerDbusDNSSearchKey) + } var ( searchDomains []string matchDomains []string @@ -146,8 +163,8 @@ func (n *networkManagerDbusConfigurator) applyDNSConfig(config HostDNSConfig, st n.routingAll = false } - connSettings[networkManagerDbusIPv4Key][networkManagerDbusDNSPriorityKey] = dbus.MakeVariant(priority) - connSettings[networkManagerDbusIPv4Key][networkManagerDbusDNSSearchKey] = dbus.MakeVariant(newDomainList) + connSettings[ipKey][networkManagerDbusDNSPriorityKey] = dbus.MakeVariant(priority) + connSettings[ipKey][networkManagerDbusDNSSearchKey] = dbus.MakeVariant(newDomainList) state := &ShutdownState{ ManagerType: networkManager, diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index d4f54dec5..6fe2e21b6 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -410,7 +410,7 @@ func (s *DefaultServer) Stop() { log.Errorf("failed to disable DNS: %v", err) } - maps.Clear(s.extraDomains) + clear(s.extraDomains) } func (s *DefaultServer) disableDNS() (retErr error) { diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index f77f6e898..1026a29fc 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -347,7 +347,7 @@ func TestUpdateDNSServer(t *testing.T) { opts := iface.WGIFaceOpts{ IFaceName: fmt.Sprintf("utun230%d", n), - Address: fmt.Sprintf("100.66.100.%d/32", n+1), + Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)), WGPort: 33100, WGPrivKey: privKey.String(), MTU: iface.DefaultMTU, @@ -448,7 +448,7 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) { privKey, _ := wgtypes.GeneratePrivateKey() opts := iface.WGIFaceOpts{ IFaceName: "utun2301", - Address: "100.66.100.1/32", + Address: wgaddr.MustParseWGAddress("100.66.100.1/32"), WGPort: 33100, WGPrivKey: privKey.String(), MTU: iface.DefaultMTU, @@ -929,7 +929,7 @@ func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) { opts := iface.WGIFaceOpts{ IFaceName: "utun2301", - Address: "100.66.100.2/24", + Address: wgaddr.MustParseWGAddress("100.66.100.2/24"), WGPort: 33100, WGPrivKey: privKey.String(), MTU: iface.DefaultMTU, diff --git a/client/internal/dns/service.go b/client/internal/dns/service.go index 1c6ce7849..04bcd5985 100644 --- a/client/internal/dns/service.go +++ b/client/internal/dns/service.go @@ -16,8 +16,8 @@ const ( // This is used when the DNS server cannot bind port 53 directly // and needs firewall rules to redirect traffic. type Firewall interface { - AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error - RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error + AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error + RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error } type service interface { diff --git a/client/internal/dns/service_listener.go b/client/internal/dns/service_listener.go index 4e09f1b7f..9c0e52af8 100644 --- a/client/internal/dns/service_listener.go +++ b/client/internal/dns/service_listener.go @@ -188,11 +188,10 @@ func (s *serviceViaListener) RuntimeIP() netip.Addr { return s.listenIP } - -// evalListenAddress figure out the listen address for the DNS server -// first check the 53 port availability on WG interface or lo, if not success -// pick a random port on WG interface for eBPF, if not success -// check the 5053 port availability on WG interface or lo without eBPF usage, +// evalListenAddress figures out the listen address for the DNS server. +// IPv4-only: all peers have a v4 overlay address, and DNS config points to v4. +// First checks port 53 on WG interface or lo, then tries eBPF on a random port, +// then falls back to port 5053. func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) { if s.customAddr != nil { return s.customAddr.Addr(), s.customAddr.Port(), nil @@ -278,7 +277,7 @@ func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) { } ebpfSrv := ebpf.GetEbpfManagerInstance() - err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP.String(), int(port)) + err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP, int(port)) if err != nil { log.Warnf("failed to load DNS forwarder eBPF program, error: %s", err) return nil, 0, false diff --git a/client/internal/dns/systemd_linux.go b/client/internal/dns/systemd_linux.go index d9854c033..573dff540 100644 --- a/client/internal/dns/systemd_linux.go +++ b/client/internal/dns/systemd_linux.go @@ -90,8 +90,12 @@ func (s *systemdDbusConfigurator) supportCustomPort() bool { } func (s *systemdDbusConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error { + family := int32(unix.AF_INET) + if config.ServerIP.Is6() { + family = unix.AF_INET6 + } defaultLinkInput := systemdDbusDNSInput{ - Family: unix.AF_INET, + Family: family, Address: config.ServerIP.AsSlice(), } if err := s.callLinkMethod(systemdDbusSetDNSMethodSuffix, []systemdDbusDNSInput{defaultLinkInput}); err != nil { diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go index 746b73ca7..a26536f6e 100644 --- a/client/internal/dns/upstream.go +++ b/client/internal/dns/upstream.go @@ -21,6 +21,7 @@ import ( "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/client/internal/dns/types" "github.com/netbirdio/netbird/client/internal/peer" @@ -29,6 +30,12 @@ import ( var currentMTU uint16 = iface.DefaultMTU +// privateClientIface is the subset of the WireGuard interface needed by GetClientPrivate. +type privateClientIface interface { + Name() string + Address() wgaddr.Address +} + func SetCurrentMTU(mtu uint16) { currentMTU = mtu } diff --git a/client/internal/dns/upstream_android.go b/client/internal/dns/upstream_android.go index ee1ca42fe..988adb7d2 100644 --- a/client/internal/dns/upstream_android.go +++ b/client/internal/dns/upstream_android.go @@ -86,7 +86,7 @@ func (u *upstreamResolver) isLocalResolver(upstream string) bool { return false } -func GetClientPrivate(ip netip.Addr, interfaceName string, dialTimeout time.Duration) (*dns.Client, error) { +func GetClientPrivate(_ privateClientIface, _ netip.Addr, dialTimeout time.Duration) (*dns.Client, error) { return &dns.Client{ Timeout: dialTimeout, Net: "udp", diff --git a/client/internal/dns/upstream_general.go b/client/internal/dns/upstream_general.go index 1143b6c51..910c3779e 100644 --- a/client/internal/dns/upstream_general.go +++ b/client/internal/dns/upstream_general.go @@ -52,7 +52,7 @@ func (u *upstreamResolver) exchange(ctx context.Context, upstream string, r *dns return ExchangeWithFallback(ctx, client, r, upstream) } -func GetClientPrivate(ip netip.Addr, interfaceName string, dialTimeout time.Duration) (*dns.Client, error) { +func GetClientPrivate(_ privateClientIface, _ netip.Addr, dialTimeout time.Duration) (*dns.Client, error) { return &dns.Client{ Timeout: dialTimeout, Net: "udp", diff --git a/client/internal/dns/upstream_ios.go b/client/internal/dns/upstream_ios.go index 02c11173b..0e04742a0 100644 --- a/client/internal/dns/upstream_ios.go +++ b/client/internal/dns/upstream_ios.go @@ -19,9 +19,7 @@ import ( type upstreamResolverIOS struct { *upstreamResolverBase - lIP netip.Addr - lNet netip.Prefix - interfaceName string + wgIface WGIface } func newUpstreamResolver( @@ -35,9 +33,7 @@ func newUpstreamResolver( ios := &upstreamResolverIOS{ upstreamResolverBase: upstreamResolverBase, - lIP: wgIface.Address().IP, - lNet: wgIface.Address().Network, - interfaceName: wgIface.Name(), + wgIface: wgIface, } ios.upstreamClient = ios @@ -65,11 +61,13 @@ func (u *upstreamResolverIOS) exchange(ctx context.Context, upstream string, r * } else { upstreamIP = upstreamIP.Unmap() } - needsPrivate := u.lNet.Contains(upstreamIP) || + addr := u.wgIface.Address() + needsPrivate := addr.Network.Contains(upstreamIP) || + addr.IPv6Net.Contains(upstreamIP) || (u.routeMatch != nil && u.routeMatch(upstreamIP)) if needsPrivate { log.Debugf("using private client to query %s via upstream %s", r.Question[0].Name, upstream) - client, err = GetClientPrivate(u.lIP, u.interfaceName, timeout) + client, err = GetClientPrivate(u.wgIface, upstreamIP, timeout) if err != nil { return nil, 0, fmt.Errorf("create private client: %s", err) } @@ -79,25 +77,33 @@ func (u *upstreamResolverIOS) exchange(ctx context.Context, upstream string, r * return ExchangeWithFallback(nil, client, r, upstream) } -// GetClientPrivate returns a new DNS client bound to the local IP address of the Netbird interface -// This method is needed for iOS -func GetClientPrivate(ip netip.Addr, interfaceName string, dialTimeout time.Duration) (*dns.Client, error) { - index, err := getInterfaceIndex(interfaceName) +// GetClientPrivate returns a new DNS client bound to the local IP of the Netbird interface. +// It selects the v6 bind address when the upstream is IPv6 and the interface has one, otherwise v4. +func GetClientPrivate(iface privateClientIface, upstreamIP netip.Addr, dialTimeout time.Duration) (*dns.Client, error) { + index, err := getInterfaceIndex(iface.Name()) if err != nil { - log.Debugf("unable to get interface index for %s: %s", interfaceName, err) + log.Debugf("unable to get interface index for %s: %s", iface.Name(), err) return nil, err } + addr := iface.Address() + bindIP := addr.IP + if upstreamIP.Is6() && addr.HasIPv6() { + bindIP = addr.IPv6 + } + + proto, opt := unix.IPPROTO_IP, unix.IP_BOUND_IF + if bindIP.Is6() { + proto, opt = unix.IPPROTO_IPV6, unix.IPV6_BOUND_IF + } + dialer := &net.Dialer{ - LocalAddr: &net.UDPAddr{ - IP: ip.AsSlice(), - Port: 0, // Let the OS pick a free port - }, - Timeout: dialTimeout, + LocalAddr: net.UDPAddrFromAddrPort(netip.AddrPortFrom(bindIP, 0)), + Timeout: dialTimeout, Control: func(network, address string, c syscall.RawConn) error { var operr error fn := func(s uintptr) { - operr = unix.SetsockoptInt(int(s), unix.IPPROTO_IP, unix.IP_BOUND_IF, index) + operr = unix.SetsockoptInt(int(s), proto, opt, index) } if err := c.Control(fn); err != nil { diff --git a/client/internal/dns_test.go b/client/internal/dns_test.go new file mode 100644 index 000000000..e15cc8fb7 --- /dev/null +++ b/client/internal/dns_test.go @@ -0,0 +1,138 @@ +package internal + +import ( + "net/netip" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" +) + +func TestCreatePTRRecord_IPv4(t *testing.T) { + record := nbdns.SimpleRecord{ + Name: "peer1.netbird.cloud.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "100.64.0.5", + } + prefix := netip.MustParsePrefix("100.64.0.0/16") + + ptr, ok := createPTRRecord(record, prefix) + require.True(t, ok) + assert.Equal(t, "5.0.64.100.in-addr.arpa.", ptr.Name) + assert.Equal(t, int(dns.TypePTR), ptr.Type) + assert.Equal(t, "peer1.netbird.cloud.", ptr.RData) +} + +func TestCreatePTRRecord_IPv6(t *testing.T) { + record := nbdns.SimpleRecord{ + Name: "peer1.netbird.cloud.", + Type: int(dns.TypeAAAA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "fd00:1234:5678::1", + } + prefix := netip.MustParsePrefix("fd00:1234:5678::/48") + + ptr, ok := createPTRRecord(record, prefix) + require.True(t, ok) + assert.Equal(t, "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.7.6.5.4.3.2.1.0.0.d.f.ip6.arpa.", ptr.Name) + assert.Equal(t, int(dns.TypePTR), ptr.Type) + assert.Equal(t, "peer1.netbird.cloud.", ptr.RData) +} + +func TestCreatePTRRecord_OutOfRange(t *testing.T) { + record := nbdns.SimpleRecord{ + Name: "peer1.netbird.cloud.", + Type: int(dns.TypeA), + RData: "10.0.0.1", + } + prefix := netip.MustParsePrefix("100.64.0.0/16") + + _, ok := createPTRRecord(record, prefix) + assert.False(t, ok) +} + +func TestGenerateReverseZoneName_IPv4(t *testing.T) { + tests := []struct { + prefix string + expected string + }{ + {"100.64.0.0/16", "64.100.in-addr.arpa."}, + {"10.0.0.0/8", "10.in-addr.arpa."}, + {"192.168.1.0/24", "1.168.192.in-addr.arpa."}, + } + + for _, tt := range tests { + t.Run(tt.prefix, func(t *testing.T) { + zone, err := generateReverseZoneName(netip.MustParsePrefix(tt.prefix)) + require.NoError(t, err) + assert.Equal(t, tt.expected, zone) + }) + } +} + +func TestGenerateReverseZoneName_IPv6(t *testing.T) { + tests := []struct { + prefix string + expected string + }{ + {"fd00:1234:5678::/48", "8.7.6.5.4.3.2.1.0.0.d.f.ip6.arpa."}, + {"fd00::/16", "0.0.d.f.ip6.arpa."}, + {"fd12:3456:789a:bcde::/64", "e.d.c.b.a.9.8.7.6.5.4.3.2.1.d.f.ip6.arpa."}, + } + + for _, tt := range tests { + t.Run(tt.prefix, func(t *testing.T) { + zone, err := generateReverseZoneName(netip.MustParsePrefix(tt.prefix)) + require.NoError(t, err) + assert.Equal(t, tt.expected, zone) + }) + } +} + +func TestCollectPTRRecords_BothFamilies(t *testing.T) { + config := &nbdns.Config{ + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud.", + Records: []nbdns.SimpleRecord{ + {Name: "peer1.netbird.cloud.", Type: int(dns.TypeA), RData: "100.64.0.1"}, + {Name: "peer1.netbird.cloud.", Type: int(dns.TypeAAAA), RData: "fd00::1"}, + {Name: "peer2.netbird.cloud.", Type: int(dns.TypeA), RData: "100.64.0.2"}, + }, + }, + }, + } + + v4Records := collectPTRRecords(config, netip.MustParsePrefix("100.64.0.0/16")) + assert.Len(t, v4Records, 2, "should collect 2 A record PTRs for the v4 prefix") + + v6Records := collectPTRRecords(config, netip.MustParsePrefix("fd00::/64")) + assert.Len(t, v6Records, 1, "should collect 1 AAAA record PTR for the v6 prefix") +} + +func TestAddReverseZone_IPv6(t *testing.T) { + config := &nbdns.Config{ + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud.", + Records: []nbdns.SimpleRecord{ + {Name: "peer1.netbird.cloud.", Type: int(dns.TypeAAAA), RData: "fd00:1234:5678::1"}, + }, + }, + }, + } + + addReverseZone(config, netip.MustParsePrefix("fd00:1234:5678::/48")) + + require.Len(t, config.CustomZones, 2) + reverseZone := config.CustomZones[1] + assert.Equal(t, "8.7.6.5.4.3.2.1.0.0.d.f.ip6.arpa.", reverseZone.Domain) + assert.Len(t, reverseZone.Records, 1) + assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type) +} diff --git a/client/internal/dnsfwd/manager.go b/client/internal/dnsfwd/manager.go index 58b88d9ef..c4c16cd3f 100644 --- a/client/internal/dnsfwd/manager.go +++ b/client/internal/dnsfwd/manager.go @@ -80,6 +80,7 @@ func (m *Manager) Start(fwdEntries []*ForwarderEntry) error { return err } + // IPv4-only: peers reach the forwarder via its v4 overlay address. localAddr := m.wgIface.Address().IP if localAddr.IsValid() && m.firewall != nil { diff --git a/client/internal/ebpf/ebpf/dns_fwd_linux.go b/client/internal/ebpf/ebpf/dns_fwd_linux.go index 93797da76..1e7774573 100644 --- a/client/internal/ebpf/ebpf/dns_fwd_linux.go +++ b/client/internal/ebpf/ebpf/dns_fwd_linux.go @@ -2,7 +2,8 @@ package ebpf import ( "encoding/binary" - "net" + "fmt" + "net/netip" log "github.com/sirupsen/logrus" ) @@ -12,7 +13,7 @@ const ( mapKeyDNSPort uint32 = 1 ) -func (tf *GeneralManager) LoadDNSFwd(ip string, dnsPort int) error { +func (tf *GeneralManager) LoadDNSFwd(ip netip.Addr, dnsPort int) error { log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort) tf.lock.Lock() defer tf.lock.Unlock() @@ -22,7 +23,11 @@ func (tf *GeneralManager) LoadDNSFwd(ip string, dnsPort int) error { return err } - err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, ip2int(ip)) + if !ip.Is4() { + return fmt.Errorf("eBPF DNS forwarder only supports IPv4, got %s", ip) + } + ip4 := ip.As4() + err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, binary.BigEndian.Uint32(ip4[:])) if err != nil { return err } @@ -45,7 +50,3 @@ func (tf *GeneralManager) FreeDNSFwd() error { return tf.unsetFeatureFlag(featureFlagDnsForwarder) } -func ip2int(ipString string) uint32 { - ip := net.ParseIP(ipString) - return binary.BigEndian.Uint32(ip.To4()) -} diff --git a/client/internal/ebpf/manager/manager.go b/client/internal/ebpf/manager/manager.go index af10142d5..25a767090 100644 --- a/client/internal/ebpf/manager/manager.go +++ b/client/internal/ebpf/manager/manager.go @@ -1,8 +1,10 @@ package manager +import "net/netip" + // Manager is used to load multiple eBPF programs. E.g., current DNS programs and WireGuard proxy type Manager interface { - LoadDNSFwd(ip string, dnsPort int) error + LoadDNSFwd(ip netip.Addr, dnsPort int) error FreeDNSFwd() error LoadWgProxy(proxyPort, wgPort int) error FreeWGProxy() error diff --git a/client/internal/engine.go b/client/internal/engine.go index 7f19e2d28..66fe6056b 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -33,6 +33,7 @@ import ( "github.com/netbirdio/netbird/client/iface/device" nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/udpmux" + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/acl" "github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/dns" @@ -64,6 +65,7 @@ import ( mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" relayClient "github.com/netbirdio/netbird/shared/relay/client" signal "github.com/netbirdio/netbird/shared/signal/client" @@ -88,8 +90,9 @@ type EngineConfig struct { WgPort int WgIfaceName string - // WgAddr is a Wireguard local address (Netbird Network IP) - WgAddr string + // WgAddr is the Wireguard local address (Netbird Network IP). + // Contains both v4 and optional v6 overlay addresses. + WgAddr wgaddr.Address // WgPrivateKey is a Wireguard private key of our peer (it MUST never leave the machine) WgPrivateKey wgtypes.Key @@ -134,6 +137,7 @@ type EngineConfig struct { DisableFirewall bool BlockLANAccess bool BlockInbound bool + DisableIPv6 bool LazyConnectionEnabled bool @@ -644,7 +648,7 @@ func (e *Engine) initFirewall() error { rosenpassPort := e.rpManager.GetAddress().Port port := firewallManager.Port{Values: []uint16{uint16(rosenpassPort)}} - // this rule is static and will be torn down on engine down by the firewall manager + // IPv4-only: rosenpass peers connect via AllowedIps[0] which is always v4. if _, err := e.firewall.AddPeerFiltering( nil, net.IP{0, 0, 0, 0}, @@ -696,10 +700,15 @@ func (e *Engine) blockLanAccess() { log.Infof("blocking route LAN access for networks: %v", toBlock) v4 := netip.PrefixFrom(netip.IPv4Unspecified(), 0) + v6 := netip.PrefixFrom(netip.IPv6Unspecified(), 0) for _, network := range toBlock { + source := v4 + if network.Addr().Is6() { + source = v6 + } if _, err := e.firewall.AddRouteFiltering( nil, - []netip.Prefix{v4}, + []netip.Prefix{source}, firewallManager.Network{Prefix: network}, firewallManager.ProtocolALL, nil, @@ -737,7 +746,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { if !ok { continue } - if !compareNetIPLists(allowedIPs, p.GetAllowedIps()) { + if !compareNetIPLists(allowedIPs, e.filterAllowedIPs(p.GetAllowedIps())) { modified = append(modified, p) continue } @@ -1016,6 +1025,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { e.config.DisableFirewall, e.config.BlockLANAccess, e.config.BlockInbound, + e.config.DisableIPv6, e.config.LazyConnectionEnabled, e.config.EnableSSHRoot, e.config.EnableSSHSFTP, @@ -1043,6 +1053,13 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { return ErrResetConnection } + if !e.config.DisableIPv6 && e.hasIPv6Changed(conf) { + log.Infof("peer IPv6 address changed, restarting client") + _ = CtxGetState(e.ctx).Wrap(ErrResetConnection) + e.clientCancel() + return ErrResetConnection + } + if conf.GetSshConfig() != nil { if err := e.updateSSH(conf.GetSshConfig()); err != nil { log.Warnf("failed handling SSH server setup: %v", err) @@ -1051,6 +1068,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { state := e.statusRecorder.GetLocalPeerState() state.IP = e.wgInterface.Address().String() + state.IPv6 = e.wgInterface.Address().IPv6String() state.PubKey = e.config.WgPrivateKey.PublicKey().String() state.KernelInterface = !e.wgInterface.IsUserspaceBind() state.FQDN = conf.GetFqdn() @@ -1059,6 +1077,28 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { return nil } + +// hasIPv6Changed reports whether the IPv6 overlay address in the peer config +// differs from the configured address (added, removed, or changed). +// Compares against e.config.WgAddr (not the interface address, which may have +// been cleared by ClearIPv6 if OS assignment failed). +func (e *Engine) hasIPv6Changed(conf *mgmProto.PeerConfig) bool { + current := e.config.WgAddr + raw := conf.GetAddressV6() + + if len(raw) == 0 { + return current.HasIPv6() + } + + prefix, err := netiputil.DecodePrefix(raw) + if err != nil { + log.Errorf("decode v6 overlay address: %v", err) + return false + } + + return !current.HasIPv6() || current.IPv6 != prefix.Addr() || current.IPv6Net != prefix.Masked() +} + func (e *Engine) receiveJobEvents() { e.jobExecutorWG.Add(1) go func() { @@ -1157,6 +1197,7 @@ func (e *Engine) receiveManagementEvents() { e.config.DisableFirewall, e.config.BlockLANAccess, e.config.BlockInbound, + e.config.DisableIPv6, e.config.LazyConnectionEnabled, e.config.EnableSSHRoot, e.config.EnableSSHSFTP, @@ -1256,7 +1297,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { protoDNSConfig = &mgmProto.DNSConfig{} } - dnsConfig := toDNSConfig(protoDNSConfig, e.wgInterface.Address().Network) + dnsConfig := toDNSConfig(protoDNSConfig, e.wgInterface.Address()) if err := e.dnsServer.UpdateDNSServer(serial, dnsConfig); err != nil { log.Errorf("failed to update dns server, err: %v", err) @@ -1411,7 +1452,9 @@ func toRouteDomains(myPubKey string, routes []*route.Route) []*dnsfwd.ForwarderE return entries } -func toDNSConfig(protoDNSConfig *mgmProto.DNSConfig, network netip.Prefix) nbdns.Config { +func toDNSConfig(protoDNSConfig *mgmProto.DNSConfig, addr wgaddr.Address) nbdns.Config { + network := addr.Network + networkV6 := addr.IPv6Net //nolint forwarderPort := uint16(protoDNSConfig.GetForwarderPort()) if forwarderPort == 0 { @@ -1468,6 +1511,9 @@ func toDNSConfig(protoDNSConfig *mgmProto.DNSConfig, network netip.Prefix) nbdns if len(dnsUpdate.CustomZones) > 0 { addReverseZone(&dnsUpdate, network) + if networkV6.IsValid() { + addReverseZone(&dnsUpdate, networkV6) + } } return dnsUpdate @@ -1477,8 +1523,10 @@ func (e *Engine) updateOfflinePeers(offlinePeers []*mgmProto.RemotePeerConfig) { replacement := make([]peer.State, len(offlinePeers)) for i, offlinePeer := range offlinePeers { log.Debugf("added offline peer %s", offlinePeer.Fqdn) + v4, v6 := overlayAddrsFromAllowedIPs(offlinePeer.GetAllowedIps(), e.wgInterface.Address().IPv6Net) replacement[i] = peer.State{ - IP: strings.Join(offlinePeer.GetAllowedIps(), ","), + IP: addrToString(v4), + IPv6: addrToString(v6), PubKey: offlinePeer.GetWgPubKey(), FQDN: offlinePeer.GetFqdn(), ConnStatus: peer.StatusIdle, @@ -1489,6 +1537,37 @@ func (e *Engine) updateOfflinePeers(offlinePeers []*mgmProto.RemotePeerConfig) { e.statusRecorder.ReplaceOfflinePeers(replacement) } +// overlayAddrsFromAllowedIPs extracts the peer's v4 and v6 overlay addresses +// from AllowedIPs strings. Only host routes (/32, /128) are considered; v6 must +// fall within ourV6Net to distinguish overlay addresses from routed prefixes. +func overlayAddrsFromAllowedIPs(allowedIPs []string, ourV6Net netip.Prefix) (v4, v6 netip.Addr) { + for _, cidr := range allowedIPs { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + log.Warnf("failed to parse AllowedIP %q: %v", cidr, err) + continue + } + addr := prefix.Addr().Unmap() + switch { + case addr.Is4() && prefix.Bits() == 32 && !v4.IsValid(): + v4 = addr + case addr.Is6() && prefix.Bits() == 128 && ourV6Net.Contains(addr) && !v6.IsValid(): + v6 = addr + } + if v4.IsValid() && v6.IsValid() { + break + } + } + return +} + +func addrToString(addr netip.Addr) string { + if !addr.IsValid() { + return "" + } + return addr.String() +} + // addNewPeers adds peers that were not know before but arrived from the Management service with the update func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { for _, p := range peersUpdate { @@ -1514,15 +1593,23 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { log.Errorf("failed to parse allowedIPS: %v", err) return err } + if allowedNetIP.Addr().Is6() && !e.wgInterface.Address().HasIPv6() { + continue + } peerIPs = append(peerIPs, allowedNetIP) } + if len(peerIPs) == 0 { + return fmt.Errorf("peer %s has no usable AllowedIPs", peerKey) + } + conn, err := e.createPeerConn(peerKey, peerIPs, peerConfig.AgentVersion) if err != nil { return fmt.Errorf("create peer connection: %w", err) } - err = e.statusRecorder.AddPeer(peerKey, peerConfig.Fqdn, peerIPs[0].Addr().String()) + peerV4, peerV6 := overlayAddrsFromAllowedIPs(peerConfig.GetAllowedIps(), e.wgInterface.Address().IPv6Net) + err = e.statusRecorder.AddPeer(peerKey, peerConfig.Fqdn, addrToString(peerV4), addrToString(peerV6)) if err != nil { log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err) } @@ -1757,6 +1844,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err e.config.DisableFirewall, e.config.BlockLANAccess, e.config.BlockInbound, + e.config.DisableIPv6, e.config.LazyConnectionEnabled, e.config.EnableSSHRoot, e.config.EnableSSHSFTP, @@ -1770,7 +1858,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err return nil, nil, false, err } routes := toRoutes(netMap.GetRoutes()) - dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address().Network) + dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address()) dnsFeatureFlag := toDNSFeatureFlag(netMap) return routes, &dnsCfg, dnsFeatureFlag, nil } @@ -1812,7 +1900,10 @@ func (e *Engine) wgInterfaceCreate() (err error) { case "android": err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains()) case "ios": - e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr) + e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String()) + if e.config.WgAddr.HasIPv6() { + e.mobileDep.NetworkChangeListener.SetInterfaceIPv6(e.config.WgAddr.IPv6String()) + } err = e.wgInterface.Create() default: err = e.wgInterface.Create() @@ -2089,6 +2180,14 @@ func (e *Engine) GetWgAddr() netip.Addr { return e.wgInterface.Address().IP } +// GetWgV6Addr returns the IPv6 overlay address of the WireGuard interface. +func (e *Engine) GetWgV6Addr() netip.Addr { + if e.wgInterface == nil { + return netip.Addr{} + } + return e.wgInterface.Address().IPv6 +} + func (e *Engine) RenewTun(fd int) error { e.syncMsgMux.Lock() wgInterface := e.wgInterface @@ -2370,8 +2469,7 @@ func getInterfacePrefixes() ([]netip.Prefix, error) { prefix := netip.PrefixFrom(addr.Unmap(), ones).Masked() ip := prefix.Addr() - // TODO: add IPv6 - if !ip.Is4() || ip.IsLoopback() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + if ip.IsLoopback() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { continue } @@ -2382,6 +2480,24 @@ func getInterfacePrefixes() ([]netip.Prefix, error) { return prefixes, nberrors.FormatErrorOrNil(merr) } +// filterAllowedIPs strips IPv6 entries when the local interface has no v6 address. +// This covers both the explicit --disable-ipv6 flag (v6 never assigned) and the +// case where OS v6 assignment failed (ClearIPv6). Without this, WireGuard would +// accept v6 traffic that the native firewall cannot filter. +func (e *Engine) filterAllowedIPs(ips []string) []string { + if e.wgInterface.Address().HasIPv6() { + return ips + } + filtered := make([]string, 0, len(ips)) + for _, s := range ips { + p, err := netip.ParsePrefix(s) + if err != nil || !p.Addr().Is6() { + filtered = append(filtered, s) + } + } + return filtered +} + // compareNetIPLists compares a list of netip.Prefix with a list of strings. // return true if both lists are equal, false otherwise. func compareNetIPLists(list1 []netip.Prefix, list2 []string) bool { diff --git a/client/internal/engine_ssh.go b/client/internal/engine_ssh.go index 1419bc262..53d2c1122 100644 --- a/client/internal/engine_ssh.go +++ b/client/internal/engine_ssh.go @@ -41,6 +41,14 @@ func (e *Engine) setupSSHPortRedirection() error { } log.Infof("SSH port redirection enabled: %s:22 -> %s:22022", localAddr, localAddr) + if v6 := e.wgInterface.Address().IPv6; v6.IsValid() { + if err := e.firewall.AddInboundDNAT(v6, firewallManager.ProtocolTCP, 22, 22022); err != nil { + log.Warnf("failed to add IPv6 SSH port redirection: %v", err) + } else { + log.Infof("SSH port redirection enabled: [%s]:22 -> [%s]:22022", v6, v6) + } + } + return nil } @@ -137,12 +145,13 @@ func (e *Engine) extractPeerSSHInfo(remotePeers []*mgmProto.RemotePeerConfig) [] continue } - peerIP := e.extractPeerIP(peerConfig) + peerV4, peerV6 := overlayAddrsFromAllowedIPs(peerConfig.GetAllowedIps(), e.wgInterface.Address().IPv6Net) hostname := e.extractHostname(peerConfig) peerInfo = append(peerInfo, sshconfig.PeerSSHInfo{ Hostname: hostname, - IP: peerIP, + IP: peerV4, + IPv6: peerV6, FQDN: peerConfig.GetFqdn(), }) } @@ -150,18 +159,6 @@ func (e *Engine) extractPeerSSHInfo(remotePeers []*mgmProto.RemotePeerConfig) [] return peerInfo } -// extractPeerIP extracts IP address from peer's allowed IPs -func (e *Engine) extractPeerIP(peerConfig *mgmProto.RemotePeerConfig) string { - if len(peerConfig.GetAllowedIps()) == 0 { - return "" - } - - if prefix, err := netip.ParsePrefix(peerConfig.GetAllowedIps()[0]); err == nil { - return prefix.Addr().String() - } - return "" -} - // extractHostname extracts short hostname from FQDN func (e *Engine) extractHostname(peerConfig *mgmProto.RemotePeerConfig) string { fqdn := peerConfig.GetFqdn() @@ -208,7 +205,7 @@ func (e *Engine) GetPeerSSHKey(peerAddress string) ([]byte, bool) { fullStatus := statusRecorder.GetFullStatus() for _, peerState := range fullStatus.Peers { - if peerState.IP == peerAddress || peerState.FQDN == peerAddress { + if peerState.IP == peerAddress || peerState.FQDN == peerAddress || peerState.IPv6 == peerAddress { if len(peerState.SSHHostKey) > 0 { return peerState.SSHHostKey, true } @@ -262,6 +259,13 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error { return fmt.Errorf("start SSH server: %w", err) } + if v6 := wgAddr.IPv6; v6.IsValid() { + v6Addr := netip.AddrPortFrom(v6, sshserver.InternalSSHPort) + if err := server.AddListener(e.ctx, v6Addr); err != nil { + log.Warnf("failed to add IPv6 SSH listener: %v", err) + } + } + e.sshServer = server if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { @@ -330,6 +334,12 @@ func (e *Engine) cleanupSSHPortRedirection() error { } log.Debugf("SSH port redirection removed: %s:22 -> %s:22022", localAddr, localAddr) + if v6 := e.wgInterface.Address().IPv6; v6.IsValid() { + if err := e.firewall.RemoveInboundDNAT(v6, firewallManager.ProtocolTCP, 22, 22022); err != nil { + log.Debugf("failed to remove IPv6 SSH port redirection: %v", err) + } + } + return nil } diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index f4c5be70a..834a49a09 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -67,6 +67,7 @@ import ( mgmt "github.com/netbirdio/netbird/shared/management/client" mgmtProto "github.com/netbirdio/netbird/shared/management/proto" relayClient "github.com/netbirdio/netbird/shared/relay/client" + "github.com/netbirdio/netbird/shared/netiputil" signal "github.com/netbirdio/netbird/shared/signal/client" "github.com/netbirdio/netbird/shared/signal/proto" signalServer "github.com/netbirdio/netbird/signal/server" @@ -95,7 +96,7 @@ type MockWGIface struct { AddressFunc func() wgaddr.Address ToInterfaceFunc func() *net.Interface UpFunc func() (*udpmux.UniversalUDPMuxDefault, error) - UpdateAddrFunc func(newAddr string) error + UpdateAddrFunc func(newAddr wgaddr.Address) error UpdatePeerFunc func(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error RemovePeerFunc func(peerKey string) error AddAllowedIPFunc func(peerKey string, allowedIP netip.Prefix) error @@ -157,7 +158,7 @@ func (m *MockWGIface) Up() (*udpmux.UniversalUDPMuxDefault, error) { return m.UpFunc() } -func (m *MockWGIface) UpdateAddr(newAddr string) error { +func (m *MockWGIface) UpdateAddr(newAddr wgaddr.Address) error { return m.UpdateAddrFunc(newAddr) } @@ -254,7 +255,7 @@ func TestEngine_SSH(t *testing.T) { ctx, cancel, &EngineConfig{ WgIfaceName: "utun101", - WgAddr: "100.64.0.1/24", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), WgPrivateKey: key, WgPort: 33100, ServerSSHAllowed: true, @@ -431,7 +432,7 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: "utun102", - WgAddr: "100.64.0.1/24", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, @@ -655,7 +656,7 @@ func TestEngine_Sync(t *testing.T) { relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: "utun103", - WgAddr: "100.64.0.1/24", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, @@ -825,7 +826,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: wgIfaceName, - WgAddr: wgAddr, + WgAddr: wgaddr.MustParseWGAddress(wgAddr), WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, @@ -843,7 +844,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { opts := iface.WGIFaceOpts{ IFaceName: wgIfaceName, - Address: wgAddr, + Address: wgaddr.MustParseWGAddress(wgAddr), WGPort: engine.config.WgPort, WGPrivKey: key.String(), MTU: iface.DefaultMTU, @@ -1032,7 +1033,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: wgIfaceName, - WgAddr: wgAddr, + WgAddr: wgaddr.MustParseWGAddress(wgAddr), WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, @@ -1050,7 +1051,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { } opts := iface.WGIFaceOpts{ IFaceName: wgIfaceName, - Address: wgAddr, + Address: wgaddr.MustParseWGAddress(wgAddr), WGPort: 33100, WGPrivKey: key.String(), MTU: iface.DefaultMTU, @@ -1555,7 +1556,7 @@ func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey strin wgPort := 33100 + i conf := &EngineConfig{ WgIfaceName: ifaceName, - WgAddr: resp.PeerConfig.Address, + WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address), WgPrivateKey: key, WgPort: wgPort, MTU: iface.DefaultMTU, @@ -1705,3 +1706,224 @@ func getPeers(e *Engine) int { return len(e.peerStore.PeersPubKey()) } + +func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte { + t.Helper() + b, err := netiputil.EncodePrefix(p) + require.NoError(t, err) + return b +} + +func TestEngine_hasIPv6Changed(t *testing.T) { + v4Only := wgaddr.MustParseWGAddress("100.64.0.1/16") + + v4v6 := wgaddr.MustParseWGAddress("100.64.0.1/16") + v4v6.IPv6 = netip.MustParseAddr("fd00::1") + v4v6.IPv6Net = netip.MustParsePrefix("fd00::1/64").Masked() + + tests := []struct { + name string + current wgaddr.Address + confV6 []byte + expected bool + }{ + { + name: "no v6 before, no v6 now", + current: v4Only, + confV6: nil, + expected: false, + }, + { + name: "no v6 before, v6 added", + current: v4Only, + confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::1/64")), + expected: true, + }, + { + name: "had v6, now removed", + current: v4v6, + confV6: nil, + expected: true, + }, + { + name: "had v6, same v6", + current: v4v6, + confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::1/64")), + expected: false, + }, + { + name: "had v6, different v6", + current: v4v6, + confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::2/64")), + expected: true, + }, + { + name: "same v6 addr, different prefix length", + current: v4v6, + confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::1/80")), + expected: true, + }, + { + name: "decode error keeps status quo", + current: v4Only, + confV6: []byte{1, 2, 3}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + engine := &Engine{ + config: &EngineConfig{WgAddr: tt.current}, + } + conf := &mgmtProto.PeerConfig{ + AddressV6: tt.confV6, + } + assert.Equal(t, tt.expected, engine.hasIPv6Changed(conf)) + }) + } +} + +func TestFilterAllowedIPs(t *testing.T) { + v4v6Addr := wgaddr.MustParseWGAddress("100.64.0.1/16") + v4v6Addr.IPv6 = netip.MustParseAddr("fd00::1") + v4v6Addr.IPv6Net = netip.MustParsePrefix("fd00::1/64").Masked() + + v4OnlyAddr := wgaddr.MustParseWGAddress("100.64.0.1/16") + + tests := []struct { + name string + addr wgaddr.Address + input []string + expected []string + }{ + { + name: "interface has v6, keep all", + addr: v4v6Addr, + input: []string{"100.64.0.1/32", "fd00::1/128"}, + expected: []string{"100.64.0.1/32", "fd00::1/128"}, + }, + { + name: "no v6, strip v6", + addr: v4OnlyAddr, + input: []string{"100.64.0.1/32", "fd00::1/128"}, + expected: []string{"100.64.0.1/32"}, + }, + { + name: "no v6, only v4", + addr: v4OnlyAddr, + input: []string{"100.64.0.1/32", "10.0.0.0/8"}, + expected: []string{"100.64.0.1/32", "10.0.0.0/8"}, + }, + { + name: "no v6, only v6 input", + addr: v4OnlyAddr, + input: []string{"fd00::1/128", "::/0"}, + expected: []string{}, + }, + { + name: "no v6, invalid prefix preserved", + addr: v4OnlyAddr, + input: []string{"100.64.0.1/32", "garbage"}, + expected: []string{"100.64.0.1/32", "garbage"}, + }, + { + name: "no v6, empty input", + addr: v4OnlyAddr, + input: []string{}, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr := tt.addr + engine := &Engine{ + config: &EngineConfig{}, + wgInterface: &MockWGIface{ + AddressFunc: func() wgaddr.Address { return addr }, + }, + } + result := engine.filterAllowedIPs(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestOverlayAddrsFromAllowedIPs(t *testing.T) { + ourV6Net := netip.MustParsePrefix("fd00:1234:5678:abcd::/64") + + tests := []struct { + name string + allowedIPs []string + ourV6Net netip.Prefix + wantV4 string + wantV6 string + }{ + { + name: "v4 only", + allowedIPs: []string{"100.64.0.1/32"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "", + }, + { + name: "v4 and v6 overlay", + allowedIPs: []string{"100.64.0.1/32", "fd00:1234:5678:abcd::1/128"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "fd00:1234:5678:abcd::1", + }, + { + name: "v4, routed v6, overlay v6", + allowedIPs: []string{"100.64.0.1/32", "2001:db8::1/128", "fd00:1234:5678:abcd::1/128"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "fd00:1234:5678:abcd::1", + }, + { + name: "routed v6 /128 outside our subnet is ignored", + allowedIPs: []string{"100.64.0.1/32", "2001:db8::1/128"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "", + }, + { + name: "routed v6 prefix is ignored", + allowedIPs: []string{"100.64.0.1/32", "fd00:1234:5678:abcd::/64"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "", + }, + { + name: "no v6 subnet configured", + allowedIPs: []string{"100.64.0.1/32", "fd00:1234:5678:abcd::1/128"}, + ourV6Net: netip.Prefix{}, + wantV4: "100.64.0.1", + wantV6: "", + }, + { + name: "v4 /24 route is ignored", + allowedIPs: []string{"100.64.0.0/24", "100.64.0.1/32"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v4, v6 := overlayAddrsFromAllowedIPs(tt.allowedIPs, tt.ourV6Net) + if tt.wantV4 == "" { + assert.False(t, v4.IsValid(), "expected no v4") + } else { + assert.Equal(t, tt.wantV4, v4.String(), "v4") + } + if tt.wantV6 == "" { + assert.False(t, v6.IsValid(), "expected no v6") + } else { + assert.Equal(t, tt.wantV6, v6.String(), "v6") + } + }) + } +} diff --git a/client/internal/iface_common.go b/client/internal/iface_common.go index 39e9bacfa..2eeac1954 100644 --- a/client/internal/iface_common.go +++ b/client/internal/iface_common.go @@ -26,7 +26,7 @@ type wgIfaceBase interface { Address() wgaddr.Address ToInterface() *net.Interface Up() (*udpmux.UniversalUDPMuxDefault, error) - UpdateAddr(newAddr string) error + UpdateAddr(newAddr wgaddr.Address) error GetProxy() wgproxy.Proxy GetProxyPort() uint16 UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error diff --git a/client/internal/lazyconn/activity/listener_bind.go b/client/internal/lazyconn/activity/listener_bind.go index 792d04215..60b8baadb 100644 --- a/client/internal/lazyconn/activity/listener_bind.go +++ b/client/internal/lazyconn/activity/listener_bind.go @@ -57,6 +57,7 @@ func NewBindListener(wgIface WgInterface, bind device.EndpointManager, cfg lazyc // deriveFakeIP creates a deterministic fake IP for bind mode based on peer's NetBird IP. // Maps peer IP 100.64.x.y to fake IP 127.2.x.y (similar to relay proxy using 127.1.x.y). // It finds the peer's actual NetBird IP by checking which allowedIP is in the same subnet as our WG interface. +// For IPv6-only peers, the last two bytes of the v6 address are used. func deriveFakeIP(wgIface WgInterface, allowedIPs []netip.Prefix) (netip.Addr, error) { if len(allowedIPs) == 0 { return netip.Addr{}, fmt.Errorf("no allowed IPs for peer") @@ -64,6 +65,7 @@ func deriveFakeIP(wgIface WgInterface, allowedIPs []netip.Prefix) (netip.Addr, e ourNetwork := wgIface.Address().Network + // Try v4 first (preferred: deterministic from overlay IP) var peerIP netip.Addr for _, allowedIP := range allowedIPs { ip := allowedIP.Addr() @@ -76,13 +78,24 @@ func deriveFakeIP(wgIface WgInterface, allowedIPs []netip.Prefix) (netip.Addr, e } } - if !peerIP.IsValid() { - return netip.Addr{}, fmt.Errorf("no peer NetBird IP found in allowed IPs") + if peerIP.IsValid() { + octets := peerIP.As4() + return netip.AddrFrom4([4]byte{127, 2, octets[2], octets[3]}), nil } - octets := peerIP.As4() - fakeIP := netip.AddrFrom4([4]byte{127, 2, octets[2], octets[3]}) - return fakeIP, nil + // Fallback: use last two bytes of first v6 overlay IP + addr := wgIface.Address() + if addr.IPv6Net.IsValid() { + for _, allowedIP := range allowedIPs { + ip := allowedIP.Addr() + if ip.Is6() && addr.IPv6Net.Contains(ip) { + raw := ip.As16() + return netip.AddrFrom4([4]byte{127, 2, raw[14], raw[15]}), nil + } + } + } + + return netip.Addr{}, fmt.Errorf("no peer NetBird IP found in allowed IPs") } func (d *BindListener) setupLazyConn() error { diff --git a/client/internal/listener/network_change.go b/client/internal/listener/network_change.go index 08bf5fd52..e0aa43abe 100644 --- a/client/internal/listener/network_change.go +++ b/client/internal/listener/network_change.go @@ -5,4 +5,5 @@ type NetworkChangeListener interface { // OnNetworkChanged invoke when network settings has been changed OnNetworkChanged(string) SetInterfaceIP(string) + SetInterfaceIPv6(string) } diff --git a/client/internal/netflow/conntrack/conntrack.go b/client/internal/netflow/conntrack/conntrack.go index 2420b1fdf..6f1da5138 100644 --- a/client/internal/netflow/conntrack/conntrack.go +++ b/client/internal/netflow/conntrack/conntrack.go @@ -316,7 +316,7 @@ func (c *ConnTrack) handleEvent(event nfct.Event) { case nftypes.TCP, nftypes.UDP, nftypes.SCTP: srcPort = flow.TupleOrig.Proto.SourcePort dstPort = flow.TupleOrig.Proto.DestinationPort - case nftypes.ICMP: + case nftypes.ICMP, nftypes.ICMPv6: icmpType = flow.TupleOrig.Proto.ICMPType icmpCode = flow.TupleOrig.Proto.ICMPCode } @@ -359,8 +359,14 @@ func (c *ConnTrack) relevantFlow(mark uint32, srcIP, dstIP netip.Addr) bool { } // fallback if mark rules are not in place - wgnet := c.iface.Address().Network - return wgnet.Contains(srcIP) || wgnet.Contains(dstIP) + addr := c.iface.Address() + if addr.Network.Contains(srcIP) || addr.Network.Contains(dstIP) { + return true + } + if addr.IPv6Net.IsValid() { + return addr.IPv6Net.Contains(srcIP) || addr.IPv6Net.Contains(dstIP) + } + return false } // mapRxPackets maps packet counts to RX based on flow direction @@ -419,17 +425,16 @@ func (c *ConnTrack) inferDirection(mark uint32, srcIP, dstIP netip.Addr) nftypes } // fallback if marks are not set - wgaddr := c.iface.Address().IP - wgnetwork := c.iface.Address().Network + addr := c.iface.Address() switch { - case wgaddr == srcIP: + case addr.IP == srcIP || (addr.IPv6.IsValid() && addr.IPv6 == srcIP): return nftypes.Egress - case wgaddr == dstIP: + case addr.IP == dstIP || (addr.IPv6.IsValid() && addr.IPv6 == dstIP): return nftypes.Ingress - case wgnetwork.Contains(srcIP): + case addr.Network.Contains(srcIP) || (addr.IPv6Net.IsValid() && addr.IPv6Net.Contains(srcIP)): // netbird network -> resource network return nftypes.Ingress - case wgnetwork.Contains(dstIP): + case addr.Network.Contains(dstIP) || (addr.IPv6Net.IsValid() && addr.IPv6Net.Contains(dstIP)): // resource network -> netbird network return nftypes.Egress } diff --git a/client/internal/netflow/logger/logger.go b/client/internal/netflow/logger/logger.go index a033a2a7c..8f8e68784 100644 --- a/client/internal/netflow/logger/logger.go +++ b/client/internal/netflow/logger/logger.go @@ -24,15 +24,17 @@ type Logger struct { cancel context.CancelFunc statusRecorder *peer.Status wgIfaceNet netip.Prefix + wgIfaceNetV6 netip.Prefix dnsCollection atomic.Bool exitNodeCollection atomic.Bool Store types.Store } -func New(statusRecorder *peer.Status, wgIfaceIPNet netip.Prefix) *Logger { +func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix) *Logger { return &Logger{ statusRecorder: statusRecorder, wgIfaceNet: wgIfaceIPNet, + wgIfaceNetV6: wgIfaceIPNetV6, Store: store.NewMemoryStore(), } } @@ -88,11 +90,11 @@ func (l *Logger) startReceiver() { var isSrcExitNode bool var isDestExitNode bool - if !l.wgIfaceNet.Contains(event.SourceIP) { + if !l.isOverlayIP(event.SourceIP) { event.SourceResourceID, isSrcExitNode = l.statusRecorder.CheckRoutes(event.SourceIP) } - if !l.wgIfaceNet.Contains(event.DestIP) { + if !l.isOverlayIP(event.DestIP) { event.DestResourceID, isDestExitNode = l.statusRecorder.CheckRoutes(event.DestIP) } @@ -136,6 +138,10 @@ func (l *Logger) UpdateConfig(dnsCollection, exitNodeCollection bool) { l.exitNodeCollection.Store(exitNodeCollection) } +func (l *Logger) isOverlayIP(ip netip.Addr) bool { + return l.wgIfaceNet.Contains(ip) || (l.wgIfaceNetV6.IsValid() && l.wgIfaceNetV6.Contains(ip)) +} + func (l *Logger) shouldStore(event *types.EventFields, isExitNode bool) bool { // check dns collection if !l.dnsCollection.Load() && event.Protocol == types.UDP && diff --git a/client/internal/netflow/logger/logger_test.go b/client/internal/netflow/logger/logger_test.go index 1144544d8..ad2eedef2 100644 --- a/client/internal/netflow/logger/logger_test.go +++ b/client/internal/netflow/logger/logger_test.go @@ -12,7 +12,7 @@ import ( ) func TestStore(t *testing.T) { - logger := logger.New(nil, netip.Prefix{}) + logger := logger.New(nil, netip.Prefix{}, netip.Prefix{}) logger.Enable() event := types.EventFields{ diff --git a/client/internal/netflow/manager.go b/client/internal/netflow/manager.go index 7752c97b0..eff083dbf 100644 --- a/client/internal/netflow/manager.go +++ b/client/internal/netflow/manager.go @@ -35,11 +35,12 @@ type Manager struct { // NewManager creates a new netflow manager func NewManager(iface nftypes.IFaceMapper, publicKey []byte, statusRecorder *peer.Status) *Manager { - var prefix netip.Prefix + var prefix, prefixV6 netip.Prefix if iface != nil { prefix = iface.Address().Network + prefixV6 = iface.Address().IPv6Net } - flowLogger := logger.New(statusRecorder, prefix) + flowLogger := logger.New(statusRecorder, prefix, prefixV6) var ct nftypes.ConnTracker if runtime.GOOS == "linux" && iface != nil && !iface.IsUserspaceBind() { @@ -269,7 +270,7 @@ func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent { }, } - if event.Protocol == nftypes.ICMP { + if event.Protocol == nftypes.ICMP || event.Protocol == nftypes.ICMPv6 { protoEvent.FlowFields.ConnectionInfo = &proto.FlowFields_IcmpInfo{ IcmpInfo: &proto.ICMPInfo{ IcmpType: uint32(event.ICMPType), diff --git a/client/internal/netflow/types/types.go b/client/internal/netflow/types/types.go index f76146ba3..3f7d0d0ad 100644 --- a/client/internal/netflow/types/types.go +++ b/client/internal/netflow/types/types.go @@ -19,6 +19,7 @@ const ( ICMP = Protocol(1) TCP = Protocol(6) UDP = Protocol(17) + ICMPv6 = Protocol(58) SCTP = Protocol(132) ) @@ -30,6 +31,8 @@ func (p Protocol) String() string { return "TCP" case 17: return "UDP" + case 58: + return "ICMPv6" case 132: return "SCTP" default: diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index e8e61f660..df746fa13 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -53,6 +53,7 @@ type RouterState struct { type State struct { Mux *sync.RWMutex IP string + IPv6 string PubKey string FQDN string ConnStatus ConnStatus @@ -106,6 +107,7 @@ func (s *State) GetRoutes() map[string]struct{} { // LocalPeerState contains the latest state of the local peer type LocalPeerState struct { IP string + IPv6 string PubKey string KernelInterface bool FQDN string @@ -259,7 +261,7 @@ func (d *Status) ReplaceOfflinePeers(replacement []State) { } // AddPeer adds peer to Daemon status map -func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string) error { +func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string) error { d.mux.Lock() defer d.mux.Unlock() @@ -270,6 +272,7 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string) error { d.peers[peerPubKey] = State{ PubKey: peerPubKey, IP: ip, + IPv6: ipv6, ConnStatus: StatusIdle, FQDN: fqdn, Mux: new(sync.RWMutex), @@ -710,6 +713,9 @@ func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) { d.localPeer = localPeerState fqdn := d.localPeer.FQDN ip := d.localPeer.IP + if d.localPeer.IPv6 != "" { + ip = ip + "\n" + d.localPeer.IPv6 + } d.mux.Unlock() d.notifier.localAddressChanged(fqdn, ip) @@ -1316,6 +1322,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus { } pbFullStatus.LocalPeerState.IP = fs.LocalPeerState.IP + pbFullStatus.LocalPeerState.Ipv6 = fs.LocalPeerState.IPv6 pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN @@ -1331,6 +1338,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus { pbPeerState := &proto.PeerState{ IP: peerState.IP, + Ipv6: peerState.IPv6, PubKey: peerState.PubKey, ConnStatus: peerState.ConnStatus.String(), ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate), diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 272638750..9bafca55a 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -8,19 +8,20 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAddPeer(t *testing.T) { key := "abc" ip := "100.108.254.1" status := NewRecorder("https://mgm") - err := status.AddPeer(key, "abc.netbird", ip) + err := status.AddPeer(key, "abc.netbird", ip, "") assert.NoError(t, err, "shouldn't return error") _, exists := status.peers[key] assert.True(t, exists, "value was found") - err = status.AddPeer(key, "abc.netbird", ip) + err = status.AddPeer(key, "abc.netbird", ip, "") assert.Error(t, err, "should return error on duplicate") } @@ -29,7 +30,7 @@ func TestGetPeer(t *testing.T) { key := "abc" ip := "100.108.254.1" status := NewRecorder("https://mgm") - err := status.AddPeer(key, "abc.netbird", ip) + err := status.AddPeer(key, "abc.netbird", ip, "") assert.NoError(t, err, "shouldn't return error") peerStatus, err := status.GetPeer(key) @@ -46,7 +47,7 @@ func TestUpdatePeerState(t *testing.T) { ip := "10.10.10.10" fqdn := "peer-a.netbird.local" status := NewRecorder("https://mgm") - _ = status.AddPeer(key, fqdn, ip) + require.NoError(t, status.AddPeer(key, fqdn, ip, "")) peerState := State{ PubKey: key, @@ -85,7 +86,7 @@ func TestGetPeerStateChangeNotifierLogic(t *testing.T) { key := "abc" ip := "10.10.10.10" status := NewRecorder("https://mgm") - _ = status.AddPeer(key, "abc.netbird", ip) + _ = status.AddPeer(key, "abc.netbird", ip, "") sub := status.SubscribeToPeerStateChanges(context.Background(), key) assert.NotNil(t, sub, "channel shouldn't be nil") diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 20c615d57..cd5bc0680 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "net" "net/url" "os" "os/user" @@ -89,6 +90,7 @@ type ConfigInput struct { DisableFirewall *bool BlockLANAccess *bool BlockInbound *bool + DisableIPv6 *bool DisableNotifications *bool @@ -127,6 +129,7 @@ type Config struct { DisableFirewall bool BlockLANAccess bool BlockInbound bool + DisableIPv6 bool DisableNotifications *bool @@ -542,6 +545,12 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.DisableIPv6 != nil && *input.DisableIPv6 != config.DisableIPv6 { + log.Infof("setting IPv6 overlay disabled=%v", *input.DisableIPv6) + config.DisableIPv6 = *input.DisableIPv6 + updated = true + } + if input.DisableNotifications != nil && input.DisableNotifications != config.DisableNotifications { if *input.DisableNotifications { log.Infof("disabling notifications") @@ -751,8 +760,7 @@ func UpdateOldManagementURL(ctx context.Context, config *Config, configPath stri return config, nil } - newURL, err := parseURL("Management URL", fmt.Sprintf("%s://%s:%d", - config.ManagementURL.Scheme, defaultManagementURL.Hostname(), 443)) + newURL, err := parseURL("Management URL", fmt.Sprintf("%s://%s", config.ManagementURL.Scheme, net.JoinHostPort(defaultManagementURL.Hostname(), "443"))) if err != nil { return nil, err } diff --git a/client/internal/relay/relay.go b/client/internal/relay/relay.go index 59be5b0a7..f00a8d93a 100644 --- a/client/internal/relay/relay.go +++ b/client/internal/relay/relay.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net" + "strconv" "sync" "time" @@ -257,7 +258,7 @@ func (p *StunTurnProbe) probeTURN(ctx context.Context, uri *stun.URI) (addr stri } }() - turnServerAddr := fmt.Sprintf("%s:%d", uri.Host, uri.Port) + turnServerAddr := net.JoinHostPort(uri.Host, strconv.Itoa(uri.Port)) var conn net.PacketConn switch uri.Proto { diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go index 1faa22dc5..11cda8dbc 100644 --- a/client/internal/rosenpass/manager.go +++ b/client/internal/rosenpass/manager.go @@ -75,7 +75,7 @@ func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuar if err != nil { return fmt.Errorf("failed to parse rosenpass address: %w", err) } - peerAddr := fmt.Sprintf("%s:%s", wireGuardIP, strPort) + peerAddr := net.JoinHostPort(wireGuardIP, strPort) if pcfg.Endpoint, err = net.ResolveUDPAddr("udp", peerAddr); err != nil { return fmt.Errorf("failed to resolve peer endpoint address: %w", err) } @@ -259,6 +259,9 @@ func findRandomAvailableUDPPort() (int, error) { } defer conn.Close() - splitAddress := strings.Split(conn.LocalAddr().String(), ":") - return strconv.Atoi(splitAddress[len(splitAddress)-1]) + _, portStr, err := net.SplitHostPort(conn.LocalAddr().String()) + if err != nil { + return 0, fmt.Errorf("parse local address %s: %w", conn.LocalAddr(), err) + } + return strconv.Atoi(portStr) } diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go new file mode 100644 index 000000000..90bbdda59 --- /dev/null +++ b/client/internal/rosenpass/manager_test.go @@ -0,0 +1,14 @@ +package rosenpass + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFindRandomAvailableUDPPort(t *testing.T) { + port, err := findRandomAvailableUDPPort() + require.NoError(t, err) + require.Greater(t, port, 0) + require.LessOrEqual(t, port, 65535) +} diff --git a/client/internal/routemanager/client/client.go b/client/internal/routemanager/client/client.go index e6ef8b876..c691c54f8 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -3,9 +3,8 @@ package client import ( "context" "fmt" - "net" + "net/netip" "reflect" - "strconv" "time" log "github.com/sirupsen/logrus" @@ -566,7 +565,7 @@ func HandlerFromRoute(params common.HandlerParams) RouteHandler { return dnsinterceptor.New(params) case handlerTypeDynamic: dns := nbdns.NewServiceViaMemory(params.WgInterface) - dnsAddr := net.JoinHostPort(dns.RuntimeIP().String(), strconv.Itoa(dns.RuntimePort())) + dnsAddr := netip.AddrPortFrom(dns.RuntimeIP(), uint16(dns.RuntimePort())) return dynamic.NewRoute(params, dnsAddr) default: return static.NewRoute(params) diff --git a/client/internal/routemanager/client/client_bench_test.go b/client/internal/routemanager/client/client_bench_test.go index 591042ac5..668aec427 100644 --- a/client/internal/routemanager/client/client_bench_test.go +++ b/client/internal/routemanager/client/client_bench_test.go @@ -46,7 +46,7 @@ func generateBenchmarkData(tier benchmarkTier) (*peer.Status, map[route.ID]*rout fqdn := fmt.Sprintf("peer-%d.example.com", i) ip := fmt.Sprintf("10.0.%d.%d", i/256, i%256) - err := statusRecorder.AddPeer(peerKey, fqdn, ip) + err := statusRecorder.AddPeer(peerKey, fqdn, ip, "") if err != nil { panic(fmt.Sprintf("failed to add peer: %v", err)) } diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index 64f2a8789..e25cc2a5c 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -582,7 +582,7 @@ func (d *DnsInterceptor) queryUpstreamDNS(ctx context.Context, w dns.ResponseWri if nsNet != nil { reply, err = nbdns.ExchangeWithNetstack(ctx, nsNet, r, upstream) } else { - client, clientErr := nbdns.GetClientPrivate(d.wgInterface.Address().IP, d.wgInterface.Name(), dnsTimeout) + client, clientErr := nbdns.GetClientPrivate(d.wgInterface, upstreamIP, dnsTimeout) if clientErr != nil { d.writeDNSError(w, r, logger, fmt.Sprintf("create DNS client: %v", clientErr)) return nil diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go index 8d1398a7a..f0efd7b22 100644 --- a/client/internal/routemanager/dynamic/route.go +++ b/client/internal/routemanager/dynamic/route.go @@ -50,10 +50,10 @@ type Route struct { cancel context.CancelFunc statusRecorder *peer.Status wgInterface iface.WGIface - resolverAddr string + resolverAddr netip.AddrPort } -func NewRoute(params common.HandlerParams, resolverAddr string) *Route { +func NewRoute(params common.HandlerParams, resolverAddr netip.AddrPort) *Route { return &Route{ route: params.Route, routeRefCounter: params.RouteRefCounter, diff --git a/client/internal/routemanager/dynamic/route_ios.go b/client/internal/routemanager/dynamic/route_ios.go index 8fed1c8f9..1ae281d56 100644 --- a/client/internal/routemanager/dynamic/route_ios.go +++ b/client/internal/routemanager/dynamic/route_ios.go @@ -17,37 +17,47 @@ import ( const dialTimeout = 10 * time.Second func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { - privateClient, err := nbdns.GetClientPrivate(r.wgInterface.Address().IP, r.wgInterface.Name(), dialTimeout) + privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout) if err != nil { return nil, fmt.Errorf("error while creating private client: %s", err) } - msg := new(dns.Msg) - msg.SetQuestion(dns.Fqdn(domain.PunycodeString()), dns.TypeA) - + fqdn := dns.Fqdn(domain.PunycodeString()) startTime := time.Now() - response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr) - if err != nil { - return nil, fmt.Errorf("DNS query for %s failed after %s: %s ", domain.SafeString(), time.Since(startTime), err) - } + var ips []net.IP + var queryErr error - if response.Rcode != dns.RcodeSuccess { - return nil, fmt.Errorf("dns response code: %s", dns.RcodeToString[response.Rcode]) - } + for _, qtype := range []uint16{dns.TypeA, dns.TypeAAAA} { + msg := new(dns.Msg) + msg.SetQuestion(fqdn, qtype) - ips := make([]net.IP, 0) - - for _, answ := range response.Answer { - if aRecord, ok := answ.(*dns.A); ok { - ips = append(ips, aRecord.A) + response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String()) + if err != nil { + if queryErr == nil { + queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err) + } + continue } - if aaaaRecord, ok := answ.(*dns.AAAA); ok { - ips = append(ips, aaaaRecord.AAAA) + + if response.Rcode != dns.RcodeSuccess { + continue + } + + for _, answ := range response.Answer { + if aRecord, ok := answ.(*dns.A); ok { + ips = append(ips, aRecord.A) + } + if aaaaRecord, ok := answ.(*dns.AAAA); ok { + ips = append(ips, aaaaRecord.AAAA) + } } } if len(ips) == 0 { + if queryErr != nil { + return nil, queryErr + } return nil, fmt.Errorf("no A or AAAA records found for %s", domain.SafeString()) } diff --git a/client/internal/routemanager/fakeip/fakeip.go b/client/internal/routemanager/fakeip/fakeip.go index 1592045d2..5be4ca12e 100644 --- a/client/internal/routemanager/fakeip/fakeip.go +++ b/client/internal/routemanager/fakeip/fakeip.go @@ -1,93 +1,145 @@ package fakeip import ( + "errors" "fmt" "net/netip" "sync" ) -// Manager manages allocation of fake IPs from the 240.0.0.0/8 block -type Manager struct { - mu sync.Mutex - nextIP netip.Addr // Next IP to allocate +var ( + // 240.0.0.1 - 240.255.255.254, block 240.0.0.0/8 (reserved, RFC 1112) + v4Base = netip.AddrFrom4([4]byte{240, 0, 0, 1}) + v4Max = netip.AddrFrom4([4]byte{240, 255, 255, 254}) + v4Block = netip.PrefixFrom(netip.AddrFrom4([4]byte{240, 0, 0, 0}), 8) + + // 0100::1 - 0100::ffff:ffff:ffff:fffe, block 0100::/64 (discard, RFC 6666) + v6Base = netip.AddrFrom16([16]byte{0x01, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01}) + v6Max = netip.AddrFrom16([16]byte{0x01, 0x00, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe}) + v6Block = netip.PrefixFrom(netip.AddrFrom16([16]byte{0x01, 0x00}), 64) +) + +// fakeIPPool holds the allocation state for a single address family. +type fakeIPPool struct { + nextIP netip.Addr + baseIP netip.Addr + maxIP netip.Addr + block netip.Prefix allocated map[netip.Addr]netip.Addr // real IP -> fake IP fakeToReal map[netip.Addr]netip.Addr // fake IP -> real IP - baseIP netip.Addr // First usable IP: 240.0.0.1 - maxIP netip.Addr // Last usable IP: 240.255.255.254 } -// NewManager creates a new fake IP manager using 240.0.0.0/8 block -func NewManager() *Manager { - baseIP := netip.AddrFrom4([4]byte{240, 0, 0, 1}) - maxIP := netip.AddrFrom4([4]byte{240, 255, 255, 254}) - - return &Manager{ - nextIP: baseIP, +func newPool(base, maxAddr netip.Addr, block netip.Prefix) *fakeIPPool { + return &fakeIPPool{ + nextIP: base, + baseIP: base, + maxIP: maxAddr, + block: block, allocated: make(map[netip.Addr]netip.Addr), fakeToReal: make(map[netip.Addr]netip.Addr), - baseIP: baseIP, - maxIP: maxIP, } } -// AllocateFakeIP allocates a fake IP for the given real IP -// Returns the fake IP, or existing fake IP if already allocated -func (m *Manager) AllocateFakeIP(realIP netip.Addr) (netip.Addr, error) { - if !realIP.Is4() { - return netip.Addr{}, fmt.Errorf("only IPv4 addresses supported") - } - - m.mu.Lock() - defer m.mu.Unlock() - - if fakeIP, exists := m.allocated[realIP]; exists { +// allocate allocates a fake IP for the given real IP. +// Returns the existing fake IP if already allocated. +func (p *fakeIPPool) allocate(realIP netip.Addr) (netip.Addr, error) { + if fakeIP, exists := p.allocated[realIP]; exists { return fakeIP, nil } - startIP := m.nextIP + startIP := p.nextIP for { - currentIP := m.nextIP + currentIP := p.nextIP // Advance to next IP, wrapping at boundary - if m.nextIP.Compare(m.maxIP) >= 0 { - m.nextIP = m.baseIP + if p.nextIP.Compare(p.maxIP) >= 0 { + p.nextIP = p.baseIP } else { - m.nextIP = m.nextIP.Next() + p.nextIP = p.nextIP.Next() } - // Check if current IP is available - if _, inUse := m.fakeToReal[currentIP]; !inUse { - m.allocated[realIP] = currentIP - m.fakeToReal[currentIP] = realIP + if _, inUse := p.fakeToReal[currentIP]; !inUse { + p.allocated[realIP] = currentIP + p.fakeToReal[currentIP] = realIP return currentIP, nil } - // Prevent infinite loop if all IPs exhausted - if m.nextIP.Compare(startIP) == 0 { - return netip.Addr{}, fmt.Errorf("no more fake IPs available in 240.0.0.0/8 block") + if p.nextIP.Compare(startIP) == 0 { + return netip.Addr{}, fmt.Errorf("no more fake IPs available in %s block", p.block) } } } -// GetFakeIP returns the fake IP for a real IP if it exists +// Manager manages allocation of fake IPs for dynamic DNS routes. +// IPv4 uses 240.0.0.0/8 (reserved), IPv6 uses 0100::/64 (discard, RFC 6666). +type Manager struct { + mu sync.Mutex + v4 *fakeIPPool + v6 *fakeIPPool +} + +// NewManager creates a new fake IP manager. +func NewManager() *Manager { + return &Manager{ + v4: newPool(v4Base, v4Max, v4Block), + v6: newPool(v6Base, v6Max, v6Block), + } +} + +func (m *Manager) pool(ip netip.Addr) *fakeIPPool { + if ip.Is6() { + return m.v6 + } + return m.v4 +} + +// AllocateFakeIP allocates a fake IP for the given real IP. +func (m *Manager) AllocateFakeIP(realIP netip.Addr) (netip.Addr, error) { + realIP = realIP.Unmap() + if !realIP.IsValid() { + return netip.Addr{}, errors.New("invalid IP address") + } + + m.mu.Lock() + defer m.mu.Unlock() + + return m.pool(realIP).allocate(realIP) +} + +// GetFakeIP returns the fake IP for a real IP if it exists. func (m *Manager) GetFakeIP(realIP netip.Addr) (netip.Addr, bool) { + realIP = realIP.Unmap() + if !realIP.IsValid() { + return netip.Addr{}, false + } + m.mu.Lock() defer m.mu.Unlock() - fakeIP, exists := m.allocated[realIP] - return fakeIP, exists + fakeIP, ok := m.pool(realIP).allocated[realIP] + return fakeIP, ok } -// GetRealIP returns the real IP for a fake IP if it exists, otherwise false +// GetRealIP returns the real IP for a fake IP if it exists. func (m *Manager) GetRealIP(fakeIP netip.Addr) (netip.Addr, bool) { + fakeIP = fakeIP.Unmap() + if !fakeIP.IsValid() { + return netip.Addr{}, false + } + m.mu.Lock() defer m.mu.Unlock() - realIP, exists := m.fakeToReal[fakeIP] - return realIP, exists + realIP, ok := m.pool(fakeIP).fakeToReal[fakeIP] + return realIP, ok } -// GetFakeIPBlock returns the fake IP block used by this manager +// GetFakeIPBlock returns the v4 fake IP block used by this manager. func (m *Manager) GetFakeIPBlock() netip.Prefix { - return netip.MustParsePrefix("240.0.0.0/8") + return m.v4.block +} + +// GetFakeIPv6Block returns the v6 fake IP block used by this manager. +func (m *Manager) GetFakeIPv6Block() netip.Prefix { + return m.v6.block } diff --git a/client/internal/routemanager/fakeip/fakeip_test.go b/client/internal/routemanager/fakeip/fakeip_test.go index ad3e4bd4e..f554f970d 100644 --- a/client/internal/routemanager/fakeip/fakeip_test.go +++ b/client/internal/routemanager/fakeip/fakeip_test.go @@ -9,16 +9,16 @@ import ( func TestNewManager(t *testing.T) { manager := NewManager() - if manager.baseIP.String() != "240.0.0.1" { - t.Errorf("Expected base IP 240.0.0.1, got %s", manager.baseIP.String()) + if manager.v4.baseIP.String() != "240.0.0.1" { + t.Errorf("Expected v4 base IP 240.0.0.1, got %s", manager.v4.baseIP.String()) } - if manager.maxIP.String() != "240.255.255.254" { - t.Errorf("Expected max IP 240.255.255.254, got %s", manager.maxIP.String()) + if manager.v4.maxIP.String() != "240.255.255.254" { + t.Errorf("Expected v4 max IP 240.255.255.254, got %s", manager.v4.maxIP.String()) } - if manager.nextIP.Compare(manager.baseIP) != 0 { - t.Errorf("Expected nextIP to start at baseIP") + if manager.v6.baseIP.String() != "100::1" { + t.Errorf("Expected v6 base IP 100::1, got %s", manager.v6.baseIP.String()) } } @@ -35,7 +35,6 @@ func TestAllocateFakeIP(t *testing.T) { t.Error("Fake IP should be IPv4") } - // Check it's in the correct range if fakeIP.As4()[0] != 240 { t.Errorf("Fake IP should be in 240.0.0.0/8 range, got %s", fakeIP.String()) } @@ -51,13 +50,31 @@ func TestAllocateFakeIP(t *testing.T) { } } -func TestAllocateFakeIPIPv6Rejection(t *testing.T) { +func TestAllocateFakeIPv6(t *testing.T) { manager := NewManager() - realIPv6 := netip.MustParseAddr("2001:db8::1") + realIP := netip.MustParseAddr("2001:db8::1") - _, err := manager.AllocateFakeIP(realIPv6) - if err == nil { - t.Error("Expected error for IPv6 address") + fakeIP, err := manager.AllocateFakeIP(realIP) + if err != nil { + t.Fatalf("Failed to allocate fake IPv6: %v", err) + } + + if !fakeIP.Is6() { + t.Error("Fake IP should be IPv6") + } + + if !netip.MustParsePrefix("100::/64").Contains(fakeIP) { + t.Errorf("Fake IP should be in 100::/64 range, got %s", fakeIP.String()) + } + + // Should return same fake IP for same real IP + fakeIP2, err := manager.AllocateFakeIP(realIP) + if err != nil { + t.Fatalf("Failed to get existing fake IPv6: %v", err) + } + + if fakeIP.Compare(fakeIP2) != 0 { + t.Errorf("Expected same fake IP, got %s and %s", fakeIP.String(), fakeIP2.String()) } } @@ -65,13 +82,11 @@ func TestGetFakeIP(t *testing.T) { manager := NewManager() realIP := netip.MustParseAddr("1.1.1.1") - // Should not exist initially _, exists := manager.GetFakeIP(realIP) if exists { t.Error("Fake IP should not exist before allocation") } - // Allocate and check expectedFakeIP, err := manager.AllocateFakeIP(realIP) if err != nil { t.Fatalf("Failed to allocate: %v", err) @@ -87,12 +102,30 @@ func TestGetFakeIP(t *testing.T) { } } +func TestGetRealIPv6(t *testing.T) { + manager := NewManager() + realIP := netip.MustParseAddr("2001:db8::1") + + fakeIP, err := manager.AllocateFakeIP(realIP) + if err != nil { + t.Fatalf("Failed to allocate: %v", err) + } + + gotReal, exists := manager.GetRealIP(fakeIP) + if !exists { + t.Error("Real IP should exist for allocated fake IP") + } + + if gotReal.Compare(realIP) != 0 { + t.Errorf("Expected real IP %s, got %s", realIP, gotReal) + } +} + func TestMultipleAllocations(t *testing.T) { manager := NewManager() allocations := make(map[netip.Addr]netip.Addr) - // Allocate multiple IPs for i := 1; i <= 100; i++ { realIP := netip.AddrFrom4([4]byte{10, 0, byte(i / 256), byte(i % 256)}) fakeIP, err := manager.AllocateFakeIP(realIP) @@ -100,7 +133,6 @@ func TestMultipleAllocations(t *testing.T) { t.Fatalf("Failed to allocate fake IP for %s: %v", realIP.String(), err) } - // Check for duplicates for _, existingFake := range allocations { if fakeIP.Compare(existingFake) == 0 { t.Errorf("Duplicate fake IP allocated: %s", fakeIP.String()) @@ -110,7 +142,6 @@ func TestMultipleAllocations(t *testing.T) { allocations[realIP] = fakeIP } - // Verify all allocations can be retrieved for realIP, expectedFake := range allocations { actualFake, exists := manager.GetFakeIP(realIP) if !exists { @@ -124,11 +155,13 @@ func TestMultipleAllocations(t *testing.T) { func TestGetFakeIPBlock(t *testing.T) { manager := NewManager() - block := manager.GetFakeIPBlock() - expected := "240.0.0.0/8" - if block.String() != expected { - t.Errorf("Expected %s, got %s", expected, block.String()) + if block := manager.GetFakeIPBlock(); block.String() != "240.0.0.0/8" { + t.Errorf("Expected 240.0.0.0/8, got %s", block.String()) + } + + if block := manager.GetFakeIPv6Block(); block.String() != "100::/64" { + t.Errorf("Expected 100::/64, got %s", block.String()) } } @@ -141,7 +174,6 @@ func TestConcurrentAccess(t *testing.T) { var wg sync.WaitGroup results := make(chan netip.Addr, numGoroutines*allocationsPerGoroutine) - // Concurrent allocations for i := 0; i < numGoroutines; i++ { wg.Add(1) go func(goroutineID int) { @@ -161,7 +193,6 @@ func TestConcurrentAccess(t *testing.T) { wg.Wait() close(results) - // Check for duplicates seen := make(map[netip.Addr]bool) count := 0 for fakeIP := range results { @@ -178,47 +209,61 @@ func TestConcurrentAccess(t *testing.T) { } func TestIPExhaustion(t *testing.T) { - // Create a manager with limited range for testing manager := &Manager{ - nextIP: netip.AddrFrom4([4]byte{240, 0, 0, 1}), - allocated: make(map[netip.Addr]netip.Addr), - fakeToReal: make(map[netip.Addr]netip.Addr), - baseIP: netip.AddrFrom4([4]byte{240, 0, 0, 1}), - maxIP: netip.AddrFrom4([4]byte{240, 0, 0, 3}), // Only 3 IPs available + v4: newPool( + netip.AddrFrom4([4]byte{240, 0, 0, 1}), + netip.AddrFrom4([4]byte{240, 0, 0, 3}), + netip.MustParsePrefix("240.0.0.0/8"), + ), + v6: newPool( + netip.MustParseAddr("100::1"), + netip.MustParseAddr("100::3"), + netip.MustParsePrefix("100::/64"), + ), } - // Allocate all available IPs - realIPs := []netip.Addr{ - netip.MustParseAddr("1.0.0.1"), - netip.MustParseAddr("1.0.0.2"), - netip.MustParseAddr("1.0.0.3"), - } - - for _, realIP := range realIPs { - _, err := manager.AllocateFakeIP(realIP) + for _, realIP := range []string{"1.0.0.1", "1.0.0.2", "1.0.0.3"} { + _, err := manager.AllocateFakeIP(netip.MustParseAddr(realIP)) if err != nil { t.Fatalf("Failed to allocate fake IP: %v", err) } } - // Try to allocate one more - should fail _, err := manager.AllocateFakeIP(netip.MustParseAddr("1.0.0.4")) if err == nil { - t.Error("Expected exhaustion error") + t.Error("Expected v4 exhaustion error") + } + + // Same for v6 + for _, realIP := range []string{"2001:db8::1", "2001:db8::2", "2001:db8::3"} { + _, err := manager.AllocateFakeIP(netip.MustParseAddr(realIP)) + if err != nil { + t.Fatalf("Failed to allocate fake IPv6: %v", err) + } + } + + _, err = manager.AllocateFakeIP(netip.MustParseAddr("2001:db8::4")) + if err == nil { + t.Error("Expected v6 exhaustion error") } } func TestWrapAround(t *testing.T) { - // Create manager starting near the end of range manager := &Manager{ - nextIP: netip.AddrFrom4([4]byte{240, 0, 0, 254}), - allocated: make(map[netip.Addr]netip.Addr), - fakeToReal: make(map[netip.Addr]netip.Addr), - baseIP: netip.AddrFrom4([4]byte{240, 0, 0, 1}), - maxIP: netip.AddrFrom4([4]byte{240, 0, 0, 254}), + v4: newPool( + netip.AddrFrom4([4]byte{240, 0, 0, 1}), + netip.AddrFrom4([4]byte{240, 0, 0, 254}), + netip.MustParsePrefix("240.0.0.0/8"), + ), + v6: newPool( + netip.MustParseAddr("100::1"), + netip.MustParseAddr("100::ffff:ffff:ffff:fffe"), + netip.MustParsePrefix("100::/64"), + ), } + // Start near the end + manager.v4.nextIP = netip.AddrFrom4([4]byte{240, 0, 0, 254}) - // Allocate the last IP fakeIP1, err := manager.AllocateFakeIP(netip.MustParseAddr("1.0.0.1")) if err != nil { t.Fatalf("Failed to allocate first IP: %v", err) @@ -228,7 +273,6 @@ func TestWrapAround(t *testing.T) { t.Errorf("Expected 240.0.0.254, got %s", fakeIP1.String()) } - // Next allocation should wrap around to the beginning fakeIP2, err := manager.AllocateFakeIP(netip.MustParseAddr("1.0.0.2")) if err != nil { t.Fatalf("Failed to allocate second IP: %v", err) @@ -238,3 +282,32 @@ func TestWrapAround(t *testing.T) { t.Errorf("Expected 240.0.0.1 after wrap, got %s", fakeIP2.String()) } } + +func TestMixedV4V6(t *testing.T) { + manager := NewManager() + + v4Fake, err := manager.AllocateFakeIP(netip.MustParseAddr("8.8.8.8")) + if err != nil { + t.Fatalf("Failed to allocate v4: %v", err) + } + + v6Fake, err := manager.AllocateFakeIP(netip.MustParseAddr("2001:db8::1")) + if err != nil { + t.Fatalf("Failed to allocate v6: %v", err) + } + + if !v4Fake.Is4() || !v6Fake.Is6() { + t.Errorf("Wrong families: v4=%s v6=%s", v4Fake, v6Fake) + } + + // Reverse lookups should work for both + gotV4, ok := manager.GetRealIP(v4Fake) + if !ok || gotV4.String() != "8.8.8.8" { + t.Errorf("v4 reverse lookup failed: got %s, ok=%v", gotV4, ok) + } + + gotV6, ok := manager.GetRealIP(v6Fake) + if !ok || gotV6.String() != "2001:db8::1" { + t.Errorf("v6 reverse lookup failed: got %s, ok=%v", gotV6, ok) + } +} diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate.go b/client/internal/routemanager/ipfwdstate/ipfwdstate.go index da81c18f9..2be1c2ae7 100644 --- a/client/internal/routemanager/ipfwdstate/ipfwdstate.go +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate.go @@ -9,7 +9,11 @@ import ( ) // IPForwardingState is a struct that keeps track of the IP forwarding state. -// todo: read initial state of the IP forwarding from the system and reset the state based on it +// todo: read initial state of the IP forwarding from the system and reset the state based on it. +// todo: separate v4/v6 forwarding state, since the sysctls are independent +// (net.ipv4.ip_forward vs net.ipv6.conf.all.forwarding). Currently the nftables +// manager shares one instance between both routers, which works only because +// EnableIPForwarding enables both sysctls in a single call. type IPForwardingState struct { enabledCounter int } diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 3923e153b..e5d9363ca 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -159,16 +159,24 @@ func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) { if config.DNSFeatureFlag { m.fakeIPManager = fakeip.NewManager() - id := uuid.NewString() + v4ID := uuid.NewString() fakeIPRoute := &route.Route{ - ID: route.ID(id), + ID: route.ID(v4ID), Network: m.fakeIPManager.GetFakeIPBlock(), - NetID: route.NetID(id), + NetID: route.NetID(v4ID), Peer: m.pubKey, NetworkType: route.IPv4Network, } - cr = append(cr, fakeIPRoute) - m.notifier.SetFakeIPRoute(fakeIPRoute) + v6ID := uuid.NewString() + fakeIPv6Route := &route.Route{ + ID: route.ID(v6ID), + Network: m.fakeIPManager.GetFakeIPv6Block(), + NetID: route.NetID(v6ID), + Peer: m.pubKey, + NetworkType: route.IPv6Network, + } + cr = append(cr, fakeIPRoute, fakeIPv6Route) + m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route}) } m.notifier.SetInitialClientRoutes(cr, routesForComparison) diff --git a/client/internal/routemanager/manager_test.go b/client/internal/routemanager/manager_test.go index 3697545ae..926f06bc9 100644 --- a/client/internal/routemanager/manager_test.go +++ b/client/internal/routemanager/manager_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/route" ) @@ -409,7 +410,7 @@ func TestManagerUpdateRoutes(t *testing.T) { } opts := iface.WGIFaceOpts{ IFaceName: fmt.Sprintf("utun43%d", n), - Address: "100.65.65.2/24", + Address: wgaddr.MustParseWGAddress("100.65.65.2/24"), WGPort: 33100, WGPrivKey: peerPrivateKey.String(), MTU: iface.DefaultMTU, diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go index 55e0b7421..140a583f7 100644 --- a/client/internal/routemanager/notifier/notifier_android.go +++ b/client/internal/routemanager/notifier/notifier_android.go @@ -16,7 +16,7 @@ import ( type Notifier struct { initialRoutes []*route.Route currentRoutes []*route.Route - fakeIPRoute *route.Route + fakeIPRoutes []*route.Route listener listener.NetworkChangeListener listenerMux sync.Mutex @@ -38,9 +38,9 @@ func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesFo n.currentRoutes = filterStatic(routesForComparison) } -// SetFakeIPRoute stores the fake IP route to be included in every TUN rebuild. -func (n *Notifier) SetFakeIPRoute(r *route.Route) { - n.fakeIPRoute = r +// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild. +func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) { + n.fakeIPRoutes = routes } func (n *Notifier) OnNewRoutes(idMap route.HAMap) { @@ -74,14 +74,12 @@ func (n *Notifier) notify() { } allRoutes := slices.Clone(n.currentRoutes) - if n.fakeIPRoute != nil { - allRoutes = append(allRoutes, n.fakeIPRoute) - } + allRoutes = append(allRoutes, n.fakeIPRoutes...) routeStrings := n.routesToStrings(allRoutes) sort.Strings(routeStrings) go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.addIPv6RangeIfNeeded(routeStrings, allRoutes), ",")) + l.OnNetworkChanged(strings.Join(routeStrings, ",")) }(n.listener) } @@ -119,14 +117,5 @@ func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool { func (n *Notifier) GetInitialRouteRanges() []string { initialStrings := n.routesToStrings(n.initialRoutes) sort.Strings(initialStrings) - return n.addIPv6RangeIfNeeded(initialStrings, n.initialRoutes) -} - -func (n *Notifier) addIPv6RangeIfNeeded(inputRanges []string, routes []*route.Route) []string { - for _, r := range routes { - if r.Network.Addr().Is4() && r.Network.Bits() == 0 { - return append(slices.Clone(inputRanges), "::/0") - } - } - return inputRanges + return initialStrings } diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index 68c85067a..27a2a722d 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -34,7 +34,7 @@ func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) { // iOS doesn't care about initial routes } -func (n *Notifier) SetFakeIPRoute(*route.Route) { +func (n *Notifier) SetFakeIPRoutes([]*route.Route) { // Not used on iOS } @@ -65,19 +65,10 @@ func (n *Notifier) notify() { } go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.addIPv6RangeIfNeeded(n.currentPrefixes), ",")) + l.OnNetworkChanged(strings.Join(n.currentPrefixes, ",")) }(n.listener) } func (n *Notifier) GetInitialRouteRanges() []string { return nil } - -func (n *Notifier) addIPv6RangeIfNeeded(inputRanges []string) []string { - for _, r := range inputRanges { - if r == "0.0.0.0/0" { - return append(slices.Clone(inputRanges), "::/0") - } - } - return inputRanges -} diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go index 97c815cf0..f57cadb0b 100644 --- a/client/internal/routemanager/notifier/notifier_other.go +++ b/client/internal/routemanager/notifier/notifier_other.go @@ -23,7 +23,7 @@ func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) { // Not used on non-mobile platforms } -func (n *Notifier) SetFakeIPRoute(*route.Route) { +func (n *Notifier) SetFakeIPRoutes([]*route.Route) { // Not used on non-mobile platforms } diff --git a/client/internal/routemanager/server/server.go b/client/internal/routemanager/server/server.go index e674c80cd..f569c0cac 100644 --- a/client/internal/routemanager/server/server.go +++ b/client/internal/routemanager/server/server.go @@ -21,6 +21,7 @@ type Router struct { firewall firewall.Manager wgInterface iface.WGIface statusRecorder *peer.Status + useNewDNSRoute bool } func NewRouter(ctx context.Context, wgInterface iface.WGIface, firewall firewall.Manager, statusRecorder *peer.Status) (*Router, error) { @@ -37,6 +38,8 @@ func (r *Router) UpdateRoutes(routesMap map[route.ID]*route.Route, useNewDNSRout r.mux.Lock() defer r.mux.Unlock() + prevUseNewDNSRoute := r.useNewDNSRoute + serverRoutesToRemove := make([]route.ID, 0) for routeID := range r.routes { @@ -48,7 +51,7 @@ func (r *Router) UpdateRoutes(routesMap map[route.ID]*route.Route, useNewDNSRout for _, routeID := range serverRoutesToRemove { oldRoute := r.routes[routeID] - err := r.removeFromServerNetwork(oldRoute) + err := r.removeFromServerNetwork(oldRoute, prevUseNewDNSRoute) if err != nil { log.Errorf("Unable to remove route id: %s, network %s, from server, got: %v", oldRoute.ID, oldRoute.Network, err) @@ -56,6 +59,8 @@ func (r *Router) UpdateRoutes(routesMap map[route.ID]*route.Route, useNewDNSRout delete(r.routes, routeID) } + r.useNewDNSRoute = useNewDNSRoute + // If routing is to be disabled, do it after routes have been removed // If routing is to be enabled, do it before adding new routes; addToServerNetwork needs routing to be enabled if len(routesMap) > 0 { @@ -85,13 +90,13 @@ func (r *Router) UpdateRoutes(routesMap map[route.ID]*route.Route, useNewDNSRout return nil } -func (r *Router) removeFromServerNetwork(route *route.Route) error { +func (r *Router) removeFromServerNetwork(route *route.Route, useNewDNSRoute bool) error { if r.ctx.Err() != nil { log.Infof("Not removing from server network because context is done") return r.ctx.Err() } - routerPair := routeToRouterPair(route, false) + routerPair := routeToRouterPair(route, useNewDNSRoute) if err := r.firewall.RemoveNatRule(routerPair); err != nil { return fmt.Errorf("remove routing rules: %w", err) } @@ -124,7 +129,7 @@ func (r *Router) CleanUp() { defer r.mux.Unlock() for _, route := range r.routes { - routerPair := routeToRouterPair(route, false) + routerPair := routeToRouterPair(route, r.useNewDNSRoute) if err := r.firewall.RemoveNatRule(routerPair); err != nil { log.Errorf("Failed to remove cleanup route: %v", err) } @@ -146,8 +151,7 @@ func routeToRouterPair(route *route.Route, useNewDNSRoute bool) firewall.RouterP if useNewDNSRoute { destination.Set = firewall.NewDomainSet(route.Domains) } else { - // TODO: add ipv6 additionally - destination = getDefaultPrefix(destination.Prefix) + destination = getDefaultPrefix(route.Network) } } else { destination.Prefix = route.Network.Masked() @@ -158,6 +162,7 @@ func routeToRouterPair(route *route.Route, useNewDNSRoute bool) firewall.RouterP Source: source, Destination: destination, Masquerade: route.Masquerade, + Dynamic: route.IsDynamic(), } } diff --git a/client/internal/routemanager/systemops/systemops.go b/client/internal/routemanager/systemops/systemops.go index c0ca21d22..165448b60 100644 --- a/client/internal/routemanager/systemops/systemops.go +++ b/client/internal/routemanager/systemops/systemops.go @@ -107,8 +107,16 @@ func (r *SysOps) validateRoute(prefix netip.Prefix) error { addr.IsInterfaceLocalMulticast(), addr.IsMulticast(), addr.IsUnspecified() && prefix.Bits() != 0, - r.wgInterface.Address().Network.Contains(addr): + r.isOwnAddress(addr): return vars.ErrRouteNotAllowed } return nil } + +func (r *SysOps) isOwnAddress(addr netip.Addr) bool { + if r.wgInterface == nil { + return false + } + wgAddr := r.wgInterface.Address() + return wgAddr.Network.Contains(addr) || (wgAddr.IPv6Net.IsValid() && wgAddr.IPv6Net.Contains(addr)) +} diff --git a/client/internal/routemanager/systemops/systemops_generic.go b/client/internal/routemanager/systemops/systemops_generic.go index bf7b95a28..2b96c14dc 100644 --- a/client/internal/routemanager/systemops/systemops_generic.go +++ b/client/internal/routemanager/systemops/systemops_generic.go @@ -221,30 +221,20 @@ func (r *SysOps) genericAddVPNRoute(prefix netip.Prefix, intf *net.Interface) er return err } - // TODO: remove once IPv6 is supported on the interface - if err := r.addToRouteTable(splitDefaultv6_1, nextHop); err != nil { - return fmt.Errorf("add unreachable route split 1: %w", err) - } - if err := r.addToRouteTable(splitDefaultv6_2, nextHop); err != nil { - if err2 := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err2 != nil { - log.Warnf("Failed to rollback route addition: %s", err2) + // When the interface has no v6, add v6 split-default as blackhole so + // unroutable v6 goes to WG (dropped, no AllowedIPs) instead of leaking + // to the system default route. When v6 is active, management sends ::/0 + // as a separate route that the dedicated handler adds. + // Soft-fail: v6 blackhole is best-effort, don't abort v4 routing on failure. + if !r.wgInterface.Address().HasIPv6() { + if err := r.addV6SplitDefault(nextHop); err != nil { + log.Warnf("failed to add v6 split-default blackhole: %s", err) } - return fmt.Errorf("add unreachable route split 2: %w", err) } return nil case vars.Defaultv6: - if err := r.addToRouteTable(splitDefaultv6_1, nextHop); err != nil { - return fmt.Errorf("add unreachable route split 1: %w", err) - } - if err := r.addToRouteTable(splitDefaultv6_2, nextHop); err != nil { - if err2 := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err2 != nil { - log.Warnf("Failed to rollback route addition: %s", err2) - } - return fmt.Errorf("add unreachable route split 2: %w", err) - } - - return nil + return r.addV6SplitDefault(nextHop) } return r.addToRouteTable(prefix, nextHop) @@ -265,30 +255,42 @@ func (r *SysOps) genericRemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) result = multierror.Append(result, err) } - // TODO: remove once IPv6 is supported on the interface - if err := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err != nil { - result = multierror.Append(result, err) - } - if err := r.removeFromRouteTable(splitDefaultv6_2, nextHop); err != nil { - result = multierror.Append(result, err) + if !r.wgInterface.Address().HasIPv6() { + result = multierror.Append(result, r.removeV6SplitDefault(nextHop)) } return nberrors.FormatErrorOrNil(result) case vars.Defaultv6: - var result *multierror.Error - if err := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err != nil { - result = multierror.Append(result, err) - } - if err := r.removeFromRouteTable(splitDefaultv6_2, nextHop); err != nil { - result = multierror.Append(result, err) - } - - return nberrors.FormatErrorOrNil(result) + return nberrors.FormatErrorOrNil(r.removeV6SplitDefault(nextHop)) default: return r.removeFromRouteTable(prefix, nextHop) } } +func (r *SysOps) addV6SplitDefault(nextHop Nexthop) error { + if err := r.addToRouteTable(splitDefaultv6_1, nextHop); err != nil { + return fmt.Errorf("add split 1: %w", err) + } + if err := r.addToRouteTable(splitDefaultv6_2, nextHop); err != nil { + if err2 := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err2 != nil { + log.Warnf("Failed to rollback v6 split-default: %s", err2) + } + return fmt.Errorf("add split 2: %w", err) + } + return nil +} + +func (r *SysOps) removeV6SplitDefault(nextHop Nexthop) *multierror.Error { + var result *multierror.Error + if err := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err != nil { + result = multierror.Append(result, err) + } + if err := r.removeFromRouteTable(splitDefaultv6_2, nextHop); err != nil { + result = multierror.Append(result, err) + } + return result +} + func (r *SysOps) setupHooks(initAddresses []net.IP, stateManager *statemanager.Manager) error { beforeHook := func(connID hooks.ConnectionID, prefix netip.Prefix) error { if _, err := r.refCounter.IncrementWithID(string(connID), prefix, struct{}{}); err != nil { diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go index 08e354a78..5695c40c3 100644 --- a/client/internal/routemanager/systemops/systemops_generic_test.go +++ b/client/internal/routemanager/systemops/systemops_generic_test.go @@ -21,6 +21,7 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/routemanager/vars" nbnet "github.com/netbirdio/netbird/client/net" ) @@ -445,7 +446,7 @@ func createWGInterface(t *testing.T, interfaceName, ipAddressCIDR string, listen opts := iface.WGIFaceOpts{ IFaceName: interfaceName, - Address: ipAddressCIDR, + Address: wgaddr.MustParseWGAddress(ipAddressCIDR), WGPrivKey: peerPrivateKey.String(), WGPort: listenPort, MTU: iface.DefaultMTU, diff --git a/client/internal/routemanager/systemops/systemops_linux.go b/client/internal/routemanager/systemops/systemops_linux.go index 39a9fd978..8c6b7d9a9 100644 --- a/client/internal/routemanager/systemops/systemops_linux.go +++ b/client/internal/routemanager/systemops/systemops_linux.go @@ -53,6 +53,8 @@ const ( // ipv4ForwardingPath is the path to the file containing the IP forwarding setting. ipv4ForwardingPath = "net.ipv4.ip_forward" + // ipv6ForwardingPath is the path to the file containing the IPv6 forwarding setting. + ipv6ForwardingPath = "net.ipv6.conf.all.forwarding" ) var ErrTableIDExists = errors.New("ID exists with different name") @@ -185,10 +187,11 @@ func (r *SysOps) AddVPNRoute(prefix netip.Prefix, intf *net.Interface) error { // No need to check if routes exist as main table takes precedence over the VPN table via Rule 1 - // TODO remove this once we have ipv6 support - if prefix == vars.Defaultv4 { + // When the peer has no IPv6, blackhole v6 to prevent leaking. + // When IPv6 is enabled, management sends ::/0 as a separate route. + if prefix == vars.Defaultv4 && (r.wgInterface == nil || !r.wgInterface.Address().HasIPv6()) { if err := addUnreachableRoute(vars.Defaultv6, NetbirdVPNTableID); err != nil { - return fmt.Errorf("add blackhole: %w", err) + return fmt.Errorf("add v6 blackhole: %w", err) } } if err := addRoute(prefix, Nexthop{netip.Addr{}, intf}, NetbirdVPNTableID); err != nil { @@ -206,10 +209,9 @@ func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) error return r.genericRemoveVPNRoute(prefix, intf) } - // TODO remove this once we have ipv6 support - if prefix == vars.Defaultv4 { + if prefix == vars.Defaultv4 && (r.wgInterface == nil || !r.wgInterface.Address().HasIPv6()) { if err := removeUnreachableRoute(vars.Defaultv6, NetbirdVPNTableID); err != nil { - return fmt.Errorf("remove unreachable route: %w", err) + log.Debugf("remove v6 blackhole: %v", err) } } if err := removeRoute(prefix, Nexthop{netip.Addr{}, intf}, NetbirdVPNTableID); err != nil { @@ -762,8 +764,13 @@ func flushRoutes(tableID, family int) error { } func EnableIPForwarding() error { - _, err := sysctl.Set(ipv4ForwardingPath, 1, false) - return err + if _, err := sysctl.Set(ipv4ForwardingPath, 1, false); err != nil { + return err + } + if _, err := sysctl.Set(ipv6ForwardingPath, 1, false); err != nil { + log.Warnf("failed to enable IPv6 forwarding: %v", err) + } + return nil } // entryExists checks if the specified ID or name already exists in the rt_tables file diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index a616f9533..33f5ab1b0 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -50,10 +50,11 @@ type CustomLogger interface { } type selectRoute struct { - NetID string - Network netip.Prefix - Domains domain.List - Selected bool + NetID string + Network netip.Prefix + Domains domain.List + Selected bool + extraNetworks []netip.Prefix } func init() { @@ -198,6 +199,7 @@ func (c *Client) GetStatusDetails() *StatusDetails { } pi := PeerInfo{ IP: p.IP, + IPv6: p.IPv6, FQDN: p.FQDN, LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint, RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint, @@ -216,7 +218,7 @@ func (c *Client) GetStatusDetails() *StatusDetails { } peerInfos[n] = pi } - return &StatusDetails{items: peerInfos, fqdn: fullStatus.LocalPeerState.FQDN, ip: fullStatus.LocalPeerState.IP} + return &StatusDetails{items: peerInfos, fqdn: fullStatus.LocalPeerState.FQDN, ip: fullStatus.LocalPeerState.IP, ipv6: fullStatus.LocalPeerState.IPv6} } // SetConnectionListener set the network connection listener @@ -366,48 +368,60 @@ func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { } routeManager := engine.GetRouteManager() - routesMap := routeManager.GetClientRoutesWithNetID() if routeManager == nil { return nil, fmt.Errorf("could not get route manager") } + routesMap := routeManager.GetClientRoutesWithNetID() routeSelector := routeManager.GetRouteSelector() if routeSelector == nil { return nil, fmt.Errorf("could not get route selector") } + v6ExitMerged := route.V6ExitMergeSet(routesMap) + routes := buildSelectRoutes(routesMap, routeSelector.IsSelected, v6ExitMerged) + resolvedDomains := c.recorder.GetResolvedDomainsStates() + + return prepareRouteSelectionDetails(routes, resolvedDomains), nil +} + +func buildSelectRoutes(routesMap map[route.NetID][]*route.Route, isSelected func(route.NetID) bool, v6Merged map[route.NetID]struct{}) []*selectRoute { var routes []*selectRoute for id, rt := range routesMap { if len(rt) == 0 { continue } - route := &selectRoute{ + if _, ok := v6Merged[id]; ok { + continue + } + + r := &selectRoute{ NetID: string(id), Network: rt[0].Network, Domains: rt[0].Domains, - Selected: routeSelector.IsSelected(id), + Selected: isSelected(id), } - routes = append(routes, route) + + v6ID := route.NetID(string(id) + route.V6ExitSuffix) + if _, ok := v6Merged[v6ID]; ok { + r.extraNetworks = []netip.Prefix{routesMap[v6ID][0].Network} + } + + routes = append(routes, r) } sort.Slice(routes, func(i, j int) bool { - iPrefix := routes[i].Network.Bits() - jPrefix := routes[j].Network.Bits() - - if iPrefix == jPrefix { - iAddr := routes[i].Network.Addr() - jAddr := routes[j].Network.Addr() - if iAddr == jAddr { - return routes[i].NetID < routes[j].NetID - } - return iAddr.String() < jAddr.String() + iBits, jBits := routes[i].Network.Bits(), routes[j].Network.Bits() + if iBits != jBits { + return iBits < jBits } - return iPrefix < jPrefix + iAddr, jAddr := routes[i].Network.Addr(), routes[j].Network.Addr() + if iAddr != jAddr { + return iAddr.Less(jAddr) + } + return routes[i].NetID < routes[j].NetID }) - resolvedDomains := c.recorder.GetResolvedDomainsStates() - - return prepareRouteSelectionDetails(routes, resolvedDomains), nil - + return routes } func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo) *RoutesSelectionDetails { @@ -443,6 +457,9 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom if len(r.Domains) > 0 { netStr = r.Domains.SafeString() } + for _, extra := range r.extraNetworks { + netStr += ", " + extra.String() + } routeSelection = append(routeSelection, RoutesSelectionInfo{ ID: r.NetID, @@ -474,7 +491,9 @@ func (c *Client) SelectRoute(id string) error { } else { log.Debugf("select route with id: %s", id) routes := toNetIDs([]string{id}) - if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routeManager.GetClientRoutesWithNetID())); err != nil { + routesMap := routeManager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) + if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routesMap)); err != nil { log.Debugf("error when selecting routes: %s", err) return fmt.Errorf("select routes: %w", err) } @@ -501,7 +520,9 @@ func (c *Client) DeselectRoute(id string) error { } else { log.Debugf("deselect route with id: %s", id) routes := toNetIDs([]string{id}) - if err := routeSelector.DeselectRoutes(routes, maps.Keys(routeManager.GetClientRoutesWithNetID())); err != nil { + routesMap := routeManager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) + if err := routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { log.Debugf("error when deselecting routes: %s", err) return fmt.Errorf("deselect routes: %w", err) } diff --git a/client/ios/NetBirdSDK/peer_notifier.go b/client/ios/NetBirdSDK/peer_notifier.go index 9b00568be..025cd94cd 100644 --- a/client/ios/NetBirdSDK/peer_notifier.go +++ b/client/ios/NetBirdSDK/peer_notifier.go @@ -5,6 +5,7 @@ package NetBirdSDK // PeerInfo describe information about the peers. It designed for the UI usage type PeerInfo struct { IP string + IPv6 string FQDN string LocalIceCandidateEndpoint string RemoteIceCandidateEndpoint string @@ -23,6 +24,11 @@ type PeerInfo struct { Routes RoutesDetails } +// GetIPv6 returns the IPv6 address of the peer +func (p PeerInfo) GetIPv6() string { + return p.IPv6 +} + // GetRoutes return with RouteDetails func (p PeerInfo) GetRouteDetails() *RoutesDetails { return &p.Routes @@ -57,6 +63,7 @@ type StatusDetails struct { items []PeerInfo fqdn string ip string + ipv6 string } // Add new PeerInfo to the collection @@ -100,3 +107,8 @@ func (array StatusDetails) GetFQDN() string { func (array StatusDetails) GetIP() string { return array.ip } + +// GetIPv6 return with the IPv6 of the local peer +func (array StatusDetails) GetIPv6() string { + return array.ipv6 +} diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index c26a6decd..ed49ccddb 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -110,6 +110,24 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { return cfg.RosenpassPermissive, err } +// GetDisableIPv6 reads disable IPv6 setting from config file +func (p *Preferences) GetDisableIPv6() (bool, error) { + if p.configInput.DisableIPv6 != nil { + return *p.configInput.DisableIPv6, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + return cfg.DisableIPv6, err +} + +// SetDisableIPv6 stores the given value and waits for commit +func (p *Preferences) SetDisableIPv6(disable bool) { + p.configInput.DisableIPv6 = &disable +} + // Commit write out the changes into config file func (p *Preferences) Commit() error { // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 11e7877f2..2c054c99a 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -342,6 +342,7 @@ type LoginRequest struct { EnableSSHRemotePortForwarding *bool `protobuf:"varint,37,opt,name=enableSSHRemotePortForwarding,proto3,oneof" json:"enableSSHRemotePortForwarding,omitempty"` DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` + DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -650,6 +651,13 @@ func (x *LoginRequest) GetSshJWTCacheTTL() int32 { return 0 } +func (x *LoginRequest) GetDisableIpv6() bool { + if x != nil && x.DisableIpv6 != nil { + return *x.DisableIpv6 + } + return false +} + type LoginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"` @@ -1182,6 +1190,7 @@ type GetConfigResponse struct { EnableSSHRemotePortForwarding bool `protobuf:"varint,23,opt,name=enableSSHRemotePortForwarding,proto3" json:"enableSSHRemotePortForwarding,omitempty"` DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"` + DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1398,6 +1407,13 @@ func (x *GetConfigResponse) GetSshJWTCacheTTL() int32 { return 0 } +func (x *GetConfigResponse) GetDisableIpv6() bool { + if x != nil { + return x.DisableIpv6 + } + return false +} + // PeerState contains the latest state of a peer type PeerState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1419,6 +1435,7 @@ type PeerState struct { Latency *durationpb.Duration `protobuf:"bytes,17,opt,name=latency,proto3" json:"latency,omitempty"` RelayAddress string `protobuf:"bytes,18,opt,name=relayAddress,proto3" json:"relayAddress,omitempty"` SshHostKey []byte `protobuf:"bytes,19,opt,name=sshHostKey,proto3" json:"sshHostKey,omitempty"` + Ipv6 string `protobuf:"bytes,20,opt,name=ipv6,proto3" json:"ipv6,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1579,6 +1596,13 @@ func (x *PeerState) GetSshHostKey() []byte { return nil } +func (x *PeerState) GetIpv6() string { + if x != nil { + return x.Ipv6 + } + return "" +} + // LocalPeerState contains the latest state of the local peer type LocalPeerState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1589,6 +1613,7 @@ type LocalPeerState struct { RosenpassEnabled bool `protobuf:"varint,5,opt,name=rosenpassEnabled,proto3" json:"rosenpassEnabled,omitempty"` RosenpassPermissive bool `protobuf:"varint,6,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"` Networks []string `protobuf:"bytes,7,rep,name=networks,proto3" json:"networks,omitempty"` + Ipv6 string `protobuf:"bytes,8,opt,name=ipv6,proto3" json:"ipv6,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1672,6 +1697,13 @@ func (x *LocalPeerState) GetNetworks() []string { return nil } +func (x *LocalPeerState) GetIpv6() string { + if x != nil { + return x.Ipv6 + } + return "" +} + // SignalState contains the latest state of a signal connection type SignalState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4009,6 +4041,7 @@ type SetConfigRequest struct { EnableSSHRemotePortForwarding *bool `protobuf:"varint,32,opt,name=enableSSHRemotePortForwarding,proto3,oneof" json:"enableSSHRemotePortForwarding,omitempty"` DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` + DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4281,6 +4314,13 @@ func (x *SetConfigRequest) GetSshJWTCacheTTL() int32 { return 0 } +func (x *SetConfigRequest) GetDisableIpv6() bool { + if x != nil && x.DisableIpv6 != nil { + return *x.DisableIpv6 + } + return false +} + type SetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -6186,7 +6226,7 @@ var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + "\n" + "\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" + - "\fEmptyRequest\"\xb6\x12\n" + + "\fEmptyRequest\"\xef\x12\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -6230,7 +6270,8 @@ const file_daemon_proto_rawDesc = "" + "\x1cenableSSHLocalPortForwarding\x18$ \x01(\bH\x17R\x1cenableSSHLocalPortForwarding\x88\x01\x01\x12I\n" + "\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + - "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01B\x13\n" + + "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + + "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -6257,7 +6298,8 @@ const file_daemon_proto_rawDesc = "" + "\x1d_enableSSHLocalPortForwardingB \n" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + - "\x0f_sshJWTCacheTTL\"\xb5\x01\n" + + "\x0f_sshJWTCacheTTLB\x0f\n" + + "\r_disable_ipv6\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + "\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" + "\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" + @@ -6290,7 +6332,7 @@ const file_daemon_proto_rawDesc = "" + "\fDownResponse\"P\n" + "\x10GetConfigRequest\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"\xdb\b\n" + + "\busername\x18\x02 \x01(\tR\busername\"\xfe\b\n" + "\x11GetConfigResponse\x12$\n" + "\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" + "\n" + @@ -6321,7 +6363,8 @@ const file_daemon_proto_rawDesc = "" + "\x1cenableSSHLocalPortForwarding\x18\x16 \x01(\bR\x1cenableSSHLocalPortForwarding\x12D\n" + "\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" + "\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" + - "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\"\xfe\x05\n" + + "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" + + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\"\x92\x06\n" + "\tPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + "\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" + @@ -6345,7 +6388,8 @@ const file_daemon_proto_rawDesc = "" + "\frelayAddress\x18\x12 \x01(\tR\frelayAddress\x12\x1e\n" + "\n" + "sshHostKey\x18\x13 \x01(\fR\n" + - "sshHostKey\"\xf0\x01\n" + + "sshHostKey\x12\x12\n" + + "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x84\x02\n" + "\x0eLocalPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + "\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" + @@ -6353,7 +6397,8 @@ const file_daemon_proto_rawDesc = "" + "\x04fqdn\x18\x04 \x01(\tR\x04fqdn\x12*\n" + "\x10rosenpassEnabled\x18\x05 \x01(\bR\x10rosenpassEnabled\x120\n" + "\x13rosenpassPermissive\x18\x06 \x01(\bR\x13rosenpassPermissive\x12\x1a\n" + - "\bnetworks\x18\a \x03(\tR\bnetworks\"S\n" + + "\bnetworks\x18\a \x03(\tR\bnetworks\x12\x12\n" + + "\x04ipv6\x18\b \x01(\tR\x04ipv6\"S\n" + "\vSignalState\x12\x10\n" + "\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" + "\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" + @@ -6534,7 +6579,7 @@ const file_daemon_proto_rawDesc = "" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + "\f_profileNameB\v\n" + "\t_username\"\x17\n" + - "\x15SwitchProfileResponse\"\xdf\x10\n" + + "\x15SwitchProfileResponse\"\x98\x11\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -6573,7 +6618,8 @@ const file_daemon_proto_rawDesc = "" + "\x1cenableSSHLocalPortForwarding\x18\x1f \x01(\bH\x14R\x1cenableSSHLocalPortForwarding\x88\x01\x01\x12I\n" + "\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + - "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01B\x13\n" + + "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + + "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -6597,7 +6643,8 @@ const file_daemon_proto_rawDesc = "" + "\x1d_enableSSHLocalPortForwardingB \n" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + - "\x0f_sshJWTCacheTTL\"\x13\n" + + "\x0f_sshJWTCacheTTLB\x0f\n" + + "\r_disable_ipv6\"\x13\n" + "\x11SetConfigResponse\"Q\n" + "\x11AddProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 3fee9eca8..dedff43e2 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -204,6 +204,7 @@ message LoginRequest { optional bool enableSSHRemotePortForwarding = 37; optional bool disableSSHAuth = 38; optional int32 sshJWTCacheTTL = 39; + optional bool disable_ipv6 = 40; } message LoginResponse { @@ -311,6 +312,8 @@ message GetConfigResponse { bool disableSSHAuth = 25; int32 sshJWTCacheTTL = 26; + + bool disable_ipv6 = 27; } // PeerState contains the latest state of a peer @@ -333,6 +336,7 @@ message PeerState { google.protobuf.Duration latency = 17; string relayAddress = 18; bytes sshHostKey = 19; + string ipv6 = 20; } // LocalPeerState contains the latest state of the local peer @@ -344,6 +348,7 @@ message LocalPeerState { bool rosenpassEnabled = 5; bool rosenpassPermissive = 6; repeated string networks = 7; + string ipv6 = 8; } // SignalState contains the latest state of a signal connection @@ -672,6 +677,7 @@ message SetConfigRequest { optional bool enableSSHRemotePortForwarding = 32; optional bool disableSSHAuth = 33; optional int32 sshJWTCacheTTL = 34; + optional bool disable_ipv6 = 35; } message SetConfigResponse{} diff --git a/client/server/network.go b/client/server/network.go index 76c5af40e..12cefbd9c 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -18,10 +18,11 @@ import ( ) type selectRoute struct { - NetID route.NetID - Network netip.Prefix - Domains domain.List - Selected bool + NetID route.NetID + Network netip.Prefix + Domains domain.List + Selected bool + extraNetworks []netip.Prefix } // ListNetworks returns a list of all available networks. @@ -50,18 +51,32 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro routesMap := routeMgr.GetClientRoutesWithNetID() routeSelector := routeMgr.GetRouteSelector() + v6ExitMerged := route.V6ExitMergeSet(routesMap) + var routes []*selectRoute for id, rt := range routesMap { if len(rt) == 0 { continue } - route := &selectRoute{ + // Skip v6 exit nodes that are merged into their v4 counterpart. + if _, ok := v6ExitMerged[id]; ok { + continue + } + + r := &selectRoute{ NetID: id, Network: rt[0].Network, Domains: rt[0].Domains, Selected: routeSelector.IsSelected(id), } - routes = append(routes, route) + + // Merge paired v6 exit node prefix into this entry. + v6ID := route.NetID(string(id) + route.V6ExitSuffix) + if _, ok := v6ExitMerged[v6ID]; ok && len(routesMap[v6ID]) > 0 { + r.extraNetworks = []netip.Prefix{routesMap[v6ID][0].Network} + } + + routes = append(routes, r) } sort.Slice(routes, func(i, j int) bool { @@ -82,9 +97,13 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro resolvedDomains := s.statusRecorder.GetResolvedDomainsStates() var pbRoutes []*proto.Network for _, route := range routes { + rangeStr := route.Network.String() + for _, extra := range route.extraNetworks { + rangeStr += ", " + extra.String() + } pbRoute := &proto.Network{ ID: string(route.NetID), - Range: route.Network.String(), + Range: rangeStr, Domains: route.Domains.ToSafeStringList(), ResolvedIPs: map[string]*proto.IPList{}, Selected: route.Selected, @@ -147,7 +166,9 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ routeSelector.SelectAllRoutes() } else { routes := toNetIDs(req.GetNetworkIDs()) - netIdRoutes := maps.Keys(routeManager.GetClientRoutesWithNetID()) + routesMap := routeManager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) + netIdRoutes := maps.Keys(routesMap) if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil { return nil, fmt.Errorf("select routes: %w", err) } @@ -197,7 +218,9 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe routeSelector.DeselectAllRoutes() } else { routes := toNetIDs(req.GetNetworkIDs()) - netIdRoutes := maps.Keys(routeManager.GetClientRoutesWithNetID()) + routesMap := routeManager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) + netIdRoutes := maps.Keys(routesMap) if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil { return nil, fmt.Errorf("deselect routes: %w", err) } diff --git a/client/server/server.go b/client/server/server.go index 648ffa8ce..bc8de8f9f 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -385,6 +385,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques config.DisableNotifications = msg.DisableNotifications config.LazyConnectionEnabled = msg.LazyConnectionEnabled config.BlockInbound = msg.BlockInbound + config.DisableIPv6 = msg.DisableIpv6 config.EnableSSHRoot = msg.EnableSSHRoot config.EnableSSHSFTP = msg.EnableSSHSFTP config.EnableSSHLocalPortForwarding = msg.EnableSSHLocalPortForwarding @@ -1483,6 +1484,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p disableDNS := cfg.DisableDNS disableClientRoutes := cfg.DisableClientRoutes disableServerRoutes := cfg.DisableServerRoutes + disableIPv6 := cfg.DisableIPv6 blockLANAccess := cfg.BlockLANAccess enableSSHRoot := false @@ -1533,6 +1535,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p DisableDns: disableDNS, DisableClientRoutes: disableClientRoutes, DisableServerRoutes: disableServerRoutes, + DisableIpv6: disableIPv6, BlockLanAccess: blockLANAccess, EnableSSHRoot: enableSSHRoot, EnableSSHSFTP: enableSSHSFTP, diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index b90b5653d..553d4ad71 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -71,6 +71,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { disableNotifications := true lazyConnectionEnabled := true blockInbound := true + disableIPv6 := true mtu := int64(1280) sshJWTCacheTTL := int32(300) @@ -95,6 +96,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { DisableNotifications: &disableNotifications, LazyConnectionEnabled: &lazyConnectionEnabled, BlockInbound: &blockInbound, + DisableIpv6: &disableIPv6, NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"}, CleanNATExternalIPs: false, CustomDNSAddress: []byte("1.1.1.1:53"), @@ -140,6 +142,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, disableNotifications, *cfg.DisableNotifications) require.Equal(t, lazyConnectionEnabled, cfg.LazyConnectionEnabled) require.Equal(t, blockInbound, cfg.BlockInbound) + require.Equal(t, disableIPv6, cfg.DisableIPv6) require.Equal(t, []string{"1.2.3.4", "5.6.7.8"}, cfg.NATExternalIPs) require.Equal(t, "1.1.1.1:53", cfg.CustomDNSAddress) // IFaceBlackList contains defaults + extras @@ -189,6 +192,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "DisableNotifications": true, "LazyConnectionEnabled": true, "BlockInbound": true, + "DisableIpv6": true, "NatExternalIPs": true, "CustomDNSAddress": true, "ExtraIFaceBlacklist": true, @@ -247,6 +251,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "disable-firewall": "DisableFirewall", "block-lan-access": "BlockLanAccess", "block-inbound": "BlockInbound", + "disable-ipv6": "DisableIpv6", "enable-lazy-connection": "LazyConnectionEnabled", "external-ip-map": "NatExternalIPs", "dns-resolver-address": "CustomDNSAddress", diff --git a/client/server/trace.go b/client/server/trace.go index e4ac91487..7fea31c49 100644 --- a/client/server/trace.go +++ b/client/server/trace.go @@ -24,14 +24,9 @@ func (s *Server) TracePacket(_ context.Context, req *proto.TracePacketRequest) ( return nil, err } - srcAddr, err := s.parseAddress(req.GetSourceIp(), engine) + srcAddr, dstAddr, err := s.resolveTraceAddresses(req.GetSourceIp(), req.GetDestinationIp(), engine) if err != nil { - return nil, fmt.Errorf("invalid source IP address: %w", err) - } - - dstAddr, err := s.parseAddress(req.GetDestinationIp(), engine) - if err != nil { - return nil, fmt.Errorf("invalid destination IP address: %w", err) + return nil, err } protocol, err := s.parseProtocol(req.GetProtocol()) @@ -89,16 +84,73 @@ func (s *Server) getPacketTracer() (packetTracer, *internal.Engine, error) { return tracer, engine, nil } -func (s *Server) parseAddress(addr string, engine *internal.Engine) (netip.Addr, error) { - if addr == "self" { - return engine.GetWgAddr(), nil +// resolveTraceAddresses parses src/dst, resolving "self" to the local overlay +// address matching the peer's address family. +func (s *Server) resolveTraceAddresses(src, dst string, engine *internal.Engine) (netip.Addr, netip.Addr, error) { + srcSelf := src == "self" + dstSelf := dst == "self" + + if srcSelf && dstSelf { + return netip.Addr{}, netip.Addr{}, fmt.Errorf("both source and destination cannot be 'self'") } + var srcAddr, dstAddr netip.Addr + var err error + + // Parse the non-self address first so we know the family for self resolution. + if !srcSelf { + if srcAddr, err = parseAddr(src); err != nil { + return netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid source IP: %w", err) + } + } + if !dstSelf { + if dstAddr, err = parseAddr(dst); err != nil { + return netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid destination IP: %w", err) + } + } + + // Determine the peer address to pick the right self address. + peer := srcAddr + if srcSelf { + peer = dstAddr + } + + if srcSelf { + if srcAddr, err = selfAddr(engine, peer); err != nil { + return netip.Addr{}, netip.Addr{}, err + } + } + if dstSelf { + if dstAddr, err = selfAddr(engine, peer); err != nil { + return netip.Addr{}, netip.Addr{}, err + } + } + + return srcAddr, dstAddr, nil +} + +func selfAddr(engine *internal.Engine, peer netip.Addr) (netip.Addr, error) { + var addr netip.Addr + if peer.Is6() { + addr = engine.GetWgV6Addr() + } else { + addr = engine.GetWgAddr() + } + if !addr.IsValid() { + family := "IPv4" + if peer.Is6() { + family = "IPv6" + } + return netip.Addr{}, fmt.Errorf("no local %s overlay address configured", family) + } + return addr, nil +} + +func parseAddr(addr string) (netip.Addr, error) { a, err := netip.ParseAddr(addr) if err != nil { return netip.Addr{}, err } - return a.Unmap(), nil } diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go index 5d69fd35c..01822ead6 100644 --- a/client/ssh/config/manager.go +++ b/client/ssh/config/manager.go @@ -3,6 +3,7 @@ package config import ( "context" "fmt" + "net/netip" "os" "path/filepath" "runtime" @@ -91,7 +92,8 @@ type Manager struct { // PeerSSHInfo represents a peer's SSH configuration information type PeerSSHInfo struct { Hostname string - IP string + IP netip.Addr + IPv6 netip.Addr FQDN string } @@ -210,8 +212,11 @@ func (m *Manager) buildPeerConfig(allHostPatterns []string) (string, error) { func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string { var hostPatterns []string - if peer.IP != "" { - hostPatterns = append(hostPatterns, peer.IP) + if peer.IP.IsValid() { + hostPatterns = append(hostPatterns, peer.IP.String()) + } + if peer.IPv6.IsValid() { + hostPatterns = append(hostPatterns, peer.IPv6.String()) } if peer.FQDN != "" { hostPatterns = append(hostPatterns, peer.FQDN) diff --git a/client/ssh/config/manager_test.go b/client/ssh/config/manager_test.go index e7380c7f2..8e6be40a3 100644 --- a/client/ssh/config/manager_test.go +++ b/client/ssh/config/manager_test.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net/netip" "os" "path/filepath" "runtime" @@ -28,12 +29,12 @@ func TestManager_SetupSSHClientConfig(t *testing.T) { peers := []PeerSSHInfo{ { Hostname: "peer1", - IP: "100.125.1.1", + IP: netip.MustParseAddr("100.125.1.1"), FQDN: "peer1.nb.internal", }, { Hostname: "peer2", - IP: "100.125.1.2", + IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal", }, } @@ -101,7 +102,7 @@ func TestManager_PeerLimit(t *testing.T) { for i := 0; i < MaxPeersForSSHConfig+10; i++ { peers = append(peers, PeerSSHInfo{ Hostname: fmt.Sprintf("peer%d", i), - IP: fmt.Sprintf("100.125.1.%d", i%254+1), + IP: netip.MustParseAddr(fmt.Sprintf("100.125.1.%d", i%254+1)), FQDN: fmt.Sprintf("peer%d.nb.internal", i), }) } @@ -127,8 +128,8 @@ func TestManager_MatchHostFormat(t *testing.T) { } peers := []PeerSSHInfo{ - {Hostname: "peer1", IP: "100.125.1.1", FQDN: "peer1.nb.internal"}, - {Hostname: "peer2", IP: "100.125.1.2", FQDN: "peer2.nb.internal"}, + {Hostname: "peer1", IP: netip.MustParseAddr("100.125.1.1"), FQDN: "peer1.nb.internal"}, + {Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"}, } err = manager.SetupSSHClientConfig(peers) @@ -167,7 +168,7 @@ func TestManager_ForcedSSHConfig(t *testing.T) { for i := 0; i < MaxPeersForSSHConfig+10; i++ { peers = append(peers, PeerSSHInfo{ Hostname: fmt.Sprintf("peer%d", i), - IP: fmt.Sprintf("100.125.1.%d", i%254+1), + IP: netip.MustParseAddr(fmt.Sprintf("100.125.1.%d", i%254+1)), FQDN: fmt.Sprintf("peer%d.nb.internal", i), }) } diff --git a/client/ssh/proxy/proxy.go b/client/ssh/proxy/proxy.go index 59007f75c..eb659fe21 100644 --- a/client/ssh/proxy/proxy.go +++ b/client/ssh/proxy/proxy.go @@ -321,7 +321,7 @@ func (p *SSHProxy) directTCPIPHandler(_ *ssh.Server, _ *cryptossh.ServerConn, ne return } - dest := fmt.Sprintf("%s:%d", payload.DestAddr, payload.DestPort) + dest := net.JoinHostPort(payload.DestAddr, strconv.Itoa(int(payload.DestPort))) log.Debugf("local port forwarding: %s", dest) backendClient, err := p.getOrCreateBackendClient(sshCtx, sshCtx.User()) diff --git a/client/ssh/server/port_forwarding.go b/client/ssh/server/port_forwarding.go index e16ff5d46..f5ac66fca 100644 --- a/client/ssh/server/port_forwarding.go +++ b/client/ssh/server/port_forwarding.go @@ -56,12 +56,12 @@ func (s *Server) configurePortForwarding(server *ssh.Server) { server.LocalPortForwardingCallback = func(ctx ssh.Context, dstHost string, dstPort uint32) bool { logger := s.getRequestLogger(ctx) if !allowLocal { - logger.Warnf("local port forwarding denied for %s:%d: disabled", dstHost, dstPort) + logger.Warnf("local port forwarding denied for %s: disabled", net.JoinHostPort(dstHost, strconv.Itoa(int(dstPort)))) return false } if err := s.checkPortForwardingPrivileges(ctx, "local", dstPort); err != nil { - logger.Warnf("local port forwarding denied for %s:%d: %v", dstHost, dstPort, err) + logger.Warnf("local port forwarding denied for %s: %v", net.JoinHostPort(dstHost, strconv.Itoa(int(dstPort))), err) return false } @@ -71,12 +71,12 @@ func (s *Server) configurePortForwarding(server *ssh.Server) { server.ReversePortForwardingCallback = func(ctx ssh.Context, bindHost string, bindPort uint32) bool { logger := s.getRequestLogger(ctx) if !allowRemote { - logger.Warnf("remote port forwarding denied for %s:%d: disabled", bindHost, bindPort) + logger.Warnf("remote port forwarding denied for %s: disabled", net.JoinHostPort(bindHost, strconv.Itoa(int(bindPort)))) return false } if err := s.checkPortForwardingPrivileges(ctx, "remote", bindPort); err != nil { - logger.Warnf("remote port forwarding denied for %s:%d: %v", bindHost, bindPort, err) + logger.Warnf("remote port forwarding denied for %s: %v", net.JoinHostPort(bindHost, strconv.Itoa(int(bindPort))), err) return false } @@ -183,15 +183,16 @@ func (s *Server) cancelTcpipForwardHandler(ctx ssh.Context, _ *ssh.Server, req * return false, nil } - key := forwardKey(fmt.Sprintf("%s:%d", payload.Host, payload.Port)) + hostPort := net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port))) + key := forwardKey(hostPort) if s.removeRemoteForwardListener(key) { - forwardAddr := fmt.Sprintf("-R %s:%d", payload.Host, payload.Port) + forwardAddr := "-R " + hostPort s.removeConnectionPortForward(ctx.RemoteAddr(), forwardAddr) - logger.Infof("remote port forwarding cancelled: %s:%d", payload.Host, payload.Port) + logger.Infof("remote port forwarding cancelled: %s", hostPort) return true, nil } - logger.Warnf("cancel-tcpip-forward failed: no listener found for %s:%d", payload.Host, payload.Port) + logger.Warnf("cancel-tcpip-forward failed: no listener found for %s", net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port)))) return false, nil } @@ -201,7 +202,7 @@ func (s *Server) handleRemoteForwardListener(ctx ssh.Context, ln net.Listener, h defer func() { if err := ln.Close(); err != nil { - logger.Debugf("remote forward listener close error for %s:%d: %v", host, port, err) + logger.Debugf("remote forward listener close error for %s: %v", net.JoinHostPort(host, strconv.Itoa(int(port))), err) } }() @@ -230,7 +231,7 @@ func (s *Server) handleRemoteForwardListener(ctx ssh.Context, ln net.Listener, h } go s.handleRemoteForwardConnection(ctx, result.conn, host, port) case <-ctx.Done(): - logger.Debugf("remote forward listener shutting down for %s:%d", host, port) + logger.Debugf("remote forward listener shutting down for %s", net.JoinHostPort(host, strconv.Itoa(int(port)))) return } } @@ -311,17 +312,17 @@ func (s *Server) setupDirectForward(ctx ssh.Context, logger *log.Entry, sshConn logger.Debugf("tcpip-forward allocated port %d for %s", actualPort, payload.Host) } - key := forwardKey(fmt.Sprintf("%s:%d", payload.Host, payload.Port)) + key := forwardKey(net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port)))) s.storeRemoteForwardListener(key, ln) - forwardAddr := fmt.Sprintf("-R %s:%d", payload.Host, actualPort) + forwardAddr := "-R " + net.JoinHostPort(payload.Host, strconv.Itoa(int(actualPort))) s.addConnectionPortForward(ctx.User(), ctx.RemoteAddr(), forwardAddr) go s.handleRemoteForwardListener(ctx, ln, payload.Host, actualPort) response := make([]byte, 4) binary.BigEndian.PutUint32(response, actualPort) - logger.Infof("remote port forwarding established: %s:%d", payload.Host, actualPort) + logger.Infof("remote port forwarding established: %s", net.JoinHostPort(payload.Host, strconv.Itoa(int(actualPort)))) return true, response } @@ -351,7 +352,7 @@ func (s *Server) handleRemoteForwardConnection(ctx ssh.Context, conn net.Conn, h channel, err := s.openForwardChannel(sshConn, host, port, remoteAddr) if err != nil { - logger.Debugf("open forward channel for %s:%d: %v", host, port, err) + logger.Debugf("open forward channel for %s: %v", net.JoinHostPort(host, strconv.Itoa(int(port))), err) _ = conn.Close() return } diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index 82d3b700f..de40d3091 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net" + "strconv" "net/netip" "slices" "strings" @@ -137,10 +138,11 @@ type sessionState struct { } type Server struct { - sshServer *ssh.Server - listener net.Listener - mu sync.RWMutex - hostKeyPEM []byte + sshServer *ssh.Server + listener net.Listener + extraListeners []net.Listener + mu sync.RWMutex + hostKeyPEM []byte // sessions tracks active SSH sessions (shell, command, SFTP). // These are created when a client opens a session channel and requests shell/exec/subsystem. @@ -254,6 +256,35 @@ func (s *Server) Start(ctx context.Context, addr netip.AddrPort) error { return nil } +// AddListener starts serving SSH on an additional address (e.g. IPv6). +// Must be called after Start. +func (s *Server) AddListener(ctx context.Context, addr netip.AddrPort) error { + s.mu.Lock() + srv := s.sshServer + if srv == nil { + s.mu.Unlock() + return errors.New("SSH server is not running") + } + + ln, addrDesc, err := s.createListener(ctx, addr) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("create listener: %w", err) + } + + s.extraListeners = append(s.extraListeners, ln) + s.mu.Unlock() + + log.Infof("SSH server also listening on %s", addrDesc) + + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, ssh.ErrServerClosed) { + log.Errorf("SSH server error on %s: %v", addrDesc, err) + } + }() + return nil +} + func (s *Server) createListener(ctx context.Context, addr netip.AddrPort) (net.Listener, string, error) { if s.netstackNet != nil { ln, err := s.netstackNet.ListenTCPAddrPort(addr) @@ -291,6 +322,8 @@ func (s *Server) Stop() error { } s.sshServer = nil s.listener = nil + extraListeners := s.extraListeners + s.extraListeners = nil s.mu.Unlock() // Close outside the lock: session handlers need s.mu for unregisterSession. @@ -298,6 +331,12 @@ func (s *Server) Stop() error { log.Debugf("close SSH server: %v", err) } + for _, ln := range extraListeners { + if err := ln.Close(); err != nil { + log.Debugf("close extra SSH listener: %v", err) + } + } + s.mu.Lock() maps.Clear(s.sessions) maps.Clear(s.pendingAuthJWT) @@ -749,11 +788,10 @@ func (s *Server) findSessionKeyByContext(ctx ssh.Context) sessionKey { func (s *Server) connectionValidator(_ ssh.Context, conn net.Conn) net.Conn { s.mu.RLock() - netbirdNetwork := s.wgAddress.Network - localIP := s.wgAddress.IP + wgAddr := s.wgAddress s.mu.RUnlock() - if !netbirdNetwork.IsValid() || !localIP.IsValid() { + if !wgAddr.Network.IsValid() || !wgAddr.IP.IsValid() { return conn } @@ -769,14 +807,17 @@ func (s *Server) connectionValidator(_ ssh.Context, conn net.Conn) net.Conn { log.Warnf("SSH connection rejected: invalid remote IP %s", tcpAddr.IP) return nil } + remoteIP = remoteIP.Unmap() // Block connections from our own IP (prevent local apps from connecting to ourselves) - if remoteIP == localIP { + if remoteIP == wgAddr.IP || wgAddr.IPv6.IsValid() && remoteIP == wgAddr.IPv6 { log.Warnf("SSH connection rejected from own IP %s", remoteIP) return nil } - if !netbirdNetwork.Contains(remoteIP) { + inV4 := wgAddr.Network.Contains(remoteIP) + inV6 := wgAddr.IPv6Net.IsValid() && wgAddr.IPv6Net.Contains(remoteIP) + if !inV4 && !inV6 { log.Warnf("SSH connection rejected from non-NetBird IP %s", remoteIP) return nil } @@ -876,20 +917,21 @@ func (s *Server) directTCPIPHandler(srv *ssh.Server, conn *cryptossh.ServerConn, s.mu.RUnlock() if !allowLocal { - logger.Warnf("local port forwarding denied for %s:%d: disabled", payload.Host, payload.Port) + logger.Warnf("local port forwarding denied for %s: disabled", net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port)))) _ = newChan.Reject(cryptossh.Prohibited, "local port forwarding disabled") return } if err := s.checkPortForwardingPrivileges(ctx, "local", payload.Port); err != nil { - logger.Warnf("local port forwarding denied for %s:%d: %v", payload.Host, payload.Port, err) + logger.Warnf("local port forwarding denied for %s: %v", net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port))), err) _ = newChan.Reject(cryptossh.Prohibited, "insufficient privileges") return } - forwardAddr := fmt.Sprintf("-L %s:%d", payload.Host, payload.Port) + hostPort := net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port))) + forwardAddr := "-L " + hostPort s.addConnectionPortForward(ctx.User(), ctx.RemoteAddr(), forwardAddr) - logger.Infof("local port forwarding: %s:%d", payload.Host, payload.Port) + logger.Infof("local port forwarding: %s", hostPort) ssh.DirectTCPIPHandler(srv, conn, newChan, ctx) } diff --git a/client/status/status.go b/client/status/status.go index 8c932bbab..11ed06c2d 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -60,6 +60,7 @@ type ConvertOptions struct { type PeerStateDetailOutput struct { FQDN string `json:"fqdn" yaml:"fqdn"` IP string `json:"netbirdIp" yaml:"netbirdIp"` + IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"` PubKey string `json:"publicKey" yaml:"publicKey"` Status string `json:"status" yaml:"status"` LastStatusUpdate time.Time `json:"lastStatusUpdate" yaml:"lastStatusUpdate"` @@ -139,6 +140,7 @@ type OutputOverview struct { SignalState SignalStateOutput `json:"signal" yaml:"signal"` Relays RelayStateOutput `json:"relays" yaml:"relays"` IP string `json:"netbirdIp" yaml:"netbirdIp"` + IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"` PubKey string `json:"publicKey" yaml:"publicKey"` KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"` FQDN string `json:"fqdn" yaml:"fqdn"` @@ -182,6 +184,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO SignalState: signalOverview, Relays: relayOverview, IP: pbFullStatus.GetLocalPeerState().GetIP(), + IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(), PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(), KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(), FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(), @@ -317,6 +320,7 @@ func mapPeers( timeLocal := pbPeerState.GetConnStatusUpdate().AsTime().Local() peerState := PeerStateDetailOutput{ IP: pbPeerState.GetIP(), + IPv6: pbPeerState.GetIpv6(), PubKey: pbPeerState.GetPubKey(), Status: pbPeerState.GetConnStatus(), LastStatusUpdate: timeLocal, @@ -417,6 +421,11 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS interfaceIP = "N/A" } + ipv6Line := "" + if o.IPv6 != "" { + ipv6Line = fmt.Sprintf("NetBird IPv6: %s\n", o.IPv6) + } + var relaysString string if showRelays { for _, relay := range o.Relays.Details { @@ -549,6 +558,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS "Nameservers: %s\n"+ "FQDN: %s\n"+ "NetBird IP: %s\n"+ + "%s"+ "Interface type: %s\n"+ "Quantum resistance: %s\n"+ "Lazy connection: %s\n"+ @@ -566,6 +576,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS dnsServersString, domain.Domain(o.FQDN).SafeString(), interfaceIP, + ipv6Line, interfaceTypeString, rosenpassEnabledStatus, lazyConnectionEnabledStatus, @@ -616,6 +627,7 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { } pbFullStatus.LocalPeerState.IP = fullStatus.LocalPeerState.IP + pbFullStatus.LocalPeerState.Ipv6 = fullStatus.LocalPeerState.IPv6 pbFullStatus.LocalPeerState.PubKey = fullStatus.LocalPeerState.PubKey pbFullStatus.LocalPeerState.KernelInterface = fullStatus.LocalPeerState.KernelInterface pbFullStatus.LocalPeerState.Fqdn = fullStatus.LocalPeerState.FQDN @@ -628,6 +640,7 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { for _, peerState := range fullStatus.Peers { pbPeerState := &proto.PeerState{ IP: peerState.IP, + Ipv6: peerState.IPv6, PubKey: peerState.PubKey, ConnStatus: peerState.ConnStatus.String(), ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate), @@ -733,9 +746,15 @@ func parsePeers(peers PeersStateOutput, rosenpassEnabled, rosenpassPermissive bo networks = strings.Join(peerState.Networks, ", ") } + ipv6Line := "" + if peerState.IPv6 != "" { + ipv6Line = fmt.Sprintf(" NetBird IPv6: %s\n", peerState.IPv6) + } + peerString := fmt.Sprintf( "\n %s:\n"+ " NetBird IP: %s\n"+ + "%s"+ " Public key: %s\n"+ " Status: %s\n"+ " -- detail --\n"+ @@ -751,6 +770,7 @@ func parsePeers(peers PeersStateOutput, rosenpassEnabled, rosenpassPermissive bo " Latency: %s\n", domain.Domain(peerState.FQDN).SafeString(), peerState.IP, + ipv6Line, peerState.PubKey, peerState.Status, peerState.ConnType, @@ -787,6 +807,9 @@ func skipDetailByFilters(peerState *proto.PeerState, peerStatus string, statusFi if len(ipsFilter) > 0 { _, ok := ipsFilter[peerState.IP] + if !ok { + _, ok = ipsFilter[peerState.Ipv6] + } if !ok { ipEval = true } @@ -905,6 +928,7 @@ func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) { peer.IceCandidateEndpoint.Remote = fmt.Sprintf("%s:%s", a.AnonymizeIPString(remoteIP), port) } + peer.IPv6 = a.AnonymizeIPString(peer.IPv6) peer.RelayAddress = a.AnonymizeURI(peer.RelayAddress) for i, route := range peer.Networks { @@ -929,6 +953,7 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error) overview.IP = a.AnonymizeIPString(overview.IP) + overview.IPv6 = a.AnonymizeIPString(overview.IPv6) for i, detail := range overview.Relays.Details { detail.URI = a.AnonymizeURI(detail.URI) detail.Error = a.AnonymizeString(detail.Error) diff --git a/client/status/status_test.go b/client/status/status_test.go index 7754eebae..0986bf0cd 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -32,6 +32,7 @@ var resp = &proto.StatusResponse{ Peers: []*proto.PeerState{ { IP: "192.168.178.101", + Ipv6: "fd00::1", PubKey: "Pubkey1", Fqdn: "peer-1.awesome-domain.com", ConnStatus: "Connected", @@ -90,6 +91,7 @@ var resp = &proto.StatusResponse{ }, LocalPeerState: &proto.LocalPeerState{ IP: "192.168.178.100/16", + Ipv6: "fd00::100", PubKey: "Some-Pub-Key", KernelInterface: true, Fqdn: "some-localhost.awesome-domain.com", @@ -130,6 +132,7 @@ var overview = OutputOverview{ Details: []PeerStateDetailOutput{ { IP: "192.168.178.101", + IPv6: "fd00::1", PubKey: "Pubkey1", FQDN: "peer-1.awesome-domain.com", Status: "Connected", @@ -204,6 +207,7 @@ var overview = OutputOverview{ }, }, IP: "192.168.178.100/16", + IPv6: "fd00::100", PubKey: "Some-Pub-Key", KernelInterface: true, FQDN: "some-localhost.awesome-domain.com", @@ -284,6 +288,7 @@ func TestParsingToJSON(t *testing.T) { { "fqdn": "peer-1.awesome-domain.com", "netbirdIp": "192.168.178.101", + "netbirdIpv6": "fd00::1", "publicKey": "Pubkey1", "status": "Connected", "lastStatusUpdate": "2001-01-01T01:01:01Z", @@ -361,6 +366,7 @@ func TestParsingToJSON(t *testing.T) { ] }, "netbirdIp": "192.168.178.100/16", + "netbirdIpv6": "fd00::100", "publicKey": "Some-Pub-Key", "usesKernelInterface": true, "fqdn": "some-localhost.awesome-domain.com", @@ -418,6 +424,7 @@ func TestParsingToYAML(t *testing.T) { details: - fqdn: peer-1.awesome-domain.com netbirdIp: 192.168.178.101 + netbirdIpv6: fd00::1 publicKey: Pubkey1 status: Connected lastStatusUpdate: 2001-01-01T01:01:01Z @@ -477,6 +484,7 @@ relays: available: false error: 'context: deadline exceeded' netbirdIp: 192.168.178.100/16 +netbirdIpv6: fd00::100 publicKey: Some-Pub-Key usesKernelInterface: true fqdn: some-localhost.awesome-domain.com @@ -523,6 +531,7 @@ func TestParsingToDetail(t *testing.T) { `Peers detail: peer-1.awesome-domain.com: NetBird IP: 192.168.178.101 + NetBird IPv6: fd00::1 Public key: Pubkey1 Status: Connected -- detail -- @@ -568,6 +577,7 @@ Nameservers: [1.1.1.1:53, 2.2.2.2:53] for [example.com, example.net] is Unavailable, reason: timeout FQDN: some-localhost.awesome-domain.com NetBird IP: 192.168.178.100/16 +NetBird IPv6: fd00::100 Interface type: Kernel Quantum resistance: false Lazy connection: false @@ -592,6 +602,7 @@ Relays: 1/2 Available Nameservers: 1/2 Available FQDN: some-localhost.awesome-domain.com NetBird IP: 192.168.178.100/16 +NetBird IPv6: fd00::100 Interface type: Kernel Quantum resistance: false Lazy connection: false diff --git a/client/system/info.go b/client/system/info.go index 175d1f07f..477d5162b 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -69,6 +69,7 @@ type Info struct { DisableFirewall bool BlockLANAccess bool BlockInbound bool + DisableIPv6 bool LazyConnectionEnabled bool @@ -83,7 +84,7 @@ func (i *Info) SetFlags( rosenpassEnabled, rosenpassPermissive bool, serverSSHAllowed *bool, disableClientRoutes, disableServerRoutes, - disableDNS, disableFirewall, blockLANAccess, blockInbound, lazyConnectionEnabled bool, + disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6, lazyConnectionEnabled bool, enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool, disableSSHAuth *bool, ) { @@ -99,6 +100,7 @@ func (i *Info) SetFlags( i.DisableFirewall = disableFirewall i.BlockLANAccess = blockLANAccess i.BlockInbound = blockInbound + i.DisableIPv6 = disableIPv6 i.LazyConnectionEnabled = lazyConnectionEnabled diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 28f98ae59..c2129c7a2 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -279,6 +279,7 @@ type serviceClient struct { sDisableDNS *widget.Check sDisableClientRoutes *widget.Check sDisableServerRoutes *widget.Check + sDisableIPv6 *widget.Check sBlockLANAccess *widget.Check sEnableSSHRoot *widget.Check sEnableSSHSFTP *widget.Check @@ -299,6 +300,7 @@ type serviceClient struct { disableDNS bool disableClientRoutes bool disableServerRoutes bool + disableIPv6 bool blockLANAccess bool enableSSHRoot bool enableSSHSFTP bool @@ -468,6 +470,7 @@ func (s *serviceClient) showSettingsUI() { s.sDisableDNS = widget.NewCheck("Keeps system DNS settings unchanged", nil) s.sDisableClientRoutes = widget.NewCheck("This peer won't route traffic to other peers", nil) s.sDisableServerRoutes = widget.NewCheck("This peer won't act as router for others", nil) + s.sDisableIPv6 = widget.NewCheck("Disable IPv6 overlay addressing", nil) s.sBlockLANAccess = widget.NewCheck("Blocks local network access when used as exit node", nil) s.sEnableSSHRoot = widget.NewCheck("Enable SSH Root Login", nil) s.sEnableSSHSFTP = widget.NewCheck("Enable SSH SFTP", nil) @@ -585,6 +588,7 @@ func (s *serviceClient) hasSettingsChanged(iMngURL string, port, mtu int64) bool s.disableDNS != s.sDisableDNS.Checked || s.disableClientRoutes != s.sDisableClientRoutes.Checked || s.disableServerRoutes != s.sDisableServerRoutes.Checked || + s.disableIPv6 != s.sDisableIPv6.Checked || s.blockLANAccess != s.sBlockLANAccess.Checked || s.hasSSHChanges() } @@ -637,6 +641,7 @@ func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) ( req.DisableDns = &s.sDisableDNS.Checked req.DisableClientRoutes = &s.sDisableClientRoutes.Checked req.DisableServerRoutes = &s.sDisableServerRoutes.Checked + req.DisableIpv6 = &s.sDisableIPv6.Checked req.BlockLanAccess = &s.sBlockLANAccess.Checked req.EnableSSHRoot = &s.sEnableSSHRoot.Checked @@ -676,24 +681,23 @@ func (s *serviceClient) sendConfigUpdate(req *proto.SetConfigRequest) error { return fmt.Errorf("set config: %w", err) } - // Reconnect if connected to apply the new settings + // Reconnect if connected to apply the new settings. + // Use a background context so the reconnect outlives the settings window. go func() { - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + status, err := conn.Status(ctx, &proto.StatusRequest{}) if err != nil { - log.Errorf("get service status: %v", err) + log.Errorf("failed to get service status: %v", err) return } if status.Status == string(internal.StatusConnected) { - // run down & up - _, err = conn.Down(s.ctx, &proto.DownRequest{}) - if err != nil { - log.Errorf("down service: %v", err) + if _, err = conn.Down(ctx, &proto.DownRequest{}); err != nil { + log.Errorf("failed to stop service: %v", err) } - - _, err = conn.Up(s.ctx, &proto.UpRequest{}) - if err != nil { - log.Errorf("up service: %v", err) - return + // TODO: wait for the service to be idle before calling Up, or use a fresh connection + if _, err = conn.Up(ctx, &proto.UpRequest{}); err != nil { + log.Errorf("failed to start service: %v", err) } } }() @@ -730,6 +734,7 @@ func (s *serviceClient) getNetworkForm() *widget.Form { {Text: "Disable DNS", Widget: s.sDisableDNS}, {Text: "Disable Client Routes", Widget: s.sDisableClientRoutes}, {Text: "Disable Server Routes", Widget: s.sDisableServerRoutes}, + {Text: "Disable IPv6", Widget: s.sDisableIPv6}, {Text: "Disable LAN Access", Widget: s.sBlockLANAccess}, }, } @@ -1327,6 +1332,7 @@ func (s *serviceClient) getSrvConfig() { s.disableDNS = cfg.DisableDNS s.disableClientRoutes = cfg.DisableClientRoutes s.disableServerRoutes = cfg.DisableServerRoutes + s.disableIPv6 = cfg.DisableIPv6 s.blockLANAccess = cfg.BlockLANAccess if cfg.EnableSSHRoot != nil { @@ -1367,6 +1373,7 @@ func (s *serviceClient) getSrvConfig() { s.sDisableDNS.SetChecked(cfg.DisableDNS) s.sDisableClientRoutes.SetChecked(cfg.DisableClientRoutes) s.sDisableServerRoutes.SetChecked(cfg.DisableServerRoutes) + s.sDisableIPv6.SetChecked(cfg.DisableIPv6) s.sBlockLANAccess.SetChecked(cfg.BlockLANAccess) if cfg.EnableSSHRoot != nil { s.sEnableSSHRoot.SetChecked(*cfg.EnableSSHRoot) @@ -1454,6 +1461,7 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config { config.DisableDNS = cfg.DisableDns config.DisableClientRoutes = cfg.DisableClientRoutes config.DisableServerRoutes = cfg.DisableServerRoutes + config.DisableIPv6 = cfg.DisableIpv6 config.BlockLANAccess = cfg.BlockLanAccess config.EnableSSHRoot = &cfg.EnableSSHRoot diff --git a/client/ui/event/event.go b/client/ui/event/event.go index ea968f60a..3b43fdc7f 100644 --- a/client/ui/event/event.go +++ b/client/ui/event/event.go @@ -112,7 +112,7 @@ func (e *Manager) handleEvent(event *proto.SystemEvent) { handlers := slices.Clone(e.handlers) e.mu.Unlock() - if event.UserMessage != "" && (enabled || event.Severity == proto.SystemEvent_CRITICAL) { + if event.UserMessage != "" && (enabled || event.Severity == proto.SystemEvent_CRITICAL) && !isV6DefaultRoutePartner(event) { title := e.getEventTitle(event) body := event.UserMessage id := event.Metadata["id"] @@ -133,6 +133,14 @@ func (e *Manager) AddHandler(handler Handler) { e.handlers = append(e.handlers, handler) } +// isV6DefaultRoutePartner reports whether the event is the IPv6 half of a +// paired v4/v6 default-route event. Management always pairs ::/0 with 0.0.0.0/0 +// for exit nodes, so the v4 partner already drives the user-facing toast and +// the v6 one is suppressed to avoid a duplicate notification. +func isV6DefaultRoutePartner(event *proto.SystemEvent) bool { + return event.Category == proto.SystemEvent_NETWORK && event.Metadata["network"] == "::/0" +} + func (e *Manager) getEventTitle(event *proto.SystemEvent) string { var prefix string switch event.Severity { diff --git a/client/ui/network.go b/client/ui/network.go index 571e871bb..1619f78a2 100644 --- a/client/ui/network.go +++ b/client/ui/network.go @@ -192,10 +192,14 @@ func getOverlappingNetworks(routes []*proto.Network) []*proto.Network { return filteredRoutes } +func isDefaultRoute(routeRange string) bool { + return routeRange == "0.0.0.0/0" || routeRange == "::/0" +} + func getExitNodeNetworks(routes []*proto.Network) []*proto.Network { var filteredRoutes []*proto.Network for _, route := range routes { - if route.Range == "0.0.0.0/0" { + if isDefaultRoute(route.Range) { filteredRoutes = append(filteredRoutes, route) } } @@ -499,7 +503,7 @@ func (s *serviceClient) getExitNodes(conn proto.DaemonServiceClient) ([]*proto.N var exitNodes []*proto.Network for _, network := range resp.Routes { - if network.Range == "0.0.0.0/0" { + if isDefaultRoute(network.Range) { exitNodes = append(exitNodes, network) } } diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index cb512f132..066fe043b 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -5,6 +5,8 @@ package main import ( "context" "fmt" + "net" + "strconv" "sync" "syscall/js" "time" @@ -83,6 +85,10 @@ func parseClientOptions(jsOptions js.Value) (netbird.Options, error) { options.DeviceName = deviceName.String() } + if disableIPv6 := jsOptions.Get("disableIPv6"); !disableIPv6.IsNull() && !disableIPv6.IsUndefined() { + options.DisableIPv6 = disableIPv6.Bool() + } + return options, nil } @@ -163,39 +169,58 @@ func createSSHMethod(client *netbird.Client) js.Func { }) } - var jwtToken string - if len(args) > 3 && !args[3].IsNull() && !args[3].IsUndefined() { - jwtToken = args[3].String() - } + jwtToken, ipVersion := parseSSHOptions(args) return createPromise(func(resolve, reject js.Value) { - sshClient := ssh.NewClient(client) - - if err := sshClient.Connect(host, port, username, jwtToken); err != nil { + jsInterface, err := connectSSH(client, host, port, username, jwtToken, ipVersion) + if err != nil { reject.Invoke(err.Error()) return } - - if err := sshClient.StartSession(80, 24); err != nil { - if closeErr := sshClient.Close(); closeErr != nil { - log.Errorf("Error closing SSH client: %v", closeErr) - } - reject.Invoke(err.Error()) - return - } - - jsInterface := ssh.CreateJSInterface(sshClient) resolve.Invoke(jsInterface) }) }) } -func performPing(client *netbird.Client, hostname string) { +func parseSSHOptions(args []js.Value) (jwtToken string, ipVersion int) { + if len(args) > 3 && !args[3].IsNull() && !args[3].IsUndefined() { + jwtToken = args[3].String() + } + if len(args) > 4 { + ipVersion = jsIPVersion(args[4]) + } + return +} + +func connectSSH(client *netbird.Client, host string, port int, username, jwtToken string, ipVersion int) (js.Value, error) { + sshClient := ssh.NewClient(client) + + if err := sshClient.Connect(host, port, username, jwtToken, ipVersion); err != nil { + return js.Undefined(), err + } + + if err := sshClient.StartSession(80, 24); err != nil { + if closeErr := sshClient.Close(); closeErr != nil { + log.Errorf("Error closing SSH client: %v", closeErr) + } + return js.Undefined(), err + } + + return ssh.CreateJSInterface(sshClient), nil +} + +func performPing(client *netbird.Client, hostname string, ipVersion int) { ctx, cancel := context.WithTimeout(context.Background(), pingTimeout) defer cancel() + // Default to ping4 to avoid dual-stack ICMP endpoint issues in wireguard-go netstack. + network := "ping4" + if ipVersion == 6 { + network = "ping6" + } + start := time.Now() - conn, err := client.Dial(ctx, "ping", hostname) + conn, err := client.Dial(ctx, network, hostname) if err != nil { js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s failed: %v", hostname, err)) return @@ -222,27 +247,39 @@ func performPing(client *netbird.Client, hostname string) { } latency := time.Since(start) - js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s: %dms", hostname, latency.Milliseconds())) + remote := conn.RemoteAddr().String() + msg := fmt.Sprintf("Ping to %s: %dms", hostname, latency.Milliseconds()) + if remote != hostname { + msg += fmt.Sprintf(" (via %s)", remote) + } + js.Global().Get("console").Call("log", msg) } -func performPingTCP(client *netbird.Client, hostname string, port int) { +func performPingTCP(client *netbird.Client, hostname string, port, ipVersion int) { ctx, cancel := context.WithTimeout(context.Background(), pingTimeout) defer cancel() - address := fmt.Sprintf("%s:%d", hostname, port) + network := ipVersionNetwork("tcp", ipVersion) + + address := net.JoinHostPort(hostname, fmt.Sprintf("%d", port)) start := time.Now() - conn, err := client.Dial(ctx, "tcp", address) + conn, err := client.Dial(ctx, network, address) if err != nil { js.Global().Get("console").Call("log", fmt.Sprintf("TCP ping to %s failed: %v", address, err)) return } latency := time.Since(start) + remote := conn.RemoteAddr().String() if err := conn.Close(); err != nil { log.Debugf("failed to close TCP connection: %v", err) } - js.Global().Get("console").Call("log", fmt.Sprintf("TCP ping to %s succeeded: %dms", address, latency.Milliseconds())) + msg := fmt.Sprintf("TCP ping to %s succeeded: %dms", address, latency.Milliseconds()) + if remote != address { + msg += fmt.Sprintf(" (via %s)", remote) + } + js.Global().Get("console").Call("log", msg) } // createPingMethod creates the ping method @@ -259,8 +296,12 @@ func createPingMethod(client *netbird.Client) js.Func { } hostname := args[0].String() + var ipVersion int + if len(args) > 1 { + ipVersion = jsIPVersion(args[1]) + } return createPromise(func(resolve, reject js.Value) { - performPing(client, hostname) + performPing(client, hostname, ipVersion) resolve.Invoke(js.Undefined()) }) }) @@ -287,8 +328,12 @@ func createPingTCPMethod(client *netbird.Client) js.Func { hostname := args[0].String() port := args[1].Int() + var ipVersion int + if len(args) > 2 { + ipVersion = jsIPVersion(args[2]) + } return createPromise(func(resolve, reject js.Value) { - performPingTCP(client, hostname, port) + performPingTCP(client, hostname, port, ipVersion) resolve.Invoke(js.Undefined()) }) }) @@ -461,6 +506,31 @@ func createSetLogLevelMethod(client *netbird.Client) js.Func { }) } +// ipVersionNetwork appends "4" or "6" to a base network string (e.g. "tcp" -> "tcp4"). +func ipVersionNetwork(base string, ipVersion int) string { + switch ipVersion { + case 4: + return base + "4" + case 6: + return base + "6" + default: + return base + } +} + +// jsIPVersion extracts an IP version (4 or 6) from a JS string or number. +func jsIPVersion(v js.Value) int { + switch v.Type() { + case js.TypeNumber: + return v.Int() + case js.TypeString: + n, _ := strconv.Atoi(v.String()) + return n + default: + return 0 + } +} + // createStartCaptureMethod creates the programmable packet capture method. // Returns a JS interface with onpacket callback and stop() method. // diff --git a/client/wasm/internal/rdp/rdcleanpath.go b/client/wasm/internal/rdp/rdcleanpath.go index 16bf63bb9..6c36fdec6 100644 --- a/client/wasm/internal/rdp/rdcleanpath.go +++ b/client/wasm/internal/rdp/rdcleanpath.go @@ -82,7 +82,7 @@ func NewRDCleanPathProxy(client interface { // CreateProxy creates a new proxy endpoint for the given destination func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value { - destination := fmt.Sprintf("%s:%s", hostname, port) + destination := net.JoinHostPort(hostname, port) return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, args []js.Value) any { resolve := args[0] diff --git a/client/wasm/internal/ssh/client.go b/client/wasm/internal/ssh/client.go index 568437e56..9cfe65266 100644 --- a/client/wasm/internal/ssh/client.go +++ b/client/wasm/internal/ssh/client.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "io" + "net" "sync" "time" @@ -45,9 +46,10 @@ func NewClient(nbClient *netbird.Client) *Client { } } -// Connect establishes an SSH connection through NetBird network -func (c *Client) Connect(host string, port int, username, jwtToken string) error { - addr := fmt.Sprintf("%s:%d", host, port) +// Connect establishes an SSH connection through NetBird network. +// ipVersion may be 4, 6, or 0 for automatic selection. +func (c *Client) Connect(host string, port int, username, jwtToken string, ipVersion int) error { + addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) logrus.Infof("SSH: Connecting to %s as %s", addr, username) authMethods, err := c.getAuthMethods(jwtToken) @@ -62,10 +64,18 @@ func (c *Client) Connect(host string, port int, username, jwtToken string) error Timeout: sshDialTimeout, } + network := "tcp" + switch ipVersion { + case 4: + network = "tcp4" + case 6: + network = "tcp6" + } + ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout) defer cancel() - conn, err := c.nbClient.Dial(ctx, "tcp", addr) + conn, err := c.nbClient.Dial(ctx, network, addr) if err != nil { return fmt.Errorf("dial %s: %w", addr, err) } diff --git a/combined/cmd/config.go b/combined/cmd/config.go index ce4df8394..9959f7a56 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -380,7 +380,7 @@ func (c *CombinedConfig) autoConfigureClientSettings(exposedProto, exposedHost, // Auto-configure local STUN servers for all ports for _, port := range c.Server.StunPorts { c.Management.Stuns = append(c.Management.Stuns, HostConfig{ - URI: fmt.Sprintf("stun:%s:%d", exposedHost, port), + URI: "stun:" + net.JoinHostPort(strings.Trim(exposedHost, "[]"), fmt.Sprintf("%d", port)), }) } } diff --git a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go index fc91b8616..3485d51fe 100644 --- a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go +++ b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go @@ -2,7 +2,7 @@ package manager import ( "context" - "net" + "net/netip" "testing" "time" @@ -56,7 +56,8 @@ func setupL4Test(t *testing.T, customPortsSupported *bool) (*Manager, store.Stor Key: "test-key", DNSLabel: "test-peer", Name: "test-peer", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, }, diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index 0fb5f46ff..d03a8dc82 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/rand/v2" + "net" "net/http" "os" "slices" @@ -1103,7 +1104,7 @@ func (m *Manager) CreateServiceFromPeer(ctx context.Context, accountID, peerID s serviceURL := "https://" + svc.Domain if service.IsL4Protocol(svc.Mode) { - serviceURL = fmt.Sprintf("%s://%s:%d", svc.Mode, svc.Domain, svc.ListenPort) + serviceURL = fmt.Sprintf("%s://%s", svc.Mode, net.JoinHostPort(svc.Domain, strconv.Itoa(int(svc.ListenPort)))) } return &service.ExposeServiceResponse{ @@ -1272,7 +1273,7 @@ func addPeerInfoToEventMeta(meta map[string]any, peer *nbpeer.Peer) map[string]a return meta } meta["peer_name"] = peer.Name - if peer.IP != nil { + if peer.IP.IsValid() { meta["peer_ip"] = peer.IP.String() } return meta diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index e9403849c..46e79f1e5 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -3,7 +3,7 @@ package manager import ( "context" "errors" - "net" + "net/netip" "testing" "time" @@ -405,7 +405,8 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { testPeer := &nbpeer.Peer{ ID: ownerPeerID, Name: "test-peer", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), } newEphemeralService := func() *rpservice.Service { @@ -682,7 +683,8 @@ func setupIntegrationTest(t *testing.T) (*Manager, store.Store) { Key: "test-key", DNSLabel: "test-peer", Name: "test-peer", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, }, @@ -751,7 +753,8 @@ func Test_validateExposePermission(t *testing.T) { Key: "other-key", DNSLabel: "other-peer", Name: "other-peer", - IP: net.ParseIP("100.64.0.2"), + IP: netip.MustParseAddr("100.64.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), Status: &nbpeer.PeerStatus{LastSeen: time.Now()}, Meta: nbpeer.PeerSystemMeta{Hostname: "other-peer"}, }) diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index ef417d3cf..12402b420 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -3,12 +3,15 @@ package grpc import ( "context" "fmt" + "net/netip" "net/url" "strings" log "github.com/sirupsen/logrus" + goproto "google.golang.org/protobuf/proto" integrationsConfig "github.com/netbirdio/management-integrations/integrations/config" + "github.com/netbirdio/netbird/client/ssh/auth" nbdns "github.com/netbirdio/netbird/dns" @@ -17,8 +20,9 @@ import ( nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/types" - "github.com/netbirdio/netbird/route" + nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" "github.com/netbirdio/netbird/shared/sshauth" ) @@ -100,7 +104,7 @@ func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, set sshConfig.JwtConfig = buildJWTConfig(httpConfig, deviceFlowConfig) } - return &proto.PeerConfig{ + peerConfig := &proto.PeerConfig{ Address: fmt.Sprintf("%s/%d", peer.IP.String(), netmask), SshConfig: sshConfig, Fqdn: fqdn, @@ -111,9 +115,25 @@ func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, set AlwaysUpdate: settings.AutoUpdateAlways, }, } + + if peer.SupportsIPv6() && peer.IPv6.IsValid() && network.NetV6.IP != nil { + ones, _ := network.NetV6.Mask.Size() + v6Prefix := netip.PrefixFrom(peer.IPv6.Unmap(), ones) + if b, err := netiputil.EncodePrefix(v6Prefix); err == nil { + peerConfig.AddressV6 = b + } + } + + return peerConfig } func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nbpeer.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *types.Settings, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse { + // IPv6 data in AllowedIPs and SourcePrefixes wildcard expansion depends on + // whether the target peer supports IPv6. Routes and firewall rules are already + // filtered at the source (network map builder). + includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() + useSourcePrefixes := peer.SupportsSourcePrefixes() + response := &proto.SyncResponse{ PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), NetworkMap: &proto.NetworkMap{ @@ -132,15 +152,15 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb response.NetworkMap.PeerConfig = response.PeerConfig remotePeers := make([]*proto.RemotePeerConfig, 0, len(networkMap.Peers)+len(networkMap.OfflinePeers)) - remotePeers = appendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName) + remotePeers = appendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6) response.RemotePeers = remotePeers response.NetworkMap.RemotePeers = remotePeers response.RemotePeersIsEmpty = len(remotePeers) == 0 response.NetworkMap.RemotePeersIsEmpty = response.RemotePeersIsEmpty - response.NetworkMap.OfflinePeers = appendRemotePeerConfig(nil, networkMap.OfflinePeers, dnsName) + response.NetworkMap.OfflinePeers = appendRemotePeerConfig(nil, networkMap.OfflinePeers, dnsName, includeIPv6) - firewallRules := toProtocolFirewallRules(networkMap.FirewallRules) + firewallRules := toProtocolFirewallRules(networkMap.FirewallRules, includeIPv6, useSourcePrefixes) response.NetworkMap.FirewallRules = firewallRules response.NetworkMap.FirewallRulesIsEmpty = len(firewallRules) == 0 @@ -195,11 +215,15 @@ func buildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]m return hashedUsers, machineUsers } -func appendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string) []*proto.RemotePeerConfig { +func appendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { for _, rPeer := range peers { + allowedIPs := []string{rPeer.IP.String() + "/32"} + if includeIPv6 && rPeer.IPv6.IsValid() { + allowedIPs = append(allowedIPs, rPeer.IPv6.String()+"/128") + } dst = append(dst, &proto.RemotePeerConfig{ WgPubKey: rPeer.Key, - AllowedIps: []string{rPeer.IP.String() + "/32"}, + AllowedIps: allowedIPs, SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, Fqdn: rPeer.FQDN(dnsName), AgentVersion: rPeer.Meta.WtVersion, @@ -253,7 +277,7 @@ func ToResponseProto(configProto nbconfig.Protocol) proto.HostConfig_Protocol { } } -func toProtocolRoutes(routes []*route.Route) []*proto.Route { +func toProtocolRoutes(routes []*nbroute.Route) []*proto.Route { protoRoutes := make([]*proto.Route, 0, len(routes)) for _, r := range routes { protoRoutes = append(protoRoutes, toProtocolRoute(r)) @@ -261,7 +285,7 @@ func toProtocolRoutes(routes []*route.Route) []*proto.Route { return protoRoutes } -func toProtocolRoute(route *route.Route) *proto.Route { +func toProtocolRoute(route *nbroute.Route) *proto.Route { return &proto.Route{ ID: string(route.ID), NetID: string(route.NetID), @@ -277,29 +301,70 @@ func toProtocolRoute(route *route.Route) *proto.Route { } // toProtocolFirewallRules converts the firewall rules to the protocol firewall rules. -func toProtocolFirewallRules(rules []*types.FirewallRule) []*proto.FirewallRule { - result := make([]*proto.FirewallRule, len(rules)) +// When useSourcePrefixes is true, the compact SourcePrefixes field is populated +// alongside the deprecated PeerIP for forward compatibility. +// Wildcard rules ("0.0.0.0") are expanded into separate v4 and v6 SourcePrefixes +// when includeIPv6 is true. +func toProtocolFirewallRules(rules []*types.FirewallRule, includeIPv6, useSourcePrefixes bool) []*proto.FirewallRule { + result := make([]*proto.FirewallRule, 0, len(rules)) for i := range rules { rule := rules[i] fwRule := &proto.FirewallRule{ PolicyID: []byte(rule.PolicyID), - PeerIP: rule.PeerIP, + PeerIP: rule.PeerIP, //nolint:staticcheck // populated for backward compatibility Direction: getProtoDirection(rule.Direction), Action: getProtoAction(rule.Action), Protocol: getProtoProtocol(rule.Protocol), Port: rule.Port, } + if useSourcePrefixes && rule.PeerIP != "" { + result = append(result, populateSourcePrefixes(fwRule, rule, includeIPv6)...) + } + if shouldUsePortRange(fwRule) { fwRule.PortInfo = rule.PortRange.ToProto() } - result[i] = fwRule + result = append(result, fwRule) } return result } + +// populateSourcePrefixes sets SourcePrefixes on fwRule and returns any +// additional rules needed (e.g. a v6 wildcard clone when the peer IP is unspecified). +func populateSourcePrefixes(fwRule *proto.FirewallRule, rule *types.FirewallRule, includeIPv6 bool) []*proto.FirewallRule { + addr, err := netip.ParseAddr(rule.PeerIP) + if err != nil { + return nil + } + + if !addr.IsUnspecified() { + fwRule.SourcePrefixes = [][]byte{netiputil.EncodeAddr(addr.Unmap())} + return nil + } + + // IPv4Unspecified/0 is always valid, error is impossible. + v4Wildcard, _ := netiputil.EncodePrefix(netip.PrefixFrom(netip.IPv4Unspecified(), 0)) + fwRule.SourcePrefixes = [][]byte{v4Wildcard} + + if !includeIPv6 { + return nil + } + + v6Rule := goproto.Clone(fwRule).(*proto.FirewallRule) + v6Rule.PeerIP = "::" //nolint:staticcheck // populated for backward compatibility + // IPv6Unspecified/0 is always valid, error is impossible. + v6Wildcard, _ := netiputil.EncodePrefix(netip.PrefixFrom(netip.IPv6Unspecified(), 0)) + v6Rule.SourcePrefixes = [][]byte{v6Wildcard} + if shouldUsePortRange(v6Rule) { + v6Rule.PortInfo = rule.PortRange.ToProto() + } + return []*proto.FirewallRule{v6Rule} +} + // getProtoDirection converts the direction to proto.RuleDirection. func getProtoDirection(direction int) proto.RuleDirection { if direction == types.FirewallRuleDirectionOUT { diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 0c1611e7f..70024bac6 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -680,11 +680,21 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee BlockLANAccess: meta.GetFlags().GetBlockLANAccess(), BlockInbound: meta.GetFlags().GetBlockInbound(), LazyConnectionEnabled: meta.GetFlags().GetLazyConnectionEnabled(), + DisableIPv6: meta.GetFlags().GetDisableIPv6(), }, - Files: files, + Files: files, + Capabilities: capabilitiesToInt32(meta.GetCapabilities()), } } +func capabilitiesToInt32(caps []proto.PeerCapability) []int32 { + result := make([]int32, len(caps)) + for i, c := range caps { + result[i] = int32(c) + } + return result +} + func (s *Server) parseRequest(ctx context.Context, req *proto.EncryptedMessage, parsed pb.Message) (wgtypes.Key, error) { peerKey, err := wgtypes.ParseKey(req.GetWgPubKey()) if err != nil { diff --git a/management/server/account.go b/management/server/account.go index 4b71ab486..45b99839f 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -329,6 +329,13 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco updateAccountPeers = true } + if ipv6SettingsChanged(oldSettings, newSettings) { + if err = am.updatePeerIPv6Addresses(ctx, transaction, accountID, newSettings); err != nil { + return err + } + updateAccountPeers = true + } + if oldSettings.RoutingPeerDNSResolutionEnabled != newSettings.RoutingPeerDNSResolutionEnabled || oldSettings.LazyConnectionEnabled != newSettings.LazyConnectionEnabled || oldSettings.DNSDomain != newSettings.DNSDomain || @@ -338,7 +345,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco } if oldSettings.GroupsPropagationEnabled != newSettings.GroupsPropagationEnabled && newSettings.GroupsPropagationEnabled { - groupsUpdated, groupChangesAffectPeers, err = propagateUserGroupMemberships(ctx, transaction, accountID) + groupsUpdated, groupChangesAffectPeers, err = am.propagateUserGroupMemberships(ctx, transaction, accountID) if err != nil { return err } @@ -393,6 +400,22 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco } am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountNetworkRangeUpdated, eventMeta) } + oldIPv6On := len(oldSettings.IPv6EnabledGroups) > 0 + newIPv6On := len(newSettings.IPv6EnabledGroups) > 0 + if oldIPv6On != newIPv6On { + if newIPv6On { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountIPv6Enabled, nil) + } else { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountIPv6Disabled, nil) + } + } + if oldSettings.NetworkRangeV6 != newSettings.NetworkRangeV6 { + eventMeta := map[string]any{ + "old_network_range_v6": oldSettings.NetworkRangeV6.String(), + "new_network_range_v6": newSettings.NetworkRangeV6.String(), + } + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountNetworkRangeUpdated, eventMeta) + } if reloadReverseProxy { if err = am.serviceManager.ReloadAllServicesForAccount(ctx, accountID); err != nil { log.WithContext(ctx).Warnf("failed to reload all services for account %s: %v", accountID, err) @@ -406,6 +429,17 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco return newSettings, nil } +func ipv6SettingsChanged(old, updated *types.Settings) bool { + if old.NetworkRangeV6 != updated.NetworkRangeV6 { + return true + } + oldGroups := slices.Clone(old.IPv6EnabledGroups) + newGroups := slices.Clone(updated.IPv6EnabledGroups) + slices.Sort(oldGroups) + slices.Sort(newGroups) + return !slices.Equal(oldGroups, newGroups) +} + func (am *DefaultAccountManager) validateSettingsUpdate(ctx context.Context, transaction store.Store, newSettings, oldSettings *types.Settings, userID, accountID string) error { halfYearLimit := 180 * 24 * time.Hour if newSettings.PeerLoginExpiration > halfYearLimit { @@ -432,9 +466,38 @@ func (am *DefaultAccountManager) validateSettingsUpdate(ctx context.Context, tra } } + if err := validateIPv6EnabledGroups(ctx, transaction, accountID, newSettings.IPv6EnabledGroups); err != nil { + return err + } + return am.integratedPeerValidator.ValidateExtraSettings(ctx, newSettings.Extra, oldSettings.Extra, userID, accountID) } +// validateIPv6EnabledGroups checks that all referenced IPv6-enabled group IDs exist in the account. +func validateIPv6EnabledGroups(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) error { + if len(groupIDs) == 0 { + return nil + } + + groups, err := transaction.GetAccountGroups(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("get groups for IPv6 validation: %w", err) + } + + existing := make(map[string]struct{}, len(groups)) + for _, g := range groups { + existing[g.ID] = struct{}{} + } + + for _, gid := range groupIDs { + if _, ok := existing[gid]; !ok { + return status.Errorf(status.InvalidArgument, "IPv6 enabled group %s does not exist", gid) + } + } + + return nil +} + func (am *DefaultAccountManager) handleRoutingPeerDNSResolutionSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { if oldSettings.RoutingPeerDNSResolutionEnabled != newSettings.RoutingPeerDNSResolutionEnabled { if newSettings.RoutingPeerDNSResolutionEnabled { @@ -739,37 +802,8 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u return status.Errorf(status.Internal, "failed to build user infos for account %s: %v", accountID, err) } - for _, otherUser := range account.Users { - if otherUser.Id == userID { - continue - } - - if otherUser.IsServiceUser { - err = am.deleteServiceUser(ctx, accountID, userID, otherUser) - if err != nil { - return err - } - continue - } - - userInfo, ok := userInfosMap[otherUser.Id] - if !ok { - return status.Errorf(status.NotFound, "user info not found for user %s", otherUser.Id) - } - - _, deleteUserErr := am.deleteRegularUser(ctx, accountID, userID, userInfo) - if deleteUserErr != nil { - return deleteUserErr - } - } - - userInfo, ok := userInfosMap[userID] - if ok { - _, err = am.deleteRegularUser(ctx, accountID, userID, userInfo) - if err != nil { - log.WithContext(ctx).Errorf("failed deleting user %s. error: %s", userID, err) - return err - } + if err = am.deleteAccountUsers(ctx, accountID, userID, account.Users, userInfosMap); err != nil { + return err } err = am.Store.DeleteAccount(ctx, account) @@ -787,6 +821,40 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u return nil } +func (am *DefaultAccountManager) deleteAccountUsers(ctx context.Context, accountID, initiatorUserID string, users map[string]*types.User, userInfosMap map[string]*types.UserInfo) error { + for _, otherUser := range users { + if otherUser.Id == initiatorUserID { + continue + } + + if otherUser.IsServiceUser { + if err := am.deleteServiceUser(ctx, accountID, initiatorUserID, otherUser); err != nil { + return err + } + continue + } + + userInfo, ok := userInfosMap[otherUser.Id] + if !ok { + return status.Errorf(status.NotFound, "user info not found for user %s", otherUser.Id) + } + + if _, err := am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo); err != nil { + return err + } + } + + userInfo, ok := userInfosMap[initiatorUserID] + if ok { + if _, err := am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo); err != nil { + log.WithContext(ctx).Errorf("failed deleting user %s. error: %s", initiatorUserID, err) + return err + } + } + + return nil +} + // AccountExists checks if an account exists. func (am *DefaultAccountManager) AccountExists(ctx context.Context, accountID string) (bool, error) { return am.Store.AccountExists(ctx, store.LockingStrengthNone, accountID) @@ -1528,6 +1596,11 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth } } + allGroupChanges := slices.Concat(addNewGroups, removeOldGroups) + if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil { + return fmt.Errorf("reconcile IPv6 for group changes: %w", err) + } + if err = transaction.IncrementNetworkSerial(ctx, userAuth.AccountId); err != nil { return fmt.Errorf("error incrementing network serial: %w", err) } @@ -1913,6 +1986,11 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain, email, nam if err := acc.AddAllGroup(disableDefaultPolicy); err != nil { log.WithContext(ctx).Errorf("error adding all group to account %s: %v", acc.Id, err) } + + if allGroup, err := acc.GetGroupAll(); err == nil { + acc.Settings.IPv6EnabledGroups = []string{allGroup.ID} + } + return acc } @@ -2019,6 +2097,10 @@ func (am *DefaultAccountManager) GetOrCreateAccountByPrivateDomain(ctx context.C return nil, false, status.Errorf(status.Internal, "failed to add all group to new account by private domain") } + if allGroup, err := newAccount.GetGroupAll(); err == nil { + newAccount.Settings.IPv6EnabledGroups = []string{allGroup.ID} + } + if err := am.Store.SaveAccount(ctx, newAccount); err != nil { log.WithContext(ctx).WithFields(log.Fields{ "accountId": newAccount.Id, @@ -2080,7 +2162,7 @@ func (am *DefaultAccountManager) UpdateToPrimaryAccount(ctx context.Context, acc // propagateUserGroupMemberships propagates all account users' group memberships to their peers. // Returns true if any groups were modified, true if those updates affect peers and an error. -func propagateUserGroupMemberships(ctx context.Context, transaction store.Store, accountID string) (groupsUpdated bool, peersAffected bool, err error) { +func (am *DefaultAccountManager) propagateUserGroupMemberships(ctx context.Context, transaction store.Store, accountID string) (groupsUpdated bool, peersAffected bool, err error) { users, err := transaction.GetAccountUsers(ctx, store.LockingStrengthNone, accountID) if err != nil { return false, false, err @@ -2102,29 +2184,13 @@ func propagateUserGroupMemberships(ctx context.Context, transaction store.Store, } } - updatedGroups := []string{} - for _, user := range users { - userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, accountID, user.Id) - if err != nil { - return false, false, err - } + updatedGroups, err := propagateAutoGroupsForUsers(ctx, transaction, accountID, users, accountGroupPeers) + if err != nil { + return false, false, err + } - for _, peer := range userPeers { - for _, groupID := range user.AutoGroups { - if _, exists := accountGroupPeers[groupID]; !exists { - // we do not wanna create the groups here - log.WithContext(ctx).Warnf("group %s does not exist for user group propagation", groupID) - continue - } - if _, exists := accountGroupPeers[groupID][peer.ID]; exists { - continue - } - if err := transaction.AddPeerToGroup(ctx, accountID, peer.ID, groupID); err != nil { - return false, false, fmt.Errorf("error adding peer %s to group %s: %w", peer.ID, groupID, err) - } - updatedGroups = append(updatedGroups, groupID) - } - } + if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, updatedGroups); err != nil { + return false, false, fmt.Errorf("reconcile IPv6 for group changes: %w", err) } peersAffected, err = areGroupChangesAffectPeers(ctx, transaction, accountID, updatedGroups) @@ -2135,6 +2201,35 @@ func propagateUserGroupMemberships(ctx context.Context, transaction store.Store, return len(updatedGroups) > 0, peersAffected, nil } +// propagateAutoGroupsForUsers adds each user's peers to their AutoGroups where not already present. +// Returns the list of group IDs that were modified. +func propagateAutoGroupsForUsers(ctx context.Context, transaction store.Store, accountID string, users []*types.User, accountGroupPeers map[string]map[string]struct{}) ([]string, error) { + var updatedGroups []string + for _, user := range users { + userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, accountID, user.Id) + if err != nil { + return nil, err + } + + for _, peer := range userPeers { + for _, groupID := range user.AutoGroups { + if _, exists := accountGroupPeers[groupID]; !exists { + log.WithContext(ctx).Warnf("group %s does not exist for user group propagation", groupID) + continue + } + if _, exists := accountGroupPeers[groupID][peer.ID]; exists { + continue + } + if err := transaction.AddPeerToGroup(ctx, accountID, peer.ID, groupID); err != nil { + return nil, fmt.Errorf("error adding peer %s to group %s: %w", peer.ID, groupID, err) + } + updatedGroups = append(updatedGroups, groupID) + } + } + } + return updatedGroups, nil +} + // reallocateAccountPeerIPs re-allocates all peer IPs when the network range changes func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, transaction store.Store, accountID string, newNetworkRange netip.Prefix) error { if !newNetworkRange.IsValid() { @@ -2156,10 +2251,10 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t return err } - var takenIPs []net.IP + var takenIPs []netip.Addr for _, peer := range peers { - newIP, err := types.AllocatePeerIP(newIPNet, takenIPs) + newIP, err := types.AllocatePeerIP(newNetworkRange, takenIPs) if err != nil { return status.Errorf(status.Internal, "allocate IP for peer %s: %v", peer.ID, err) } @@ -2183,13 +2278,199 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t return nil } +// updatePeerIPv6Addresses assigns or removes IPv6 addresses for all peers +// based on the current IPv6 settings. When IPv6 is enabled, peers without a +// v6 address get one allocated. When disabled, all v6 addresses are cleared. +// When the v6 range changes, all v6 addresses are reallocated. +func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transaction store.Store, accountID, peerID string, newIPv6 netip.Addr) error { + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "") + if err != nil { + return fmt.Errorf("get peers: %w", err) + } + for _, p := range peers { + if p.ID != peerID && p.IPv6.IsValid() && p.IPv6 == newIPv6 { + return status.Errorf(status.InvalidArgument, "IPv6 %s is already assigned to peer %s", newIPv6, p.Name) + } + } + return nil +} + +func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) error { + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "") + if err != nil { + return fmt.Errorf("get peers: %w", err) + } + + network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthUpdate, accountID) + if err != nil { + return fmt.Errorf("get network: %w", err) + } + + if err := am.ensureIPv6Subnet(ctx, transaction, accountID, settings, network); err != nil { + return err + } + + allowedPeers, err := am.buildIPv6AllowedPeers(ctx, transaction, accountID, settings) + if err != nil { + return err + } + + v6Prefix, err := netip.ParsePrefix(network.NetV6.String()) + if err != nil { + return fmt.Errorf("parse IPv6 prefix: %w", err) + } + + if err := am.assignPeerIPv6Addresses(ctx, transaction, accountID, peers, network, allowedPeers, v6Prefix); err != nil { + return err + } + + log.WithContext(ctx).Infof("updated IPv6 addresses for %d peers in account %s (groups=%d)", + len(peers), accountID, len(settings.IPv6EnabledGroups)) + + return nil +} + +// reconcileIPv6ForGroupChanges checks whether the given group IDs overlap with +// the account's IPv6EnabledGroups. If they do, it runs a full IPv6 address +// reconciliation so that peers gaining or losing membership in an IPv6-enabled +// group get their addresses assigned or removed. +func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) error { + settings, err := transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("get account settings: %w", err) + } + + if len(settings.IPv6EnabledGroups) == 0 { + return nil + } + + enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups)) + for _, gid := range settings.IPv6EnabledGroups { + enabledSet[gid] = struct{}{} + } + + affected := false + for _, gid := range groupIDs { + if _, ok := enabledSet[gid]; ok { + affected = true + break + } + } + + if !affected { + return nil + } + + return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings) +} + +func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings, network *types.Network) error { + if settings.NetworkRangeV6.IsValid() { + network.NetV6 = net.IPNet{ + IP: settings.NetworkRangeV6.Masked().Addr().AsSlice(), + Mask: net.CIDRMask(settings.NetworkRangeV6.Bits(), 128), + } + return transaction.UpdateAccountNetworkV6(ctx, accountID, network.NetV6) + } + if network.NetV6.IP == nil { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + network.NetV6 = types.AllocateIPv6Subnet(r) + + // Sync settings to match the allocated subnet so SaveAccountSettings persists it. + ones, _ := network.NetV6.Mask.Size() + addr, _ := netip.AddrFromSlice(network.NetV6.IP) + settings.NetworkRangeV6 = netip.PrefixFrom(addr.Unmap(), ones) + + return transaction.UpdateAccountNetworkV6(ctx, accountID, network.NetV6) + } + return nil +} + +func (am *DefaultAccountManager) assignPeerIPv6Addresses( + ctx context.Context, transaction store.Store, accountID string, + peers []*nbpeer.Peer, network *types.Network, + allowedPeers map[string]struct{}, v6Prefix netip.Prefix, +) error { + takenV6 := make(map[netip.Addr]struct{}) + for _, peer := range peers { + if _, ok := allowedPeers[peer.ID]; ok && peer.IPv6.IsValid() && network.NetV6.Contains(peer.IPv6.AsSlice()) { + takenV6[peer.IPv6] = struct{}{} + } + } + + for _, peer := range peers { + _, allowed := allowedPeers[peer.ID] + oldIPv6 := peer.IPv6 + + if !allowed { + peer.IPv6 = netip.Addr{} + } else if !peer.IPv6.IsValid() || !network.NetV6.Contains(peer.IPv6.AsSlice()) { + newIP, err := allocateIPv6WithRetry(v6Prefix, takenV6, peer.ID) + if err != nil { + return err + } + peer.IPv6 = newIP + } + + if peer.IPv6 == oldIPv6 { + continue + } + + if err := transaction.SavePeer(ctx, accountID, peer); err != nil { + return fmt.Errorf("save peer %s: %w", peer.ID, err) + } + } + return nil +} + +func allocateIPv6WithRetry(prefix netip.Prefix, taken map[netip.Addr]struct{}, peerID string) (netip.Addr, error) { + for attempts := 0; attempts < 10; attempts++ { + newIP, err := types.AllocateRandomPeerIPv6(prefix) + if err != nil { + return netip.Addr{}, fmt.Errorf("allocate v6 for peer %s: %w", peerID, err) + } + if _, ok := taken[newIP]; !ok { + taken[newIP] = struct{}{} + return newIP, nil + } + } + return netip.Addr{}, fmt.Errorf("allocate v6 for peer %s: exhausted 10 attempts", peerID) +} + +func (am *DefaultAccountManager) buildIPv6AllowedPeers(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) (map[string]struct{}, error) { + if len(settings.IPv6EnabledGroups) == 0 { + return make(map[string]struct{}), nil + } + + groups, err := transaction.GetAccountGroups(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("get groups: %w", err) + } + + enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups)) + for _, gid := range settings.IPv6EnabledGroups { + enabledSet[gid] = struct{}{} + } + + allowedPeers := make(map[string]struct{}) + for _, group := range groups { + if _, ok := enabledSet[group.ID]; !ok { + continue + } + for _, peerID := range group.Peers { + allowedPeers[peerID] = struct{}{} + } + } + return allowedPeers, nil +} + func (am *DefaultAccountManager) validateIPForUpdate(account *types.Account, peers []*nbpeer.Peer, peerID string, newIP netip.Addr) error { if !account.Network.Net.Contains(newIP.AsSlice()) { return status.Errorf(status.InvalidArgument, "IP %s is not within the account network range %s", newIP.String(), account.Network.Net.String()) } for _, peer := range peers { - if peer.ID != peerID && peer.IP.Equal(newIP.AsSlice()) { + if peer.ID != peerID && peer.IP == newIP { return status.Errorf(status.InvalidArgument, "IP %s is already assigned to peer %s", newIP.String(), peer.ID) } } @@ -2236,7 +2517,7 @@ func (am *DefaultAccountManager) updatePeerIPInTransaction(ctx context.Context, return fmt.Errorf("get peer: %w", err) } - if existingPeer.IP.Equal(newIP.AsSlice()) { + if existingPeer.IP == newIP { return nil } @@ -2271,7 +2552,7 @@ func (am *DefaultAccountManager) savePeerIPUpdate(ctx context.Context, transacti eventMeta := peer.EventMeta(dnsDomain) oldIP := peer.IP.String() - peer.IP = newIP.AsSlice() + peer.IP = newIP err = transaction.SavePeer(ctx, accountID, peer) if err != nil { return fmt.Errorf("save peer: %w", err) @@ -2284,6 +2565,84 @@ func (am *DefaultAccountManager) savePeerIPUpdate(ctx context.Context, transacti return nil } +// UpdatePeerIPv6 updates the IPv6 overlay address of a peer, validating it's +// within the account's v6 network range and not already taken. +func (am *DefaultAccountManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error { + allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update) + if err != nil { + return fmt.Errorf("validate user permissions: %w", err) + } + if !allowed { + return status.NewPermissionDeniedError() + } + + var updateNetworkMap bool + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var txErr error + updateNetworkMap, txErr = am.updatePeerIPv6InTransaction(ctx, transaction, accountID, peerID, newIPv6) + return txErr + }) + if err != nil { + return err + } + + if updateNetworkMap { + if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peerID}); err != nil { + return fmt.Errorf("notify network map controller: %w", err) + } + } + return nil +} + +// updatePeerIPv6InTransaction validates and applies an IPv6 address change within a store transaction. +func (am *DefaultAccountManager) updatePeerIPv6InTransaction(ctx context.Context, transaction store.Store, accountID, peerID string, newIPv6 netip.Addr) (bool, error) { + network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthShare, accountID) + if err != nil { + return false, fmt.Errorf("get network: %w", err) + } + + if network.NetV6.IP == nil { + return false, status.Errorf(status.PreconditionFailed, "IPv6 is not configured for this account") + } + + if !network.NetV6.Contains(newIPv6.AsSlice()) { + return false, status.Errorf(status.InvalidArgument, "IP %s is not within the account IPv6 range %s", newIPv6, network.NetV6.String()) + } + + settings, err := transaction.GetAccountSettings(ctx, store.LockingStrengthShare, accountID) + if err != nil { + return false, fmt.Errorf("get settings: %w", err) + } + + allowedPeers, err := am.buildIPv6AllowedPeers(ctx, transaction, accountID, settings) + if err != nil { + return false, err + } + if _, ok := allowedPeers[peerID]; !ok { + return false, status.Errorf(status.PreconditionFailed, "peer is not in any IPv6-enabled group") + } + + peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID) + if err != nil { + return false, fmt.Errorf("get peer: %w", err) + } + + if peer.IPv6.IsValid() && peer.IPv6 == newIPv6 { + return false, nil + } + + if err := am.checkIPv6Collision(ctx, transaction, accountID, peerID, newIPv6); err != nil { + return false, err + } + + peer.IPv6 = newIPv6 + if err := transaction.SavePeer(ctx, accountID, peer); err != nil { + return false, fmt.Errorf("save peer: %w", err) + } + + return true, nil +} + func (am *DefaultAccountManager) GetUserIDByPeerKey(ctx context.Context, peerKey string) (string, error) { return am.Store.GetUserIDByPeerKey(ctx, store.LockingStrengthNone, peerKey) } diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 626ed222d..71af0645c 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -65,6 +65,7 @@ type Manager interface { DeletePeer(ctx context.Context, accountID, peerID, userID string) error UpdatePeer(ctx context.Context, accountID, userID string, p *nbpeer.Peer) (*nbpeer.Peer, error) UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error + UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index 8f3b22ecc..7ffc41d73 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -1709,6 +1709,18 @@ func (mr *MockManagerMockRecorder) UpdatePeerIP(ctx, accountID, userID, peerID, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeerIP", reflect.TypeOf((*MockManager)(nil).UpdatePeerIP), ctx, accountID, userID, peerID, newIP) } +func (m *MockManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatePeerIPv6", ctx, accountID, userID, peerID, newIPv6) + ret0, _ := ret[0].(error) + return ret0 +} + +func (mr *MockManagerMockRecorder) UpdatePeerIPv6(ctx, accountID, userID, peerID, newIPv6 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeerIPv6", reflect.TypeOf((*MockManager)(nil).UpdatePeerIPv6), ctx, accountID, userID, peerID, newIPv6) +} + // UpdateToPrimaryAccount mocks base method. func (m *MockManager) UpdateToPrimaryAccount(ctx context.Context, accountId string) error { m.ctrl.T.Helper() diff --git a/management/server/account_test.go b/management/server/account_test.go index e259856e3..6bb875f99 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -160,7 +160,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { "peer-1": { ID: peerID1, Key: "peer-1-key", - IP: net.IP{100, 64, 0, 1}, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + IPv6: netip.MustParseAddr("fd00::6440:1"), Name: peerID1, DNSLabel: peerID1, Status: &nbpeer.PeerStatus{ @@ -174,7 +175,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { "peer-2": { ID: peerID2, Key: "peer-2-key", - IP: net.IP{100, 64, 0, 1}, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + IPv6: netip.MustParseAddr("fd00::6440:1"), Name: peerID2, DNSLabel: peerID2, Status: &nbpeer.PeerStatus{ @@ -198,7 +200,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { "peer-1": { ID: peerID1, Key: "peer-1-key", - IP: net.IP{100, 64, 0, 1}, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + IPv6: netip.MustParseAddr("fd00::6440:1"), Name: peerID1, DNSLabel: peerID1, Status: &nbpeer.PeerStatus{ @@ -213,7 +216,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { "peer-2": { ID: peerID2, Key: "peer-2-key", - IP: net.IP{100, 64, 0, 1}, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + IPv6: netip.MustParseAddr("fd00::6440:1"), Name: peerID2, DNSLabel: peerID2, Status: &nbpeer.PeerStatus{ @@ -237,7 +241,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-1": { // ID: peerID1, // Key: "peer-1-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID1, // DNSLabel: peerID1, // Status: &PeerStatus{ @@ -251,7 +255,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-2": { // ID: peerID2, // Key: "peer-2-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID2, // DNSLabel: peerID2, // Status: &PeerStatus{ @@ -265,7 +269,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-3": { // ID: peerID3, // Key: "peer-3-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID3, // DNSLabel: peerID3, // Status: &PeerStatus{ @@ -288,7 +292,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-1": { // ID: peerID1, // Key: "peer-1-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID1, // DNSLabel: peerID1, // Status: &PeerStatus{ @@ -302,7 +306,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-2": { // ID: peerID2, // Key: "peer-2-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID2, // DNSLabel: peerID2, // Status: &PeerStatus{ @@ -316,7 +320,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-3": { // ID: peerID3, // Key: "peer-3-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID3, // DNSLabel: peerID3, // Status: &PeerStatus{ @@ -339,7 +343,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-1": { // ID: peerID1, // Key: "peer-1-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID1, // DNSLabel: peerID1, // Status: &PeerStatus{ @@ -353,7 +357,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-2": { // ID: peerID2, // Key: "peer-2-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID2, // DNSLabel: peerID2, // Status: &PeerStatus{ @@ -367,7 +371,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-3": { // ID: peerID3, // Key: "peer-3-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID3, // DNSLabel: peerID3, // Status: &PeerStatus{ @@ -1084,7 +1088,7 @@ func TestAccountManager_AddPeer(t *testing.T) { t.Errorf("expecting just added peer to have key = %s, got %s", expectedPeerKey, peer.Key) } - if !account.Network.Net.Contains(peer.IP) { + if !account.Network.Net.Contains(peer.IP.AsSlice()) { t.Errorf("expecting just added peer's IP %s to be in a network range %s", peer.IP.String(), account.Network.Net.String()) } @@ -1148,7 +1152,7 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) { t.Errorf("expecting just added peer to have key = %s, got %s", expectedPeerKey, peer.Key) } - if !account.Network.Net.Contains(peer.IP) { + if !account.Network.Net.Contains(peer.IP.AsSlice()) { t.Errorf("expecting just added peer's IP %s to be in a network range %s", peer.IP.String(), account.Network.Net.String()) } @@ -2788,11 +2792,46 @@ func TestAccount_SetJWTGroups(t *testing.T) { account := &types.Account{ Id: "accountID", Peers: map[string]*nbpeer.Peer{ - "peer1": {ID: "peer1", Key: "key1", UserID: "user1", IP: net.IP{1, 1, 1, 1}, DNSLabel: "peer1.domain.test"}, - "peer2": {ID: "peer2", Key: "key2", UserID: "user1", IP: net.IP{2, 2, 2, 2}, DNSLabel: "peer2.domain.test"}, - "peer3": {ID: "peer3", Key: "key3", UserID: "user1", IP: net.IP{3, 3, 3, 3}, DNSLabel: "peer3.domain.test"}, - "peer4": {ID: "peer4", Key: "key4", UserID: "user2", IP: net.IP{4, 4, 4, 4}, DNSLabel: "peer4.domain.test"}, - "peer5": {ID: "peer5", Key: "key5", UserID: "user2", IP: net.IP{5, 5, 5, 5}, DNSLabel: "peer5.domain.test"}, + "peer1": { + ID: "peer1", + Key: "key1", + UserID: "user1", + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + DNSLabel: "peer1.domain.test", + }, + "peer2": { + ID: "peer2", + Key: "key2", + UserID: "user1", + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2"), + DNSLabel: "peer2.domain.test", + }, + "peer3": { + ID: "peer3", + Key: "key3", + UserID: "user1", + IP: netip.AddrFrom4([4]byte{3, 3, 3, 3}), + IPv6: netip.MustParseAddr("fd00::3"), + DNSLabel: "peer3.domain.test", + }, + "peer4": { + ID: "peer4", + Key: "key4", + UserID: "user2", + IP: netip.AddrFrom4([4]byte{4, 4, 4, 4}), + IPv6: netip.MustParseAddr("fd00::4"), + DNSLabel: "peer4.domain.test", + }, + "peer5": { + ID: "peer5", + Key: "key5", + UserID: "user2", + IP: netip.AddrFrom4([4]byte{5, 5, 5, 5}), + IPv6: netip.MustParseAddr("fd00::5"), + DNSLabel: "peer5.domain.test", + }, }, Groups: map[string]*types.Group{ "group1": {ID: "group1", Name: "group1", Issued: types.GroupIssuedAPI, Peers: []string{}}, @@ -3549,16 +3588,32 @@ func TestPropagateUserGroupMemberships(t *testing.T) { account, err := manager.GetOrCreateAccountByUser(ctx, auth.UserAuth{UserId: initiatorId, Domain: domain}) require.NoError(t, err) - peer1 := &nbpeer.Peer{ID: "peer1", AccountID: account.Id, Key: "key1", UserID: initiatorId, IP: net.IP{1, 1, 1, 1}, DNSLabel: "peer1.domain.test"} + peer1 := &nbpeer.Peer{ + ID: "peer1", + AccountID: account.Id, + Key: "key1", + UserID: initiatorId, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + DNSLabel: "peer1.domain.test", + } err = manager.Store.AddPeerToAccount(ctx, peer1) require.NoError(t, err) - peer2 := &nbpeer.Peer{ID: "peer2", AccountID: account.Id, Key: "key2", UserID: initiatorId, IP: net.IP{2, 2, 2, 2}, DNSLabel: "peer2.domain.test"} + peer2 := &nbpeer.Peer{ + ID: "peer2", + AccountID: account.Id, + Key: "key2", + UserID: initiatorId, + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2"), + DNSLabel: "peer2.domain.test", + } err = manager.Store.AddPeerToAccount(ctx, peer2) require.NoError(t, err) t.Run("should skip propagation when the user has no groups", func(t *testing.T) { - groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id) + groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id) require.NoError(t, err) assert.False(t, groupsUpdated) assert.False(t, groupChangesAffectPeers) @@ -3574,7 +3629,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) { user.AutoGroups = append(user.AutoGroups, group1.ID) require.NoError(t, manager.Store.SaveUser(ctx, user)) - groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id) + groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id) require.NoError(t, err) assert.True(t, groupsUpdated) assert.False(t, groupChangesAffectPeers) @@ -3612,7 +3667,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) { }, true) require.NoError(t, err) - groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id) + groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id) require.NoError(t, err) assert.True(t, groupsUpdated) assert.True(t, groupChangesAffectPeers) @@ -3627,7 +3682,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) { }) t.Run("should not update membership or account peers when no changes", func(t *testing.T) { - groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id) + groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id) require.NoError(t, err) assert.False(t, groupsUpdated) assert.False(t, groupChangesAffectPeers) @@ -3640,7 +3695,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) { user.AutoGroups = []string{"group1"} require.NoError(t, manager.Store.SaveUser(ctx, user)) - groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id) + groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id) require.NoError(t, err) assert.False(t, groupsUpdated) assert.False(t, groupChangesAffectPeers) @@ -3754,11 +3809,10 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) { account, err := manager.Store.GetAccount(context.Background(), accountID) require.NoError(t, err, "unable to get account") - newIP, err := types.AllocatePeerIP(account.Network.Net, []net.IP{peer1.IP, peer2.IP}) + newIP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), []netip.Addr{peer1.IP, peer2.IP}) require.NoError(t, err, "unable to allocate new IP") - newAddr := netip.MustParseAddr(newIP.String()) - err = manager.UpdatePeerIP(context.Background(), accountID, userID, peer1.ID, newAddr) + err = manager.UpdatePeerIP(context.Background(), accountID, userID, peer1.ID, newIP) require.NoError(t, err, "unable to update peer IP") updatedPeer, err := manager.GetPeer(context.Background(), accountID, peer1.ID, userID) @@ -3916,6 +3970,109 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangeChange(t *testi } } +func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testing.T) { + manager, _, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + // New accounts default to All group in IPv6EnabledGroups, so all 3 peers should have IPv6. + settings, err := manager.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + require.NotEmpty(t, settings.IPv6EnabledGroups, "new account should have IPv6 enabled for All group") + + peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + require.NoError(t, err) + for _, p := range peers { + assert.True(t, p.IPv6.IsValid(), "peer %s should have IPv6 with All group enabled", p.ID) + } + + // Create a group with only peer1 and peer2. + partialGroup := &types.Group{ + ID: "ipv6-partial-group", + AccountID: accountID, + Name: "IPv6Partial", + } + err = manager.Store.CreateGroup(ctx, partialGroup) + require.NoError(t, err) + require.NoError(t, manager.Store.AddPeerToGroup(ctx, accountID, peer1.ID, partialGroup.ID)) + require.NoError(t, manager.Store.AddPeerToGroup(ctx, accountID, peer2.ID, partialGroup.ID)) + + // Switch IPv6EnabledGroups to only the partial group. + updatedSettings, err := manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + PeerLoginExpirationEnabled: true, + IPv6EnabledGroups: []string{partialGroup.ID}, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + assert.Equal(t, []string{partialGroup.ID}, updatedSettings.IPv6EnabledGroups) + + // peer1 and peer2 should have IPv6; peer3 should not. + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + require.NoError(t, err) + peerMap := make(map[string]*nbpeer.Peer, len(peers)) + for _, p := range peers { + peerMap[p.ID] = p + } + assert.True(t, peerMap[peer1.ID].IPv6.IsValid(), "peer1 in partial group should keep IPv6") + assert.True(t, peerMap[peer2.ID].IPv6.IsValid(), "peer2 in partial group should keep IPv6") + assert.False(t, peerMap[peer3.ID].IPv6.IsValid(), "peer3 not in partial group should lose IPv6") + + // Clearing all groups disables IPv6 for everyone. + updatedSettings, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + PeerLoginExpirationEnabled: true, + IPv6EnabledGroups: []string{}, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + assert.Empty(t, updatedSettings.IPv6EnabledGroups) + + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + require.NoError(t, err) + for _, p := range peers { + assert.False(t, p.IPv6.IsValid(), "peer %s should have no IPv6 when groups cleared", p.ID) + } + + // Re-enabling with the partial group should allocate IPv6 only for peer1 and peer2. + _, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + PeerLoginExpirationEnabled: true, + IPv6EnabledGroups: []string{partialGroup.ID}, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + require.NoError(t, err) + peerMap = make(map[string]*nbpeer.Peer, len(peers)) + for _, p := range peers { + peerMap[p.ID] = p + } + assert.True(t, peerMap[peer1.ID].IPv6.IsValid(), "peer1 should get IPv6 back") + assert.True(t, peerMap[peer2.ID].IPv6.IsValid(), "peer2 should get IPv6 back") + assert.False(t, peerMap[peer3.ID].IPv6.IsValid(), "peer3 still excluded") + + // No-op update with the same groups should not cause errors. + _, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + PeerLoginExpirationEnabled: true, + IPv6EnabledGroups: []string{partialGroup.ID}, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + // Setting a nonexistent group ID should fail. + _, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + PeerLoginExpirationEnabled: true, + IPv6EnabledGroups: []string{"nonexistent-group-id"}, + Extra: &types.ExtraSettings{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") +} + func TestUpdateUserAuthWithSingleMode(t *testing.T) { t.Run("sets defaults and overrides domain from store", func(t *testing.T) { ctrl := gomock.NewController(t) diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index ddc3e00c3..2388115ff 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -231,6 +231,10 @@ const ( DomainDeleted Activity = 119 // DomainValidated indicates that a custom domain was validated DomainValidated Activity = 120 + // AccountIPv6Enabled indicates that a user enabled IPv6 overlay for the account + AccountIPv6Enabled Activity = 121 + // AccountIPv6Disabled indicates that a user disabled IPv6 overlay for the account + AccountIPv6Disabled Activity = 122 AccountDeleted Activity = 99999 ) @@ -347,6 +351,9 @@ var activityMap = map[Activity]Code{ AccountAutoUpdateAlwaysEnabled: {"Account auto-update always enabled", "account.setting.auto.update.always.enable"}, AccountAutoUpdateAlwaysDisabled: {"Account auto-update always disabled", "account.setting.auto.update.always.disable"}, + AccountIPv6Enabled: {"Account IPv6 overlay enabled", "account.setting.ipv6.enable"}, + AccountIPv6Disabled: {"Account IPv6 overlay disabled", "account.setting.ipv6.disable"}, + IdentityProviderCreated: {"Identity provider created", "identityprovider.create"}, IdentityProviderUpdated: {"Identity provider updated", "identityprovider.update"}, IdentityProviderDeleted: {"Identity provider deleted", "identityprovider.delete"}, diff --git a/management/server/group.go b/management/server/group.go index e1d05171e..870a441ac 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -174,6 +174,10 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use return err } + if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -278,37 +282,17 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us var globalErr error groupIDs := make([]string, 0, len(groups)) for _, newGroup := range groups { - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { - return err - } - - newGroup.AccountID = accountID - - if err = transaction.UpdateGroup(ctx, newGroup); err != nil { - return err - } - - err = transaction.IncrementNetworkSerial(ctx, accountID) - if err != nil { - return err - } - - events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) - eventsToStore = append(eventsToStore, events...) - - groupIDs = append(groupIDs, newGroup.ID) - - return nil - }) + events, err := am.updateSingleGroup(ctx, accountID, userID, newGroup) if err != nil { log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err) if len(groups) == 1 { return err } globalErr = errors.Join(globalErr, err) - // continue updating other groups + continue } + eventsToStore = append(eventsToStore, events...) + groupIDs = append(groupIDs, newGroup.ID) } updateAccountPeers, err = areGroupChangesAffectPeers(ctx, am.Store, accountID, groupIDs) @@ -327,6 +311,33 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us return globalErr } +func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) ([]func(), error) { + var events []func() + err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { + return err + } + + newGroup.AccountID = accountID + + if err := transaction.UpdateGroup(ctx, newGroup); err != nil { + return err + } + + if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil { + return err + } + + if err := transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return err + } + + events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) + return nil + }) + return events, err +} + // prepareGroupEvents prepares a list of event functions to be stored. func (am *DefaultAccountManager) prepareGroupEvents(ctx context.Context, transaction store.Store, accountID, userID string, newGroup *types.Group) []func() { var eventsToStore []func() @@ -458,6 +469,10 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us return err } + if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, groupIDsToDelete); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -486,6 +501,10 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr return err } + if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -552,6 +571,10 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, return err } + if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { diff --git a/management/server/group_ipv6_test.go b/management/server/group_ipv6_test.go new file mode 100644 index 000000000..e4603c879 --- /dev/null +++ b/management/server/group_ipv6_test.go @@ -0,0 +1,125 @@ +package server + +import ( + "context" + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// TestGroupIPv6Assignment verifies that peers gain or lose IPv6 addresses +// when they are added to or removed from an IPv6-enabled group. +func TestGroupIPv6Assignment(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err) + + ctx := context.Background() + userID := groupAdminUserID + + account, err := createAccount(am, "ipv6-grp-test", userID, "ipv6test.example.com") + require.NoError(t, err) + + // Allocate IPv6 subnet for the account + account.Network.NetV6 = types.AllocateIPv6Subnet(rand.New(rand.NewSource(time.Now().UnixNano()))) + require.NoError(t, am.Store.SaveAccount(ctx, account)) + + // Create setup key + setupKey, err := am.CreateSetupKey(ctx, account.Id, "ipv6-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + + // Create an IPv6-enabled group + ipv6GroupID := "ipv6-enabled-grp" + err = am.CreateGroup(ctx, account.Id, userID, &types.Group{ + ID: ipv6GroupID, + Name: "IPv6 Enabled", + Issued: types.GroupIssuedAPI, + Peers: []string{}, + }) + require.NoError(t, err) + + // Enable IPv6 on that group + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, account.Id) + require.NoError(t, err) + settings.IPv6EnabledGroups = []string{ipv6GroupID} + require.NoError(t, am.Store.SaveAccountSettings(ctx, account.Id, settings)) + + // Register a peer (will be in "All" group, not the IPv6 group) + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + peer, _, _, err := am.AddPeer(ctx, "", setupKey.Key, "", &nbpeer.Peer{ + Key: key.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "ipv6-test-host"}, + }, false) + require.NoError(t, err) + assert.False(t, peer.IPv6.IsValid(), "peer should not have IPv6 before joining an IPv6-enabled group") + + t.Run("GroupAddPeer assigns IPv6", func(t *testing.T) { + err := am.GroupAddPeer(ctx, account.Id, ipv6GroupID, peer.ID) + require.NoError(t, err) + + p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID) + require.NoError(t, err) + assert.True(t, p.IPv6.IsValid(), "peer should have an IPv6 address after joining the group") + }) + + t.Run("GroupDeletePeer clears IPv6", func(t *testing.T) { + err := am.GroupDeletePeer(ctx, account.Id, ipv6GroupID, peer.ID) + require.NoError(t, err) + + p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID) + require.NoError(t, err) + assert.False(t, p.IPv6.IsValid(), "peer should not have IPv6 after removal from the group") + }) + + t.Run("UpdateGroup with peer addition assigns IPv6", func(t *testing.T) { + grp, err := am.Store.GetGroupByID(ctx, store.LockingStrengthNone, account.Id, ipv6GroupID) + require.NoError(t, err) + + grp.Peers = append(grp.Peers, peer.ID) + err = am.UpdateGroup(ctx, account.Id, userID, grp) + require.NoError(t, err) + + p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID) + require.NoError(t, err) + assert.True(t, p.IPv6.IsValid(), "peer should have IPv6 after UpdateGroup adds it") + }) + + t.Run("UpdateGroup with peer removal clears IPv6", func(t *testing.T) { + grp, err := am.Store.GetGroupByID(ctx, store.LockingStrengthNone, account.Id, ipv6GroupID) + require.NoError(t, err) + + grp.Peers = []string{} + err = am.UpdateGroup(ctx, account.Id, userID, grp) + require.NoError(t, err) + + p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID) + require.NoError(t, err) + assert.False(t, p.IPv6.IsValid(), "peer should lose IPv6 after UpdateGroup removes it") + }) + + t.Run("non-IPv6 group changes do not affect IPv6", func(t *testing.T) { + err := am.CreateGroup(ctx, account.Id, userID, &types.Group{ + ID: "regular-grp", + Name: "Regular Group", + Issued: types.GroupIssuedAPI, + Peers: []string{}, + }) + require.NoError(t, err) + + err = am.GroupAddPeer(ctx, account.Id, "regular-grp", peer.ID) + require.NoError(t, err) + + p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID) + require.NoError(t, err) + assert.False(t, p.IPv6.IsValid(), "peer should not get IPv6 from a non-IPv6 group") + }) +} diff --git a/management/server/group_test.go b/management/server/group_test.go index 5821b90a3..22fda2671 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -5,7 +5,6 @@ import ( "encoding/binary" "errors" "fmt" - "net" "net/netip" "strconv" "sync" @@ -999,10 +998,10 @@ func Test_AddPeerAndAddToAll(t *testing.T) { assert.Equal(t, totalPeers, len(account.Peers), "Expected %d peers in account %s, got %d", totalPeers, accountID, len(account.Peers)) } -func uint32ToIP(n uint32) net.IP { - ip := make(net.IP, 4) - binary.BigEndian.PutUint32(ip, n) - return ip +func uint32ToIP(n uint32) netip.Addr { + var b [4]byte + binary.BigEndian.PutUint32(b[:], n) + return netip.AddrFrom4(b) } func Test_IncrementNetworkSerial(t *testing.T) { diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index cc5567e3d..31820b9fb 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -4,10 +4,13 @@ import ( "context" "encoding/json" "fmt" + "math" "net/http" "net/netip" "time" + log "github.com/sirupsen/logrus" + "github.com/gorilla/mux" goversion "github.com/hashicorp/go-version" @@ -29,7 +32,9 @@ const ( // MinNetworkBits is the minimum prefix length for IPv4 network ranges (e.g., /29 gives 8 addresses, /28 gives 16) MinNetworkBitsIPv4 = 28 // MinNetworkBitsIPv6 is the minimum prefix length for IPv6 network ranges - MinNetworkBitsIPv6 = 120 + MinNetworkBitsIPv6 = 120 + // MaxNetworkSizeIPv6 is the largest allowed IPv6 prefix (smallest number) + MaxNetworkSizeIPv6 = 48 disableAutoUpdate = "disabled" autoUpdateLatestVersion = "latest" ) @@ -76,12 +81,35 @@ func validateMinimumSize(prefix netip.Prefix) error { if addr.Is4() && prefix.Bits() > MinNetworkBitsIPv4 { return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv4", MinNetworkBitsIPv4) } - if addr.Is6() && prefix.Bits() > MinNetworkBitsIPv6 { - return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv6", MinNetworkBitsIPv6) + if addr.Is6() { + if prefix.Bits() > MinNetworkBitsIPv6 { + return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv6", MinNetworkBitsIPv6) + } + if prefix.Bits() < MaxNetworkSizeIPv6 { + return status.Errorf(status.InvalidArgument, "network range too large: maximum size is /%d for IPv6", MaxNetworkSizeIPv6) + } } return nil } +func (h *handler) parseAndValidateNetworkRange(ctx context.Context, accountID, userID, rangeStr string, requireV6 bool) (netip.Prefix, error) { + prefix, err := netip.ParsePrefix(rangeStr) + if err != nil { + return netip.Prefix{}, status.Errorf(status.InvalidArgument, "invalid CIDR format: %v", err) + } + prefix = prefix.Masked() + if requireV6 && !prefix.Addr().Is6() { + return netip.Prefix{}, status.Errorf(status.InvalidArgument, "network range must be an IPv6 address") + } + if !requireV6 && prefix.Addr().Is6() { + return netip.Prefix{}, status.Errorf(status.InvalidArgument, "network range must be an IPv4 address") + } + if err := h.validateNetworkRange(ctx, accountID, userID, prefix); err != nil { + return netip.Prefix{}, err + } + return prefix, nil +} + func (h *handler) validateNetworkRange(ctx context.Context, accountID, userID string, networkRange netip.Prefix) error { if !networkRange.IsValid() { return nil @@ -117,9 +145,12 @@ func (h *handler) validateCapacity(ctx context.Context, accountID, userID string } func calculateMaxHosts(prefix netip.Prefix) int64 { - availableAddresses := prefix.Addr().BitLen() - prefix.Bits() - maxHosts := int64(1) << availableAddresses + hostBits := prefix.Addr().BitLen() - prefix.Bits() + if hostBits >= 63 { + return math.MaxInt64 + } + maxHosts := int64(1) << hostBits if prefix.Addr().Is4() { maxHosts -= 2 // network and broadcast addresses } @@ -164,6 +195,24 @@ func (h *handler) getAllAccounts(w http.ResponseWriter, r *http.Request) { } resp := toAccountResponse(accountID, settings, meta, onboarding) + + // Populate effective network ranges when settings don't have explicit overrides. + if resp.Settings.NetworkRange == nil || resp.Settings.NetworkRangeV6 == nil { + v4, v6, err := h.settingsManager.GetEffectiveNetworkRanges(r.Context(), accountID) + if err != nil { + log.WithContext(r.Context()).Warnf("get effective network ranges: %v", err) + } else { + if resp.Settings.NetworkRange == nil && v4.IsValid() { + s := v4.String() + resp.Settings.NetworkRange = &s + } + if resp.Settings.NetworkRangeV6 == nil && v6.IsValid() { + s := v6.String() + resp.Settings.NetworkRangeV6 = &s + } + } + } + util.WriteJSONObject(r.Context(), w, []*api.Account{resp}) } @@ -228,6 +277,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS if req.Settings.AutoUpdateAlways != nil { returnSettings.AutoUpdateAlways = *req.Settings.AutoUpdateAlways } + if req.Settings.Ipv6EnabledGroups != nil { + returnSettings.IPv6EnabledGroups = *req.Settings.Ipv6EnabledGroups + } return returnSettings, nil } @@ -262,18 +314,23 @@ func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) { return } if req.Settings.NetworkRange != nil && *req.Settings.NetworkRange != "" { - prefix, err := netip.ParsePrefix(*req.Settings.NetworkRange) + prefix, err := h.parseAndValidateNetworkRange(r.Context(), accountID, userID, *req.Settings.NetworkRange, false) if err != nil { - util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid CIDR format: %v", err), w) - return - } - if err := h.validateNetworkRange(r.Context(), accountID, userID, prefix); err != nil { util.WriteError(r.Context(), err, w) return } settings.NetworkRange = prefix } + if req.Settings.NetworkRangeV6 != nil && *req.Settings.NetworkRangeV6 != "" { + prefix, err := h.parseAndValidateNetworkRange(r.Context(), accountID, userID, *req.Settings.NetworkRangeV6, true) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + settings.NetworkRangeV6 = prefix + } + var onboarding *types.AccountOnboarding if req.Onboarding != nil { onboarding = &types.AccountOnboarding{ @@ -352,6 +409,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A DnsDomain: &settings.DNSDomain, AutoUpdateVersion: &settings.AutoUpdateVersion, AutoUpdateAlways: &settings.AutoUpdateAlways, + Ipv6EnabledGroups: &settings.IPv6EnabledGroups, EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled, LocalAuthDisabled: &settings.LocalAuthDisabled, } @@ -360,6 +418,10 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A networkRangeStr := settings.NetworkRange.String() apiSettings.NetworkRange = &networkRangeStr } + if settings.NetworkRangeV6.IsValid() { + networkRangeV6Str := settings.NetworkRangeV6.String() + apiSettings.NetworkRangeV6 = &networkRangeV6Str + } apiOnboarding := api.AccountOnboarding{ OnboardingFlowPending: onboarding.OnboardingFlowPending, diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index 739dfe2f6..fc1517a30 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -5,8 +5,10 @@ import ( "context" "encoding/json" "io" + "math" "net/http" "net/http/httptest" + "net/netip" "testing" "time" @@ -31,6 +33,10 @@ func initAccountsTestData(t *testing.T, account *types.Account) *handler { GetSettings(gomock.Any(), account.Id, "test_user"). Return(account.Settings, nil). AnyTimes() + settingsMockManager.EXPECT(). + GetEffectiveNetworkRanges(gomock.Any(), account.Id). + Return(netip.Prefix{}, netip.Prefix{}, nil). + AnyTimes() return &handler{ accountManager: &mock_server.MockAccountManager{ @@ -336,3 +342,27 @@ func TestAccounts_AccountsHandler(t *testing.T) { }) } } + +func TestCalculateMaxHosts(t *testing.T) { + tests := []struct { + name string + prefix string + min int64 + }{ + {"v4 /24", "100.64.0.0/24", 254}, + {"v4 /16", "100.64.0.0/16", 65534}, + {"v4 /28", "100.64.0.0/28", 14}, + {"v6 /64", "fd00::/64", math.MaxInt64}, + {"v6 /120", "fd00::/120", 256}, + {"v6 /112", "fd00::/112", 65536}, + {"v6 /48", "fd00::/48", math.MaxInt64}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefix := netip.MustParsePrefix(tt.prefix) + got := calculateMaxHosts(prefix) + assert.Equal(t, tt.min, got) + }) + } +} diff --git a/management/server/http/handlers/dns/nameservers_handler.go b/management/server/http/handlers/dns/nameservers_handler.go index bce1c4b78..dbbdf3ed9 100644 --- a/management/server/http/handlers/dns/nameservers_handler.go +++ b/management/server/http/handlers/dns/nameservers_handler.go @@ -3,7 +3,10 @@ package dns import ( "encoding/json" "fmt" + "net" "net/http" + "strconv" + "strings" "github.com/gorilla/mux" log "github.com/sirupsen/logrus" @@ -201,7 +204,11 @@ func (h *nameserversHandler) getNameserverGroup(w http.ResponseWriter, r *http.R func toServerNSList(apiNSList []api.Nameserver) ([]nbdns.NameServer, error) { var nsList []nbdns.NameServer for _, apiNS := range apiNSList { - parsed, err := nbdns.ParseNameServerURL(fmt.Sprintf("%s://%s:%d", apiNS.NsType, apiNS.Ip, apiNS.Port)) + host, err := unwrapBracketedHost(apiNS.Ip) + if err != nil { + return nil, err + } + parsed, err := nbdns.ParseNameServerURL(fmt.Sprintf("%s://%s", apiNS.NsType, net.JoinHostPort(host, strconv.Itoa(apiNS.Port)))) if err != nil { return nil, err } @@ -211,6 +218,18 @@ func toServerNSList(apiNSList []api.Nameserver) ([]nbdns.NameServer, error) { return nsList, nil } +// unwrapBracketedHost returns ip with surrounding brackets stripped, rejecting +// inputs with mismatched brackets. +func unwrapBracketedHost(ip string) (string, error) { + if !strings.ContainsAny(ip, "[]") { + return ip, nil + } + if !strings.HasPrefix(ip, "[") || !strings.HasSuffix(ip, "]") { + return "", fmt.Errorf("malformed bracketed address: %s", ip) + } + return ip[1 : len(ip)-1], nil +} + func toNameserverGroupResponse(serverNSGroup *nbdns.NameServerGroup) *api.NameserverGroup { var nsList []api.Nameserver for _, ns := range serverNSGroup.NameServers { diff --git a/management/server/http/handlers/dns/nameservers_handler_test.go b/management/server/http/handlers/dns/nameservers_handler_test.go index 4716782f3..a165f009b 100644 --- a/management/server/http/handlers/dns/nameservers_handler_test.go +++ b/management/server/http/handlers/dns/nameservers_handler_test.go @@ -233,3 +233,37 @@ func TestNameserversHandlers(t *testing.T) { }) } } + +func TestToServerNSList_IPv6(t *testing.T) { + tests := []struct { + name string + input []api.Nameserver + expectIP netip.Addr + }{ + { + name: "IPv4", + input: []api.Nameserver{ + {Ip: "1.1.1.1", NsType: "udp", Port: 53}, + }, + expectIP: netip.MustParseAddr("1.1.1.1"), + }, + { + name: "IPv6", + input: []api.Nameserver{ + {Ip: "2001:4860:4860::8888", NsType: "udp", Port: 53}, + }, + expectIP: netip.MustParseAddr("2001:4860:4860::8888"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := toServerNSList(tc.input) + assert.NoError(t, err) + if assert.Len(t, result, 1) { + assert.Equal(t, tc.expectIP, result[0].IP) + assert.Equal(t, 53, result[0].Port) + } + }) + } +} diff --git a/management/server/http/handlers/groups/groups_handler_test.go b/management/server/http/handlers/groups/groups_handler_test.go index c7b4cbcdd..57e238630 100644 --- a/management/server/http/handlers/groups/groups_handler_test.go +++ b/management/server/http/handlers/groups/groups_handler_test.go @@ -7,8 +7,8 @@ import ( "errors" "fmt" "io" - "net" "net/http" + "net/netip" "net/http/httptest" "strings" "testing" @@ -29,8 +29,8 @@ import ( ) var TestPeers = map[string]*nbpeer.Peer{ - "A": {Key: "A", ID: "peer-A-ID", IP: net.ParseIP("100.100.100.100")}, - "B": {Key: "B", ID: "peer-B-ID", IP: net.ParseIP("200.200.200.200")}, + "A": {Key: "A", ID: "peer-A-ID", IP: netip.MustParseAddr("100.100.100.100")}, + "B": {Key: "B", ID: "peer-B-ID", IP: netip.MustParseAddr("200.200.200.200")}, } func initGroupTestData(initGroups ...*types.Group) *handler { diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index bf6937a49..91026a374 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -220,6 +220,18 @@ func (h *Handler) updatePeer(ctx context.Context, accountID, userID, peerID stri } } + if req.Ipv6 != nil { + v6Addr, err := parseIPv6(req.Ipv6) + if err != nil { + util.WriteError(ctx, status.Errorf(status.InvalidArgument, "%v", err), w) + return + } + if err = h.accountManager.UpdatePeerIPv6(ctx, accountID, userID, peerID, v6Addr); err != nil { + util.WriteError(ctx, err, w) + return + } + } + peer, err := h.accountManager.UpdatePeer(ctx, accountID, userID, update) if err != nil { util.WriteError(ctx, err, w) @@ -355,6 +367,21 @@ func (h *Handler) setApprovalRequiredFlag(respBody []*api.PeerBatch, validPeersM } } +func parseIPv6(s *string) (netip.Addr, error) { + if s == nil { + return netip.Addr{}, fmt.Errorf("IPv6 address is nil") + } + addr, err := netip.ParseAddr(*s) + if err != nil { + return netip.Addr{}, fmt.Errorf("invalid IPv6 address %s: %w", *s, err) + } + addr = addr.Unmap() + if !addr.Is6() { + return netip.Addr{}, fmt.Errorf("address %s is not IPv6", *s) + } + return addr, nil +} + // GetAccessiblePeers returns a list of all peers that the specified peer can connect to within the network. func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) { userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) @@ -529,6 +556,7 @@ func peerToAccessiblePeer(peer *nbpeer.Peer, dnsDomain string) api.AccessiblePee GeonameId: int(peer.Location.GeoNameID), Id: peer.ID, Ip: peer.IP.String(), + Ipv6: peerIPv6String(peer), LastSeen: peer.Status.LastSeen, Name: peer.Name, Os: peer.Meta.OS, @@ -547,6 +575,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD Id: peer.ID, Name: peer.Name, Ip: peer.IP.String(), + Ipv6: peerIPv6String(peer), ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, @@ -601,6 +630,7 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn Id: peer.ID, Name: peer.Name, Ip: peer.IP.String(), + Ipv6: peerIPv6String(peer), ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, @@ -677,3 +707,11 @@ func fqdnList(extraLabels []string, dnsDomain string) []string { } return fqdnList } + +func peerIPv6String(peer *nbpeer.Peer) *string { + if !peer.IPv6.IsValid() { + return nil + } + s := peer.IPv6.String() + return &s +} diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go index 6b3616597..9db095c8d 100644 --- a/management/server/http/handlers/peers/peers_handler_test.go +++ b/management/server/http/handlers/peers/peers_handler_test.go @@ -146,7 +146,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { UpdatePeerIPFunc: func(_ context.Context, accountID, userID, peerID string, newIP netip.Addr) error { for _, peer := range peers { if peer.ID == peerID { - peer.IP = net.IP(newIP.AsSlice()) + peer.IP = newIP return nil } } @@ -228,7 +228,8 @@ func TestGetPeers(t *testing.T) { peer := &nbpeer.Peer{ ID: testPeerID, Key: "key", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), Status: &nbpeer.PeerStatus{Connected: true}, Name: "PeerName", LoginExpirationEnabled: false, @@ -368,7 +369,8 @@ func TestGetAccessiblePeers(t *testing.T) { peer1 := &nbpeer.Peer{ ID: "peer1", Key: "key1", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00:1234::1"), Status: &nbpeer.PeerStatus{Connected: true}, Name: "peer1", LoginExpirationEnabled: false, @@ -378,7 +380,8 @@ func TestGetAccessiblePeers(t *testing.T) { peer2 := &nbpeer.Peer{ ID: "peer2", Key: "key2", - IP: net.ParseIP("100.64.0.2"), + IP: netip.MustParseAddr("100.64.0.2"), + IPv6: netip.MustParseAddr("fd00:1234::2"), Status: &nbpeer.PeerStatus{Connected: true}, Name: "peer2", LoginExpirationEnabled: false, @@ -388,7 +391,8 @@ func TestGetAccessiblePeers(t *testing.T) { peer3 := &nbpeer.Peer{ ID: "peer3", Key: "key3", - IP: net.ParseIP("100.64.0.3"), + IP: netip.MustParseAddr("100.64.0.3"), + IPv6: netip.MustParseAddr("fd00:1234::3"), Status: &nbpeer.PeerStatus{Connected: true}, Name: "peer3", LoginExpirationEnabled: false, @@ -532,7 +536,8 @@ func TestPeersHandlerUpdatePeerIP(t *testing.T) { testPeer := &nbpeer.Peer{ ID: testPeerID, Key: "key", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), Status: &nbpeer.PeerStatus{Connected: false, LastSeen: time.Now()}, Name: "test-host@netbird.io", LoginExpirationEnabled: false, diff --git a/management/server/http/testing/testing_tools/tools.go b/management/server/http/testing/testing_tools/tools.go index b7a63b104..9a78620c9 100644 --- a/management/server/http/testing/testing_tools/tools.go +++ b/management/server/http/testing/testing_tools/tools.go @@ -5,9 +5,9 @@ import ( "context" "fmt" "io" - "net" "net/http" "net/http/httptest" + "net/netip" "os" "strconv" "testing" @@ -133,7 +133,7 @@ func PopulateTestData(b *testing.B, am account.Manager, peers, groups, users, se ID: fmt.Sprintf("oldpeer-%d", i), DNSLabel: fmt.Sprintf("oldpeer-%d", i), Key: peerKey.PublicKey().String(), - IP: net.ParseIP(fmt.Sprintf("100.64.%d.%d", i/256, i%256)), + IP: netip.MustParseAddr(fmt.Sprintf("100.64.%d.%d", i/256, i%256)), Status: &nbpeer.PeerStatus{LastSeen: time.Now().UTC(), Connected: true}, UserID: TestUserId, } diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index ac4d0c6d6..08091d4b7 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -63,6 +63,7 @@ type MockAccountManager struct { UpdatePeerMetaFunc func(ctx context.Context, peerID string, meta nbpeer.PeerSystemMeta) error UpdatePeerFunc func(ctx context.Context, accountID, userID string, peer *nbpeer.Peer) (*nbpeer.Peer, error) UpdatePeerIPFunc func(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error + UpdatePeerIPv6Func func(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error CreateRouteFunc func(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peer string, peerGroups []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupIDs []string, enabled bool, userID string, keepRoute bool, isSelected bool) (*route.Route, error) GetRouteFunc func(ctx context.Context, accountID string, routeID route.ID, userID string) (*route.Route, error) SaveRouteFunc func(ctx context.Context, accountID string, userID string, route *route.Route) error @@ -539,6 +540,13 @@ func (am *MockAccountManager) UpdatePeerIP(ctx context.Context, accountID, userI return status.Errorf(codes.Unimplemented, "method UpdatePeerIP is not implemented") } +func (am *MockAccountManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error { + if am.UpdatePeerIPv6Func != nil { + return am.UpdatePeerIPv6Func(ctx, accountID, userID, peerID, newIPv6) + } + return status.Errorf(codes.Unimplemented, "method UpdatePeerIPv6 is not implemented") +} + // CreateRoute mock implementation of CreateRoute from server.AccountManager interface func (am *MockAccountManager) CreateRoute(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peerID string, peerGroupIDs []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupID []string, enabled bool, userID string, keepRoute bool, isSelected bool) (*route.Route, error) { if am.CreateRouteFunc != nil { diff --git a/management/server/peer.go b/management/server/peer.go index 25c6ecd8c..8a39fbbb8 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -6,6 +6,7 @@ import ( b64 "encoding/base64" "fmt" "net" + "net/netip" "slices" "strings" "time" @@ -521,6 +522,27 @@ func (am *DefaultAccountManager) GetPeerNetwork(ctx context.Context, peerID stri return account.Network.Copy(), err } +// peerWillHaveIPv6 checks whether the peer's future group memberships +// (auto-groups + allGroupID) overlap with IPv6EnabledGroups. +func peerWillHaveIPv6(settings *types.Settings, groupsToAdd []string, allGroupID string) bool { + enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups)) + for _, gid := range settings.IPv6EnabledGroups { + enabledSet[gid] = struct{}{} + } + + if allGroupID != "" { + if _, ok := enabledSet[allGroupID]; ok { + return true + } + } + for _, gid := range groupsToAdd { + if _, ok := enabledSet[gid]; ok { + return true + } + } + return false +} + type peerAddAuthConfig struct { AccountID string SetupKeyID string @@ -715,8 +737,11 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe maxAttempts := 10 for attempt := 1; attempt <= maxAttempts; attempt++ { - var freeIP net.IP - freeIP, err = types.AllocateRandomPeerIP(network.Net) + netPrefix, err := netip.ParsePrefix(network.Net.String()) + if err != nil { + return nil, nil, nil, fmt.Errorf("parse network prefix: %w", err) + } + freeIP, err := types.AllocateRandomPeerIP(netPrefix) if err != nil { return nil, nil, nil, fmt.Errorf("failed to get free IP: %w", err) } @@ -736,6 +761,29 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe newPeer.DNSLabel = freeLabel newPeer.IP = freeIP + if len(settings.IPv6EnabledGroups) > 0 && network.NetV6.IP != nil { + var allGroupID string + if !peer.ProxyMeta.Embedded { + allGroup, err := am.Store.GetGroupByName(ctx, store.LockingStrengthNone, accountID, "All") + if err != nil { + log.WithContext(ctx).Debugf("get All group for IPv6 allocation: %v", err) + } else { + allGroupID = allGroup.ID + } + } + if peerWillHaveIPv6(settings, peerAddConfig.GroupsToAdd, allGroupID) { + v6Prefix, err := netip.ParsePrefix(network.NetV6.String()) + if err != nil { + return nil, nil, nil, fmt.Errorf("parse IPv6 prefix: %w", err) + } + freeIPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) + if err != nil { + return nil, nil, nil, fmt.Errorf("allocate peer IPv6: %w", err) + } + newPeer.IPv6 = freeIPv6 + } + } + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { err = transaction.AddPeerToAccount(ctx, newPeer) if err != nil { @@ -805,10 +853,6 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return nil, nil, nil, fmt.Errorf("failed to add peer to database: %w", err) } - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to add peer to database after %d attempts: %w", maxAttempts, err) - } - if newPeer == nil { return nil, nil, nil, fmt.Errorf("new peer is nil") } @@ -834,21 +878,24 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return p, nmap, pc, err } -func getPeerIPDNSLabel(ip net.IP, peerHostName string) (string, error) { - ip = ip.To4() +func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) { + if !ip.Is4() { + return "", fmt.Errorf("DNS label generation requires an IPv4 address, got %s", ip) + } + b := ip.As4() dnsName, err := nbdns.GetParsedDomainLabel(peerHostName) if err != nil { return "", fmt.Errorf("failed to parse peer host name %s: %w", peerHostName, err) } - return fmt.Sprintf("%s-%d-%d", dnsName, ip[2], ip[3]), nil + return fmt.Sprintf("%s-%d-%d", dnsName, b[2], b[3]), nil } // SyncPeer checks whether peer is eligible for receiving NetworkMap (authenticated) and returns its NetworkMap if eligible func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { var peer *nbpeer.Peer - var updated, versionChanged bool + var updated, versionChanged, ipv6CapabilityChanged bool var err error var postureChecks []*posture.Checks var peerGroupIDs []string @@ -884,7 +931,9 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return err } + oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) updated, versionChanged = peer.UpdateMetaIfNew(sync.Meta) + ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) if updated { am.metrics.AccountManagerMetrics().CountPeerMetUpdate() log.WithContext(ctx).Tracef("peer %s metadata updated", peer.ID) @@ -908,7 +957,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return nil, nil, nil, 0, err } - if isStatusChanged || sync.UpdateAccountPeers || (updated && (len(postureChecks) > 0 || versionChanged)) { + if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(postureChecks) > 0 || versionChanged)) { err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}) if err != nil { return nil, nil, nil, 0, fmt.Errorf("notify network map controller of peer update: %w", err) @@ -958,6 +1007,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer var peer *nbpeer.Peer var updateRemotePeers bool var isPeerUpdated bool + var ipv6CapabilityChanged bool var postureChecks []*posture.Checks var peerGroupIDs []string @@ -997,7 +1047,9 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return err } + oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) isPeerUpdated, _ = peer.UpdateMetaIfNew(login.Meta) + ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) if isPeerUpdated { am.metrics.AccountManagerMetrics().CountPeerMetUpdate() shouldStorePeer = true @@ -1035,7 +1087,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return nil, nil, nil, err } - if updateRemotePeers || isStatusChanged || (isPeerUpdated && len(postureChecks) > 0) { + if updateRemotePeers || isStatusChanged || ipv6CapabilityChanged || (isPeerUpdated && len(postureChecks) > 0) { err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}) if err != nil { return nil, nil, nil, fmt.Errorf("notify network map controller of peer update: %w", err) diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index db392ddda..17df761a1 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -11,6 +11,12 @@ import ( "github.com/netbirdio/netbird/shared/management/http/api" ) +// Peer capability constants mirror the proto enum values. +const ( + PeerCapabilitySourcePrefixes int32 = 1 + PeerCapabilityIPv6Overlay int32 = 2 +) + // Peer represents a machine connected to the network. // The Peer is a WireGuard peer identified by a public key type Peer struct { @@ -21,7 +27,9 @@ type Peer struct { // WireGuard public key Key string // uniqueness index (check migrations) // IP address of the Peer - IP net.IP `gorm:"serializer:json"` // uniqueness index per accountID (check migrations) + IP netip.Addr `gorm:"serializer:json"` // uniqueness index per accountID (check migrations) + // IPv6 overlay address of the Peer, zero value if IPv6 is not enabled for the account. + IPv6 netip.Addr `gorm:"serializer:json"` // Meta is a Peer system meta data Meta PeerSystemMeta `gorm:"embedded;embeddedPrefix:meta_"` // ProxyMeta is metadata related to proxy peers @@ -115,6 +123,7 @@ type Flags struct { DisableFirewall bool BlockLANAccess bool BlockInbound bool + DisableIPv6 bool LazyConnectionEnabled bool } @@ -138,6 +147,7 @@ type PeerSystemMeta struct { //nolint:revive Environment Environment `gorm:"serializer:json"` Flags Flags `gorm:"serializer:json"` Files []File `gorm:"serializer:json"` + Capabilities []int32 `gorm:"serializer:json"` } func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool { @@ -182,7 +192,8 @@ func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool { p.SystemManufacturer == other.SystemManufacturer && p.Environment.Cloud == other.Environment.Cloud && p.Environment.Platform == other.Environment.Platform && - p.Flags.isEqual(other.Flags) + p.Flags.isEqual(other.Flags) && + capabilitiesEqual(p.Capabilities, other.Capabilities) } func (p PeerSystemMeta) isEmpty() bool { @@ -210,6 +221,37 @@ func (p *Peer) AddedWithSSOLogin() bool { return p.UserID != "" } +// HasCapability reports whether the peer has the given capability. +func (p *Peer) HasCapability(capability int32) bool { + return slices.Contains(p.Meta.Capabilities, capability) +} + +// SupportsIPv6 reports whether the peer supports IPv6 overlay. +func (p *Peer) SupportsIPv6() bool { + return !p.Meta.Flags.DisableIPv6 && p.HasCapability(PeerCapabilityIPv6Overlay) +} + +// SupportsSourcePrefixes reports whether the peer reads SourcePrefixes. +func (p *Peer) SupportsSourcePrefixes() bool { + return p.HasCapability(PeerCapabilitySourcePrefixes) +} + +func capabilitiesEqual(a, b []int32) bool { + if len(a) != len(b) { + return false + } + set := make(map[int32]struct{}, len(a)) + for _, c := range a { + set[c] = struct{}{} + } + for _, c := range b { + if _, ok := set[c]; !ok { + return false + } + } + return true +} + // Copy copies Peer object func (p *Peer) Copy() *Peer { peerStatus := p.Status @@ -221,6 +263,7 @@ func (p *Peer) Copy() *Peer { AccountID: p.AccountID, Key: p.Key, IP: p.IP, + IPv6: p.IPv6, Meta: p.Meta, Name: p.Name, DNSLabel: p.DNSLabel, @@ -323,9 +366,13 @@ func (p *Peer) FQDN(dnsDomain string) string { // EventMeta returns activity event meta related to the peer func (p *Peer) EventMeta(dnsDomain string) map[string]any { - return map[string]any{"name": p.Name, "fqdn": p.FQDN(dnsDomain), "ip": p.IP, "created_at": p.CreatedAt, + meta := map[string]any{"name": p.Name, "fqdn": p.FQDN(dnsDomain), "ip": p.IP, "created_at": p.CreatedAt, "location_city_name": p.Location.CityName, "location_country_code": p.Location.CountryCode, "location_geo_name_id": p.Location.GeoNameID, "location_connection_ip": p.Location.ConnectionIP} + if p.IPv6.IsValid() { + meta["ipv6"] = p.IPv6.String() + } + return meta } // Copy PeerStatus @@ -369,5 +416,6 @@ func (f Flags) isEqual(other Flags) bool { f.DisableFirewall == other.DisableFirewall && f.BlockLANAccess == other.BlockLANAccess && f.BlockInbound == other.BlockInbound && - f.LazyConnectionEnabled == other.LazyConnectionEnabled + f.LazyConnectionEnabled == other.LazyConnectionEnabled && + f.DisableIPv6 == other.DisableIPv6 } diff --git a/management/server/peer/peer_test.go b/management/server/peer/peer_test.go index 1aa3f6ffc..c5b512069 100644 --- a/management/server/peer/peer_test.go +++ b/management/server/peer/peer_test.go @@ -5,6 +5,7 @@ import ( "net/netip" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -141,3 +142,25 @@ func TestFlags_IsEqual(t *testing.T) { }) } } + +func TestPeerCapabilities(t *testing.T) { + tests := []struct { + name string + capabilities []int32 + ipv6 bool + srcPrefixes bool + }{ + {"no capabilities", nil, false, false}, + {"only source prefixes", []int32{PeerCapabilitySourcePrefixes}, false, true}, + {"only ipv6", []int32{PeerCapabilityIPv6Overlay}, true, false}, + {"both", []int32{PeerCapabilitySourcePrefixes, PeerCapabilityIPv6Overlay}, true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Peer{Meta: PeerSystemMeta{Capabilities: tt.capabilities}} + assert.Equal(t, tt.ipv6, p.SupportsIPv6()) + assert.Equal(t, tt.srcPrefixes, p.SupportsSourcePrefixes()) + }) + } +} diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 36809d354..07acf865f 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -754,7 +754,8 @@ func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccou ID: fmt.Sprintf("peer-%d", i), DNSLabel: fmt.Sprintf("peer-%d", i), Key: peerKey.PublicKey().String(), - IP: net.ParseIP(fmt.Sprintf("100.64.%d.%d", i/256, i%256)), + IP: netip.MustParseAddr(fmt.Sprintf("100.64.%d.%d", i/256, i%256)), + IPv6: netip.MustParseAddr(fmt.Sprintf("fd00::%d", i+1)), Status: &nbpeer.PeerStatus{LastSeen: time.Now().UTC(), Connected: true}, UserID: regularUser, } @@ -783,7 +784,15 @@ func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccou account.Networks = append(account.Networks, network) ips := account.GetTakenIPs() - peerIP, err := types.AllocatePeerIP(account.Network.Net, ips) + peerIP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, nil, "", "", err + } + v6Prefix, err := netip.ParsePrefix(account.Network.NetV6.String()) + if err != nil { + return nil, nil, "", "", err + } + peerIPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, nil, "", "", err } @@ -794,6 +803,7 @@ func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccou DNSLabel: fmt.Sprintf("peer-nr-%d", len(account.Peers)+1), Key: peerKey.PublicKey().String(), IP: peerIP, + IPv6: peerIPv6, Status: &nbpeer.PeerStatus{LastSeen: time.Now().UTC(), Connected: true}, UserID: regularUser, Meta: nbpeer.PeerSystemMeta{ @@ -1068,7 +1078,8 @@ func TestToSyncResponse(t *testing.T) { }, } peer := &nbpeer.Peer{ - IP: net.ParseIP("192.168.1.1"), + IP: netip.MustParseAddr("192.168.1.1"), + IPv6: netip.MustParseAddr("fd00::1"), SSHEnabled: true, Key: "peer-key", DNSLabel: "peer1", @@ -1079,9 +1090,21 @@ func TestToSyncResponse(t *testing.T) { Signature: "turn-pass", } networkMap := &types.NetworkMap{ - Network: &types.Network{Net: *ipnet, Serial: 1000}, - Peers: []*nbpeer.Peer{{IP: net.ParseIP("192.168.1.2"), Key: "peer2-key", DNSLabel: "peer2", SSHEnabled: true, SSHKey: "peer2-ssh-key"}}, - OfflinePeers: []*nbpeer.Peer{{IP: net.ParseIP("192.168.1.3"), Key: "peer3-key", DNSLabel: "peer3", SSHEnabled: true, SSHKey: "peer3-ssh-key"}}, + Network: &types.Network{Net: *ipnet, Serial: 1000}, + Peers: []*nbpeer.Peer{{ + IP: netip.MustParseAddr("192.168.1.2"), + IPv6: netip.MustParseAddr("fd00::2"), + Key: "peer2-key", + DNSLabel: "peer2", + SSHEnabled: true, + SSHKey: "peer2-ssh-key"}}, + OfflinePeers: []*nbpeer.Peer{{ + IP: netip.MustParseAddr("192.168.1.3"), + IPv6: netip.MustParseAddr("fd00::3"), + Key: "peer3-key", + DNSLabel: "peer3", + SSHEnabled: true, + SSHKey: "peer3-ssh-key"}}, Routes: []*nbroute.Route{ { ID: "route1", @@ -1228,6 +1251,7 @@ func TestToSyncResponse(t *testing.T) { assert.Equal(t, int64(53), response.NetworkMap.DNSConfig.NameServerGroups[0].NameServers[0].GetPort()) // assert network map Firewall assert.Equal(t, 1, len(response.NetworkMap.FirewallRules)) + //nolint:staticcheck // testing backward-compatible field assert.Equal(t, "192.168.1.2", response.NetworkMap.FirewallRules[0].PeerIP) assert.Equal(t, proto.RuleDirection_IN, response.NetworkMap.FirewallRules[0].Direction) assert.Equal(t, proto.RuleAction_ACCEPT, response.NetworkMap.FirewallRules[0].Action) @@ -1290,7 +1314,8 @@ func Test_RegisterPeerByUser(t *testing.T) { ID: xid.New().String(), AccountID: existingAccountID, Key: "newPeerKey", - IP: net.IP{123, 123, 123, 123}, + IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}), + IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"), Meta: nbpeer.PeerSystemMeta{ Hostname: "newPeer", GoOS: "linux", @@ -1378,7 +1403,8 @@ func Test_RegisterPeerBySetupKey(t *testing.T) { newPeerTemplate := &nbpeer.Peer{ AccountID: existingAccountID, UserID: "", - IP: net.IP{123, 123, 123, 123}, + IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}), + IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"), Meta: nbpeer.PeerSystemMeta{ Hostname: "newPeer", GoOS: "linux", @@ -1539,7 +1565,8 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) { AccountID: existingAccountID, Key: "newPeerKey", UserID: "", - IP: net.IP{123, 123, 123, 123}, + IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}), + IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"), Meta: nbpeer.PeerSystemMeta{ Hostname: "newPeer", GoOS: "linux", @@ -1624,7 +1651,8 @@ func Test_LoginPeer(t *testing.T) { newPeerTemplate := &nbpeer.Peer{ AccountID: existingAccountID, UserID: "", - IP: net.IP{123, 123, 123, 123}, + IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}), + IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"), Meta: nbpeer.PeerSystemMeta{ Hostname: "newPeer", GoOS: "linux", @@ -2126,14 +2154,16 @@ func Test_DeletePeer(t *testing.T) { ID: "peer1", AccountID: accountID, Key: "key1", - IP: net.IP{1, 1, 1, 1}, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1"), DNSLabel: "peer1.test", }, "peer2": { ID: "peer2", AccountID: accountID, Key: "key2", - IP: net.IP{2, 2, 2, 2}, + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2"), DNSLabel: "peer2.test", }, } @@ -2730,6 +2760,67 @@ func TestProcessPeerAddAuth(t *testing.T) { }) } +func TestPeerWillHaveIPv6(t *testing.T) { + settings := &types.Settings{ + IPv6EnabledGroups: []string{"all-group-id", "group-a"}, + } + + assert.True(t, peerWillHaveIPv6(settings, nil, "all-group-id"), "peer in All group should get IPv6") + assert.True(t, peerWillHaveIPv6(settings, []string{"group-a"}, ""), "peer with matching auto-group should get IPv6") + assert.False(t, peerWillHaveIPv6(settings, []string{"group-b"}, "other-all"), "peer with no matching groups should not get IPv6") + assert.False(t, peerWillHaveIPv6(settings, nil, ""), "embedded peer with no groups should not get IPv6") + + emptySettings := &types.Settings{IPv6EnabledGroups: []string{}} + assert.False(t, peerWillHaveIPv6(emptySettings, []string{"group-a"}, "all-group-id"), "no IPv6 groups means no IPv6") +} + +// TestSyncPeer_IPv6CapabilityChangePropagates ensures that when a peer reports +// a new IPv6 overlay capability via SyncPeer (e.g. after a client upgrade or +// flipping --disable-ipv6) without bumping its WtVersion, other account peers +// receive a fresh network map so their AAAA records for it become unstale. +func TestSyncPeer_IPv6CapabilityChangePropagates(t *testing.T) { + manager, updateManager, _, peer1, peer2, _ := setupNetworkMapTest(t) + + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) + + // Drain any initial updates from setup. + drain := func() { + for { + select { + case <-updMsg: + case <-time.After(200 * time.Millisecond): + return + } + } + } + drain() + + t.Run("no propagation when capabilities are unchanged", func(t *testing.T) { + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: peer2.Meta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldNotReceiveUpdate(t, updMsg) + }) + + t.Run("propagation when IPv6 capability is added", func(t *testing.T) { + newMeta := peer2.Meta + newMeta.Capabilities = append([]int32{}, peer2.Meta.Capabilities...) + newMeta.Capabilities = append(newMeta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay) + + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: newMeta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldReceiveUpdate(t, updMsg) + }) +} + func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") diff --git a/management/server/policy_test.go b/management/server/policy_test.go index a553b7d05..1eae07e79 100644 --- a/management/server/policy_test.go +++ b/management/server/policy_test.go @@ -3,7 +3,7 @@ package server import ( "context" "fmt" - "net" + "net/netip" "testing" "time" @@ -20,53 +20,53 @@ func TestAccount_getPeersByPolicy(t *testing.T) { Peers: map[string]*nbpeer.Peer{ "peerA": { ID: "peerA", - IP: net.ParseIP("100.65.14.88"), + IP: netip.MustParseAddr("100.65.14.88"), Status: &nbpeer.PeerStatus{}, }, "peerB": { ID: "peerB", - IP: net.ParseIP("100.65.80.39"), + IP: netip.MustParseAddr("100.65.80.39"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{WtVersion: "0.48.0"}, }, "peerC": { ID: "peerC", - IP: net.ParseIP("100.65.254.139"), + IP: netip.MustParseAddr("100.65.254.139"), Status: &nbpeer.PeerStatus{}, }, "peerD": { ID: "peerD", - IP: net.ParseIP("100.65.62.5"), + IP: netip.MustParseAddr("100.65.62.5"), Status: &nbpeer.PeerStatus{}, }, "peerE": { ID: "peerE", - IP: net.ParseIP("100.65.32.206"), + IP: netip.MustParseAddr("100.65.32.206"), Status: &nbpeer.PeerStatus{}, }, "peerF": { ID: "peerF", - IP: net.ParseIP("100.65.250.202"), + IP: netip.MustParseAddr("100.65.250.202"), Status: &nbpeer.PeerStatus{}, }, "peerG": { ID: "peerG", - IP: net.ParseIP("100.65.13.186"), + IP: netip.MustParseAddr("100.65.13.186"), Status: &nbpeer.PeerStatus{}, }, "peerH": { ID: "peerH", - IP: net.ParseIP("100.65.29.55"), + IP: netip.MustParseAddr("100.65.29.55"), Status: &nbpeer.PeerStatus{}, }, "peerI": { ID: "peerI", - IP: net.ParseIP("100.65.31.2"), + IP: netip.MustParseAddr("100.65.31.2"), Status: &nbpeer.PeerStatus{}, }, "peerK": { ID: "peerK", - IP: net.ParseIP("100.32.80.1"), + IP: netip.MustParseAddr("100.32.80.1"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{WtVersion: "0.30.0"}, }, @@ -540,17 +540,17 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { Peers: map[string]*nbpeer.Peer{ "peerA": { ID: "peerA", - IP: net.ParseIP("100.65.14.88"), + IP: netip.MustParseAddr("100.65.14.88"), Status: &nbpeer.PeerStatus{}, }, "peerB": { ID: "peerB", - IP: net.ParseIP("100.65.80.39"), + IP: netip.MustParseAddr("100.65.80.39"), Status: &nbpeer.PeerStatus{}, }, "peerC": { ID: "peerC", - IP: net.ParseIP("100.65.254.139"), + IP: netip.MustParseAddr("100.65.254.139"), Status: &nbpeer.PeerStatus{}, }, }, @@ -746,7 +746,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { Peers: map[string]*nbpeer.Peer{ "peerA": { ID: "peerA", - IP: net.ParseIP("100.65.14.88"), + IP: netip.MustParseAddr("100.65.14.88"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -756,7 +756,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerB": { ID: "peerB", - IP: net.ParseIP("100.65.80.39"), + IP: netip.MustParseAddr("100.65.80.39"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -766,7 +766,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerC": { ID: "peerC", - IP: net.ParseIP("100.65.254.139"), + IP: netip.MustParseAddr("100.65.254.139"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -776,7 +776,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerD": { ID: "peerD", - IP: net.ParseIP("100.65.62.5"), + IP: netip.MustParseAddr("100.65.62.5"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -786,7 +786,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerE": { ID: "peerE", - IP: net.ParseIP("100.65.32.206"), + IP: netip.MustParseAddr("100.65.32.206"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -796,7 +796,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerF": { ID: "peerF", - IP: net.ParseIP("100.65.250.202"), + IP: netip.MustParseAddr("100.65.250.202"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -806,7 +806,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerG": { ID: "peerG", - IP: net.ParseIP("100.65.13.186"), + IP: netip.MustParseAddr("100.65.13.186"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -816,7 +816,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerH": { ID: "peerH", - IP: net.ParseIP("100.65.29.55"), + IP: netip.MustParseAddr("100.65.29.55"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -826,7 +826,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerI": { ID: "peerI", - IP: net.ParseIP("100.65.21.56"), + IP: netip.MustParseAddr("100.65.21.56"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "windows", diff --git a/management/server/route_test.go b/management/server/route_test.go index d0caf4b9b..79014790f 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -2,7 +2,6 @@ package server import ( "context" - "net" "net/netip" "testing" "time" @@ -1333,14 +1332,24 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou return nil, err } + v6Prefix, err := netip.ParsePrefix(account.Network.NetV6.String()) + if err != nil { + return nil, err + } + ips := account.GetTakenIPs() - peer1IP, err := types.AllocatePeerIP(account.Network.Net, ips) + peer1IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, err + } + peer1IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, err } peer1 := &nbpeer.Peer{ IP: peer1IP, + IPv6: peer1IPv6, ID: peer1ID, Key: peer1Key, Name: "test-host1@netbird.io", @@ -1361,13 +1370,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou account.Peers[peer1.ID] = peer1 ips = account.GetTakenIPs() - peer2IP, err := types.AllocatePeerIP(account.Network.Net, ips) + peer2IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, err + } + peer2IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, err } peer2 := &nbpeer.Peer{ IP: peer2IP, + IPv6: peer2IPv6, ID: peer2ID, Key: peer2Key, Name: "test-host2@netbird.io", @@ -1388,13 +1402,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou account.Peers[peer2.ID] = peer2 ips = account.GetTakenIPs() - peer3IP, err := types.AllocatePeerIP(account.Network.Net, ips) + peer3IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, err + } + peer3IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, err } peer3 := &nbpeer.Peer{ IP: peer3IP, + IPv6: peer3IPv6, ID: peer3ID, Key: peer3Key, Name: "test-host3@netbird.io", @@ -1415,13 +1434,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou account.Peers[peer3.ID] = peer3 ips = account.GetTakenIPs() - peer4IP, err := types.AllocatePeerIP(account.Network.Net, ips) + peer4IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, err + } + peer4IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, err } peer4 := &nbpeer.Peer{ IP: peer4IP, + IPv6: peer4IPv6, ID: peer4ID, Key: peer4Key, Name: "test-host4@netbird.io", @@ -1442,13 +1466,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou account.Peers[peer4.ID] = peer4 ips = account.GetTakenIPs() - peer5IP, err := types.AllocatePeerIP(account.Network.Net, ips) + peer5IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, err + } + peer5IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, err } peer5 := &nbpeer.Peer{ IP: peer5IP, + IPv6: peer5IPv6, ID: peer5ID, Key: peer5Key, Name: "test-host5@netbird.io", @@ -1549,7 +1578,8 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { Peers: map[string]*nbpeer.Peer{ "peerA": { ID: "peerA", - IP: net.ParseIP("100.65.14.88"), + IP: netip.MustParseAddr("100.65.14.88"), + IPv6: netip.MustParseAddr("fd00::1"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -1557,18 +1587,21 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { }, "peerB": { ID: "peerB", - IP: net.ParseIP(peerBIp), + IP: netip.MustParseAddr(peerBIp), + IPv6: netip.MustParseAddr("fd00::2"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{}, }, "peerC": { ID: "peerC", - IP: net.ParseIP(peerCIp), + IP: netip.MustParseAddr(peerCIp), + IPv6: netip.MustParseAddr("fd00::3"), Status: &nbpeer.PeerStatus{}, }, "peerD": { ID: "peerD", - IP: net.ParseIP("100.65.62.5"), + IP: netip.MustParseAddr("100.65.62.5"), + IPv6: netip.MustParseAddr("fd00::4"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -1576,7 +1609,8 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { }, "peerE": { ID: "peerE", - IP: net.ParseIP("100.65.32.206"), + IP: netip.MustParseAddr("100.65.32.206"), + IPv6: netip.MustParseAddr("fd00::5"), Key: peer1Key, Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ @@ -1585,27 +1619,32 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { }, "peerF": { ID: "peerF", - IP: net.ParseIP("100.65.250.202"), + IP: netip.MustParseAddr("100.65.250.202"), + IPv6: netip.MustParseAddr("fd00::6"), Status: &nbpeer.PeerStatus{}, }, "peerG": { ID: "peerG", - IP: net.ParseIP("100.65.13.186"), + IP: netip.MustParseAddr("100.65.13.186"), + IPv6: netip.MustParseAddr("fd00::7"), Status: &nbpeer.PeerStatus{}, }, "peerH": { ID: "peerH", - IP: net.ParseIP(peerHIp), + IP: netip.MustParseAddr(peerHIp), + IPv6: netip.MustParseAddr("fd00::8"), Status: &nbpeer.PeerStatus{}, }, "peerJ": { ID: "peerJ", - IP: net.ParseIP(peerJIp), + IP: netip.MustParseAddr(peerJIp), + IPv6: netip.MustParseAddr("fd00::a"), Status: &nbpeer.PeerStatus{}, }, "peerK": { ID: "peerK", - IP: net.ParseIP(peerKIp), + IP: netip.MustParseAddr(peerKIp), + IPv6: netip.MustParseAddr("fd00::b"), Status: &nbpeer.PeerStatus{}, }, }, @@ -2129,84 +2168,101 @@ func TestAccount_GetPeerNetworkResourceFirewallRules(t *testing.T) { Peers: map[string]*nbpeer.Peer{ "peerA": { ID: "peerA", - IP: net.ParseIP("100.65.14.88"), + IP: netip.MustParseAddr("100.65.14.88"), + IPv6: netip.MustParseAddr("fd00::1"), Key: "peerA", Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ - GoOS: "linux", + GoOS: "linux", + Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay}, }, }, "peerB": { ID: "peerB", - IP: net.ParseIP(peerBIp), + IP: netip.MustParseAddr(peerBIp), + IPv6: netip.MustParseAddr("fd00::2"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{}, }, "peerC": { ID: "peerC", - IP: net.ParseIP(peerCIp), + IP: netip.MustParseAddr(peerCIp), + IPv6: netip.MustParseAddr("fd00::3"), Status: &nbpeer.PeerStatus{}, }, "peerD": { ID: "peerD", - IP: net.ParseIP("100.65.62.5"), + IP: netip.MustParseAddr("100.65.62.5"), + IPv6: netip.MustParseAddr("fd00::4"), Key: "peerD", Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ - GoOS: "linux", + GoOS: "linux", + Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay}, }, }, "peerE": { ID: "peerE", - IP: net.ParseIP("100.65.32.206"), + IP: netip.MustParseAddr("100.65.32.206"), + IPv6: netip.MustParseAddr("fd00::5"), Key: "peerE", Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ - GoOS: "linux", + GoOS: "linux", + Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay}, }, }, "peerF": { ID: "peerF", - IP: net.ParseIP("100.65.250.202"), + IP: netip.MustParseAddr("100.65.250.202"), + IPv6: netip.MustParseAddr("fd00::6"), Status: &nbpeer.PeerStatus{}, }, "peerG": { ID: "peerG", - IP: net.ParseIP("100.65.13.186"), + IP: netip.MustParseAddr("100.65.13.186"), + IPv6: netip.MustParseAddr("fd00::7"), Status: &nbpeer.PeerStatus{}, }, "peerH": { ID: "peerH", - IP: net.ParseIP(peerHIp), + IP: netip.MustParseAddr(peerHIp), + IPv6: netip.MustParseAddr("fd00::8"), Status: &nbpeer.PeerStatus{}, }, "peerJ": { ID: "peerJ", - IP: net.ParseIP(peerJIp), + IP: netip.MustParseAddr(peerJIp), + IPv6: netip.MustParseAddr("fd00::a"), Status: &nbpeer.PeerStatus{}, }, "peerK": { ID: "peerK", - IP: net.ParseIP(peerKIp), + IP: netip.MustParseAddr(peerKIp), + IPv6: netip.MustParseAddr("fd00::b"), Status: &nbpeer.PeerStatus{}, }, "peerL": { ID: "peerL", - IP: net.ParseIP("100.65.19.186"), + IP: netip.MustParseAddr("100.65.19.186"), + IPv6: netip.MustParseAddr("fd00::d"), Key: "peerL", Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ - GoOS: "linux", + GoOS: "linux", + Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay}, }, }, "peerM": { ID: "peerM", - IP: net.ParseIP(peerMIp), + IP: netip.MustParseAddr(peerMIp), + IPv6: netip.MustParseAddr("fd00::e"), Status: &nbpeer.PeerStatus{}, }, "peerN": { ID: "peerN", - IP: net.ParseIP("100.65.20.18"), + IP: netip.MustParseAddr("100.65.20.18"), + IPv6: netip.MustParseAddr("fd00::f"), Key: "peerN", Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ @@ -2215,7 +2271,8 @@ func TestAccount_GetPeerNetworkResourceFirewallRules(t *testing.T) { }, "peerO": { ID: "peerO", - IP: net.ParseIP(peerOIp), + IP: netip.MustParseAddr(peerOIp), + IPv6: netip.MustParseAddr("fd00::10"), Status: &nbpeer.PeerStatus{}, }, }, diff --git a/management/server/settings/manager.go b/management/server/settings/manager.go index 74af0a3ef..345d857f9 100644 --- a/management/server/settings/manager.go +++ b/management/server/settings/manager.go @@ -5,6 +5,7 @@ package settings import ( "context" "fmt" + "net/netip" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/integrations/extra_settings" @@ -22,6 +23,9 @@ type Manager interface { GetSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) GetExtraSettings(ctx context.Context, accountID string) (*types.ExtraSettings, error) UpdateExtraSettings(ctx context.Context, accountID, userID string, extraSettings *types.ExtraSettings) (bool, error) + // GetEffectiveNetworkRanges returns the actual allocated network ranges (v4 and v6). + // This includes auto-allocated ranges even when no custom override was set. + GetEffectiveNetworkRanges(ctx context.Context, accountID string) (v4, v6 netip.Prefix, err error) } // IdpConfig holds IdP-related configuration that is set at runtime @@ -115,3 +119,28 @@ func (m *managerImpl) GetExtraSettings(ctx context.Context, accountID string) (* func (m *managerImpl) UpdateExtraSettings(ctx context.Context, accountID, userID string, extraSettings *types.ExtraSettings) (bool, error) { return m.extraSettingsManager.UpdateExtraSettings(ctx, accountID, userID, extraSettings) } + +// GetEffectiveNetworkRanges returns the actual allocated network ranges from the account's network object. +func (m *managerImpl) GetEffectiveNetworkRanges(ctx context.Context, accountID string) (netip.Prefix, netip.Prefix, error) { + network, err := m.store.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return netip.Prefix{}, netip.Prefix{}, fmt.Errorf("get account network: %w", err) + } + + var v4, v6 netip.Prefix + if network.Net.IP != nil { + addr, ok := netip.AddrFromSlice(network.Net.IP) + if ok { + ones, _ := network.Net.Mask.Size() + v4 = netip.PrefixFrom(addr.Unmap(), ones) + } + } + if network.NetV6.IP != nil { + addr, ok := netip.AddrFromSlice(network.NetV6.IP) + if ok { + ones, _ := network.NetV6.Mask.Size() + v6 = netip.PrefixFrom(addr.Unmap(), ones) + } + } + return v4, v6, nil +} diff --git a/management/server/settings/manager_mock.go b/management/server/settings/manager_mock.go index dc2f2ebfe..4bedb2cf7 100644 --- a/management/server/settings/manager_mock.go +++ b/management/server/settings/manager_mock.go @@ -6,6 +6,7 @@ package settings import ( context "context" + netip "net/netip" reflect "reflect" gomock "github.com/golang/mock/gomock" @@ -94,3 +95,19 @@ func (mr *MockManagerMockRecorder) UpdateExtraSettings(ctx, accountID, userID, e mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateExtraSettings", reflect.TypeOf((*MockManager)(nil).UpdateExtraSettings), ctx, accountID, userID, extraSettings) } + +// GetEffectiveNetworkRanges mocks base method. +func (m *MockManager) GetEffectiveNetworkRanges(ctx context.Context, accountID string) (netip.Prefix, netip.Prefix, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEffectiveNetworkRanges", ctx, accountID) + ret0, _ := ret[0].(netip.Prefix) + ret1, _ := ret[1].(netip.Prefix) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetEffectiveNetworkRanges indicates an expected call of GetEffectiveNetworkRanges. +func (mr *MockManagerMockRecorder) GetEffectiveNetworkRanges(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEffectiveNetworkRanges", reflect.TypeOf((*MockManager)(nil).GetEffectiveNetworkRanges), ctx, accountID) +} diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 1fa3d08ee..973101ce3 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net" + "net/netip" "os" "path/filepath" "runtime" @@ -1503,7 +1504,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc SELECT id, created_by, created_at, domain, domain_category, is_domain_primary_account, -- Embedded Network - network_identifier, network_net, network_dns, network_serial, + network_identifier, network_net, network_net_v6, network_dns, network_serial, -- Embedded DNSSettings dns_settings_disabled_management_groups, -- Embedded Settings @@ -1512,7 +1513,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_regular_users_view_blocked, settings_groups_propagation_enabled, settings_jwt_groups_enabled, settings_jwt_groups_claim_name, settings_jwt_allow_groups, settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, - settings_lazy_connection_enabled, + settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1531,12 +1532,15 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sRoutingPeerDNSResolutionEnabled sql.NullBool sDNSDomain sql.NullString sNetworkRange sql.NullString + sNetworkRangeV6 sql.NullString + sIPv6EnabledGroups sql.NullString sLazyConnectionEnabled sql.NullBool sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString sExtraIntegratedValidatorGroups sql.NullString networkNet sql.NullString + networkNetV6 sql.NullString dnsSettingsDisabledGroups sql.NullString networkIdentifier sql.NullString networkDns sql.NullString @@ -1545,14 +1549,14 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc ) err := s.pool.QueryRow(ctx, accountQuery, accountID).Scan( &account.Id, &account.CreatedBy, &createdAt, &account.Domain, &account.DomainCategory, &account.IsDomainPrimaryAccount, - &networkIdentifier, &networkNet, &networkDns, &networkSerial, + &networkIdentifier, &networkNet, &networkNetV6, &networkDns, &networkSerial, &dnsSettingsDisabledGroups, &sPeerLoginExpirationEnabled, &sPeerLoginExpiration, &sPeerInactivityExpirationEnabled, &sPeerInactivityExpiration, &sRegularUsersViewBlocked, &sGroupsPropagationEnabled, &sJWTGroupsEnabled, &sJWTGroupsClaimName, &sJWTAllowGroups, &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, - &sLazyConnectionEnabled, + &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1621,6 +1625,15 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sNetworkRange.Valid { _ = json.Unmarshal([]byte(sNetworkRange.String), &account.Settings.NetworkRange) } + if networkNetV6.Valid { + _ = json.Unmarshal([]byte(networkNetV6.String), &account.Network.NetV6) + } + if sNetworkRangeV6.Valid { + _ = json.Unmarshal([]byte(sNetworkRangeV6.String), &account.Settings.NetworkRangeV6) + } + if sIPv6EnabledGroups.Valid { + _ = json.Unmarshal([]byte(sIPv6EnabledGroups.String), &account.Settings.IPv6EnabledGroups) + } if sExtraPeerApprovalEnabled.Valid { account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool @@ -1702,12 +1715,12 @@ func (s *SqlStore) getSetupKeys(ctx context.Context, accountID string) ([]types. func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Peer, error) { const query = `SELECT id, account_id, key, ip, name, dns_label, user_id, ssh_key, ssh_enabled, login_expiration_enabled, - inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname, - meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version, + inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname, + meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version, meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer, - meta_environment, meta_flags, meta_files, peer_status_last_seen, peer_status_connected, peer_status_login_expired, - peer_status_requires_approval, location_connection_ip, location_country_code, location_city_name, - location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster FROM peers WHERE account_id = $1` + meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_connected, peer_status_login_expired, + peer_status_requires_approval, location_connection_ip, location_country_code, location_city_name, + location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6 FROM peers WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1721,7 +1734,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee sshEnabled, loginExpirationEnabled, inactivityExpirationEnabled, ephemeral, allowExtraDNSLabels sql.NullBool peerStatusLastSeen sql.NullTime peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool - ip, extraDNS, netAddr, env, flags, files, connIP []byte + ip, extraDNS, netAddr, env, flags, files, capabilities, connIP, ipv6 []byte metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString @@ -1733,9 +1746,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee &loginExpirationEnabled, &inactivityExpirationEnabled, &lastLogin, &createdAt, &ephemeral, &extraDNS, &allowExtraDNSLabels, &metaHostname, &metaGoOS, &metaKernel, &metaCore, &metaPlatform, &metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr, - &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, + &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities, &peerStatusLastSeen, &peerStatusConnected, &peerStatusLoginExpired, &peerStatusRequiresApproval, &connIP, - &locationCountryCode, &locationCityName, &locationGeoNameID, &proxyEmbedded, &proxyCluster) + &locationCountryCode, &locationCityName, &locationGeoNameID, &proxyEmbedded, &proxyCluster, &ipv6) if err == nil { if lastLogin.Valid { @@ -1828,6 +1841,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee if ip != nil { _ = json.Unmarshal(ip, &p.IP) } + if ipv6 != nil { + _ = json.Unmarshal(ipv6, &p.IPv6) + } if extraDNS != nil { _ = json.Unmarshal(extraDNS, &p.ExtraDNSLabels) } @@ -1843,6 +1859,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee if files != nil { _ = json.Unmarshal(files, &p.Meta.Files) } + if capabilities != nil { + _ = json.Unmarshal(capabilities, &p.Meta.Capabilities) + } if connIP != nil { _ = json.Unmarshal(connIP, &p.Location.ConnectionIP) } @@ -2586,7 +2605,7 @@ func (s *SqlStore) GetAccountIDBySetupKey(ctx context.Context, setupKey string) return accountID, nil } -func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]net.IP, error) { +func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]netip.Addr, error) { tx := s.db if lockStrength != LockingStrengthNone { tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) @@ -2594,7 +2613,6 @@ func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength var ipJSONStrings []string - // Fetch the IP addresses as JSON strings result := tx.Model(&nbpeer.Peer{}). Where("account_id = ?", accountID). Pluck("ip", &ipJSONStrings) @@ -2605,14 +2623,13 @@ func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength return nil, status.Errorf(status.Internal, "issue getting IPs from store: %s", result.Error) } - // Convert the JSON strings to net.IP objects - ips := make([]net.IP, len(ipJSONStrings)) + ips := make([]netip.Addr, len(ipJSONStrings)) for i, ipJSON := range ipJSONStrings { - var ip net.IP + var ip netip.Addr if err := json.Unmarshal([]byte(ipJSON), &ip); err != nil { return nil, status.Errorf(status.Internal, "issue parsing IP JSON from store") } - ips[i] = ip + ips[i] = ip.Unmap() } return ips, nil @@ -3214,7 +3231,7 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre query = query.Where("name LIKE ?", "%"+nameFilter+"%") } if ipFilter != "" { - query = query.Where("ip LIKE ?", "%"+ipFilter+"%") + query = query.Where("ip LIKE ? OR ipv6 LIKE ?", "%"+ipFilter+"%", "%"+ipFilter+"%") } if err := query.Find(&peers).Error; err != nil { @@ -4090,9 +4107,10 @@ func (s *SqlStore) SaveAccountSettings(ctx context.Context, accountID string, se return status.Errorf(status.Internal, "failed to save account settings to store") } - if result.RowsAffected == 0 { - return status.NewAccountNotFoundError(accountID) - } + // MySQL reports RowsAffected=0 for no-op updates where values don't change, + // unlike SQLite/Postgres which report matched rows. Skip the check since the + // caller (UpdateAccountSettings) already verified the account exists via + // GetAccountSettings with LockingStrengthUpdate. return nil } @@ -4517,11 +4535,15 @@ func (s *SqlStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) } + column := "ip" + if ip.To4() == nil { + column = "ipv6" + } jsonValue := fmt.Sprintf(`"%s"`, ip.String()) var peer nbpeer.Peer result := tx. - Take(&peer, "account_id = ? AND ip = ?", accountID, jsonValue) + Take(&peer, fmt.Sprintf("account_id = ? AND %s = ?", column), accountID, jsonValue) if result.Error != nil { // no logging here return nil, status.Errorf(status.Internal, "failed to get peer from store") @@ -4643,6 +4665,27 @@ func (s *SqlStore) UpdateAccountNetwork(ctx context.Context, accountID string, i return nil } +// UpdateAccountNetworkV6 updates the IPv6 network range for the account. +func (s *SqlStore) UpdateAccountNetworkV6(ctx context.Context, accountID string, ipNet net.IPNet) error { + patch := accountNetworkPatch{ + Network: &types.Network{NetV6: ipNet}, + } + + result := s.db. + Model(&types.Account{}). + Where(idQueryCondition, accountID). + Updates(&patch) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update account network v6: %v", result.Error) + return status.Errorf(status.Internal, "update account network v6") + } + if result.RowsAffected == 0 { + return status.NewAccountNotFoundError(accountID) + } + return nil +} + func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*nbpeer.Peer, error) { if len(groupIDs) == 0 { return []*nbpeer.Peer{}, nil diff --git a/management/server/store/sql_store_get_account_test.go b/management/server/store/sql_store_get_account_test.go index 69e346ae7..9a9de8cdd 100644 --- a/management/server/store/sql_store_get_account_test.go +++ b/management/server/store/sql_store_get_account_test.go @@ -148,7 +148,8 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) { AccountID: accountID, Key: "peer-key-1-AAAA", Name: "Peer 1", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{ Hostname: "peer1.example.com", GoOS: "linux", @@ -195,7 +196,8 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) { AccountID: accountID, Key: "peer-key-2-BBBB", Name: "Peer 2", - IP: net.ParseIP("100.64.0.2"), + IP: netip.MustParseAddr("100.64.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), Meta: nbpeer.PeerSystemMeta{ Hostname: "peer2.example.com", GoOS: "darwin", @@ -232,7 +234,8 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) { AccountID: accountID, Key: "peer-key-3-CCCC", Name: "Peer 3 (Ephemeral)", - IP: net.ParseIP("100.64.0.3"), + IP: netip.MustParseAddr("100.64.0.3"), + IPv6: netip.MustParseAddr("fd00::3"), Meta: nbpeer.PeerSystemMeta{ Hostname: "peer3.example.com", GoOS: "windows", @@ -710,7 +713,7 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) { require.True(t, exists, "Peer 1 should exist") assert.Equal(t, "Peer 1", p1.Name, "Peer 1 name mismatch") assert.Equal(t, "peer-key-1-AAAA", p1.Key, "Peer 1 key mismatch") - assert.True(t, p1.IP.Equal(net.ParseIP("100.64.0.1")), "Peer 1 IP mismatch") + assert.Equal(t, netip.MustParseAddr("100.64.0.1"), p1.IP, "Peer 1 IP mismatch") assert.Equal(t, userID1, p1.UserID, "Peer 1 user ID mismatch") assert.True(t, p1.SSHEnabled, "Peer 1 SSH should be enabled") assert.Equal(t, "ssh-rsa AAAAB3NzaC1...", p1.SSHKey, "Peer 1 SSH key mismatch") diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 5a5616abc..2819265c3 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -94,11 +94,12 @@ func runLargeTest(t *testing.T, store Store) { for n := 0; n < numPerAccount; n++ { netIP := randomIPv4() peerID := fmt.Sprintf("%s-peer-%d", account.Id, n) + addr, _ := netip.AddrFromSlice(netIP) peer := &nbpeer.Peer{ ID: peerID, Key: peerID, - IP: netIP, + IP: addr.Unmap(), Name: peerID, DNSLabel: peerID, UserID: "testuser", @@ -235,7 +236,8 @@ func Test_SaveAccount(t *testing.T) { account.SetupKeys[setupKey.Key] = setupKey account.Peers["testpeer"] = &nbpeer.Peer{ Key: "peerkey", - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -249,7 +251,8 @@ func Test_SaveAccount(t *testing.T) { account2.SetupKeys[setupKey.Key] = setupKey account2.Peers["testpeer2"] = &nbpeer.Peer{ Key: "peerkey2", - IP: net.IP{127, 0, 0, 2}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 2}), + IPv6: netip.MustParseAddr("fd00::2"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name 2", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -316,7 +319,8 @@ func TestSqlite_DeleteAccount(t *testing.T) { account.SetupKeys[setupKey.Key] = setupKey account.Peers["testpeer"] = &nbpeer.Peer{ Key: "peerkey", - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -499,7 +503,8 @@ func TestSqlStore_SavePeer(t *testing.T) { peer := &nbpeer.Peer{ Key: "peerkey", ID: "testpeer", - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{Hostname: "testingpeer"}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -556,7 +561,8 @@ func TestSqlStore_SavePeerStatus(t *testing.T) { account.Peers["testpeer"] = &nbpeer.Peer{ Key: "peerkey", ID: "testpeer", - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -784,7 +790,8 @@ func newAccount(store Store, id int) error { account.SetupKeys[setupKey.Key] = setupKey account.Peers["p"+str] = &nbpeer.Peer{ Key: "peerkey" + str, - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -823,7 +830,8 @@ func TestPostgresql_SaveAccount(t *testing.T) { account.SetupKeys[setupKey.Key] = setupKey account.Peers["testpeer"] = &nbpeer.Peer{ Key: "peerkey", - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -837,7 +845,8 @@ func TestPostgresql_SaveAccount(t *testing.T) { account2.SetupKeys[setupKey.Key] = setupKey account2.Peers["testpeer2"] = &nbpeer.Peer{ Key: "peerkey2", - IP: net.IP{127, 0, 0, 2}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 2}), + IPv6: netip.MustParseAddr("fd00::2"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name 2", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -903,7 +912,8 @@ func TestPostgresql_DeleteAccount(t *testing.T) { account.SetupKeys[setupKey.Key] = setupKey account.Peers["testpeer"] = &nbpeer.Peer{ Key: "peerkey", - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -1010,37 +1020,39 @@ func TestSqlite_GetTakenIPs(t *testing.T) { takenIPs, err := store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) require.NoError(t, err) - assert.Equal(t, []net.IP{}, takenIPs) + assert.Equal(t, []netip.Addr{}, takenIPs) peer1 := &nbpeer.Peer{ ID: "peer1", AccountID: existingAccountID, Key: "key1", DNSLabel: "peer1", - IP: net.IP{1, 1, 1, 1}, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), } err = store.AddPeerToAccount(context.Background(), peer1) require.NoError(t, err) takenIPs, err = store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) require.NoError(t, err) - ip1 := net.IP{1, 1, 1, 1}.To16() - assert.Equal(t, []net.IP{ip1}, takenIPs) + ip1 := netip.AddrFrom4([4]byte{1, 1, 1, 1}) + assert.Equal(t, []netip.Addr{ip1}, takenIPs) peer2 := &nbpeer.Peer{ ID: "peer1second", AccountID: existingAccountID, Key: "key2", DNSLabel: "peer1-1", - IP: net.IP{2, 2, 2, 2}, + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2:2:2:2"), } err = store.AddPeerToAccount(context.Background(), peer2) require.NoError(t, err) takenIPs, err = store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) require.NoError(t, err) - ip2 := net.IP{2, 2, 2, 2}.To16() - assert.Equal(t, []net.IP{ip1, ip2}, takenIPs) + ip2 := netip.AddrFrom4([4]byte{2, 2, 2, 2}) + assert.Equal(t, []netip.Addr{ip1, ip2}, takenIPs) } func TestSqlite_GetPeerLabelsInAccount(t *testing.T) { @@ -1060,7 +1072,8 @@ func TestSqlite_GetPeerLabelsInAccount(t *testing.T) { AccountID: existingAccountID, Key: "key1", DNSLabel: "peer1", - IP: net.IP{1, 1, 1, 1}, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), } err = store.AddPeerToAccount(context.Background(), peer1) require.NoError(t, err) @@ -1074,7 +1087,8 @@ func TestSqlite_GetPeerLabelsInAccount(t *testing.T) { AccountID: existingAccountID, Key: "key2", DNSLabel: "peer1-1", - IP: net.IP{2, 2, 2, 2}, + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2:2:2:2"), } err = store.AddPeerToAccount(context.Background(), peer2) require.NoError(t, err) @@ -1127,7 +1141,8 @@ func Test_AddPeerWithSameIP(t *testing.T) { ID: "peer1", AccountID: existingAccountID, Key: "key1", - IP: net.IP{1, 1, 1, 1}, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), } err = store.AddPeerToAccount(context.Background(), peer1) require.NoError(t, err) @@ -1136,7 +1151,8 @@ func Test_AddPeerWithSameIP(t *testing.T) { ID: "peer1second", AccountID: existingAccountID, Key: "key2", - IP: net.IP{1, 1, 1, 1}, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::2:2:2:2"), } err = store.AddPeerToAccount(context.Background(), peer2) require.Error(t, err) @@ -2640,7 +2656,8 @@ func TestSqlStore_AddPeerToAccount(t *testing.T) { ID: "peer1", AccountID: accountID, Key: "key", - IP: net.IP{1, 1, 1, 1}, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), Meta: nbpeer.PeerSystemMeta{ Hostname: "hostname", GoOS: "linux", @@ -3815,10 +3832,10 @@ func BenchmarkGetAccountPeers(b *testing.B) { } } -func intToIPv4(n uint32) net.IP { - ip := make(net.IP, 4) - binary.BigEndian.PutUint32(ip, n) - return ip +func intToIPv4(n uint32) netip.Addr { + var b [4]byte + binary.BigEndian.PutUint32(b[:], n) + return netip.AddrFrom4(b) } func TestSqlStore_GetPeersByGroupIDs(t *testing.T) { @@ -3945,7 +3962,8 @@ func TestSqlStore_GetUserIDByPeerKey(t *testing.T) { Key: peerKey, AccountID: existingAccountID, UserID: userID, - IP: net.IP{10, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{10, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::a00:1"), DNSLabel: "test-peer-1", } @@ -3982,7 +4000,8 @@ func TestSqlStore_GetUserIDByPeerKey_NoUserID(t *testing.T) { Key: peerKey, AccountID: existingAccountID, UserID: "", - IP: net.IP{10, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{10, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::a00:1"), DNSLabel: "test-peer-1", } @@ -4009,7 +4028,8 @@ func TestSqlStore_ApproveAccountPeers(t *testing.T) { AccountID: accountID, DNSLabel: "peer1.netbird.cloud", Key: "peer1-key", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), Status: &nbpeer.PeerStatus{ RequiresApproval: true, LastSeen: time.Now().UTC(), @@ -4020,7 +4040,8 @@ func TestSqlStore_ApproveAccountPeers(t *testing.T) { AccountID: accountID, DNSLabel: "peer2.netbird.cloud", Key: "peer2-key", - IP: net.ParseIP("100.64.0.2"), + IP: netip.MustParseAddr("100.64.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), Status: &nbpeer.PeerStatus{ RequiresApproval: true, LastSeen: time.Now().UTC(), @@ -4031,7 +4052,8 @@ func TestSqlStore_ApproveAccountPeers(t *testing.T) { AccountID: accountID, DNSLabel: "peer3.netbird.cloud", Key: "peer3-key", - IP: net.ParseIP("100.64.0.3"), + IP: netip.MustParseAddr("100.64.0.3"), + IPv6: netip.MustParseAddr("fd00::3"), Status: &nbpeer.PeerStatus{ RequiresApproval: false, LastSeen: time.Now().UTC(), diff --git a/management/server/store/sqlstore_bench_test.go b/management/server/store/sqlstore_bench_test.go index 81c4b33ae..a38b4a8c1 100644 --- a/management/server/store/sqlstore_bench_test.go +++ b/management/server/store/sqlstore_bench_test.go @@ -344,7 +344,8 @@ func setupBenchmarkDB(b testing.TB) (*SqlStore, func(), string) { ID: fmt.Sprintf("peer-%d", i), AccountID: accountID, Key: fmt.Sprintf("peerkey-%d", i), - IP: net.ParseIP(fmt.Sprintf("100.64.0.%d", i+1)), + IP: netip.MustParseAddr(fmt.Sprintf("100.64.0.%d", i+1)), + IPv6: netip.MustParseAddr(fmt.Sprintf("fd00::%d", i+1)), Name: fmt.Sprintf("peer-name-%d", i), Status: &nbpeer.PeerStatus{Connected: i%2 == 0, LastSeen: time.Now()}, }) diff --git a/management/server/store/store.go b/management/server/store/store.go index 447c85547..db98bc644 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -185,7 +185,7 @@ type Store interface { SaveNameServerGroup(ctx context.Context, nameServerGroup *dns.NameServerGroup) error DeleteNameServerGroup(ctx context.Context, accountID, nameServerGroupID string) error - GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]net.IP, error) + GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]netip.Addr, error) IncrementNetworkSerial(ctx context.Context, accountId string) error GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountId string) (*types.Network, error) @@ -225,6 +225,7 @@ type Store interface { IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error) MarkAccountPrimary(ctx context.Context, accountID string) error UpdateAccountNetwork(ctx context.Context, accountID string, ipNet net.IPNet) error + UpdateAccountNetworkV6(ctx context.Context, accountID string, ipNet net.IPNet) error GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) ([]*types.PolicyRule, error) // SetFieldEncrypt sets the field encryptor for encrypting sensitive user data. diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index d8bd826a8..6c2c9bbc3 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -7,6 +7,7 @@ package store import ( context "context" net "net" + netip "net/netip" reflect "reflect" time "time" @@ -2138,10 +2139,10 @@ func (mr *MockStoreMockRecorder) GetStoreEngine() *gomock.Call { } // GetTakenIPs mocks base method. -func (m *MockStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]net.IP, error) { +func (m *MockStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]netip.Addr, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTakenIPs", ctx, lockStrength, accountId) - ret0, _ := ret[0].([]net.IP) + ret0, _ := ret[0].([]netip.Addr) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2952,6 +2953,20 @@ func (mr *MockStoreMockRecorder) UpdateAccountNetwork(ctx, accountID, ipNet inte return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountNetwork", reflect.TypeOf((*MockStore)(nil).UpdateAccountNetwork), ctx, accountID, ipNet) } +// UpdateAccountNetworkV6 mocks base method. +func (m *MockStore) UpdateAccountNetworkV6(ctx context.Context, accountID string, ipNet net.IPNet) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAccountNetworkV6", ctx, accountID, ipNet) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateAccountNetworkV6 indicates an expected call of UpdateAccountNetworkV6. +func (mr *MockStoreMockRecorder) UpdateAccountNetworkV6(ctx, accountID, ipNet interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountNetworkV6", reflect.TypeOf((*MockStore)(nil).UpdateAccountNetworkV6), ctx, accountID, ipNet) +} + // UpdateCustomDomain mocks base method. func (m *MockStore) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) { m.ctrl.T.Helper() diff --git a/management/server/types/account.go b/management/server/types/account.go index e7c1e2dce..49600163a 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -3,7 +3,6 @@ package types import ( "context" "fmt" - "net" "net/netip" "slices" "strconv" @@ -270,6 +269,8 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn domainSuffix := "." + dnsDomain + ipv6AllowedPeers := a.peerIPv6AllowedSet() + var sb strings.Builder for _, peer := range a.Peers { if peer.DNSLabel == "" { @@ -281,13 +282,31 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn sb.WriteString(peer.DNSLabel) sb.WriteString(domainSuffix) + fqdn := sb.String() customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ - Name: sb.String(), + Name: fqdn, Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: defaultTTL, RData: peer.IP.String(), }) + // Only advertise AAAA for peers that have a valid IPv6, whose client supports it, + // and that belong to an IPv6-enabled group. Old clients don't configure v6 on their + // WireGuard interface, so resolving their AAAA causes connections to hang. + // Capability changes (client upgrade/downgrade, --disable-ipv6 toggle) propagate + // to other peers via SyncPeer/LoginPeer regardless of version change, so AAAA + // records refresh when a peer first reports the IPv6 overlay capability. + _, peerAllowed := ipv6AllowedPeers[peer.ID] + hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed + if hasIPv6 { + customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ + Name: fqdn, + Type: int(dns.TypeAAAA), + Class: nbdns.DefaultClass, + TTL: defaultTTL, + RData: peer.IPv6.String(), + }) + } sb.Reset() for _, extraLabel := range peer.ExtraDNSLabels { @@ -295,13 +314,23 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn sb.WriteString(extraLabel) sb.WriteString(domainSuffix) + extraFqdn := sb.String() customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ - Name: sb.String(), + Name: extraFqdn, Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: defaultTTL, RData: peer.IP.String(), }) + if hasIPv6 { + customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ + Name: extraFqdn, + Type: int(dns.TypeAAAA), + Class: nbdns.DefaultClass, + TTL: defaultTTL, + RData: peer.IPv6.String(), + }) + } sb.Reset() } @@ -569,8 +598,43 @@ func (a *Account) GetPeerGroups(peerID string) LookupMap { return groupList } -func (a *Account) GetTakenIPs() []net.IP { - var takenIps []net.IP +// PeerIPv6Allowed reports whether the given peer is in any of the account's IPv6 enabled groups. +// Returns false if IPv6 is disabled or no groups are configured. +func (a *Account) PeerIPv6Allowed(peerID string) bool { + if len(a.Settings.IPv6EnabledGroups) == 0 { + return false + } + + for _, groupID := range a.Settings.IPv6EnabledGroups { + group, ok := a.Groups[groupID] + if !ok { + continue + } + if slices.Contains(group.Peers, peerID) { + return true + } + } + return false +} + +// peerIPv6AllowedSet returns a set of peer IDs that belong to any IPv6-enabled group. +func (a *Account) peerIPv6AllowedSet() map[string]struct{} { + result := make(map[string]struct{}) + for _, groupID := range a.Settings.IPv6EnabledGroups { + group, ok := a.Groups[groupID] + if !ok { + continue + } + for _, peerID := range group.Peers { + result[peerID] = struct{}{} + } + } + return result +} + +// GetTakenIPs returns all peer IP addresses currently allocated in the account. +func (a *Account) GetTakenIPs() []netip.Addr { + takenIps := make([]netip.Addr, 0, len(a.Peers)) for _, existingPeer := range a.Peers { takenIps = append(takenIps, existingPeer.IP) } @@ -927,10 +991,17 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) - continue + } else { + rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...) } - rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...) + rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{ + direction: direction, + dirStr: strconv.Itoa(direction), + protocolStr: string(protocol), + actionStr: string(rule.Action), + portsJoined: strings.Join(rule.Ports, ","), + }) } }, func() ([]*nbpeer.Peer, []*FirewallRule) { return peers, rules @@ -1045,7 +1116,7 @@ func (a *Account) GetPostureChecks(postureChecksID string) *posture.Checks { return nil } -func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, validatedPeersMap map[string]struct{}, distributionPeers map[string]struct{}) []*RouteFirewallRule { +func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, validatedPeersMap map[string]struct{}, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule { var fwRules []*RouteFirewallRule for _, policy := range policies { if !policy.Enabled { @@ -1058,7 +1129,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli } rulePeers := a.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers, validatedPeersMap) - rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN) + rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) fwRules = append(fwRules, rules...) } } @@ -1140,7 +1211,7 @@ func (a *Account) GetPeerNetworkResourceFirewallRules(ctx context.Context, peer resourceAppliedPolicies := resourcePolicies[string(route.GetResourceID())] distributionPeers := getPoliciesSourcePeers(resourceAppliedPolicies, a.Groups) - rules := a.getRouteFirewallRules(ctx, peer.ID, resourceAppliedPolicies, route, validatedPeersMap, distributionPeers) + rules := a.getRouteFirewallRules(ctx, peer.ID, resourceAppliedPolicies, route, validatedPeersMap, distributionPeers, peer.SupportsIPv6() && peer.IPv6.IsValid()) for _, rule := range rules { if len(rule.SourceRanges) > 0 { routesFirewallRules = append(routesFirewallRules, rule) @@ -1595,24 +1666,32 @@ func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { } // filterZoneRecordsForPeers filters DNS records to only include peers to connect. +// AAAA records are excluded when the requesting peer lacks IPv6 capability. func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord { filteredRecords := make([]nbdns.SimpleRecord, 0, len(customZone.Records)) - peerIPs := make(map[string]struct{}) + peerIPs := make(map[netip.Addr]struct{}, len(peersToConnect)+len(expiredPeers)+2) + includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() - // Add peer's own IP to include its own DNS records - peerIPs[peer.IP.String()] = struct{}{} - - for _, peerToConnect := range peersToConnect { - peerIPs[peerToConnect.IP.String()] = struct{}{} + addPeerIPs := func(p *nbpeer.Peer) { + peerIPs[p.IP] = struct{}{} + if includeIPv6 && p.IPv6.IsValid() { + peerIPs[p.IPv6] = struct{}{} + } } - for _, expiredPeer := range expiredPeers { - peerIPs[expiredPeer.IP.String()] = struct{}{} + addPeerIPs(peer) + for _, p := range peersToConnect { + addPeerIPs(p) + } + for _, p := range expiredPeers { + addPeerIPs(p) } for _, record := range customZone.Records { - if _, exists := peerIPs[record.RData]; exists { - filteredRecords = append(filteredRecords, record) + if addr, err := netip.ParseAddr(record.RData); err == nil { + if _, exists := peerIPs[addr.Unmap()]; exists { + filteredRecords = append(filteredRecords, record) + } } } diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go index bd4244546..2b4f7e051 100644 --- a/management/server/types/account_components.go +++ b/management/server/types/account_components.go @@ -115,7 +115,7 @@ func (a *Account) GetPeerNetworkMapComponents( components.Groups = relevantGroups components.Policies = relevantPolicies components.Routes = relevantRoutes - components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers) + components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid()) peerGroups := a.GetPeerGroups(peerID) components.AccountZones = filterPeerAppliedZones(ctx, accountZones, peerGroups) @@ -539,15 +539,22 @@ func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{} } } -func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*nbpeer.Peer) []nbdns.SimpleRecord { +func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*nbpeer.Peer, includeIPv6 bool) []nbdns.SimpleRecord { if len(records) == 0 || len(peers) == 0 { return nil } - peerIPs := make(map[string]struct{}, len(peers)) + // Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6 + // address) are not filtered out when peers have IPv6 assigned. When the + // requesting peer doesn't have IPv6, omit v6 IPs so AAAA records get dropped. + peerIPs := make(map[string]struct{}, len(peers)*2) for _, peer := range peers { - if peer != nil { - peerIPs[peer.IP.String()] = struct{}{} + if peer == nil { + continue + } + peerIPs[peer.IP.String()] = struct{}{} + if includeIPv6 && peer.IPv6.IsValid() { + peerIPs[peer.IPv6.String()] = struct{}{} } } diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index 9b1c9e31d..a1a616882 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -3,7 +3,7 @@ package types import ( "context" "fmt" - "net" + "net/netip" "testing" "github.com/miekg/dns" @@ -921,7 +921,11 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) { }, peersToConnect: []*nbpeer.Peer{}, expiredPeers: []*nbpeer.Peer{}, - peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")}, + peer: &nbpeer.Peer{ + ID: "router", + IP: netip.MustParseAddr("10.0.0.100"), + IPv6: netip.MustParseAddr("fd00::a00:64"), + }, expectedRecords: []nbdns.SimpleRecord{ {Name: "router.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.100"}, }, @@ -948,14 +952,19 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) { var peers []*nbpeer.Peer for _, i := range []int{1, 5, 10, 25, 50, 75, 100} { peers = append(peers, &nbpeer.Peer{ - ID: fmt.Sprintf("peer%d", i), - IP: net.ParseIP(fmt.Sprintf("10.0.%d.%d", i/256, i%256)), + ID: fmt.Sprintf("peer%d", i), + IP: netip.MustParseAddr(fmt.Sprintf("10.0.%d.%d", i/256, i%256)), + IPv6: netip.MustParseAddr(fmt.Sprintf("fd00::%d", i)), }) } return peers }(), expiredPeers: []*nbpeer.Peer{}, - peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")}, + peer: &nbpeer.Peer{ + ID: "router", + IP: netip.MustParseAddr("10.0.0.100"), + IPv6: netip.MustParseAddr("fd00::a00:64"), + }, expectedRecords: func() []nbdns.SimpleRecord { var records []nbdns.SimpleRecord for _, i := range []int{1, 5, 10, 25, 50, 75, 100} { @@ -986,11 +995,27 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) { }, }, peersToConnect: []*nbpeer.Peer{ - {ID: "peer1", IP: net.ParseIP("10.0.0.1"), DNSLabel: "peer1", ExtraDNSLabels: []string{"peer1-alt", "peer1-backup"}}, - {ID: "peer2", IP: net.ParseIP("10.0.0.2"), DNSLabel: "peer2", ExtraDNSLabels: []string{"peer2-service"}}, + { + ID: "peer1", + IP: netip.MustParseAddr("10.0.0.1"), + IPv6: netip.MustParseAddr("fd00::a00:1"), + DNSLabel: "peer1", + ExtraDNSLabels: []string{"peer1-alt", "peer1-backup"}, + }, + { + ID: "peer2", + IP: netip.MustParseAddr("10.0.0.2"), + IPv6: netip.MustParseAddr("fd00::a00:2"), + DNSLabel: "peer2", + ExtraDNSLabels: []string{"peer2-service"}, + }, }, expiredPeers: []*nbpeer.Peer{}, - peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")}, + peer: &nbpeer.Peer{ + ID: "router", + IP: netip.MustParseAddr("10.0.0.100"), + IPv6: netip.MustParseAddr("fd00::a00:64"), + }, expectedRecords: []nbdns.SimpleRecord{ {Name: "peer1.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, {Name: "peer1-alt.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, @@ -1012,12 +1037,24 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) { }, }, peersToConnect: []*nbpeer.Peer{ - {ID: "peer1", IP: net.ParseIP("10.0.0.1")}, + { + ID: "peer1", + IP: netip.MustParseAddr("10.0.0.1"), + IPv6: netip.MustParseAddr("fd00::a00:1"), + }, }, expiredPeers: []*nbpeer.Peer{ - {ID: "expired-peer", IP: net.ParseIP("10.0.0.99")}, + { + ID: "expired-peer", + IP: netip.MustParseAddr("10.0.0.99"), + IPv6: netip.MustParseAddr("fd00::a00:63"), + }, + }, + peer: &nbpeer.Peer{ + ID: "router", + IP: netip.MustParseAddr("10.0.0.100"), + IPv6: netip.MustParseAddr("fd00::a00:64"), }, - peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")}, expectedRecords: []nbdns.SimpleRecord{ {Name: "peer1.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, {Name: "expired-peer.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.99"}, diff --git a/management/server/types/firewall_rule.go b/management/server/types/firewall_rule.go index 19222a607..b76a94290 100644 --- a/management/server/types/firewall_rule.go +++ b/management/server/types/firewall_rule.go @@ -48,16 +48,26 @@ func (r *FirewallRule) Equal(other *FirewallRule) bool { } // generateRouteFirewallRules generates a list of firewall rules for a given route. -func generateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) []*RouteFirewallRule { +// For static routes, source ranges match the destination family (v4 or v6). +// For dynamic routes (domain-based), separate v4 and v6 rules are generated +// so the routing peer's forwarding chain allows both address families. +func generateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { rulesExists := make(map[string]struct{}) rules := make([]*RouteFirewallRule, 0) - sourceRanges := make([]string, 0, len(groupPeers)) - for _, peer := range groupPeers { - if peer == nil { - continue - } - sourceRanges = append(sourceRanges, fmt.Sprintf(AllowedIPsFormat, peer.IP)) + v4Sources, v6Sources := splitPeerSourcesByFamily(groupPeers) + + isV6Route := route.Network.Addr().Is6() + + // Skip v6 destination routes entirely for peers without IPv6 support + if isV6Route && !includeIPv6 { + return rules + } + + // Pick sources matching the destination family + sourceRanges := v4Sources + if isV6Route { + sourceRanges = v6Sources } baseRule := RouteFirewallRule{ @@ -71,18 +81,47 @@ func generateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule IsDynamic: route.IsDynamic(), } - // generate rule for port range if len(rule.Ports) == 0 { rules = append(rules, generateRulesWithPortRanges(baseRule, rule, rulesExists)...) } else { rules = append(rules, generateRulesWithPorts(ctx, baseRule, rule, rulesExists)...) } - // TODO: generate IPv6 rules for dynamic routes + // Generate v6 counterpart for dynamic routes and 0.0.0.0/0 exit node routes. + isDefaultV4 := !isV6Route && route.Network.Bits() == 0 + if includeIPv6 && (route.IsDynamic() || isDefaultV4) && len(v6Sources) > 0 { + v6Rule := baseRule + v6Rule.SourceRanges = v6Sources + if isDefaultV4 { + v6Rule.Destination = "::/0" + v6Rule.RouteID = route.ID + "-v6-default" + } + if len(rule.Ports) == 0 { + rules = append(rules, generateRulesWithPortRanges(v6Rule, rule, rulesExists)...) + } else { + rules = append(rules, generateRulesWithPorts(ctx, v6Rule, rule, rulesExists)...) + } + } return rules } +// splitPeerSourcesByFamily separates peer IPs into v4 (/32) and v6 (/128) source ranges. +func splitPeerSourcesByFamily(groupPeers []*nbpeer.Peer) (v4, v6 []string) { + v4 = make([]string, 0, len(groupPeers)) + v6 = make([]string, 0, len(groupPeers)) + for _, peer := range groupPeers { + if peer == nil { + continue + } + v4 = append(v4, fmt.Sprintf(AllowedIPsFormat, peer.IP)) + if peer.IPv6.IsValid() { + v6 = append(v6, fmt.Sprintf(AllowedIPsV6Format, peer.IPv6)) + } + } + return +} + // generateRulesForPeer generates rules for a given peer based on ports and port ranges. func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule { rules := make([]*RouteFirewallRule, 0) diff --git a/management/server/types/firewall_rule_test.go b/management/server/types/firewall_rule_test.go new file mode 100644 index 000000000..8d97a46bc --- /dev/null +++ b/management/server/types/firewall_rule_test.go @@ -0,0 +1,197 @@ +package types + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/domain" +) + +func TestSplitPeerSourcesByFamily(t *testing.T) { + peers := []*nbpeer.Peer{ + { + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + }, + { + IP: netip.MustParseAddr("100.64.0.2"), + }, + { + IP: netip.MustParseAddr("100.64.0.3"), + IPv6: netip.MustParseAddr("fd00::3"), + }, + nil, + } + + v4, v6 := splitPeerSourcesByFamily(peers) + + assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32", "100.64.0.3/32"}, v4) + assert.Equal(t, []string{"fd00::1/128", "fd00::3/128"}, v6) +} + +func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { + peers := []*nbpeer.Peer{ + { + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + }, + { + IP: netip.MustParseAddr("100.64.0.2"), + }, + } + + r := &route.Route{ + ID: "route1", + Network: netip.MustParsePrefix("10.0.0.0/24"), + } + rule := &PolicyRule{ + PolicyID: "policy1", + ID: "rule1", + Action: PolicyTrafficActionAccept, + Protocol: PolicyRuleProtocolALL, + } + + rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + + require.Len(t, rules, 1) + assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges, "v4 route should only have v4 sources") + assert.Equal(t, "10.0.0.0/24", rules[0].Destination) +} + +func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { + peers := []*nbpeer.Peer{ + { + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + }, + { + IP: netip.MustParseAddr("100.64.0.2"), + }, + } + + r := &route.Route{ + ID: "route1", + Network: netip.MustParsePrefix("2001:db8::/32"), + } + rule := &PolicyRule{ + PolicyID: "policy1", + ID: "rule1", + Action: PolicyTrafficActionAccept, + Protocol: PolicyRuleProtocolALL, + } + + rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + + require.Len(t, rules, 1) + assert.Equal(t, []string{"fd00::1/128"}, rules[0].SourceRanges, "v6 route should only have v6 sources") +} + +func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { + peers := []*nbpeer.Peer{ + { + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + }, + { + IP: netip.MustParseAddr("100.64.0.2"), + }, + } + + r := &route.Route{ + ID: "route1", + NetworkType: route.DomainNetwork, + Domains: domain.List{"example.com"}, + } + rule := &PolicyRule{ + PolicyID: "policy1", + ID: "rule1", + Action: PolicyTrafficActionAccept, + Protocol: PolicyRuleProtocolALL, + } + + rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + + require.Len(t, rules, 2, "dynamic route should produce both v4 and v6 rules") + assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges) + assert.Equal(t, []string{"fd00::1/128"}, rules[1].SourceRanges) + assert.Equal(t, rules[0].Domains, rules[1].Domains) + assert.True(t, rules[0].IsDynamic) + assert.True(t, rules[1].IsDynamic) +} + +func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { + peers := []*nbpeer.Peer{ + {IP: netip.MustParseAddr("100.64.0.1")}, + {IP: netip.MustParseAddr("100.64.0.2")}, + } + + r := &route.Route{ + ID: "route1", + NetworkType: route.DomainNetwork, + Domains: domain.List{"example.com"}, + } + rule := &PolicyRule{ + PolicyID: "policy1", + ID: "rule1", + Action: PolicyTrafficActionAccept, + Protocol: PolicyRuleProtocolALL, + } + + rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + + require.Len(t, rules, 1, "no v6 peers means only v4 rule") + assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges) +} + +func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { + peers := []*nbpeer.Peer{ + { + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + }, + { + IP: netip.MustParseAddr("100.64.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), + }, + } + + t.Run("v6 route excluded", func(t *testing.T) { + r := &route.Route{ + ID: "route1", + Network: netip.MustParsePrefix("2001:db8::/32"), + } + rule := &PolicyRule{ + PolicyID: "policy1", + ID: "rule1", + Action: PolicyTrafficActionAccept, + Protocol: PolicyRuleProtocolALL, + } + + rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) + assert.Empty(t, rules, "v6 route should produce no rules when includeIPv6 is false") + }) + + t.Run("dynamic route only v4", func(t *testing.T) { + r := &route.Route{ + ID: "route1", + NetworkType: route.DomainNetwork, + Domains: domain.List{"example.com"}, + } + rule := &PolicyRule{ + PolicyID: "policy1", + ID: "rule1", + Action: PolicyTrafficActionAccept, + Protocol: PolicyRuleProtocolALL, + } + + rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) + require.Len(t, rules, 1, "dynamic route with includeIPv6=false should produce only v4 rule") + assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges) + }) +} diff --git a/management/server/types/ipv6_endtoend_test.go b/management/server/types/ipv6_endtoend_test.go new file mode 100644 index 000000000..ddd1f649f --- /dev/null +++ b/management/server/types/ipv6_endtoend_test.go @@ -0,0 +1,156 @@ +package types_test + +import ( + "net/netip" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) { + account := createComponentTestAccount() + + // Make all peers IPv6-capable and assign IPv6 addrs. + v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay, nbpeer.PeerCapabilitySourcePrefixes} + account.Peers["peer-src-1"].Meta.Capabilities = v6Caps + account.Peers["peer-src-1"].IPv6 = netip.MustParseAddr("fd00::1") + account.Peers["peer-src-2"].Meta.Capabilities = v6Caps + account.Peers["peer-src-2"].IPv6 = netip.MustParseAddr("fd00::2") + account.Peers["peer-dst-1"].Meta.Capabilities = v6Caps + account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3") + + // Mark group-src and group-dst as IPv6-enabled. + account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"} + + validated := allPeersValidated(account) + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + require.NotNil(t, nm) + + t.Run("v6 AAAA records emitted", func(t *testing.T) { + require.NotEmpty(t, nm.DNSConfig.CustomZones, "expected at least one custom zone") + var hasAAAA bool + var hasA bool + for _, z := range nm.DNSConfig.CustomZones { + for _, r := range z.Records { + if r.Type == int(dns.TypeAAAA) { + hasAAAA = true + } + if r.Type == int(dns.TypeA) { + hasA = true + } + } + } + assert.True(t, hasA, "expected A records") + assert.True(t, hasAAAA, "expected AAAA records for IPv6-enabled peers") + }) + + t.Run("v6 AllowedIPs would be advertised", func(t *testing.T) { + // nm.Peers contains *nbpeer.Peer; IPv6 should be set on those peers + var foundV6 bool + for _, p := range nm.Peers { + if p.IPv6.IsValid() { + foundV6 = true + } + } + assert.True(t, foundV6, "remote peers should have IPv6 set so AllowedIPs gets v6") + }) + + t.Run("v6 firewall rules emitted", func(t *testing.T) { + require.NotEmpty(t, nm.FirewallRules, "expected firewall rules") + var hasV4 bool + var hasV6 bool + for _, r := range nm.FirewallRules { + addr, err := netip.ParseAddr(r.PeerIP) + if err != nil { + continue + } + if addr.Is4() { + hasV4 = true + } + if addr.Is6() { + hasV6 = true + } + } + assert.True(t, hasV4, "expected at least one v4 firewall rule (peer IP)") + assert.True(t, hasV6, "expected at least one v6 firewall rule (peer IPv6)") + }) +} + +// TestNetworkMapComponents_RemotePeerWithoutCapability checks the asymmetric +// case where the target peer is IPv6-capable but a remote peer has an IPv6 +// address assigned in the DB without yet reporting the capability flag. +// In that case the remote peer's v6 still appears in AllowedIPs (gated on +// the target peer's capability) but its AAAA record does not (gated on the +// remote peer's own capability). +func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) { + account := createComponentTestAccount() + + v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay, nbpeer.PeerCapabilitySourcePrefixes} + // Target is fully capable. + account.Peers["peer-src-1"].Meta.Capabilities = v6Caps + account.Peers["peer-src-1"].IPv6 = netip.MustParseAddr("fd00::1") + // Remote peer has v6 assigned but no capability flag yet (e.g. old client). + account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3") + + account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"} + + validated := allPeersValidated(account) + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + require.NotNil(t, nm) + + t.Run("AllowedIPs include remote v6", func(t *testing.T) { + var dst *nbpeer.Peer + for _, p := range nm.Peers { + if p.ID == "peer-dst-1" { + dst = p + } + } + require.NotNil(t, dst) + assert.True(t, dst.IPv6.IsValid(), "remote peer's v6 should still be present so AllowedIPs gets v6/128 (gated on target peer cap)") + }) + + t.Run("no AAAA for non-capable remote peer", func(t *testing.T) { + for _, z := range nm.DNSConfig.CustomZones { + for _, r := range z.Records { + if r.Type == int(dns.TypeAAAA) && r.RData == "fd00::3" { + t.Errorf("AAAA record for non-capable remote peer should NOT be emitted, got %+v", r) + } + } + } + }) +} + +// TestNetworkMapComponents_IPv6Disabled_NoV6Output asserts that a peer that +// does not support IPv6 (e.g. older client without the capability flag) gets +// no v6 firewall rules and no AAAA records, even if other peers have IPv6. +func TestNetworkMapComponents_IPv6Disabled_NoV6Output(t *testing.T) { + account := createComponentTestAccount() + + v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay} + account.Peers["peer-src-2"].Meta.Capabilities = v6Caps + account.Peers["peer-src-2"].IPv6 = netip.MustParseAddr("fd00::2") + account.Peers["peer-dst-1"].Meta.Capabilities = v6Caps + account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3") + // peer-src-1 (target) intentionally has no capability and no IPv6. + + account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"} + + validated := allPeersValidated(account) + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + require.NotNil(t, nm) + + t.Run("no v6 firewall rules", func(t *testing.T) { + for _, r := range nm.FirewallRules { + addr, err := netip.ParseAddr(r.PeerIP) + if err != nil { + continue + } + assert.False(t, addr.Is6(), "v6 firewall rules should not be emitted for non-IPv6 peer (got %s)", r.PeerIP) + } + }) +} diff --git a/management/server/types/ipv6_groups_test.go b/management/server/types/ipv6_groups_test.go new file mode 100644 index 000000000..5151e1b1f --- /dev/null +++ b/management/server/types/ipv6_groups_test.go @@ -0,0 +1,234 @@ +package types + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +func TestPeerIPv6Allowed(t *testing.T) { + account := &Account{ + Groups: map[string]*Group{ + "group-all": {ID: "group-all", Name: "All", Peers: []string{"peer1", "peer2", "peer3"}}, + "group-devs": {ID: "group-devs", Name: "Devs", Peers: []string{"peer1", "peer2"}}, + "group-infra": {ID: "group-infra", Name: "Infra", Peers: []string{"peer2", "peer3"}}, + "group-empty": {ID: "group-empty", Name: "Empty", Peers: []string{}}, + }, + Settings: &Settings{}, + } + + tests := []struct { + name string + enabledGroups []string + peerID string + expected bool + }{ + { + name: "empty groups list disables IPv6 for all", + enabledGroups: []string{}, + peerID: "peer1", + expected: false, + }, + { + name: "All group enables IPv6 for everyone", + enabledGroups: []string{"group-all"}, + peerID: "peer1", + expected: true, + }, + { + name: "peer in enabled group gets IPv6", + enabledGroups: []string{"group-devs"}, + peerID: "peer1", + expected: true, + }, + { + name: "peer not in any enabled group denied IPv6", + enabledGroups: []string{"group-devs"}, + peerID: "peer3", + expected: false, + }, + { + name: "peer in multiple groups, one enabled", + enabledGroups: []string{"group-infra"}, + peerID: "peer2", + expected: true, + }, + { + name: "peer in multiple groups, other one enabled", + enabledGroups: []string{"group-devs"}, + peerID: "peer2", + expected: true, + }, + { + name: "multiple enabled groups, peer in one", + enabledGroups: []string{"group-devs", "group-infra"}, + peerID: "peer1", + expected: true, + }, + { + name: "multiple enabled groups, peer in both", + enabledGroups: []string{"group-devs", "group-infra"}, + peerID: "peer2", + expected: true, + }, + { + name: "nonexistent group ID in enabled list", + enabledGroups: []string{"group-deleted"}, + peerID: "peer1", + expected: false, + }, + { + name: "empty group in enabled list", + enabledGroups: []string{"group-empty"}, + peerID: "peer1", + expected: false, + }, + { + name: "unknown peer ID", + enabledGroups: []string{"group-all"}, + peerID: "peer-unknown", + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + account.Settings.IPv6EnabledGroups = tc.enabledGroups + result := account.PeerIPv6Allowed(tc.peerID) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestIPv6RecalculationOnGroupChange(t *testing.T) { + peerWithV6 := func(id string, v6 string) *nbpeer.Peer { + p := &nbpeer.Peer{ + ID: id, + IP: netip.MustParseAddr("100.64.0.1"), + } + if v6 != "" { + p.IPv6 = netip.MustParseAddr(v6) + } + return p + } + + t.Run("peer loses IPv6 when removed from enabled groups", func(t *testing.T) { + peer := peerWithV6("peer1", "fd00::1") + + account := &Account{ + Peers: map[string]*nbpeer.Peer{"peer1": peer}, + Groups: map[string]*Group{ + "group-a": {ID: "group-a", Peers: []string{"peer1"}}, + "group-b": {ID: "group-b", Peers: []string{}}, + }, + Settings: &Settings{ + IPv6EnabledGroups: []string{"group-a"}, + }, + } + + assert.True(t, account.PeerIPv6Allowed("peer1"), "peer should be allowed before change") + + // Move peer out of enabled group + account.Groups["group-a"].Peers = []string{} + account.Groups["group-b"].Peers = []string{"peer1"} + + assert.False(t, account.PeerIPv6Allowed("peer1"), "peer should be denied after group change") + }) + + t.Run("peer gains IPv6 when added to enabled group", func(t *testing.T) { + peer := peerWithV6("peer1", "") + + account := &Account{ + Peers: map[string]*nbpeer.Peer{"peer1": peer}, + Groups: map[string]*Group{ + "group-a": {ID: "group-a", Peers: []string{}}, + "group-b": {ID: "group-b", Peers: []string{"peer1"}}, + }, + Settings: &Settings{ + IPv6EnabledGroups: []string{"group-a"}, + }, + } + + assert.False(t, account.PeerIPv6Allowed("peer1"), "peer should be denied before change") + + // Add peer to enabled group + account.Groups["group-a"].Peers = []string{"peer1"} + + assert.True(t, account.PeerIPv6Allowed("peer1"), "peer should be allowed after joining enabled group") + }) + + t.Run("peer in two groups, one leaves enabled list", func(t *testing.T) { + peer := peerWithV6("peer1", "fd00::1") + + account := &Account{ + Peers: map[string]*nbpeer.Peer{"peer1": peer}, + Groups: map[string]*Group{ + "group-a": {ID: "group-a", Peers: []string{"peer1"}}, + "group-b": {ID: "group-b", Peers: []string{"peer1"}}, + }, + Settings: &Settings{ + IPv6EnabledGroups: []string{"group-a", "group-b"}, + }, + } + + assert.True(t, account.PeerIPv6Allowed("peer1")) + + // Remove group-a from enabled list, peer still in group-b + account.Settings.IPv6EnabledGroups = []string{"group-b"} + + assert.True(t, account.PeerIPv6Allowed("peer1"), "peer should still be allowed via group-b") + }) + + t.Run("peer in two groups, both leave enabled list", func(t *testing.T) { + peer := peerWithV6("peer1", "fd00::1") + + account := &Account{ + Peers: map[string]*nbpeer.Peer{"peer1": peer}, + Groups: map[string]*Group{ + "group-a": {ID: "group-a", Peers: []string{"peer1"}}, + "group-b": {ID: "group-b", Peers: []string{"peer1"}}, + }, + Settings: &Settings{ + IPv6EnabledGroups: []string{"group-a", "group-b"}, + }, + } + + assert.True(t, account.PeerIPv6Allowed("peer1")) + + // Clear all enabled groups + account.Settings.IPv6EnabledGroups = []string{} + + assert.False(t, account.PeerIPv6Allowed("peer1"), "peer should be denied when no groups enabled") + }) + + t.Run("enabling a group gives only its peers IPv6", func(t *testing.T) { + account := &Account{ + Peers: map[string]*nbpeer.Peer{ + "peer1": peerWithV6("peer1", ""), + "peer2": peerWithV6("peer2", ""), + "peer3": peerWithV6("peer3", ""), + }, + Groups: map[string]*Group{ + "group-devs": {ID: "group-devs", Peers: []string{"peer1", "peer2"}}, + "group-infra": {ID: "group-infra", Peers: []string{"peer2", "peer3"}}, + }, + Settings: &Settings{ + IPv6EnabledGroups: []string{"group-devs"}, + }, + } + + assert.True(t, account.PeerIPv6Allowed("peer1"), "peer1 in devs") + assert.True(t, account.PeerIPv6Allowed("peer2"), "peer2 in devs") + assert.False(t, account.PeerIPv6Allowed("peer3"), "peer3 not in devs") + + // Add infra group + account.Settings.IPv6EnabledGroups = []string{"group-devs", "group-infra"} + + assert.True(t, account.PeerIPv6Allowed("peer1"), "peer1 still in devs") + assert.True(t, account.PeerIPv6Allowed("peer2"), "peer2 in both") + assert.True(t, account.PeerIPv6Allowed("peer3"), "peer3 now in infra") + }) +} diff --git a/management/server/types/network.go b/management/server/types/network.go index 0d13de10f..fe67bfd97 100644 --- a/management/server/types/network.go +++ b/management/server/types/network.go @@ -2,8 +2,11 @@ package types import ( "encoding/binary" + "fmt" "math/rand" "net" + "net/netip" + "slices" "sync" "time" @@ -27,6 +30,12 @@ const ( // AllowedIPsFormat generates Wireguard AllowedIPs format (e.g. 100.64.30.1/32) AllowedIPsFormat = "%s/32" + // AllowedIPsV6Format generates AllowedIPs format for v6 (e.g. fd12:3456:7890::1/128) + AllowedIPsV6Format = "%s/128" + + // IPv6SubnetSize is the prefix length of per-account IPv6 subnets. + // Each account gets a /64 from its unique /48 ULA prefix. + IPv6SubnetSize = 64 ) type NetworkMap struct { @@ -111,7 +120,9 @@ func ipToBytes(ip net.IP) []byte { type Network struct { Identifier string `json:"id"` Net net.IPNet `gorm:"serializer:json"` - Dns string + // NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated. + NetV6 net.IPNet `gorm:"serializer:json"` + Dns string // Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added). // Used to synchronize state to the client apps. Serial uint64 @@ -121,20 +132,45 @@ type Network struct { // NewNetwork creates a new Network initializing it with a Serial=0 // It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets) +// and a random /64 subnet from fd00:4e42::/32 for IPv6. func NewNetwork() *Network { - n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize) sub, _ := n.Subnet(SubnetSize) - s := rand.NewSource(time.Now().Unix()) + s := rand.NewSource(time.Now().UnixNano()) r := rand.New(s) intn := r.Intn(len(sub)) return &Network{ Identifier: xid.New().String(), Net: sub[intn].IPNet, + NetV6: AllocateIPv6Subnet(r), Dns: "", - Serial: 0} + Serial: 0, + } +} + +// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix. +// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID. +// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm +// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts. +func AllocateIPv6Subnet(r *rand.Rand) net.IPNet { + ip := make(net.IP, 16) + ip[0] = 0xfd + // Bytes 1-5: 40-bit random Global ID + ip[1] = byte(r.Intn(256)) + ip[2] = byte(r.Intn(256)) + ip[3] = byte(r.Intn(256)) + ip[4] = byte(r.Intn(256)) + ip[5] = byte(r.Intn(256)) + // Bytes 6-7: 16-bit random Subnet ID + ip[6] = byte(r.Intn(256)) + ip[7] = byte(r.Intn(256)) + + return net.IPNet{ + IP: ip, + Mask: net.CIDRMask(IPv6SubnetSize, 128), + } } // IncSerial increments Serial by 1 reflecting that the network state has been changed @@ -157,19 +193,19 @@ func (n *Network) Copy() *Network { return &Network{ Identifier: n.Identifier, Net: n.Net, + NetV6: n.NetV6, Dns: n.Dns, Serial: n.Serial, } } -// AllocatePeerIP pics an available IP from an net.IPNet. -// This method considers already taken IPs and reuses IPs if there are gaps in takenIps -// E.g. if ipNet=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3 -func AllocatePeerIP(ipNet net.IPNet, takenIps []net.IP) (net.IP, error) { - baseIP := ipToUint32(ipNet.IP.Mask(ipNet.Mask)) - - ones, bits := ipNet.Mask.Size() - hostBits := bits - ones +// AllocatePeerIP picks an available IP from a netip.Prefix. +// This method considers already taken IPs and reuses IPs if there are gaps in takenIps. +// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3. +func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) { + b := prefix.Masked().Addr().As4() + baseIP := binary.BigEndian.Uint32(b[:]) + hostBits := 32 - prefix.Bits() totalIPs := uint32(1 << hostBits) taken := make(map[uint32]struct{}, len(takenIps)+1) @@ -177,7 +213,8 @@ func AllocatePeerIP(ipNet net.IPNet, takenIps []net.IP) (net.IP, error) { taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP for _, ip := range takenIps { - taken[ipToUint32(ip)] = struct{}{} + ab := ip.As4() + taken[binary.BigEndian.Uint32(ab[:])] = struct{}{} } rng := rand.New(rand.NewSource(time.Now().UnixNano())) @@ -198,15 +235,14 @@ func AllocatePeerIP(ipNet net.IPNet, takenIps []net.IP) (net.IP, error) { } } - return nil, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", ipNet.String()) + return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String()) } -func AllocateRandomPeerIP(ipNet net.IPNet) (net.IP, error) { - baseIP := ipToUint32(ipNet.IP.Mask(ipNet.Mask)) - - ones, bits := ipNet.Mask.Size() - hostBits := bits - ones - +// AllocateRandomPeerIP picks a random available IP from a netip.Prefix. +func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) { + b := prefix.Masked().Addr().As4() + baseIP := binary.BigEndian.Uint32(b[:]) + hostBits := 32 - prefix.Bits() totalIPs := uint32(1 << hostBits) rng := rand.New(rand.NewSource(time.Now().UnixNano())) @@ -216,18 +252,75 @@ func AllocateRandomPeerIP(ipNet net.IPNet) (net.IP, error) { return uint32ToIP(candidate), nil } -func ipToUint32(ip net.IP) uint32 { - ip = ip.To4() - if len(ip) < 4 { - return 0 +// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix. +// Only the host bits (after the prefix length) are randomized. +func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) { + ones := prefix.Bits() + if ones == 0 || ones > 126 || !prefix.Addr().Is6() { + return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String()) } - return binary.BigEndian.Uint32(ip) + + ip := prefix.Addr().As16() + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + + // Determine which byte the host bits start in + firstHostByte := ones / 8 + // If the prefix doesn't end on a byte boundary, handle the partial byte + partialBits := ones % 8 + + if partialBits > 0 { + // Keep the network bits in the partial byte, randomize the rest + hostMask := byte(0xff >> partialBits) + ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask) + firstHostByte++ + } + + // Randomize remaining full host bytes + for i := firstHostByte; i < 16; i++ { + ip[i] = byte(rng.Intn(256)) + } + + // Avoid all-zeros and all-ones host parts by checking only host bits. + if isHostAllZeroOrOnes(ip[:], ones) { + ip = prefix.Masked().Addr().As16() + ip[15] |= 0x01 + } + + return netip.AddrFrom16(ip).Unmap(), nil } -func uint32ToIP(n uint32) net.IP { - ip := make(net.IP, 4) - binary.BigEndian.PutUint32(ip, n) - return ip +// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones. +func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool { + hostStart := prefixLen / 8 + partialBits := prefixLen % 8 + + hostSlice := slices.Clone(ip[hostStart:]) + if partialBits > 0 { + hostSlice[0] &= 0xff >> partialBits + } + + allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 }) + if allZero { + return true + } + + // Build the all-ones mask for host bits + onesMask := make([]byte, len(hostSlice)) + for i := range onesMask { + onesMask[i] = 0xff + } + if partialBits > 0 { + onesMask[0] = 0xff >> partialBits + } + + return slices.Equal(hostSlice, onesMask) +} + +func uint32ToIP(n uint32) netip.Addr { + var b [4]byte + binary.BigEndian.PutUint32(b[:], n) + return netip.AddrFrom4(b) } // generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list diff --git a/management/server/types/network_test.go b/management/server/types/network_test.go index 4c1459ce5..d8a06dbbc 100644 --- a/management/server/types/network_test.go +++ b/management/server/types/network_test.go @@ -1,7 +1,9 @@ package types import ( + "encoding/binary" "net" + "net/netip" "testing" "github.com/stretchr/testify/assert" @@ -17,10 +19,10 @@ func TestNewNetwork(t *testing.T) { } func TestAllocatePeerIP(t *testing.T) { - ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}} - var ips []net.IP + prefix := netip.MustParsePrefix("100.64.0.0/24") + var ips []netip.Addr for i := 0; i < 252; i++ { - ip, err := AllocatePeerIP(ipNet, ips) + ip, err := AllocatePeerIP(prefix, ips) if err != nil { t.Fatal(err) } @@ -41,19 +43,19 @@ func TestAllocatePeerIP(t *testing.T) { func TestAllocatePeerIPSmallSubnet(t *testing.T) { // Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30) - ipNet := net.IPNet{IP: net.ParseIP("10.0.0.0"), Mask: net.IPMask{255, 255, 255, 224}} - var ips []net.IP + prefix := netip.MustParsePrefix("10.0.0.0/27") + var ips []netip.Addr // Allocate all available IPs in the /27 network for i := 0; i < 30; i++ { - ip, err := AllocatePeerIP(ipNet, ips) + ip, err := AllocatePeerIP(prefix, ips) if err != nil { t.Fatal(err) } // Verify IP is within the correct range - if !ipNet.Contains(ip) { - t.Errorf("allocated IP %s is not within network %s", ip.String(), ipNet.String()) + if !prefix.Contains(ip) { + t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String()) } ips = append(ips, ip) @@ -72,7 +74,7 @@ func TestAllocatePeerIPSmallSubnet(t *testing.T) { } // Try to allocate one more IP - should fail as network is full - _, err := AllocatePeerIP(ipNet, ips) + _, err := AllocatePeerIP(prefix, ips) if err == nil { t.Error("expected error when network is full, but got none") } @@ -95,10 +97,11 @@ func TestAllocatePeerIPVariousCIDRs(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, ipNet, err := net.ParseCIDR(tc.cidr) + prefix, err := netip.ParsePrefix(tc.cidr) require.NoError(t, err) + prefix = prefix.Masked() - var ips []net.IP + var ips []netip.Addr // For larger networks, test only a subset to avoid long test runs testCount := tc.expectedUsable @@ -108,21 +111,21 @@ func TestAllocatePeerIPVariousCIDRs(t *testing.T) { // Allocate IPs and verify they're within the correct range for i := 0; i < testCount; i++ { - ip, err := AllocatePeerIP(*ipNet, ips) + ip, err := AllocatePeerIP(prefix, ips) require.NoError(t, err, "failed to allocate IP %d", i) // Verify IP is within the correct range - assert.True(t, ipNet.Contains(ip), "allocated IP %s is not within network %s", ip.String(), ipNet.String()) + assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String()) // Verify IP is not network or broadcast address - networkIP := ipNet.IP.Mask(ipNet.Mask) - ones, bits := ipNet.Mask.Size() - hostBits := bits - ones - broadcastInt := uint32(ipToUint32(networkIP)) + (1 << hostBits) - 1 - broadcastIP := uint32ToIP(broadcastInt) + networkAddr := prefix.Masked().Addr() + hostBits := 32 - prefix.Bits() + b := networkAddr.As4() + baseIP := binary.BigEndian.Uint32(b[:]) + broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1) - assert.False(t, ip.Equal(networkIP), "allocated network address %s", ip.String()) - assert.False(t, ip.Equal(broadcastIP), "allocated broadcast address %s", ip.String()) + assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String()) + assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String()) ips = append(ips, ip) } @@ -151,3 +154,111 @@ func TestGenerateIPs(t *testing.T) { t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String()) } } + +func TestNewNetworkHasIPv6(t *testing.T) { + network := NewNetwork() + + assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated") + assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6") + assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)") + + ones, bits := network.NetV6.Mask.Size() + assert.Equal(t, 64, ones, "v6 subnet should be /64") + assert.Equal(t, 128, bits) +} + +func TestAllocateIPv6SubnetUniqueness(t *testing.T) { + seen := make(map[string]struct{}) + for i := 0; i < 100; i++ { + network := NewNetwork() + key := network.NetV6.IP.String() + _, duplicate := seen[key] + assert.False(t, duplicate, "duplicate v6 subnet: %s", key) + seen[key] = struct{}{} + } +} + +func TestAllocateRandomPeerIPv6(t *testing.T) { + prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64") + + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + + assert.True(t, ip.Is6(), "should be IPv6") + assert.True(t, prefix.Contains(ip), "should be within subnet") + // First 8 bytes (network prefix) should match + b := ip.As16() + prefixBytes := prefix.Addr().As16() + assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match") + // Interface ID should not be all zeros + allZero := true + for _, v := range b[8:] { + if v != 0 { + allZero = false + break + } + } + assert.False(t, allZero, "interface ID should not be all zeros") +} + +func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) { + tests := []struct { + name string + cidr string + prefix int + }{ + {"standard /64", "fd00:1234:5678:abcd::/64", 64}, + {"small /112", "fd00:1234:5678:abcd::/112", 112}, + {"large /48", "fd00:1234::/48", 48}, + {"non-boundary /60", "fd00:1234:5670::/60", 60}, + {"non-boundary /52", "fd00:1230::/52", 52}, + {"minimum /120", "fd00:1234:5678:abcd::100/120", 120}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefix, err := netip.ParsePrefix(tt.cidr) + require.NoError(t, err) + prefix = prefix.Masked() + + assert.Equal(t, tt.prefix, prefix.Bits()) + + for i := 0; i < 50; i++ { + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix) + } + }) + } +} + +func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) { + // For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary + prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112") + + prefixBytes := prefix.Addr().As16() + for i := 0; i < 20; i++ { + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + // First 14 bytes (112 bits = 14 bytes) must match the network + b := ip.As16() + assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112") + } +} + +func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) { + // For a /60, the first 7.5 bytes are network, so byte 7 is partial + prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60") + + prefixBytes := prefix.Addr().As16() + for i := 0; i < 50; i++ { + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + b := ip.As16() + assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix) + // First 7 bytes must match exactly + assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60") + // Byte 7: top 4 bits (0xc = 1100) must be preserved + assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60") + } +} diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go index 6f84c8d30..3a7e20ec5 100644 --- a/management/server/types/networkmap_components.go +++ b/management/server/types/networkmap_components.go @@ -3,7 +3,6 @@ package types import ( "context" "maps" - "net" "net/netip" "slices" "strconv" @@ -114,13 +113,17 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers) - routesUpdate := c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups) - routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID) + includeIPv6 := false + if p := c.Peers[targetPeerID]; p != nil { + includeIPv6 = p.SupportsIPv6() && p.IPv6.IsValid() + } + routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6) + routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6) isRouter, networkResourcesRoutes, sourcePeers := c.getNetworkResourcesRoutesToSync(targetPeerID) var networkResourcesFirewallRules []*RouteFirewallRule if isRouter { - networkResourcesFirewallRules = c.getPeerNetworkResourceFirewallRules(ctx, targetPeerID, networkResourcesRoutes) + networkResourcesFirewallRules = c.getPeerNetworkResourceFirewallRules(ctx, targetPeerID, networkResourcesRoutes, includeIPv6) } peersToConnectIncludingRouters := c.addNetworksRoutingPeers( @@ -156,7 +159,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { return &NetworkMap{ Peers: peersToConnectIncludingRouters, Network: c.Network.Copy(), - Routes: append(networkResourcesRoutes, routesUpdate...), + Routes: append(filterAndExpandRoutes(networkResourcesRoutes, includeIPv6), routesUpdate...), DNSConfig: dnsUpdate, OfflinePeers: expiredPeers, FirewallRules: firewallRules, @@ -296,7 +299,7 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) ( peersExists[peer.ID] = struct{}{} } - peerIP := net.IP(peer.IP).String() + peerIP := peer.IP.String() fr := FirewallRule{ PolicyID: rule.ID, @@ -315,10 +318,17 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) ( if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) - continue + } else { + rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...) } - rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...) + rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{ + direction: direction, + dirStr: dirStr, + protocolStr: protocolStr, + actionStr: actionStr, + portsJoined: portsJoined, + }) } }, func() ([]*nbpeer.Peer, []*FirewallRule) { return peers, rules @@ -454,6 +464,29 @@ func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns return false } +// filterAndExpandRoutes drops v6 routes for non-capable peers and duplicates +// the default v4 route (0.0.0.0/0) as ::/0 for v6-capable peers. +// TODO: the "-v6" suffix on IDs could collide with user-supplied route IDs. +func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Route { + filtered := make([]*route.Route, 0, len(routes)) + for _, r := range routes { + if !includeIPv6 && r.Network.Addr().Is6() { + continue + } + filtered = append(filtered, r) + + if includeIPv6 && r.Network.Bits() == 0 && r.Network.Addr().Is4() { + v6 := r.Copy() + v6.ID = r.ID + "-v6-default" + v6.NetID = r.NetID + "-v6" + v6.Network = netip.MustParsePrefix("::/0") + v6.NetworkType = route.IPv6Network + filtered = append(filtered, v6) + } + } + return filtered +} + func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*nbpeer.Peer, peerGroups LookupMap) []*route.Route { routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID) peerRoutesMembership := make(LookupMap) @@ -550,13 +583,13 @@ func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*rout return filteredRoutes } -func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, peerID string) []*RouteFirewallRule { +func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, peerID string, includeIPv6 bool) []*RouteFirewallRule { routesFirewallRules := make([]*RouteFirewallRule, 0) enabledRoutes, _ := c.getRoutingPeerRoutes(peerID) for _, r := range enabledRoutes { if len(r.AccessControlGroups) == 0 { - defaultPermit := c.getDefaultPermit(r) + defaultPermit := c.getDefaultPermit(r, includeIPv6) routesFirewallRules = append(routesFirewallRules, defaultPermit...) continue } @@ -565,7 +598,7 @@ func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, p for _, accessGroup := range r.AccessControlGroups { policies := c.getAllRoutePoliciesFromGroups([]string{accessGroup}) - rules := c.getRouteFirewallRules(ctx, peerID, policies, r, distributionPeers) + rules := c.getRouteFirewallRules(ctx, peerID, policies, r, distributionPeers, includeIPv6) routesFirewallRules = append(routesFirewallRules, rules...) } } @@ -573,8 +606,10 @@ func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, p return routesFirewallRules } -func (c *NetworkMapComponents) getDefaultPermit(r *route.Route) []*RouteFirewallRule { - var rules []*RouteFirewallRule +func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool) []*RouteFirewallRule { + if r.Network.Addr().Is6() && !includeIPv6 { + return nil + } sources := []string{"0.0.0.0/0"} if r.Network.Addr().Is6() { @@ -591,9 +626,9 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route) []*RouteFirewall RouteID: r.ID, } - rules = append(rules, &rule) + rules := []*RouteFirewallRule{&rule} - if r.IsDynamic() { + if includeIPv6 && r.IsDynamic() { ruleV6 := rule ruleV6.SourceRanges = []string{"::/0"} rules = append(rules, &ruleV6) @@ -632,7 +667,7 @@ func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups return routePolicies } -func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}) []*RouteFirewallRule { +func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule { var fwRules []*RouteFirewallRule for _, policy := range policies { if !policy.Enabled { @@ -645,7 +680,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID } rulePeers := c.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers) - rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN) + rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) fwRules = append(fwRules, rules...) } } @@ -710,33 +745,49 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (b } } - addedResourceRoute := false - for _, policy := range c.ResourcePoliciesMap[resource.ID] { - var peers []string - if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { - peers = []string{policy.Rules[0].SourceResource.ID} - } else { - peers = c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) - } - if addSourcePeers { - for _, pID := range c.getPostureValidPeers(peers, policy.SourcePostureChecks) { - allSourcePeers[pID] = struct{}{} - } - } else if slices.Contains(peers, peerID) && c.ValidatePostureChecksOnPeer(peerID, policy.SourcePostureChecks) { - for peerId, router := range networkRoutingPeers { - routes = append(routes, c.getNetworkResourcesRoutes(resource, peerId, router)...) - } - addedResourceRoute = true - } - if addedResourceRoute { - break - } - } + newRoutes := c.processResourcePolicies(peerID, resource, networkRoutingPeers, addSourcePeers, allSourcePeers) + routes = append(routes, newRoutes...) } return isRoutingPeer, routes, allSourcePeers } +func (c *NetworkMapComponents) processResourcePolicies( + peerID string, + resource *resourceTypes.NetworkResource, + networkRoutingPeers map[string]*routerTypes.NetworkRouter, + addSourcePeers bool, + allSourcePeers map[string]struct{}, +) []*route.Route { + var routes []*route.Route + + for _, policy := range c.ResourcePoliciesMap[resource.ID] { + peers := c.getResourcePolicyPeers(policy) + if addSourcePeers { + for _, pID := range c.getPostureValidPeers(peers, policy.SourcePostureChecks) { + allSourcePeers[pID] = struct{}{} + } + continue + } + + if slices.Contains(peers, peerID) && c.ValidatePostureChecksOnPeer(peerID, policy.SourcePostureChecks) { + for peerId, router := range networkRoutingPeers { + routes = append(routes, c.getNetworkResourcesRoutes(resource, peerId, router)...) + } + break + } + } + + return routes +} + +func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string { + if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { + return []string{policy.Rules[0].SourceResource.ID} + } + return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) +} + func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *resourceTypes.NetworkResource, peerID string, router *routerTypes.NetworkRouter) []*route.Route { resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID] @@ -796,7 +847,7 @@ func (c *NetworkMapComponents) getPostureValidPeers(inputPeers []string, posture return dest } -func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route) []*RouteFirewallRule { +func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route, includeIPv6 bool) []*RouteFirewallRule { routesFirewallRules := make([]*RouteFirewallRule, 0) peerInfo := c.GetPeerInfo(peerID) @@ -813,7 +864,7 @@ func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.C resourcePolicies := c.ResourcePoliciesMap[resourceID] distributionPeers := c.getPoliciesSourcePeers(resourcePolicies) - rules := c.getRouteFirewallRules(ctx, peerID, resourcePolicies, r, distributionPeers) + rules := c.getRouteFirewallRules(ctx, peerID, resourcePolicies, r, distributionPeers, includeIPv6) for _, rule := range rules { if len(rule.SourceRanges) > 0 { routesFirewallRules = append(routesFirewallRules, rule) @@ -897,3 +948,36 @@ func (c *NetworkMapComponents) addNetworksRoutingPeers( return peersToConnect } + +type firewallRuleContext struct { + direction int + dirStr string + protocolStr string + actionStr string + portsJoined string +} + +func appendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc firewallRuleContext) []*FirewallRule { + if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6() || !targetPeer.IPv6.IsValid() { + return rules + } + + v6IP := peer.IPv6.String() + v6RuleID := rule.ID + v6IP + rc.dirStr + rc.protocolStr + rc.actionStr + rc.portsJoined + if _, ok := rulesExists[v6RuleID]; ok { + return rules + } + rulesExists[v6RuleID] = struct{}{} + + v6fr := FirewallRule{ + PolicyID: rule.ID, + PeerIP: v6IP, + Direction: rc.direction, + Action: rc.actionStr, + Protocol: rc.protocolStr, + } + if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { + return append(rules, &v6fr) + } + return append(rules, expandPortsAndRanges(v6fr, rule, targetPeer)...) +} diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go index 5cd41ff10..bcfb6fdf9 100644 --- a/management/server/types/networkmap_components_correctness_test.go +++ b/management/server/types/networkmap_components_correctness_test.go @@ -42,7 +42,7 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( for i := range numPeers { peerID := fmt.Sprintf("peer-%d", i) - ip := net.IP{100, byte(64 + i/65536), byte((i / 256) % 256), byte(i % 256)} + ip := netip.AddrFrom4([4]byte{100, byte(64 + i/65536), byte((i / 256) % 256), byte(i % 256)}) wtVersion := "0.25.0" if i%2 == 0 { wtVersion = "0.40.0" @@ -1083,7 +1083,7 @@ func TestComponents_PeerIsNameserverExcludedFromNSGroup(t *testing.T) { nsIP := account.Peers["peer-0"].IP account.NameServerGroups["ns-self"] = &nbdns.NameServerGroup{ ID: "ns-self", Name: "Self NS", Enabled: true, Groups: []string{"group-all"}, - NameServers: []nbdns.NameServer{{IP: netip.AddrFrom4([4]byte{nsIP[0], nsIP[1], nsIP[2], nsIP[3]}), NSType: nbdns.UDPNameServerType, Port: 53}}, + NameServers: []nbdns.NameServer{{IP: nsIP, NSType: nbdns.UDPNameServerType, Port: 53}}, } nm := componentsNetworkMap(account, "peer-0", validatedPeers) diff --git a/management/server/types/networkmap_components_test.go b/management/server/types/networkmap_components_test.go index dde639ccb..1a99b4511 100644 --- a/management/server/types/networkmap_components_test.go +++ b/management/server/types/networkmap_components_test.go @@ -681,22 +681,22 @@ func TestNetworkMapComponents_RouterExcludesOtherNetworkRoutes(t *testing.T) { func createComponentTestAccount() *types.Account { peers := map[string]*nbpeer.Peer{ "peer-src-1": { - ID: "peer-src-1", IP: net.IP{100, 64, 0, 1}, Key: "key-src-1", DNSLabel: "src1", + ID: "peer-src-1", IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), Key: "key-src-1", DNSLabel: "src1", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-1", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"}, }, "peer-src-2": { - ID: "peer-src-2", IP: net.IP{100, 64, 0, 2}, Key: "key-src-2", DNSLabel: "src2", + ID: "peer-src-2", IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), Key: "key-src-2", DNSLabel: "src2", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-1", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"}, }, "peer-dst-1": { - ID: "peer-dst-1", IP: net.IP{100, 64, 0, 3}, Key: "key-dst-1", DNSLabel: "dst1", + ID: "peer-dst-1", IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), Key: "key-dst-1", DNSLabel: "dst1", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-2", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"}, }, "peer-router-1": { - ID: "peer-router-1", IP: net.IP{100, 64, 0, 10}, Key: "key-router-1", DNSLabel: "router1", + ID: "peer-router-1", IP: netip.AddrFrom4([4]byte{100, 64, 0, 10}), Key: "key-router-1", DNSLabel: "router1", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-1", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"}, }, diff --git a/management/server/types/settings.go b/management/server/types/settings.go index 4ea79ec72..264a018d4 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -46,6 +46,8 @@ type Settings struct { // NetworkRange is the custom network range for that account NetworkRange netip.Prefix `gorm:"serializer:json"` + // NetworkRangeV6 is the custom IPv6 network range for that account + NetworkRangeV6 netip.Prefix `gorm:"serializer:json"` // PeerExposeEnabled enables or disables peer-initiated service expose PeerExposeEnabled bool @@ -65,6 +67,12 @@ type Settings struct { // when false, updates require user interaction from the UI AutoUpdateAlways bool `gorm:"default:false"` + // IPv6EnabledGroups is the list of group IDs whose peers receive IPv6 overlay addresses. + // Peers not in any of these groups will not be allocated an IPv6 address. + // Empty list means IPv6 is disabled for the account. + // For new accounts this defaults to the All group. + IPv6EnabledGroups []string `gorm:"serializer:json"` + // EmbeddedIdpEnabled indicates if the embedded identity provider is enabled. // This is a runtime-only field, not stored in the database. EmbeddedIdpEnabled bool `gorm:"-"` @@ -94,8 +102,10 @@ func (s *Settings) Copy() *Settings { LazyConnectionEnabled: s.LazyConnectionEnabled, DNSDomain: s.DNSDomain, NetworkRange: s.NetworkRange, + NetworkRangeV6: s.NetworkRangeV6, AutoUpdateVersion: s.AutoUpdateVersion, AutoUpdateAlways: s.AutoUpdateAlways, + IPv6EnabledGroups: slices.Clone(s.IPv6EnabledGroups), EmbeddedIdpEnabled: s.EmbeddedIdpEnabled, LocalAuthDisabled: s.LocalAuthDisabled, } diff --git a/management/server/user.go b/management/server/user.go index 43e0a9821..892d982e7 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "time" "unicode" @@ -825,6 +826,11 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact } } } + + allGroupChanges := slices.Concat(removedGroups, addedGroups) + if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges); err != nil { + return false, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err) + } } updateAccountPeers := len(userPeers) > 0 diff --git a/proxy/cmd/proxy/cmd/debug.go b/proxy/cmd/proxy/cmd/debug.go index 1b1664490..49afc7638 100644 --- a/proxy/cmd/proxy/cmd/debug.go +++ b/proxy/cmd/proxy/cmd/debug.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "syscall" + "time" "github.com/spf13/cobra" @@ -62,7 +63,11 @@ var debugSyncCmd = &cobra.Command{ SilenceUsage: true, } -var pingTimeout string +var ( + pingTimeout time.Duration + pingIPv4 bool + pingIPv6 bool +) var debugPingCmd = &cobra.Command{ Use: "ping [port]", @@ -134,7 +139,10 @@ func init() { debugStatusCmd.Flags().StringVar(&statusFilterByStatus, "filter-by-status", "", "Filter by status (idle|connecting|connected)") debugStatusCmd.Flags().StringVar(&statusFilterByConnectionType, "filter-by-connection-type", "", "Filter by connection type (P2P|Relayed)") - debugPingCmd.Flags().StringVar(&pingTimeout, "timeout", "", "Ping timeout (e.g., 10s)") + debugPingCmd.Flags().DurationVar(&pingTimeout, "timeout", 0, "Ping timeout (e.g., 10s)") + debugPingCmd.Flags().BoolVarP(&pingIPv4, "ipv4", "4", false, "Force IPv4") + debugPingCmd.Flags().BoolVarP(&pingIPv6, "ipv6", "6", false, "Force IPv6") + debugPingCmd.MarkFlagsMutuallyExclusive("ipv4", "ipv6") debugCaptureCmd.Flags().DurationP("duration", "d", 0, "Capture duration (0 = server default)") debugCaptureCmd.Flags().Bool("pcap", false, "Force pcap binary output (default when --output is set)") @@ -190,7 +198,14 @@ func runDebugPing(cmd *cobra.Command, args []string) error { } port = p } - return getDebugClient(cmd).PingTCP(cmd.Context(), args[0], args[1], port, pingTimeout) + var ipVersion string + switch { + case pingIPv4: + ipVersion = "4" + case pingIPv6: + ipVersion = "6" + } + return getDebugClient(cmd).PingTCP(cmd.Context(), args[0], args[1], port, pingTimeout, ipVersion) } func runDebugLogLevel(cmd *cobra.Command, args []string) error { diff --git a/proxy/internal/debug/client.go b/proxy/internal/debug/client.go index e01149522..09c25afb2 100644 --- a/proxy/internal/debug/client.go +++ b/proxy/internal/debug/client.go @@ -6,10 +6,12 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/url" "strings" "time" + ) // StatusFilters contains filter options for status queries. @@ -230,12 +232,16 @@ func (c *Client) ClientSyncResponse(ctx context.Context, accountID string) error } // PingTCP performs a TCP ping through a client. -func (c *Client) PingTCP(ctx context.Context, accountID, host string, port int, timeout string) error { +// ipVersion may be "4", "6", or "" for automatic. +func (c *Client) PingTCP(ctx context.Context, accountID, host string, port int, timeout time.Duration, ipVersion string) error { params := url.Values{} params.Set("host", host) params.Set("port", fmt.Sprintf("%d", port)) - if timeout != "" { - params.Set("timeout", timeout) + if timeout > 0 { + params.Set("timeout", timeout.String()) + } + if ipVersion != "" { + params.Set("ip_version", ipVersion) } path := fmt.Sprintf("/debug/clients/%s/pingtcp?%s", url.PathEscape(accountID), params.Encode()) @@ -244,11 +250,17 @@ func (c *Client) PingTCP(ctx context.Context, accountID, host string, port int, func (c *Client) printPingResult(data map[string]any) { success, _ := data["success"].(bool) + host := net.JoinHostPort(fmt.Sprint(data["host"]), fmt.Sprint(data["port"])) if success { - _, _ = fmt.Fprintf(c.out, "Success: %v:%v\n", data["host"], data["port"]) + remote, _ := data["remote"].(string) + if remote != "" && remote != host { + _, _ = fmt.Fprintf(c.out, "Success: %s (via %s)\n", host, remote) + } else { + _, _ = fmt.Fprintf(c.out, "Success: %s\n", host) + } _, _ = fmt.Fprintf(c.out, "Latency: %v\n", data["latency"]) } else { - _, _ = fmt.Fprintf(c.out, "Failed: %v:%v\n", data["host"], data["port"]) + _, _ = fmt.Fprintf(c.out, "Failed: %s\n", host) c.printError(data) } } diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go index 6cd124554..23ca4adbb 100644 --- a/proxy/internal/debug/handler.go +++ b/proxy/internal/debug/handler.go @@ -9,6 +9,7 @@ import ( "fmt" "html/template" "maps" + "net" "net/http" "slices" "strconv" @@ -527,13 +528,18 @@ func (h *Handler) handlePingTCP(w http.ResponseWriter, r *http.Request, accountI } } + network := "tcp" + if v := r.URL.Query().Get("ip_version"); v == "4" || v == "6" { + network += v + } + ctx, cancel := context.WithTimeout(r.Context(), timeout) defer cancel() - address := fmt.Sprintf("%s:%d", host, port) + address := net.JoinHostPort(host, strconv.Itoa(port)) start := time.Now() - conn, err := client.Dial(ctx, "tcp", address) + conn, err := client.Dial(ctx, network, address) if err != nil { h.writeJSON(w, map[string]interface{}{ "success": false, @@ -543,18 +549,22 @@ func (h *Handler) handlePingTCP(w http.ResponseWriter, r *http.Request, accountI }) return } + + remote := conn.RemoteAddr().String() if err := conn.Close(); err != nil { h.logger.Debugf("close tcp ping connection: %v", err) } latency := time.Since(start) - h.writeJSON(w, map[string]interface{}{ + resp := map[string]interface{}{ "success": true, "host": host, "port": port, + "remote": remote, "latency_ms": latency.Milliseconds(), "latency": formatDuration(latency), - }) + } + h.writeJSON(w, resp) } func (h *Handler) handleLogLevel(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { diff --git a/relay/test/benchmark_test.go b/relay/test/benchmark_test.go index 4dfea6da1..6b1131f1e 100644 --- a/relay/test/benchmark_test.go +++ b/relay/test/benchmark_test.go @@ -337,7 +337,7 @@ func runTurnDataTransfer(t *testing.T, testData []byte) time.Duration { func getTurnClient(t *testing.T, address string, conn net.Conn) (*turn.Client, error) { t.Helper() // Dial TURN Server - addrStr := fmt.Sprintf("%s:%d", address, 443) + addrStr := net.JoinHostPort(address, "443") fac := logging.NewDefaultLoggerFactory() //fac.DefaultLogLevel = logging.LogLevelTrace diff --git a/relay/testec2/turn_allocator.go b/relay/testec2/turn_allocator.go index fd86208df..440f6222a 100644 --- a/relay/testec2/turn_allocator.go +++ b/relay/testec2/turn_allocator.go @@ -52,7 +52,7 @@ func AllocateTurnClient(serverAddr string) *TurnConn { func getTurnClient(address string, conn net.Conn) (*turn.Client, error) { // Dial TURN Server - addrStr := fmt.Sprintf("%s:%d", address, 443) + addrStr := net.JoinHostPort(address, "443") fac := logging.NewDefaultLoggerFactory() //fac.DefaultLogLevel = logging.LogLevelTrace diff --git a/route/route.go b/route/route.go index c724e7c7d..97b9721f6 100644 --- a/route/route.go +++ b/route/route.go @@ -20,6 +20,9 @@ const ( MaxMetric = 9999 // MaxNetIDChar Max Network Identifier MaxNetIDChar = 40 + + // V6ExitSuffix is appended to a v4 exit node NetID to form its v6 counterpart. + V6ExitSuffix = "-v6" ) const ( @@ -215,3 +218,61 @@ func ParseNetwork(networkString string) (NetworkType, netip.Prefix, error) { return IPv4Network, masked, nil } + +var ( + v4Default = netip.PrefixFrom(netip.IPv4Unspecified(), 0) + v6Default = netip.PrefixFrom(netip.IPv6Unspecified(), 0) +) + +// IsV4DefaultRoute reports whether p is the IPv4 default route (0.0.0.0/0). +func IsV4DefaultRoute(p netip.Prefix) bool { return p == v4Default } + +// IsV6DefaultRoute reports whether p is the IPv6 default route (::/0). +func IsV6DefaultRoute(p netip.Prefix) bool { return p == v6Default } + +// ExpandV6ExitPairs appends the paired "-v6" exit node NetID for any v4 exit +// node (0.0.0.0/0) in ids that has a matching v6 counterpart (::/0) in routesMap. +// It modifies and returns the input slice. +func ExpandV6ExitPairs(ids []NetID, routesMap map[NetID][]*Route) []NetID { + for _, id := range ids { + rt, ok := routesMap[id] + if !ok || len(rt) == 0 || !IsV4DefaultRoute(rt[0].Network) { + continue + } + v6ID := NetID(string(id) + V6ExitSuffix) + if v6Rt, ok := routesMap[v6ID]; ok && len(v6Rt) > 0 && IsV6DefaultRoute(v6Rt[0].Network) { + if !slices.Contains(ids, v6ID) { + ids = append(ids, v6ID) + } + } + } + return ids +} + +// V6ExitMergeSet scans routesMap and returns the set of v6 exit node NetIDs +// that should be hidden from the UI because they are paired with a v4 exit node. +// A v6 ID is paired when it has suffix "-v6", its route is ::/0, and the base +// name (without "-v6") exists with route 0.0.0.0/0. +func V6ExitMergeSet(routesMap map[NetID][]*Route) map[NetID]struct{} { + merged := make(map[NetID]struct{}) + for id, rt := range routesMap { + if len(rt) == 0 { + continue + } + name := string(id) + if !IsV6DefaultRoute(rt[0].Network) || !strings.HasSuffix(name, V6ExitSuffix) { + continue + } + baseName := NetID(strings.TrimSuffix(name, V6ExitSuffix)) + if baseRt, ok := routesMap[baseName]; ok && len(baseRt) > 0 && IsV4DefaultRoute(baseRt[0].Network) { + merged[id] = struct{}{} + } + } + return merged +} + +// HasV6ExitPair reports whether id has a paired v6 exit node in the merge set. +func HasV6ExitPair(id NetID, v6Merged map[NetID]struct{}) bool { + _, ok := v6Merged[NetID(string(id)+"-v6")] + return ok +} diff --git a/route/route_test.go b/route/route_test.go new file mode 100644 index 000000000..dab707ed3 --- /dev/null +++ b/route/route_test.go @@ -0,0 +1,108 @@ +package route + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExpandV6ExitPairs(t *testing.T) { + v4ExitRoute := &Route{Network: netip.MustParsePrefix("0.0.0.0/0")} + v6ExitRoute := &Route{Network: netip.MustParsePrefix("::/0")} + regularRoute := &Route{Network: netip.MustParsePrefix("10.0.0.0/8")} + + tests := []struct { + name string + ids []NetID + routesMap map[NetID][]*Route + expected []NetID + }{ + { + name: "v4 exit node with matching v6 pair", + ids: []NetID{"exit-node"}, + routesMap: map[NetID][]*Route{ + "exit-node": {v4ExitRoute}, + "exit-node-v6": {v6ExitRoute}, + }, + expected: []NetID{"exit-node", "exit-node-v6"}, + }, + { + name: "v4 exit node without v6 pair", + ids: []NetID{"exit-node"}, + routesMap: map[NetID][]*Route{ + "exit-node": {v4ExitRoute}, + }, + expected: []NetID{"exit-node"}, + }, + { + name: "regular route is not expanded", + ids: []NetID{"office"}, + routesMap: map[NetID][]*Route{ + "office": {regularRoute}, + "office-v6": {v6ExitRoute}, + }, + expected: []NetID{"office"}, + }, + { + name: "v6 already included is not duplicated", + ids: []NetID{"exit-node", "exit-node-v6"}, + routesMap: map[NetID][]*Route{ + "exit-node": {v4ExitRoute}, + "exit-node-v6": {v6ExitRoute}, + }, + expected: []NetID{"exit-node", "exit-node-v6"}, + }, + { + name: "multiple exit nodes expanded independently", + ids: []NetID{"exit-a", "exit-b"}, + routesMap: map[NetID][]*Route{ + "exit-a": {v4ExitRoute}, + "exit-a-v6": {v6ExitRoute}, + "exit-b": {v4ExitRoute}, + "exit-b-v6": {v6ExitRoute}, + }, + expected: []NetID{"exit-a", "exit-b", "exit-a-v6", "exit-b-v6"}, + }, + { + name: "v6 suffix but not exit node network", + ids: []NetID{"office"}, + routesMap: map[NetID][]*Route{ + "office": {regularRoute}, + "office-v6": {regularRoute}, + }, + expected: []NetID{"office"}, + }, + { + name: "user-chosen name for exit node with v6 pair", + ids: []NetID{"my-exit"}, + routesMap: map[NetID][]*Route{ + "my-exit": {v4ExitRoute}, + "my-exit-v6": {v6ExitRoute}, + }, + expected: []NetID{"my-exit", "my-exit-v6"}, + }, + { + name: "real-world management-generated IDs", + ids: []NetID{"0.0.0.0/0"}, + routesMap: map[NetID][]*Route{ + "0.0.0.0/0": {v4ExitRoute}, + "0.0.0.0/0-v6": {v6ExitRoute}, + }, + expected: []NetID{"0.0.0.0/0", "0.0.0.0/0-v6"}, + }, + { + name: "empty input", + ids: []NetID{}, + routesMap: map[NetID][]*Route{}, + expected: []NetID{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExpandV6ExitPairs(tt.ids, tt.routesMap) + assert.ElementsMatch(t, tt.expected, result) + }) + } +} diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 80625fe06..58895b7c2 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -937,8 +937,22 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta { DisableFirewall: info.DisableFirewall, BlockLANAccess: info.BlockLANAccess, BlockInbound: info.BlockInbound, + DisableIPv6: info.DisableIPv6, LazyConnectionEnabled: info.LazyConnectionEnabled, }, + + Capabilities: peerCapabilities(*info), } } + +// peerCapabilities returns the capabilities this client supports. +func peerCapabilities(info system.Info) []proto.PeerCapability { + caps := []proto.PeerCapability{ + proto.PeerCapability_PeerCapabilitySourcePrefixes, + } + if !info.DisableIPv6 { + caps = append(caps, proto.PeerCapability_PeerCapabilityIPv6Overlay) + } + return caps +} diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 327e20614..8e6ee54cc 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -341,7 +341,11 @@ components: description: Allows to define a custom network range for the account in CIDR format type: string format: cidr - example: 100.64.0.0/16 + network_range_v6: + description: Allows to define a custom IPv6 network range for the account in CIDR format. + type: string + format: cidr + example: fd00:1234:5678::/64 peer_expose_enabled: description: Enables or disables peer expose. If enabled, peers can expose local services through the reverse proxy using the CLI. type: boolean @@ -377,6 +381,12 @@ components: type: boolean readOnly: true example: false + ipv6_enabled_groups: + description: List of group IDs whose peers receive IPv6 overlay addresses. Peers not in any of these groups will not be allocated an IPv6 address. New accounts default to the All group. + type: array + items: + type: string + example: ["ch8i4ug6lnn4g9hqv7m0"] required: - peer_login_expiration_enabled - peer_login_expiration @@ -776,6 +786,11 @@ components: type: string format: ipv4 example: 100.64.0.15 + ipv6: + description: Peer's IPv6 overlay address. Omitted if IPv6 is not enabled for the account. + type: string + format: ipv6 + example: "fd00:4e42:ab12::1" required: - name - ssh_enabled @@ -795,6 +810,11 @@ components: description: Peer's IP address type: string example: 10.64.0.1 + ipv6: + description: Peer's IPv6 overlay address + type: string + format: ipv6 + example: "fd00:4e42:ab12::1" connection_ip: description: Peer's public connection IP address type: string @@ -1013,6 +1033,10 @@ components: description: Peer's IP address type: string example: 10.64.0.1 + ipv6: + description: Peer's IPv6 overlay address + type: string + example: "fd00:4e42:ab12::1" dns_label: description: Peer's DNS label is the parsed peer name for domain resolution. It is used to form an FQDN by appending the account's domain to the peer label. e.g. peer-dns-label.netbird.cloud type: string diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index dc916f81a..f8ea07be7 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1381,6 +1381,9 @@ type AccessiblePeer struct { // Ip Peer's IP address Ip string `json:"ip"` + // Ipv6 Peer's IPv6 overlay address + Ipv6 *string `json:"ipv6,omitempty"` + // LastSeen Last time peer connected to Netbird's management service LastSeen time.Time `json:"last_seen"` @@ -1465,6 +1468,9 @@ type AccountSettings struct { // GroupsPropagationEnabled Allows propagate the new user auto groups to peers that belongs to the user GroupsPropagationEnabled *bool `json:"groups_propagation_enabled,omitempty"` + // Ipv6EnabledGroups List of group IDs whose peers receive IPv6 overlay addresses. Peers not in any of these groups will not be allocated an IPv6 address. New accounts default to the All group. + Ipv6EnabledGroups *[]string `json:"ipv6_enabled_groups,omitempty"` + // JwtAllowGroups List of groups to which users are allowed access JwtAllowGroups *[]string `json:"jwt_allow_groups,omitempty"` @@ -1483,6 +1489,9 @@ type AccountSettings struct { // NetworkRange Allows to define a custom network range for the account in CIDR format NetworkRange *string `json:"network_range,omitempty"` + // NetworkRangeV6 Allows to define a custom IPv6 network range for the account in CIDR format. + NetworkRangeV6 *string `json:"network_range_v6,omitempty"` + // PeerExposeEnabled Enables or disables peer expose. If enabled, peers can expose local services through the reverse proxy using the CLI. PeerExposeEnabled bool `json:"peer_expose_enabled"` @@ -3141,6 +3150,9 @@ type Peer struct { // Ip Peer's IP address Ip string `json:"ip"` + // Ipv6 Peer's IPv6 overlay address + Ipv6 *string `json:"ipv6,omitempty"` + // KernelVersion Peer's operating system kernel version KernelVersion string `json:"kernel_version"` @@ -3232,6 +3244,9 @@ type PeerBatch struct { // Ip Peer's IP address Ip string `json:"ip"` + // Ipv6 Peer's IPv6 overlay address + Ipv6 *string `json:"ipv6,omitempty"` + // KernelVersion Peer's operating system kernel version KernelVersion string `json:"kernel_version"` @@ -3331,7 +3346,10 @@ type PeerRequest struct { InactivityExpirationEnabled bool `json:"inactivity_expiration_enabled"` // Ip Peer's IP address - Ip *string `json:"ip,omitempty"` + Ip *string `json:"ip,omitempty"` + + // Ipv6 Peer's IPv6 overlay address. Omitted if IPv6 is not enabled for the account. + Ipv6 *string `json:"ipv6,omitempty"` LoginExpirationEnabled bool `json:"login_expiration_enabled"` Name string `json:"name"` SshEnabled bool `json:"ssh_enabled"` diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index 604f9c793..13f4fbc8d 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -71,6 +71,59 @@ func (JobStatus) EnumDescriptor() ([]byte, []int) { return file_management_proto_rawDescGZIP(), []int{0} } +// PeerCapability represents a feature the client binary supports. +// Reported in PeerSystemMeta.capabilities on every login/sync. +type PeerCapability int32 + +const ( + PeerCapability_PeerCapabilityUnknown PeerCapability = 0 + // Client reads SourcePrefixes instead of the deprecated PeerIP string. + PeerCapability_PeerCapabilitySourcePrefixes PeerCapability = 1 + // Client handles IPv6 overlay addresses and firewall rules. + PeerCapability_PeerCapabilityIPv6Overlay PeerCapability = 2 +) + +// Enum value maps for PeerCapability. +var ( + PeerCapability_name = map[int32]string{ + 0: "PeerCapabilityUnknown", + 1: "PeerCapabilitySourcePrefixes", + 2: "PeerCapabilityIPv6Overlay", + } + PeerCapability_value = map[string]int32{ + "PeerCapabilityUnknown": 0, + "PeerCapabilitySourcePrefixes": 1, + "PeerCapabilityIPv6Overlay": 2, + } +) + +func (x PeerCapability) Enum() *PeerCapability { + p := new(PeerCapability) + *p = x + return p +} + +func (x PeerCapability) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PeerCapability) Descriptor() protoreflect.EnumDescriptor { + return file_management_proto_enumTypes[1].Descriptor() +} + +func (PeerCapability) Type() protoreflect.EnumType { + return &file_management_proto_enumTypes[1] +} + +func (x PeerCapability) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PeerCapability.Descriptor instead. +func (PeerCapability) EnumDescriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{1} +} + type RuleProtocol int32 const ( @@ -113,11 +166,11 @@ func (x RuleProtocol) String() string { } func (RuleProtocol) Descriptor() protoreflect.EnumDescriptor { - return file_management_proto_enumTypes[1].Descriptor() + return file_management_proto_enumTypes[2].Descriptor() } func (RuleProtocol) Type() protoreflect.EnumType { - return &file_management_proto_enumTypes[1] + return &file_management_proto_enumTypes[2] } func (x RuleProtocol) Number() protoreflect.EnumNumber { @@ -126,7 +179,7 @@ func (x RuleProtocol) Number() protoreflect.EnumNumber { // Deprecated: Use RuleProtocol.Descriptor instead. func (RuleProtocol) EnumDescriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{1} + return file_management_proto_rawDescGZIP(), []int{2} } type RuleDirection int32 @@ -159,11 +212,11 @@ func (x RuleDirection) String() string { } func (RuleDirection) Descriptor() protoreflect.EnumDescriptor { - return file_management_proto_enumTypes[2].Descriptor() + return file_management_proto_enumTypes[3].Descriptor() } func (RuleDirection) Type() protoreflect.EnumType { - return &file_management_proto_enumTypes[2] + return &file_management_proto_enumTypes[3] } func (x RuleDirection) Number() protoreflect.EnumNumber { @@ -172,7 +225,7 @@ func (x RuleDirection) Number() protoreflect.EnumNumber { // Deprecated: Use RuleDirection.Descriptor instead. func (RuleDirection) EnumDescriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{2} + return file_management_proto_rawDescGZIP(), []int{3} } type RuleAction int32 @@ -205,11 +258,11 @@ func (x RuleAction) String() string { } func (RuleAction) Descriptor() protoreflect.EnumDescriptor { - return file_management_proto_enumTypes[3].Descriptor() + return file_management_proto_enumTypes[4].Descriptor() } func (RuleAction) Type() protoreflect.EnumType { - return &file_management_proto_enumTypes[3] + return &file_management_proto_enumTypes[4] } func (x RuleAction) Number() protoreflect.EnumNumber { @@ -218,7 +271,7 @@ func (x RuleAction) Number() protoreflect.EnumNumber { // Deprecated: Use RuleAction.Descriptor instead. func (RuleAction) EnumDescriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{3} + return file_management_proto_rawDescGZIP(), []int{4} } type ExposeProtocol int32 @@ -260,11 +313,11 @@ func (x ExposeProtocol) String() string { } func (ExposeProtocol) Descriptor() protoreflect.EnumDescriptor { - return file_management_proto_enumTypes[4].Descriptor() + return file_management_proto_enumTypes[5].Descriptor() } func (ExposeProtocol) Type() protoreflect.EnumType { - return &file_management_proto_enumTypes[4] + return &file_management_proto_enumTypes[5] } func (x ExposeProtocol) Number() protoreflect.EnumNumber { @@ -273,7 +326,7 @@ func (x ExposeProtocol) Number() protoreflect.EnumNumber { // Deprecated: Use ExposeProtocol.Descriptor instead. func (ExposeProtocol) EnumDescriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{4} + return file_management_proto_rawDescGZIP(), []int{5} } type HostConfig_Protocol int32 @@ -315,11 +368,11 @@ func (x HostConfig_Protocol) String() string { } func (HostConfig_Protocol) Descriptor() protoreflect.EnumDescriptor { - return file_management_proto_enumTypes[5].Descriptor() + return file_management_proto_enumTypes[6].Descriptor() } func (HostConfig_Protocol) Type() protoreflect.EnumType { - return &file_management_proto_enumTypes[5] + return &file_management_proto_enumTypes[6] } func (x HostConfig_Protocol) Number() protoreflect.EnumNumber { @@ -358,11 +411,11 @@ func (x DeviceAuthorizationFlowProvider) String() string { } func (DeviceAuthorizationFlowProvider) Descriptor() protoreflect.EnumDescriptor { - return file_management_proto_enumTypes[6].Descriptor() + return file_management_proto_enumTypes[7].Descriptor() } func (DeviceAuthorizationFlowProvider) Type() protoreflect.EnumType { - return &file_management_proto_enumTypes[6] + return &file_management_proto_enumTypes[7] } func (x DeviceAuthorizationFlowProvider) Number() protoreflect.EnumNumber { @@ -1201,6 +1254,7 @@ type Flags struct { EnableSSHLocalPortForwarding bool `protobuf:"varint,13,opt,name=enableSSHLocalPortForwarding,proto3" json:"enableSSHLocalPortForwarding,omitempty"` EnableSSHRemotePortForwarding bool `protobuf:"varint,14,opt,name=enableSSHRemotePortForwarding,proto3" json:"enableSSHRemotePortForwarding,omitempty"` DisableSSHAuth bool `protobuf:"varint,15,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"` + DisableIPv6 bool `protobuf:"varint,16,opt,name=disableIPv6,proto3" json:"disableIPv6,omitempty"` } func (x *Flags) Reset() { @@ -1340,6 +1394,13 @@ func (x *Flags) GetDisableSSHAuth() bool { return false } +func (x *Flags) GetDisableIPv6() bool { + if x != nil { + return x.DisableIPv6 + } + return false +} + // PeerSystemMeta is machine meta data like OS and version. type PeerSystemMeta struct { state protoimpl.MessageState @@ -1363,6 +1424,7 @@ type PeerSystemMeta struct { Environment *Environment `protobuf:"bytes,15,opt,name=environment,proto3" json:"environment,omitempty"` Files []*File `protobuf:"bytes,16,rep,name=files,proto3" json:"files,omitempty"` Flags *Flags `protobuf:"bytes,17,opt,name=flags,proto3" json:"flags,omitempty"` + Capabilities []PeerCapability `protobuf:"varint,18,rep,packed,name=capabilities,proto3,enum=management.PeerCapability" json:"capabilities,omitempty"` } func (x *PeerSystemMeta) Reset() { @@ -1516,6 +1578,13 @@ func (x *PeerSystemMeta) GetFlags() *Flags { return nil } +func (x *PeerSystemMeta) GetCapabilities() []PeerCapability { + if x != nil { + return x.Capabilities + } + return nil +} + type LoginResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2163,6 +2232,8 @@ type PeerConfig struct { Mtu int32 `protobuf:"varint,7,opt,name=mtu,proto3" json:"mtu,omitempty"` // Auto-update config AutoUpdate *AutoUpdateSettings `protobuf:"bytes,8,opt,name=autoUpdate,proto3" json:"autoUpdate,omitempty"` + // IPv6 overlay address as compact bytes: 16 bytes IP + 1 byte prefix length. + AddressV6 []byte `protobuf:"bytes,9,opt,name=address_v6,json=addressV6,proto3" json:"address_v6,omitempty"` } func (x *PeerConfig) Reset() { @@ -2253,6 +2324,13 @@ func (x *PeerConfig) GetAutoUpdate() *AutoUpdateSettings { return nil } +func (x *PeerConfig) GetAddressV6() []byte { + if x != nil { + return x.AddressV6 + } + return nil +} + type AutoUpdateSettings struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -3562,6 +3640,9 @@ type FirewallRule struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields + // Use sourcePrefixes instead. + // + // Deprecated: Do not use. PeerIP string `protobuf:"bytes,1,opt,name=PeerIP,proto3" json:"PeerIP,omitempty"` Direction RuleDirection `protobuf:"varint,2,opt,name=Direction,proto3,enum=management.RuleDirection" json:"Direction,omitempty"` Action RuleAction `protobuf:"varint,3,opt,name=Action,proto3,enum=management.RuleAction" json:"Action,omitempty"` @@ -3570,6 +3651,11 @@ type FirewallRule struct { PortInfo *PortInfo `protobuf:"bytes,6,opt,name=PortInfo,proto3" json:"PortInfo,omitempty"` // PolicyID is the ID of the policy that this rule belongs to PolicyID []byte `protobuf:"bytes,7,opt,name=PolicyID,proto3" json:"PolicyID,omitempty"` + // CustomProtocol is a custom protocol ID when Protocol is CUSTOM. + CustomProtocol uint32 `protobuf:"varint,8,opt,name=customProtocol,proto3" json:"customProtocol,omitempty"` + // Compact source IP prefixes for this rule, supersedes PeerIP. + // Each entry is 5 bytes (v4) or 17 bytes (v6): [IP bytes][1 byte prefix_len]. + SourcePrefixes [][]byte `protobuf:"bytes,9,rep,name=sourcePrefixes,proto3" json:"sourcePrefixes,omitempty"` } func (x *FirewallRule) Reset() { @@ -3604,6 +3690,7 @@ func (*FirewallRule) Descriptor() ([]byte, []int) { return file_management_proto_rawDescGZIP(), []int{41} } +// Deprecated: Do not use. func (x *FirewallRule) GetPeerIP() string { if x != nil { return x.PeerIP @@ -3653,6 +3740,20 @@ func (x *FirewallRule) GetPolicyID() []byte { return nil } +func (x *FirewallRule) GetCustomProtocol() uint32 { + if x != nil { + return x.CustomProtocol + } + return 0 +} + +func (x *FirewallRule) GetSourcePrefixes() [][]byte { + if x != nil { + return x.SourcePrefixes + } + return nil +} + type NetworkAddress struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -4542,7 +4643,7 @@ var file_management_proto_rawDesc = []byte{ 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, - 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0xbf, 0x05, 0x0a, 0x05, 0x46, 0x6c, 0x61, 0x67, 0x73, + 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0xe1, 0x05, 0x0a, 0x05, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, @@ -4586,551 +4687,571 @@ var file_management_proto_rawDesc = []byte{ 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, - 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x22, 0xf2, 0x04, 0x0a, 0x0e, 0x50, 0x65, 0x65, - 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x68, - 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, - 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, 0x16, 0x0a, 0x06, 0x6b, - 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6b, 0x65, 0x72, - 0x6e, 0x65, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, - 0x6f, 0x72, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, - 0x6f, 0x72, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x02, 0x4f, 0x53, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x74, - 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x75, - 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x6b, 0x65, 0x72, - 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x1c, 0x0a, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, - 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, - 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, - 0x65, 0x73, 0x73, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, - 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, - 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, - 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, - 0x26, 0x0a, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, - 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, - 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, - 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, - 0x72, 0x12, 0x39, 0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, - 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, - 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x05, - 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66, - 0x69, 0x6c, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x11, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x22, 0xb4, 0x01, - 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, - 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, - 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, - 0x65, 0x63, 0x6b, 0x73, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, - 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x65, - 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, - 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, - 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, - 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74, - 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74, - 0x75, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, 0x72, 0x6e, - 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, - 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x12, - 0x2e, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, - 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, - 0x2d, 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c, 0x61, - 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, - 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48, - 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, - 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x00, 0x12, 0x07, 0x0a, - 0x03, 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x02, - 0x12, 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x44, - 0x54, 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, - 0x74, 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, - 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, - 0x65, 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2e, - 0x0a, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x65, 0x78, 0x69, 0x74, - 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, - 0x0a, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, - 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x75, - 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x75, - 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6b, 0x65, - 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, - 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, - 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, 0x50, 0x72, - 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x68, - 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, - 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, - 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0xd3, 0x02, 0x0a, 0x0a, 0x50, 0x65, - 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, - 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, - 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x64, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, - 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, - 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x48, 0x0a, - 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, - 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, - 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, - 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, - 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, - 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x10, 0x0a, - 0x03, 0x6d, 0x74, 0x75, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12, - 0x3e, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, - 0x6e, 0x67, 0x73, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x22, - 0x52, 0x0a, 0x12, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, - 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, - 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, - 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, - 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, - 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, - 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, - 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, - 0x74, 0x79, 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a, - 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, - 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, - 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x61, + 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64, + 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x22, 0xb2, 0x05, 0x0a, 0x0e, 0x50, + 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, + 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x6f, 0x4f, + 0x53, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, 0x16, 0x0a, + 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6b, + 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, + 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, + 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x02, 0x4f, 0x53, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, + 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, + 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x6b, + 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x46, 0x0a, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, + 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, + 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, + 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, + 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, + 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, + 0x72, 0x65, 0x72, 0x12, 0x39, 0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, + 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, + 0x74, 0x52, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x26, + 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, + 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, + 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x12, + 0x3e, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, + 0x12, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, + 0x79, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, + 0xb4, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, + 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, + 0x65, 0x63, 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, + 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, + 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, + 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x0d, 0x4e, + 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, + 0x73, 0x74, 0x75, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, + 0x72, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, + 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, + 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, + 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, + 0x6c, 0x12, 0x2d, 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, + 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, + 0x12, 0x2a, 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x22, 0x98, 0x01, 0x0a, + 0x0a, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, + 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, + 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, + 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x00, 0x12, + 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, + 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, 0x08, 0x0a, + 0x04, 0x44, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, 0x61, 0x79, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, + 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, + 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, + 0x75, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, + 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, + 0x12, 0x2e, 0x0a, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x65, 0x78, + 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, + 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, + 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, + 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, + 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, + 0x0a, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, + 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, + 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, + 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, + 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0a, + 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, + 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, + 0x48, 0x0a, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, + 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, + 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, + 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, + 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, + 0x10, 0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, + 0x75, 0x12, 0x3e, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, + 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36, + 0x22, 0x52, 0x0a, 0x12, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, + 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x4d, 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, + 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, + 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, - 0x65, 0x65, 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, - 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, - 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, - 0x75, 0x6c, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, - 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, - 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, - 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, - 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, - 0x52, 0x75, 0x6c, 0x65, 0x52, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, - 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, 0x75, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, + 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, + 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, + 0x70, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, + 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, + 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, + 0x65, 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, + 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, + 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, + 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, + 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, + 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, + 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, - 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72, - 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, - 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72, - 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, - 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, - 0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, - 0x48, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82, - 0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73, - 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, - 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, - 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, - 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, - 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, - 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, - 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, - 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64, - 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65, - 0x78, 0x65, 0x73, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, - 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, - 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, - 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, - 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, - 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, - 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, - 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a, - 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, - 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, - 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09, - 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, - 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, - 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, - 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, - 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a, - 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53, - 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42, - 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, - 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, - 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, - 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, - 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, - 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, - 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, - 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a, - 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, - 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65, - 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55, - 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, - 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, - 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, - 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18, - 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, - 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, - 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, - 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, - 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, - 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, - 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65, - 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d, - 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74, - 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, - 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, - 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, - 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, - 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, - 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, - 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, - 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, - 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, - 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, - 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, - 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x28, - 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, - 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, 0x73, - 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, - 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, - 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, - 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, - 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, - 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, - 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, - 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, - 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, - 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, - 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, - 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, - 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, - 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, - 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, - 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, - 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa7, 0x02, 0x0a, 0x0c, 0x46, 0x69, - 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x50, 0x65, - 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, - 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, - 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, - 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, - 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, - 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, - 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, - 0x79, 0x49, 0x44, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, - 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, - 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, - 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, - 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, - 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, - 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, - 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, - 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, - 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, - 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, - 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, - 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, - 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, - 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, - 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, - 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, - 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, - 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, - 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, - 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, - 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, - 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, - 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, - 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, - 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, - 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, - 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, - 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, - 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, - 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, - 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, - 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, - 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, - 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, - 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, - 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, - 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, - 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, - 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, - 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, - 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, - 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, - 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, - 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, - 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, - 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, - 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, - 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, - 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, - 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, - 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, - 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, - 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, - 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, 0x0e, - 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x10, 0x00, - 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x10, 0x01, 0x12, - 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, 0x4c, 0x0a, 0x0c, 0x52, - 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, - 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, - 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, - 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, - 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, - 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, - 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, - 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, - 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x2a, - 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, - 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, - 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, - 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55, - 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, - 0x4c, 0x53, 0x10, 0x04, 0x32, 0xfd, 0x06, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, - 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, - 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, - 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, + 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, + 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, + 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, + 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, + 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, + 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, + 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, + 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, + 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, + 0x12, 0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, + 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x22, + 0x82, 0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, + 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, + 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, + 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, + 0x6e, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, + 0x75, 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, + 0x65, 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, + 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, + 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, + 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, + 0x65, 0x78, 0x65, 0x73, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, + 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, + 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, + 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, + 0x49, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, + 0x65, 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, + 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, + 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, + 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, + 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, + 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, + 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, + 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, - 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, - 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, - 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, - 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, - 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, - 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, - 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x4c, 0x0a, - 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, - 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, - 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, - 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, + 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, + 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, + 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, + 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, + 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, + 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, + 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, + 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, + 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, + 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, + 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, + 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, + 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, + 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, + 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, + 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, + 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, + 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, + 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, + 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, + 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, + 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, + 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, + 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, + 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, + 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, + 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, + 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, + 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, + 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, + 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, + 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, + 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, + 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, + 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, + 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, + 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, + 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, + 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, + 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, + 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, + 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, + 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, + 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, + 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, + 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, + 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, + 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a, 0x0c, 0x46, + 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, + 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, + 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, + 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, + 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, + 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, + 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, + 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, + 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, + 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, + 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, + 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, + 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, + 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, + 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, + 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, + 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, + 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, + 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, + 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, + 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, + 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, + 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, + 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, + 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, + 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, + 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, + 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, + 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, + 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, + 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, + 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, + 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, + 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, + 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, + 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, + 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, + 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, + 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, + 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, + 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, + 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, + 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, + 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, + 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, + 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, + 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, + 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, + 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, + 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, + 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, + 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, + 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, + 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, + 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, + 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, + 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, + 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x12, 0x12, 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, + 0x65, 0x64, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, + 0x2a, 0x6c, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, + 0x74, 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, + 0x6c, 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, + 0x1c, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, + 0x1d, 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, + 0x79, 0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x2a, 0x4c, + 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, + 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, + 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, + 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, + 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20, 0x0a, 0x0d, + 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, + 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, + 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, + 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, + 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, + 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, + 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, + 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, + 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, + 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, + 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xfd, 0x06, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, + 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, + 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, + 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, + 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, + 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, + 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, + 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, + 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, + 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, + 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, + 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53, + 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, 0x6f, + 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x1c, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, + 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, + 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, + 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, + 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, + 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, + 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, + 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -5145,166 +5266,168 @@ func file_management_proto_rawDescGZIP() []byte { return file_management_proto_rawDescData } -var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 7) +var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 8) var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 55) var file_management_proto_goTypes = []interface{}{ (JobStatus)(0), // 0: management.JobStatus - (RuleProtocol)(0), // 1: management.RuleProtocol - (RuleDirection)(0), // 2: management.RuleDirection - (RuleAction)(0), // 3: management.RuleAction - (ExposeProtocol)(0), // 4: management.ExposeProtocol - (HostConfig_Protocol)(0), // 5: management.HostConfig.Protocol - (DeviceAuthorizationFlowProvider)(0), // 6: management.DeviceAuthorizationFlow.provider - (*EncryptedMessage)(nil), // 7: management.EncryptedMessage - (*JobRequest)(nil), // 8: management.JobRequest - (*JobResponse)(nil), // 9: management.JobResponse - (*BundleParameters)(nil), // 10: management.BundleParameters - (*BundleResult)(nil), // 11: management.BundleResult - (*SyncRequest)(nil), // 12: management.SyncRequest - (*SyncResponse)(nil), // 13: management.SyncResponse - (*SyncMetaRequest)(nil), // 14: management.SyncMetaRequest - (*LoginRequest)(nil), // 15: management.LoginRequest - (*PeerKeys)(nil), // 16: management.PeerKeys - (*Environment)(nil), // 17: management.Environment - (*File)(nil), // 18: management.File - (*Flags)(nil), // 19: management.Flags - (*PeerSystemMeta)(nil), // 20: management.PeerSystemMeta - (*LoginResponse)(nil), // 21: management.LoginResponse - (*ServerKeyResponse)(nil), // 22: management.ServerKeyResponse - (*Empty)(nil), // 23: management.Empty - (*NetbirdConfig)(nil), // 24: management.NetbirdConfig - (*HostConfig)(nil), // 25: management.HostConfig - (*RelayConfig)(nil), // 26: management.RelayConfig - (*FlowConfig)(nil), // 27: management.FlowConfig - (*JWTConfig)(nil), // 28: management.JWTConfig - (*ProtectedHostConfig)(nil), // 29: management.ProtectedHostConfig - (*PeerConfig)(nil), // 30: management.PeerConfig - (*AutoUpdateSettings)(nil), // 31: management.AutoUpdateSettings - (*NetworkMap)(nil), // 32: management.NetworkMap - (*SSHAuth)(nil), // 33: management.SSHAuth - (*MachineUserIndexes)(nil), // 34: management.MachineUserIndexes - (*RemotePeerConfig)(nil), // 35: management.RemotePeerConfig - (*SSHConfig)(nil), // 36: management.SSHConfig - (*DeviceAuthorizationFlowRequest)(nil), // 37: management.DeviceAuthorizationFlowRequest - (*DeviceAuthorizationFlow)(nil), // 38: management.DeviceAuthorizationFlow - (*PKCEAuthorizationFlowRequest)(nil), // 39: management.PKCEAuthorizationFlowRequest - (*PKCEAuthorizationFlow)(nil), // 40: management.PKCEAuthorizationFlow - (*ProviderConfig)(nil), // 41: management.ProviderConfig - (*Route)(nil), // 42: management.Route - (*DNSConfig)(nil), // 43: management.DNSConfig - (*CustomZone)(nil), // 44: management.CustomZone - (*SimpleRecord)(nil), // 45: management.SimpleRecord - (*NameServerGroup)(nil), // 46: management.NameServerGroup - (*NameServer)(nil), // 47: management.NameServer - (*FirewallRule)(nil), // 48: management.FirewallRule - (*NetworkAddress)(nil), // 49: management.NetworkAddress - (*Checks)(nil), // 50: management.Checks - (*PortInfo)(nil), // 51: management.PortInfo - (*RouteFirewallRule)(nil), // 52: management.RouteFirewallRule - (*ForwardingRule)(nil), // 53: management.ForwardingRule - (*ExposeServiceRequest)(nil), // 54: management.ExposeServiceRequest - (*ExposeServiceResponse)(nil), // 55: management.ExposeServiceResponse - (*RenewExposeRequest)(nil), // 56: management.RenewExposeRequest - (*RenewExposeResponse)(nil), // 57: management.RenewExposeResponse - (*StopExposeRequest)(nil), // 58: management.StopExposeRequest - (*StopExposeResponse)(nil), // 59: management.StopExposeResponse - nil, // 60: management.SSHAuth.MachineUsersEntry - (*PortInfo_Range)(nil), // 61: management.PortInfo.Range - (*timestamppb.Timestamp)(nil), // 62: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 63: google.protobuf.Duration + (PeerCapability)(0), // 1: management.PeerCapability + (RuleProtocol)(0), // 2: management.RuleProtocol + (RuleDirection)(0), // 3: management.RuleDirection + (RuleAction)(0), // 4: management.RuleAction + (ExposeProtocol)(0), // 5: management.ExposeProtocol + (HostConfig_Protocol)(0), // 6: management.HostConfig.Protocol + (DeviceAuthorizationFlowProvider)(0), // 7: management.DeviceAuthorizationFlow.provider + (*EncryptedMessage)(nil), // 8: management.EncryptedMessage + (*JobRequest)(nil), // 9: management.JobRequest + (*JobResponse)(nil), // 10: management.JobResponse + (*BundleParameters)(nil), // 11: management.BundleParameters + (*BundleResult)(nil), // 12: management.BundleResult + (*SyncRequest)(nil), // 13: management.SyncRequest + (*SyncResponse)(nil), // 14: management.SyncResponse + (*SyncMetaRequest)(nil), // 15: management.SyncMetaRequest + (*LoginRequest)(nil), // 16: management.LoginRequest + (*PeerKeys)(nil), // 17: management.PeerKeys + (*Environment)(nil), // 18: management.Environment + (*File)(nil), // 19: management.File + (*Flags)(nil), // 20: management.Flags + (*PeerSystemMeta)(nil), // 21: management.PeerSystemMeta + (*LoginResponse)(nil), // 22: management.LoginResponse + (*ServerKeyResponse)(nil), // 23: management.ServerKeyResponse + (*Empty)(nil), // 24: management.Empty + (*NetbirdConfig)(nil), // 25: management.NetbirdConfig + (*HostConfig)(nil), // 26: management.HostConfig + (*RelayConfig)(nil), // 27: management.RelayConfig + (*FlowConfig)(nil), // 28: management.FlowConfig + (*JWTConfig)(nil), // 29: management.JWTConfig + (*ProtectedHostConfig)(nil), // 30: management.ProtectedHostConfig + (*PeerConfig)(nil), // 31: management.PeerConfig + (*AutoUpdateSettings)(nil), // 32: management.AutoUpdateSettings + (*NetworkMap)(nil), // 33: management.NetworkMap + (*SSHAuth)(nil), // 34: management.SSHAuth + (*MachineUserIndexes)(nil), // 35: management.MachineUserIndexes + (*RemotePeerConfig)(nil), // 36: management.RemotePeerConfig + (*SSHConfig)(nil), // 37: management.SSHConfig + (*DeviceAuthorizationFlowRequest)(nil), // 38: management.DeviceAuthorizationFlowRequest + (*DeviceAuthorizationFlow)(nil), // 39: management.DeviceAuthorizationFlow + (*PKCEAuthorizationFlowRequest)(nil), // 40: management.PKCEAuthorizationFlowRequest + (*PKCEAuthorizationFlow)(nil), // 41: management.PKCEAuthorizationFlow + (*ProviderConfig)(nil), // 42: management.ProviderConfig + (*Route)(nil), // 43: management.Route + (*DNSConfig)(nil), // 44: management.DNSConfig + (*CustomZone)(nil), // 45: management.CustomZone + (*SimpleRecord)(nil), // 46: management.SimpleRecord + (*NameServerGroup)(nil), // 47: management.NameServerGroup + (*NameServer)(nil), // 48: management.NameServer + (*FirewallRule)(nil), // 49: management.FirewallRule + (*NetworkAddress)(nil), // 50: management.NetworkAddress + (*Checks)(nil), // 51: management.Checks + (*PortInfo)(nil), // 52: management.PortInfo + (*RouteFirewallRule)(nil), // 53: management.RouteFirewallRule + (*ForwardingRule)(nil), // 54: management.ForwardingRule + (*ExposeServiceRequest)(nil), // 55: management.ExposeServiceRequest + (*ExposeServiceResponse)(nil), // 56: management.ExposeServiceResponse + (*RenewExposeRequest)(nil), // 57: management.RenewExposeRequest + (*RenewExposeResponse)(nil), // 58: management.RenewExposeResponse + (*StopExposeRequest)(nil), // 59: management.StopExposeRequest + (*StopExposeResponse)(nil), // 60: management.StopExposeResponse + nil, // 61: management.SSHAuth.MachineUsersEntry + (*PortInfo_Range)(nil), // 62: management.PortInfo.Range + (*timestamppb.Timestamp)(nil), // 63: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 64: google.protobuf.Duration } var file_management_proto_depIdxs = []int32{ - 10, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters + 11, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters 0, // 1: management.JobResponse.status:type_name -> management.JobStatus - 11, // 2: management.JobResponse.bundle:type_name -> management.BundleResult - 20, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta - 24, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig - 30, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig - 35, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig - 32, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap - 50, // 8: management.SyncResponse.Checks:type_name -> management.Checks - 20, // 9: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta - 20, // 10: management.LoginRequest.meta:type_name -> management.PeerSystemMeta - 16, // 11: management.LoginRequest.peerKeys:type_name -> management.PeerKeys - 49, // 12: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress - 17, // 13: management.PeerSystemMeta.environment:type_name -> management.Environment - 18, // 14: management.PeerSystemMeta.files:type_name -> management.File - 19, // 15: management.PeerSystemMeta.flags:type_name -> management.Flags - 24, // 16: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig - 30, // 17: management.LoginResponse.peerConfig:type_name -> management.PeerConfig - 50, // 18: management.LoginResponse.Checks:type_name -> management.Checks - 62, // 19: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp - 25, // 20: management.NetbirdConfig.stuns:type_name -> management.HostConfig - 29, // 21: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig - 25, // 22: management.NetbirdConfig.signal:type_name -> management.HostConfig - 26, // 23: management.NetbirdConfig.relay:type_name -> management.RelayConfig - 27, // 24: management.NetbirdConfig.flow:type_name -> management.FlowConfig - 5, // 25: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol - 63, // 26: management.FlowConfig.interval:type_name -> google.protobuf.Duration - 25, // 27: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig - 36, // 28: management.PeerConfig.sshConfig:type_name -> management.SSHConfig - 31, // 29: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings - 30, // 30: management.NetworkMap.peerConfig:type_name -> management.PeerConfig - 35, // 31: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig - 42, // 32: management.NetworkMap.Routes:type_name -> management.Route - 43, // 33: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig - 35, // 34: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig - 48, // 35: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule - 52, // 36: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule - 53, // 37: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule - 33, // 38: management.NetworkMap.sshAuth:type_name -> management.SSHAuth - 60, // 39: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry - 36, // 40: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig - 28, // 41: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig - 6, // 42: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider - 41, // 43: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 41, // 44: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 46, // 45: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup - 44, // 46: management.DNSConfig.CustomZones:type_name -> management.CustomZone - 45, // 47: management.CustomZone.Records:type_name -> management.SimpleRecord - 47, // 48: management.NameServerGroup.NameServers:type_name -> management.NameServer - 2, // 49: management.FirewallRule.Direction:type_name -> management.RuleDirection - 3, // 50: management.FirewallRule.Action:type_name -> management.RuleAction - 1, // 51: management.FirewallRule.Protocol:type_name -> management.RuleProtocol - 51, // 52: management.FirewallRule.PortInfo:type_name -> management.PortInfo - 61, // 53: management.PortInfo.range:type_name -> management.PortInfo.Range - 3, // 54: management.RouteFirewallRule.action:type_name -> management.RuleAction - 1, // 55: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol - 51, // 56: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo - 1, // 57: management.ForwardingRule.protocol:type_name -> management.RuleProtocol - 51, // 58: management.ForwardingRule.destinationPort:type_name -> management.PortInfo - 51, // 59: management.ForwardingRule.translatedPort:type_name -> management.PortInfo - 4, // 60: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol - 34, // 61: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes - 7, // 62: management.ManagementService.Login:input_type -> management.EncryptedMessage - 7, // 63: management.ManagementService.Sync:input_type -> management.EncryptedMessage - 23, // 64: management.ManagementService.GetServerKey:input_type -> management.Empty - 23, // 65: management.ManagementService.isHealthy:input_type -> management.Empty - 7, // 66: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage - 7, // 67: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage - 7, // 68: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage - 7, // 69: management.ManagementService.Logout:input_type -> management.EncryptedMessage - 7, // 70: management.ManagementService.Job:input_type -> management.EncryptedMessage - 7, // 71: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage - 7, // 72: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage - 7, // 73: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage - 7, // 74: management.ManagementService.Login:output_type -> management.EncryptedMessage - 7, // 75: management.ManagementService.Sync:output_type -> management.EncryptedMessage - 22, // 76: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse - 23, // 77: management.ManagementService.isHealthy:output_type -> management.Empty - 7, // 78: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage - 7, // 79: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage - 23, // 80: management.ManagementService.SyncMeta:output_type -> management.Empty - 23, // 81: management.ManagementService.Logout:output_type -> management.Empty - 7, // 82: management.ManagementService.Job:output_type -> management.EncryptedMessage - 7, // 83: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage - 7, // 84: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage - 7, // 85: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage - 74, // [74:86] is the sub-list for method output_type - 62, // [62:74] is the sub-list for method input_type - 62, // [62:62] is the sub-list for extension type_name - 62, // [62:62] is the sub-list for extension extendee - 0, // [0:62] is the sub-list for field type_name + 12, // 2: management.JobResponse.bundle:type_name -> management.BundleResult + 21, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta + 25, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig + 31, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig + 36, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig + 33, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap + 51, // 8: management.SyncResponse.Checks:type_name -> management.Checks + 21, // 9: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta + 21, // 10: management.LoginRequest.meta:type_name -> management.PeerSystemMeta + 17, // 11: management.LoginRequest.peerKeys:type_name -> management.PeerKeys + 50, // 12: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress + 18, // 13: management.PeerSystemMeta.environment:type_name -> management.Environment + 19, // 14: management.PeerSystemMeta.files:type_name -> management.File + 20, // 15: management.PeerSystemMeta.flags:type_name -> management.Flags + 1, // 16: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability + 25, // 17: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig + 31, // 18: management.LoginResponse.peerConfig:type_name -> management.PeerConfig + 51, // 19: management.LoginResponse.Checks:type_name -> management.Checks + 63, // 20: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp + 26, // 21: management.NetbirdConfig.stuns:type_name -> management.HostConfig + 30, // 22: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig + 26, // 23: management.NetbirdConfig.signal:type_name -> management.HostConfig + 27, // 24: management.NetbirdConfig.relay:type_name -> management.RelayConfig + 28, // 25: management.NetbirdConfig.flow:type_name -> management.FlowConfig + 6, // 26: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol + 64, // 27: management.FlowConfig.interval:type_name -> google.protobuf.Duration + 26, // 28: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig + 37, // 29: management.PeerConfig.sshConfig:type_name -> management.SSHConfig + 32, // 30: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings + 31, // 31: management.NetworkMap.peerConfig:type_name -> management.PeerConfig + 36, // 32: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig + 43, // 33: management.NetworkMap.Routes:type_name -> management.Route + 44, // 34: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig + 36, // 35: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig + 49, // 36: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule + 53, // 37: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule + 54, // 38: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule + 34, // 39: management.NetworkMap.sshAuth:type_name -> management.SSHAuth + 61, // 40: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry + 37, // 41: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig + 29, // 42: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig + 7, // 43: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider + 42, // 44: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 42, // 45: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 47, // 46: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup + 45, // 47: management.DNSConfig.CustomZones:type_name -> management.CustomZone + 46, // 48: management.CustomZone.Records:type_name -> management.SimpleRecord + 48, // 49: management.NameServerGroup.NameServers:type_name -> management.NameServer + 3, // 50: management.FirewallRule.Direction:type_name -> management.RuleDirection + 4, // 51: management.FirewallRule.Action:type_name -> management.RuleAction + 2, // 52: management.FirewallRule.Protocol:type_name -> management.RuleProtocol + 52, // 53: management.FirewallRule.PortInfo:type_name -> management.PortInfo + 62, // 54: management.PortInfo.range:type_name -> management.PortInfo.Range + 4, // 55: management.RouteFirewallRule.action:type_name -> management.RuleAction + 2, // 56: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol + 52, // 57: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo + 2, // 58: management.ForwardingRule.protocol:type_name -> management.RuleProtocol + 52, // 59: management.ForwardingRule.destinationPort:type_name -> management.PortInfo + 52, // 60: management.ForwardingRule.translatedPort:type_name -> management.PortInfo + 5, // 61: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol + 35, // 62: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes + 8, // 63: management.ManagementService.Login:input_type -> management.EncryptedMessage + 8, // 64: management.ManagementService.Sync:input_type -> management.EncryptedMessage + 24, // 65: management.ManagementService.GetServerKey:input_type -> management.Empty + 24, // 66: management.ManagementService.isHealthy:input_type -> management.Empty + 8, // 67: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 68: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 69: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage + 8, // 70: management.ManagementService.Logout:input_type -> management.EncryptedMessage + 8, // 71: management.ManagementService.Job:input_type -> management.EncryptedMessage + 8, // 72: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage + 8, // 73: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage + 8, // 74: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage + 8, // 75: management.ManagementService.Login:output_type -> management.EncryptedMessage + 8, // 76: management.ManagementService.Sync:output_type -> management.EncryptedMessage + 23, // 77: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse + 24, // 78: management.ManagementService.isHealthy:output_type -> management.Empty + 8, // 79: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage + 8, // 80: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage + 24, // 81: management.ManagementService.SyncMeta:output_type -> management.Empty + 24, // 82: management.ManagementService.Logout:output_type -> management.Empty + 8, // 83: management.ManagementService.Job:output_type -> management.EncryptedMessage + 8, // 84: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage + 8, // 85: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage + 8, // 86: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage + 75, // [75:87] is the sub-list for method output_type + 63, // [63:75] is the sub-list for method input_type + 63, // [63:63] is the sub-list for extension type_name + 63, // [63:63] is the sub-list for extension extendee + 0, // [0:63] is the sub-list for field type_name } func init() { file_management_proto_init() } @@ -5977,7 +6100,7 @@ func file_management_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_management_proto_rawDesc, - NumEnums: 7, + NumEnums: 8, NumMessages: 55, NumExtensions: 0, NumServices: 1, diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index 70a530679..461a614fe 100644 --- a/shared/management/proto/management.proto +++ b/shared/management/proto/management.proto @@ -200,6 +200,18 @@ message Flags { bool enableSSHLocalPortForwarding = 13; bool enableSSHRemotePortForwarding = 14; bool disableSSHAuth = 15; + + bool disableIPv6 = 16; +} + +// PeerCapability represents a feature the client binary supports. +// Reported in PeerSystemMeta.capabilities on every login/sync. +enum PeerCapability { + PeerCapabilityUnknown = 0; + // Client reads SourcePrefixes instead of the deprecated PeerIP string. + PeerCapabilitySourcePrefixes = 1; + // Client handles IPv6 overlay addresses and firewall rules. + PeerCapabilityIPv6Overlay = 2; } // PeerSystemMeta is machine meta data like OS and version. @@ -221,6 +233,8 @@ message PeerSystemMeta { Environment environment = 15; repeated File files = 16; Flags flags = 17; + + repeated PeerCapability capabilities = 18; } message LoginResponse { @@ -335,6 +349,9 @@ message PeerConfig { // Auto-update config AutoUpdateSettings autoUpdate = 8; + + // IPv6 overlay address as compact bytes: 16 bytes IP + 1 byte prefix length. + bytes address_v6 = 9; } message AutoUpdateSettings { @@ -567,7 +584,8 @@ enum RuleAction { // FirewallRule represents a firewall rule message FirewallRule { - string PeerIP = 1; + // Use sourcePrefixes instead. + string PeerIP = 1 [deprecated = true]; RuleDirection Direction = 2; RuleAction Action = 3; RuleProtocol Protocol = 4; @@ -576,6 +594,13 @@ message FirewallRule { // PolicyID is the ID of the policy that this rule belongs to bytes PolicyID = 7; + + // CustomProtocol is a custom protocol ID when Protocol is CUSTOM. + uint32 customProtocol = 8; + + // Compact source IP prefixes for this rule, supersedes PeerIP. + // Each entry is 5 bytes (v4) or 17 bytes (v6): [IP bytes][1 byte prefix_len]. + repeated bytes sourcePrefixes = 9; } message NetworkAddress { diff --git a/shared/netiputil/compact.go b/shared/netiputil/compact.go new file mode 100644 index 000000000..0cd2b8a20 --- /dev/null +++ b/shared/netiputil/compact.go @@ -0,0 +1,78 @@ +// Package netiputil provides compact binary encoding for IP prefixes used in +// the management proto wire format. +// +// Format: [IP bytes][1 byte prefix_len] +// - IPv4: 5 bytes total (4 IP + 1 prefix_len, 0-32) +// - IPv6: 17 bytes total (16 IP + 1 prefix_len, 0-128) +// +// Address family is determined by length: 5 = v4, 17 = v6. +package netiputil + +import ( + "fmt" + "net/netip" +) + +// EncodePrefix encodes a netip.Prefix into compact bytes. +// The address is always unmapped before encoding. +func EncodePrefix(p netip.Prefix) ([]byte, error) { + addr := p.Addr().Unmap() + bits := p.Bits() + if addr.Is4() && bits > 32 { + return nil, fmt.Errorf("invalid prefix length %d for IPv4 address %s (max 32)", bits, addr) + } + return append(addr.AsSlice(), byte(bits)), nil +} + +// DecodePrefix decodes compact bytes into a netip.Prefix. +func DecodePrefix(b []byte) (netip.Prefix, error) { + switch len(b) { + case 5: + var ip4 [4]byte + copy(ip4[:], b) + bits := int(b[len(b)-1]) + if bits > 32 { + return netip.Prefix{}, fmt.Errorf("invalid IPv4 prefix length %d (max 32)", bits) + } + return netip.PrefixFrom(netip.AddrFrom4(ip4), bits), nil + case 17: + var ip6 [16]byte + copy(ip6[:], b) + addr := netip.AddrFrom16(ip6).Unmap() + bits := int(b[len(b)-1]) + if addr.Is4() { + if bits > 32 { + return netip.Prefix{}, fmt.Errorf("invalid prefix length %d for v4-mapped address (max 32)", bits) + } + } else if bits > 128 { + return netip.Prefix{}, fmt.Errorf("invalid IPv6 prefix length %d (max 128)", bits) + } + return netip.PrefixFrom(addr, bits), nil + default: + return netip.Prefix{}, fmt.Errorf("invalid compact prefix length %d (expected 5 or 17)", len(b)) + } +} + +// EncodeAddr encodes a netip.Addr into compact prefix bytes with a host prefix +// length (/32 for v4, /128 for v6). The address is always unmapped before encoding. +func EncodeAddr(a netip.Addr) []byte { + a = a.Unmap() + bits := 128 + if a.Is4() { + bits = 32 + } + // Host prefix lengths are always valid for the address family, so error is impossible. + b, _ := EncodePrefix(netip.PrefixFrom(a, bits)) + return b +} + +// DecodeAddr decodes compact prefix bytes and returns only the address, +// discarding the prefix length. Useful when the prefix length is implied +// (e.g. peer overlay IPs are always /32 or /128). +func DecodeAddr(b []byte) (netip.Addr, error) { + p, err := DecodePrefix(b) + if err != nil { + return netip.Addr{}, err + } + return p.Addr(), nil +} diff --git a/shared/netiputil/compact_test.go b/shared/netiputil/compact_test.go new file mode 100644 index 000000000..1e7c7ed82 --- /dev/null +++ b/shared/netiputil/compact_test.go @@ -0,0 +1,175 @@ +package netiputil + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeDecodePrefix(t *testing.T) { + tests := []struct { + name string + prefix string + size int + }{ + { + name: "v4 host", + prefix: "100.64.0.1/32", + size: 5, + }, + { + name: "v4 network", + prefix: "10.0.0.0/8", + size: 5, + }, + { + name: "v4 default", + prefix: "0.0.0.0/0", + size: 5, + }, + { + name: "v6 host", + prefix: "fd00::1/128", + size: 17, + }, + { + name: "v6 network", + prefix: "fd00:1234:5678::/48", + size: 17, + }, + { + name: "v6 default", + prefix: "::/0", + size: 17, + }, + { + name: "v4 /16 overlay", + prefix: "100.64.0.1/16", + size: 5, + }, + { + name: "v6 /64 overlay", + prefix: "fd00::abcd:1/64", + size: 17, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := netip.MustParsePrefix(tt.prefix) + b, err := EncodePrefix(p) + require.NoError(t, err) + assert.Equal(t, tt.size, len(b), "encoded size") + + decoded, err := DecodePrefix(b) + require.NoError(t, err) + assert.Equal(t, p, decoded) + }) + } +} + +func TestEncodePrefixUnmaps(t *testing.T) { + // v4-mapped v6 address should encode as v4 + mapped := netip.MustParsePrefix("::ffff:10.1.2.3/32") + b, err := EncodePrefix(mapped) + require.NoError(t, err) + assert.Equal(t, 5, len(b), "v4-mapped should encode as 5 bytes") + + decoded, err := DecodePrefix(b) + require.NoError(t, err) + assert.Equal(t, netip.MustParsePrefix("10.1.2.3/32"), decoded) +} + +func TestEncodePrefixUnmapsRejectsInvalidBits(t *testing.T) { + // v4-mapped v6 with bits > 32 should return an error + mapped128 := netip.MustParsePrefix("::ffff:10.1.2.3/128") + _, err := EncodePrefix(mapped128) + require.Error(t, err) + + // v4-mapped v6 with bits=96 should also return an error + mapped96 := netip.MustParsePrefix("::ffff:10.0.0.0/96") + _, err = EncodePrefix(mapped96) + require.Error(t, err) + + // v4-mapped v6 with bits=32 should succeed + mapped32 := netip.MustParsePrefix("::ffff:10.1.2.3/32") + b, err := EncodePrefix(mapped32) + require.NoError(t, err) + assert.Equal(t, 5, len(b), "v4-mapped should encode as 5 bytes") + + decoded, err := DecodePrefix(b) + require.NoError(t, err) + assert.Equal(t, netip.MustParsePrefix("10.1.2.3/32"), decoded) +} + +func TestDecodeAddr(t *testing.T) { + v4 := netip.MustParseAddr("100.64.0.5") + b := EncodeAddr(v4) + assert.Equal(t, 5, len(b)) + + got, err := DecodeAddr(b) + require.NoError(t, err) + assert.Equal(t, v4, got) + + v6 := netip.MustParseAddr("fd00::1") + b = EncodeAddr(v6) + assert.Equal(t, 17, len(b)) + + got, err = DecodeAddr(b) + require.NoError(t, err) + assert.Equal(t, v6, got) +} + +func TestDecodePrefixInvalidLength(t *testing.T) { + _, err := DecodePrefix([]byte{1, 2, 3}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid compact prefix length 3") + + _, err = DecodePrefix(nil) + assert.Error(t, err) + + _, err = DecodePrefix([]byte{}) + assert.Error(t, err) +} + +func TestDecodePrefixInvalidBits(t *testing.T) { + // v4 with bits > 32 + b := []byte{10, 0, 0, 1, 33} + _, err := DecodePrefix(b) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid IPv4 prefix length 33") + + // v6 with bits > 128 + b = make([]byte, 17) + b[0] = 0xfd + b[16] = 129 + _, err = DecodePrefix(b) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid IPv6 prefix length 129") +} + +func TestDecodePrefixUnmapsV6Input(t *testing.T) { + addr := netip.MustParseAddr("::ffff:192.168.1.1") + + // v4-mapped v6 with bits > 32 should return an error + raw := addr.As16() + bInvalid := make([]byte, 17) + copy(bInvalid, raw[:]) + bInvalid[16] = 128 + + _, err := DecodePrefix(bInvalid) + require.Error(t, err, "v4-mapped address with /128 prefix should be rejected") + assert.Contains(t, err.Error(), "invalid prefix length") + + // v4-mapped v6 with valid /32 should decode and unmap correctly + bValid := make([]byte, 17) + copy(bValid, raw[:]) + bValid[16] = 32 + + decoded, err := DecodePrefix(bValid) + require.NoError(t, err) + assert.True(t, decoded.Addr().Is4(), "should be unmapped to v4") + assert.Equal(t, netip.MustParsePrefix("192.168.1.1/32"), decoded) +} diff --git a/shared/relay/client/dialer/quic/quic.go b/shared/relay/client/dialer/quic/quic.go index 602803b19..86f6f178d 100644 --- a/shared/relay/client/dialer/quic/quic.go +++ b/shared/relay/client/dialer/quic/quic.go @@ -49,7 +49,7 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, InitialPacketSize: nbRelay.QUICInitialPacketSize, } - udpConn, err := nbnet.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + udpConn, err := nbnet.ListenUDP("udp", &net.UDPAddr{Port: 0}) if err != nil { log.Errorf("failed to listen on UDP: %s", err) return nil, err diff --git a/upload-server/server/s3_test.go b/upload-server/server/s3_test.go index 7ab1bb379..a72356409 100644 --- a/upload-server/server/s3_test.go +++ b/upload-server/server/s3_test.go @@ -3,6 +3,7 @@ package server import ( "context" "encoding/json" + "net" "net/http" "net/http/httptest" "runtime" @@ -52,7 +53,7 @@ func Test_S3HandlerGetUploadURL(t *testing.T) { hostIP, err := c.Host(ctx) require.NoError(t, err) - awsEndpoint := "http://" + hostIP + ":" + mappedPort.Port() + awsEndpoint := "http://" + net.JoinHostPort(hostIP, mappedPort.Port()) t.Setenv("AWS_REGION", awsRegion) t.Setenv("AWS_ENDPOINT_URL", awsEndpoint) diff --git a/util/capture/text.go b/util/capture/text.go index b44bd0cad..fbb26654e 100644 --- a/util/capture/text.go +++ b/util/capture/text.go @@ -4,7 +4,9 @@ import ( "encoding/binary" "fmt" "io" + "net" "net/netip" + "strconv" "strings" "time" @@ -91,9 +93,9 @@ func (tw *TextWriter) writeTCP(timeStr string, dir Direction, info *packetInfo, } if !tw.verbose { - _, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s] length %d%s\n", + _, err := fmt.Fprintf(tw.w, "%s %s %s > %s [%s] length %d%s\n", timeStr, tag(dir, "TCP"), - info.srcIP, info.srcPort, info.dstIP, info.dstPort, + net.JoinHostPort(info.srcIP.String(), strconv.Itoa(int(info.srcPort))), net.JoinHostPort(info.dstIP.String(), strconv.Itoa(int(info.dstPort))), flags, plen, annotation) if err != nil { return err @@ -125,9 +127,9 @@ func (tw *TextWriter) writeTCP(timeStr string, dir Direction, info *packetInfo, verbose := tw.verboseIP(data, info.family) - _, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s]%s%s, win %d%s, length %d%s%s\n", + _, err := fmt.Fprintf(tw.w, "%s %s %s > %s [%s]%s%s, win %d%s, length %d%s%s\n", timeStr, tag(dir, "TCP"), - info.srcIP, info.srcPort, info.dstIP, info.dstPort, + net.JoinHostPort(info.srcIP.String(), strconv.Itoa(int(info.srcPort))), net.JoinHostPort(info.dstIP.String(), strconv.Itoa(int(info.dstPort))), flags, seqStr, ackStr, tcp.Window, opts, plen, annotation, verbose) if err != nil { return err @@ -153,9 +155,9 @@ func (tw *TextWriter) writeUDP(timeStr string, dir Direction, info *packetInfo, if tw.verbose { verbose = tw.verboseIP(data, info.family) } - _, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d %s%s\n", + _, err := fmt.Fprintf(tw.w, "%s %s %s > %s %s%s\n", timeStr, tag(dir, "UDP"), - info.srcIP, info.srcPort, info.dstIP, info.dstPort, + net.JoinHostPort(info.srcIP.String(), strconv.Itoa(int(info.srcPort))), net.JoinHostPort(info.dstIP.String(), strconv.Itoa(int(info.dstPort))), s, verbose) return err } @@ -165,9 +167,9 @@ func (tw *TextWriter) writeUDP(timeStr string, dir Direction, info *packetInfo, if tw.verbose { verbose = tw.verboseIP(data, info.family) } - _, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d%s\n", + _, err := fmt.Fprintf(tw.w, "%s %s %s > %s length %d%s\n", timeStr, tag(dir, "UDP"), - info.srcIP, info.srcPort, info.dstIP, info.dstPort, + net.JoinHostPort(info.srcIP.String(), strconv.Itoa(int(info.srcPort))), net.JoinHostPort(info.dstIP.String(), strconv.Itoa(int(info.dstPort))), plen, verbose) if err != nil { return err @@ -216,9 +218,9 @@ func (tw *TextWriter) writeICMPv6(timeStr string, dir Direction, info *packetInf } func (tw *TextWriter) writeFallback(timeStr string, dir Direction, proto string, info *packetInfo, data []byte) error { - _, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d\n", + _, err := fmt.Fprintf(tw.w, "%s %s %s > %s length %d\n", timeStr, tag(dir, proto), - info.srcIP, info.srcPort, info.dstIP, info.dstPort, + net.JoinHostPort(info.srcIP.String(), strconv.Itoa(int(info.srcPort))), net.JoinHostPort(info.dstIP.String(), strconv.Itoa(int(info.dstPort))), len(data)-info.hdrLen) return err } From a4fd5a78b4e0c034aac929c01914731f08665d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Thu, 7 May 2026 12:59:01 +0200 Subject: [PATCH 63/80] [client/ui-wails] Set application Name to "NetBird" for Windows toasts Windows uses application.Options.Name as the toast AppUserModelID and as the registry path the Wails notifier reads/writes its CustomActivator under (HKCU\Software\Classes\AppUserModelId\). The MSI installer seeds those under "NetBird"; with the previous "netbird-ui" Name the app would have written under a different identity and the toast activator CLSID the installer pre-registers would have been orphaned. --- client/ui-wails/main.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/ui-wails/main.go b/client/ui-wails/main.go index e5f4d4a7a..9bc3d5692 100644 --- a/client/ui-wails/main.go +++ b/client/ui-wails/main.go @@ -67,7 +67,15 @@ func main() { var tray *Tray app := application.New(application.Options{ - Name: "netbird-ui", + // Windows uses Name as the AppUserModelID for toast notifications + // (see notifications_windows.go: cfg.Name -> wn.appName -> AppID) + // and as the registry path under HKCU\Software\Classes\AppUserModelId\. + // Must match the System.AppUserModel.ID value the MSI sets on the + // Start Menu shortcut (client/netbird.wxs) and the AppUserModelId + // key the installer pre-populates with the toast activator CLSID; + // otherwise toasts show under a different identity and the MSI's + // CustomActivator registry value is orphaned. + Name: "NetBird", Description: "NetBird desktop client", Icon: iconWindow, Assets: application.AssetOptions{ From a5cc8da05494782156d626c0cdce4009891ec61f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Thu, 7 May 2026 13:00:51 +0200 Subject: [PATCH 64/80] [client] Pre-seed CustomActivator CLSID under HKCU AppUserModelId\NetBird The Wails notifications service reads HKCU\Software\Classes\AppUserModelId\ \CustomActivator on first startup; if present it uses that GUID as the toast activator CLSID, otherwise it generates a fresh UUID and writes it back. Without an installer-supplied value the per-machine GUID diverges from the ToastActivatorCLSID baked into the Start Menu and Desktop shortcuts, and the COM activator never fires when a toast is clicked. Seed the same CLSID the shortcuts use so the two sides match. --- client/netbird.wxs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/netbird.wxs b/client/netbird.wxs index ea01040d1..4ca96cab8 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -59,6 +59,14 @@ + + From 39eac377e425dc7efd6872eeb50e0e494c0f25d8 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 7 May 2026 15:55:59 +0200 Subject: [PATCH 65/80] [management] add update reason to buffered calls (#6103) --- .../controllers/network_map/controller/controller.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 36de950e9..590773dda 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -221,9 +221,13 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin return nil } -func (c *Controller) bufferSendUpdateAccountPeers(ctx context.Context, accountID string) error { +func (c *Controller) bufferSendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error { log.WithContext(ctx).Tracef("buffer sending update peers for account %s from %s", accountID, util.GetCallerName()) + if c.accountManagerMetrics != nil { + c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation)) + } + bufUpd, _ := c.sendAccountUpdateLocks.LoadOrStore(accountID, &bufferUpdate{}) b := bufUpd.(*bufferUpdate) @@ -570,7 +574,7 @@ func isPeerInPolicySourceGroups(account *types.Account, peerID string, policy *t } func (c *Controller) OnPeersUpdated(ctx context.Context, accountID string, peerIDs []string) error { - err := c.bufferSendUpdateAccountPeers(ctx, accountID) + err := c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate}) if err != nil { log.WithContext(ctx).Errorf("failed to buffer update account peers for peer update in account %s: %v", accountID, err) } @@ -580,7 +584,7 @@ func (c *Controller) OnPeersUpdated(ctx context.Context, accountID string, peerI func (c *Controller) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error { log.WithContext(ctx).Debugf("OnPeersAdded call to add peers: %v", peerIDs) - return c.bufferSendUpdateAccountPeers(ctx, accountID) + return c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationCreate}) } func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error { @@ -616,7 +620,7 @@ func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerI c.peersUpdateManager.CloseChannel(ctx, peerID) } - return c.bufferSendUpdateAccountPeers(ctx, accountID) + return c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete}) } // GetNetworkMap returns Network map for a given peer (omits original peer from the Peers result) From 7da94a4956af76f7187733aa488e9d20a0f62202 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 7 May 2026 16:16:48 +0200 Subject: [PATCH 66/80] [misc] Update CONTRIBUTING.md (#6076) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efc7d9460..960cd30e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ There are many ways that you can contribute: - Sharing use cases in slack or Reddit - Bug fix or feature enhancement -If you haven't already, join our slack workspace [here](https://join.slack.com/t/netbirdio/shared_invite/zt-vrahf41g-ik1v7fV8du6t0RwxSrJ96A), we would love to discuss topics that need community contribution and enhancements to existing features. +If you haven't already, join our slack workspace [here](https://docs.netbird.io/slack-url), we would love to discuss topics that need community contribution and enhancements to existing features. ## Contents From e89aad09f5c2ae2205720ec42f665ad948af8f66 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Fri, 8 May 2026 16:31:20 +0200 Subject: [PATCH 67/80] [management] Enable MFA for local users (#5804) * wip: totp for local users * fix providers not getting populated * polished UI and fix post_login_redirect_uri * fix: make sure logout is only prompted from oidc flow Signed-off-by: jnfrati * update templates Signed-off-by: jnfrati * deps: update dex dependency Signed-off-by: jnfrati * fix qube issues Signed-off-by: jnfrati * replace window with globalThis on home html Signed-off-by: jnfrati * fixed coderabbit comments Signed-off-by: jnfrati * debug * remove unused config and rename totp issuer * deps: update dex reference to latest * add dashboard post logout redirect uri to embedded config * implemented api for mfa configuration * update docs and config parsing * catch error on idp manager init mfa * fix tests * Add remember me for MFA * Add cookie encryption and session share between tabs * fixed logout showing non actionable error and session cookie encription key * fixed missing mfa settings on sql query for account * fix code index for mfa activity --------- Signed-off-by: jnfrati Co-authored-by: braginini --- combined/cmd/config.go | 36 ++-- combined/config.yaml.example | 10 + go.mod | 44 +++-- go.sum | 97 +++++++--- idp/dex/config.go | 161 ++++++++++++++++- idp/dex/provider.go | 79 +++++++- idp/dex/provider_test.go | 26 +++ idp/dex/web/templates/home.html | 12 ++ idp/dex/web/templates/logout.html | 14 ++ idp/dex/web/templates/password.html | 2 + idp/dex/web/templates/totp_verify.html | 44 +++++ idp/dex/web/templates/webauthn_verify.html | 12 ++ management/internals/server/modules.go | 32 +++- management/server/account.go | 26 +++ management/server/activity/codes.go | 8 + .../handlers/accounts/accounts_handler.go | 4 + .../accounts/accounts_handler_test.go | 6 + management/server/idp/embedded.go | 171 +++++++++++++++++- management/server/idp/embedded_test.go | 67 +++++++ management/server/store/sql_store.go | 15 +- management/server/types/settings.go | 5 + shared/management/http/api/openapi.yml | 4 + shared/management/http/api/types.gen.go | 3 + 23 files changed, 791 insertions(+), 87 deletions(-) create mode 100644 idp/dex/web/templates/home.html create mode 100644 idp/dex/web/templates/logout.html create mode 100644 idp/dex/web/templates/totp_verify.html create mode 100644 idp/dex/web/templates/webauthn_verify.html diff --git a/combined/cmd/config.go b/combined/cmd/config.go index 9959f7a56..fe350e52a 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -133,13 +133,18 @@ type ManagementConfig struct { // AuthConfig contains authentication/identity provider settings type AuthConfig struct { - Issuer string `yaml:"issuer"` - LocalAuthDisabled bool `yaml:"localAuthDisabled"` - SignKeyRefreshEnabled bool `yaml:"signKeyRefreshEnabled"` - Storage AuthStorageConfig `yaml:"storage"` - DashboardRedirectURIs []string `yaml:"dashboardRedirectURIs"` - CLIRedirectURIs []string `yaml:"cliRedirectURIs"` - Owner *AuthOwnerConfig `yaml:"owner,omitempty"` + Issuer string `yaml:"issuer"` + LocalAuthDisabled bool `yaml:"localAuthDisabled"` + SignKeyRefreshEnabled bool `yaml:"signKeyRefreshEnabled"` + MfaSessionMaxLifetime string `yaml:"mfaSessionMaxLifetime"` + MfaSessionIdleTimeout string `yaml:"mfaSessionIdleTimeout"` + MfaSessionRememberMe bool `yaml:"mfaSessionRememberMe"` + SessionCookieEncryptionKey string `yaml:"sessionCookieEncryptionKey"` + Storage AuthStorageConfig `yaml:"storage"` + DashboardRedirectURIs []string `yaml:"dashboardRedirectURIs"` + CLIRedirectURIs []string `yaml:"cliRedirectURIs"` + Owner *AuthOwnerConfig `yaml:"owner,omitempty"` + DashboardPostLogoutRedirectURIs []string `yaml:"dashboardPostLogoutRedirectURIs"` } // AuthStorageConfig contains auth storage settings @@ -581,10 +586,14 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb } cfg := &idp.EmbeddedIdPConfig{ - Enabled: true, - Issuer: mgmt.Auth.Issuer, - LocalAuthDisabled: mgmt.Auth.LocalAuthDisabled, - SignKeyRefreshEnabled: mgmt.Auth.SignKeyRefreshEnabled, + Enabled: true, + Issuer: mgmt.Auth.Issuer, + LocalAuthDisabled: mgmt.Auth.LocalAuthDisabled, + SignKeyRefreshEnabled: mgmt.Auth.SignKeyRefreshEnabled, + MfaSessionMaxLifetime: mgmt.Auth.MfaSessionMaxLifetime, + MfaSessionIdleTimeout: mgmt.Auth.MfaSessionIdleTimeout, + MfaSessionRememberMe: mgmt.Auth.MfaSessionRememberMe, + SessionCookieEncryptionKey: mgmt.Auth.SessionCookieEncryptionKey, Storage: idp.EmbeddedStorageConfig{ Type: authStorageType, Config: idp.EmbeddedStorageTypeConfig{ @@ -592,8 +601,9 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb DSN: authStorageDSN, }, }, - DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs, - CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs, + DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs, + CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs, + DashboardPostLogoutRedirectURIs: mgmt.Auth.DashboardPostLogoutRedirectURIs, } if mgmt.Auth.Owner != nil && mgmt.Auth.Owner.Email != "" { diff --git a/combined/config.yaml.example b/combined/config.yaml.example index af85b0477..66bc71703 100644 --- a/combined/config.yaml.example +++ b/combined/config.yaml.example @@ -86,6 +86,13 @@ server: issuer: "https://example.com/oauth2" localAuthDisabled: false signKeyRefreshEnabled: false + # MFA session settings (applies when TOTP is enabled for an account) + # mfaSessionMaxLifetime: "24h" # Max duration for an MFA session from creation + # mfaSessionIdleTimeout: "1h" # MFA session expires after this idle period + # mfaSessionRememberMe: false # Pre-check "remember me" on login so the MFA session persists across tabs/restarts + # Optional AES key for encrypting embedded IdP session cookies. Can also be set via NB_IDP_SESSION_COOKIE_ENCRYPTION_KEY. + # Must be 16/24/32 raw bytes or base64-encoded to one of those lengths (for example: openssl rand -hex 16). + # sessionCookieEncryptionKey: "" # OAuth2 redirect URIs for dashboard dashboardRedirectURIs: - "https://app.example.com/nb-auth" @@ -93,6 +100,9 @@ server: # OAuth2 redirect URIs for CLI cliRedirectURIs: - "http://localhost:53000/" + # OAuth2 post-logout redirect URIs for dashboard (RP-initiated logout) + # dashboardPostLogoutRedirectURIs: + # - "https://app.example.com/" # Optional initial admin user # owner: # email: "admin@example.com" diff --git a/go.mod b/go.mod index bc4e8af15..84aeab941 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/onsi/gomega v1.27.6 github.com/rs/cors v1.8.0 github.com/sirupsen/logrus v1.9.4 - github.com/spf13/cobra v1.10.1 + github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 github.com/vishvananda/netlink v1.3.1 golang.org/x/crypto v0.50.0 @@ -41,11 +41,11 @@ require ( github.com/cilium/ebpf v0.15.0 github.com/coder/websocket v1.8.14 github.com/coreos/go-iptables v0.7.0 - github.com/coreos/go-oidc/v3 v3.14.1 + github.com/coreos/go-oidc/v3 v3.18.0 github.com/creack/pty v1.1.24 github.com/crowdsecurity/crowdsec v1.7.7 github.com/crowdsecurity/go-cs-bouncer v0.0.21 - github.com/dexidp/dex v0.0.0-00010101000000-000000000000 + github.com/dexidp/dex v2.13.0+incompatible github.com/dexidp/dex/api/v2 v2.4.0 github.com/ebitengine/purego v0.8.4 github.com/eko/gocache/lib/v4 v4.2.0 @@ -53,9 +53,9 @@ require ( github.com/eko/gocache/store/redis/v4 v4.2.2 github.com/fsnotify/fsnotify v1.9.0 github.com/gliderlabs/ssh v0.3.8 - github.com/go-jose/go-jose/v4 v4.1.3 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/godbus/dbus/v5 v5.1.0 - github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang/mock v1.6.0 github.com/google/go-cmp v0.7.0 github.com/google/gopacket v1.1.19 @@ -113,7 +113,7 @@ require ( go.opentelemetry.io/otel/exporters/prometheus v0.64.0 go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 - go.uber.org/mock v0.5.2 + go.uber.org/mock v0.6.0 go.uber.org/zap v1.27.0 goauthentik.io/api/v3 v3.2023051.3 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b @@ -141,7 +141,7 @@ require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/AppsFlyer/go-sundheit v0.6.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect - github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect @@ -168,6 +168,7 @@ require ( github.com/aws/smithy-go v1.23.0 // indirect github.com/beevik/etree v1.6.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -183,6 +184,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fredbi/uri v1.1.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/fyne-io/gl-js v0.2.0 // indirect github.com/fyne-io/glfw-js v0.3.0 // indirect github.com/fyne-io/image v0.1.1 // indirect @@ -190,7 +192,7 @@ require ( github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect - github.com/go-ldap/ldap/v3 v3.4.12 // indirect + github.com/go-ldap/ldap/v3 v3.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -206,11 +208,15 @@ require ( github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-text/render v0.2.0 // indirect github.com/go-text/typesetting v0.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/webauthn v0.16.4 // indirect + github.com/go-webauthn/x v0.2.3 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/btree v1.1.2 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.21.0 // indirect @@ -218,7 +224,13 @@ require ( github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/huin/goupnp v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -238,13 +250,13 @@ require ( github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/koron/go-ssdp v0.0.4 // indirect github.com/kr/fs v0.1.0 // indirect - github.com/lib/pq v1.10.9 // indirect + github.com/lib/pq v1.12.3 // indirect github.com/libdns/libdns v0.2.2 // indirect github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect - github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/mattn/go-sqlite3 v1.14.42 // indirect github.com/mdelapenya/tlscert v0.2.0 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect @@ -265,8 +277,10 @@ require ( github.com/nxadm/tail v1.4.11 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/onsi/ginkgo/v2 v2.9.5 // indirect + github.com/openbao/openbao/api/v2 v2.5.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pion/dtls/v2 v2.2.10 // indirect github.com/pion/dtls/v3 v3.0.9 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect @@ -275,11 +289,13 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/rymdport/portal v0.4.2 // indirect github.com/shirou/gopsutil/v4 v4.25.8 // indirect github.com/shoenig/go-m1cpu v0.2.1 // indirect @@ -288,11 +304,13 @@ require ( github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/tinylib/msgp v1.6.3 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/zeebo/blake3 v0.2.3 // indirect go.mongodb.org/mongo-driver v1.17.9 // indirect @@ -319,10 +337,12 @@ replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-202 replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260107100953-33b7c9d03db0 -replace github.com/cloudflare/circl => github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 +replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 replace github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 -replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.0 +replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260415145816-a0c6b40ff9f2 + +replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260415145816-a0c6b40ff9f2 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 diff --git a/go.sum b/go.sum index d54dc01e6..851d1ce66 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3R cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 h1:b8xUw3004wk+3ipBhu0VU4RtUJsegMIiqjxSK4++lzA= +codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6/go.mod h1:+CauBF6R70Jqcyl8N2hC8pAXYbWkGIezuSbuGLtRhnw= cunicu.li/go-rosenpass v0.4.0 h1:LtPtBgFWY/9emfgC4glKLEqS0MJTylzV6+ChRhiZERw= cunicu.li/go-rosenpass v0.4.0/go.mod h1:MPbjH9nxV4l3vEagKVdFNwHOketqgS5/To1VYJplf/M= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= @@ -23,8 +25,8 @@ github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl github.com/AppsFlyer/go-sundheit v0.6.0/go.mod h1:LDdBHD6tQBtmHsdW+i1GwdTt6Wqc0qazf5ZEJVTbTME= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= -github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= +github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -91,6 +93,8 @@ github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sL github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -117,8 +121,8 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/coreos/go-iptables v0.7.0 h1:XWM3V+MPRr5/q51NuWSgU0fqMad64Zyxs8ZUoMsamr8= github.com/coreos/go-iptables v0.7.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= -github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= -github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -130,14 +134,10 @@ github.com/crowdsecurity/go-cs-bouncer v0.0.21 h1:arPz0VtdVSaz+auOSfHythzkZVLyy1 github.com/crowdsecurity/go-cs-bouncer v0.0.21/go.mod h1:4JiH0XXA4KKnnWThItUpe5+heJHWzsLOSA2IWJqUDBA= github.com/crowdsecurity/go-cs-lib v0.0.25 h1:Ov6VPW9yV+OPsbAIQk1iTkEWhwkpaG0v3lrBzeqjzj4= github.com/crowdsecurity/go-cs-lib v0.0.25/go.mod h1:X0GMJY2CxdA1S09SpuqIKaWQsvRGxXmecUp9cP599dE= -github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 h1:/DS5cDX3FJdl+XaN2D7XAwFpuanTxnp52DBLZAaJKx0= -github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6/go.mod h1:+CauBF6R70Jqcyl8N2hC8pAXYbWkGIezuSbuGLtRhnw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dexidp/dex/api/v2 v2.4.0 h1:gNba7n6BKVp8X4Jp24cxYn5rIIGhM6kDOXcZoL6tr9A= -github.com/dexidp/dex/api/v2 v2.4.0/go.mod h1:/p550ADvFFh7K95VmhUD+jgm15VdaNnab9td8DHOpyI= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -156,6 +156,8 @@ github.com/eko/gocache/store/go_cache/v4 v4.2.2 h1:tAI9nl6TLoJyKG1ujF0CS0n/IgTEM github.com/eko/gocache/store/go_cache/v4 v4.2.2/go.mod h1:T9zkHokzr8K9EiC7RfMbDg6HSwaV6rv3UdcNu13SGcA= github.com/eko/gocache/store/redis/v4 v4.2.2 h1:Thw31fzGuH3WzJywsdbMivOmP550D6JS7GDHhvCJPA0= github.com/eko/gocache/store/redis/v4 v4.2.2/go.mod h1:LaTxLKx9TG/YUEybQvPMij++D7PBTIJ4+pzvk0ykz0w= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -171,6 +173,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= @@ -189,10 +193,10 @@ github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= -github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= +github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -229,12 +233,20 @@ github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI6 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q= +github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4= +github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA= +github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= @@ -243,8 +255,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -276,6 +288,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= @@ -308,15 +324,29 @@ github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xN github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= @@ -387,8 +417,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s= github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/libdns/route53 v1.5.0 h1:2SKdpPFl/qgWsXQvsLNJJAoX7rSxlk7zgoL4jnWdXVA= @@ -406,9 +436,13 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= @@ -451,8 +485,10 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/netbirdio/dex v0.244.0 h1:1GOvi8wnXYassnKGildzNqRHq0RbcfEUw7LKYpKIN7U= -github.com/netbirdio/dex v0.244.0/go.mod h1:STGInJhPcAflrHmDO7vyit2kSq03PdL+8zQPoGALtcU= +github.com/netbirdio/dex v0.244.1-0.20260415145816-a0c6b40ff9f2 h1:AP7OM/JnTogod3rVcLsMuilSG94kWQCr3z6R4rfVXnc= +github.com/netbirdio/dex v0.244.1-0.20260415145816-a0c6b40ff9f2/go.mod h1:+trSlzHNmdJGvz0oLEyyiuaPstUeD7YO6B3Fx9nyziY= +github.com/netbirdio/dex/api/v2 v2.0.0-20260415145816-a0c6b40ff9f2 h1:HEEGJPsVw7/p7SEL3HWP4vaInxHo8OJSEaOkHpUAk+M= +github.com/netbirdio/dex/api/v2 v2.0.0-20260415145816-a0c6b40ff9f2/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M= github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus= github.com/netbirdio/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= @@ -489,6 +525,8 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/openbao/openbao/api/v2 v2.5.1 h1:Br79D6L20SbAa5P7xqENxmvv8LyI4HoKosPy7klhn4o= +github.com/openbao/openbao/api/v2 v2.5.1/go.mod h1:Dh5un77tqGgMbmlVEqjqN+8/dMyUohnkaQVg/wXW0Ig= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -501,6 +539,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs= github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/dtls/v2 v2.2.10 h1:u2Axk+FyIR1VFTPurktB+1zoEPGIW3bmyj3LEFrXjAA= github.com/pion/dtls/v2 v2.2.10/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= @@ -542,6 +582,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -565,6 +607,8 @@ github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU= @@ -587,8 +631,8 @@ github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= @@ -628,6 +672,8 @@ github.com/ti-mo/conntrack v0.5.1 h1:opEwkFICnDbQc0BUXl73PHBK0h23jEIFVjXsqvF4GY0 github.com/ti-mo/conntrack v0.5.1/go.mod h1:T6NCbkMdVU4qEIgwL0njA6lw/iCAbzchlnwm1Sa314o= github.com/ti-mo/netfilter v0.5.2 h1:CTjOwFuNNeZ9QPdRXt1MZFLFUf84cKtiQutNauHWd40= github.com/ti-mo/netfilter v0.5.2/go.mod h1:Btx3AtFiOVdHReTDmP9AE+hlkOcvIy403u7BXXbWZKo= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= @@ -646,6 +692,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= @@ -690,14 +738,15 @@ go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lI go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= goauthentik.io/api/v3 v3.2023051.3 h1:NebAhD/TeTWNo/9X3/Uj+rM5fG1HaiLOlKTNLQv9Qq4= goauthentik.io/api/v3 v3.2023051.3/go.mod h1:nYECml4jGbp/541hj8GcylKQG1gVBsKppHy4+7G8u4U= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/idp/dex/config.go b/idp/dex/config.go index e686233ad..56ed998c2 100644 --- a/idp/dex/config.go +++ b/idp/dex/config.go @@ -51,6 +51,70 @@ type YAMLConfig struct { // StaticPasswords cause the server use this list of passwords rather than // querying the storage. StaticPasswords []Password `yaml:"staticPasswords" json:"staticPasswords"` + + // Sessions holds authentication session configuration. + // Requires DEX_SESSIONS_ENABLED=true feature flag. + Sessions *Sessions `yaml:"sessions" json:"sessions"` + + // MFA holds multi-factor authentication configuration. + MFA MFAConfig `yaml:"mfa" json:"mfa"` +} + +type Sessions struct { + // CookieName is the name of the session cookie. Defaults to "dex_session". + CookieName string `yaml:"cookieName" json:"cookieName"` + // AbsoluteLifetime is the maximum session lifetime from creation. Defaults to "24h". + AbsoluteLifetime string `yaml:"absoluteLifetime" json:"absoluteLifetime"` + // ValidIfNotUsedFor is the idle timeout. Defaults to "1h". + ValidIfNotUsedFor string `yaml:"validIfNotUsedFor" json:"validIfNotUsedFor"` + // RememberMeCheckedByDefault controls the default state of the "remember me" checkbox. + RememberMeCheckedByDefault *bool `yaml:"rememberMeCheckedByDefault" json:"rememberMeCheckedByDefault"` + // CookieEncryptionKey is the AES key for encrypting session cookies. + // Must be 16, 24, or 32 bytes for AES-128, AES-192, or AES-256. + // If empty, cookies are not encrypted. + CookieEncryptionKey string `yaml:"cookieEncryptionKey" json:"cookieEncryptionKey"` + // SSOSharedWithDefault is the default SSO sharing policy for clients without explicit ssoSharedWith. + // "all" = share with all clients, "none" = share with no one (default: "none"). + SSOSharedWithDefault string `yaml:"ssoSharedWithDefault" json:"ssoSharedWithDefault"` +} + +type MFAConfig struct { + Authenticators []MFAAuthenticator `yaml:"authenticators" json:"authenticators"` +} + +type MFAAuthenticator struct { + ID string `yaml:"id" json:"id"` + Type string `yaml:"type" json:"type"` + Config map[string]interface{} `yaml:"config" json:"config"` + + ConnectorTypes []string `yaml:"connectorTypes" json:"connectorTypes"` +} + +type TOTPConfig struct { + Issuer string `yaml:"issuer" json:"issuer"` +} + +// WebAuthnConfig holds configuration for a WebAuthn authenticator. +type WebAuthnConfig struct { + // RPDisplayName is the human-readable relying party name shown in the browser + // dialog during key registration and authentication (e.g., "My Company SSO"). + RPDisplayName string `yaml:"rpDisplayName" json:"rpDisplayName"` + // RPID is the relying party identifier — must match the domain in the browser + // address bar. If empty, derived from the issuer URL hostname. + // Example: "auth.example.com" + RPID string `yaml:"rpID" json:"rpID"` + // RPOrigins is the list of allowed origins for WebAuthn ceremonies. + // If empty, derived from the issuer URL (scheme + host). + // Example: ["https://auth.example.com"] + RPOrigins []string `yaml:"rpOrigins" json:"rpOrigins"` + // AttestationPreference controls what attestation data the authenticator should provide: + // "none" — don't request attestation (simpler, more private) + // "indirect" — authenticator may anonymize attestation (default) + // "direct" — request full attestation (for enterprise key model verification) + AttestationPreference string `yaml:"attestationPreference" json:"attestationPreference"` + // Timeout is the duration allowed for the browser WebAuthn ceremony + // (registration or login). Defaults to "60s". + Timeout string `yaml:"timeout" json:"timeout"` } // Web is the config format for the HTTP server. @@ -116,7 +180,6 @@ type Storage struct { Config map[string]interface{} `yaml:"config" json:"config"` } -// Password represents a static user configuration type Password storage.Password func (p *Password) UnmarshalYAML(node *yaml.Node) error { @@ -429,9 +492,98 @@ func (c *YAMLConfig) Validate() error { if !c.EnablePasswordDB && len(c.StaticPasswords) != 0 { return fmt.Errorf("cannot specify static passwords without enabling password db") } + return nil } +func buildTotpConfig(auth MFAAuthenticator) (*server.TOTPProvider, error) { + data, err := json.Marshal(auth.Config) + if err != nil { + return nil, fmt.Errorf("failed to marshal TOTP config id: %s - %w", auth.ID, err) + } + + var cfg TOTPConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse TOTP config id: %s - %w", auth.ID, err) + } + + return server.NewTOTPProvider(cfg.Issuer, auth.ConnectorTypes), nil +} + +func buildWebAuthnConfig(auth MFAAuthenticator, issuerURL string) (*server.WebAuthnProvider, error) { + data, err := json.Marshal(auth.Config) + if err != nil { + return nil, fmt.Errorf("failed to marshal WebAuthn config id: %s - %w", auth.ID, err) + } + + var cfg WebAuthnConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse WebAuthn config id: %s - %w", auth.ID, err) + } + + provider, err := server.NewWebAuthnProvider(cfg.RPDisplayName, cfg.RPID, cfg.RPOrigins, + cfg.AttestationPreference, cfg.Timeout, issuerURL, auth.ConnectorTypes) + if err != nil { + return nil, fmt.Errorf("failed to create WebAuthn provider id: %s - err: %w", auth.ID, err) + } + + return provider, nil +} + +func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider { + if len(authenticators) == 0 { + return nil + } + + providers := make(map[string]server.MFAProvider, len(authenticators)) + for _, auth := range authenticators { + switch auth.Type { + case "TOTP": + provider, err := buildTotpConfig(auth) + if err != nil { + logger.Error("failed to parse TOTP config", "id", auth.ID, "err", err) + continue + } + providers[auth.ID] = provider + logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type) + case "WebAuthn": + provider, err := buildWebAuthnConfig(auth, issuerURL) + if err != nil { + logger.Error("failed to parse WebAuthn config", "id", auth.ID, "err", err) + continue + } + providers[auth.ID] = provider + logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type) + default: + logger.Error("unknown MFA authenticator type, skipping", "id", auth.ID, "type", auth.Type) + } + } + return providers +} + +func buildSessionsConfig(sessions *Sessions) *server.SessionConfig { + if sessions == nil { + return nil + } + + if sessions.RememberMeCheckedByDefault == nil { + defaultRememberMeCheckedByDefault := false + sessions.RememberMeCheckedByDefault = &defaultRememberMeCheckedByDefault + } + + absoluteLifetime, _ := parseDuration(sessions.AbsoluteLifetime) + validIfNotUsedFor, _ := parseDuration(sessions.ValidIfNotUsedFor) + + return &server.SessionConfig{ + CookieEncryptionKey: []byte(sessions.CookieEncryptionKey), + CookieName: sessions.CookieName, + AbsoluteLifetime: absoluteLifetime, + ValidIfNotUsedFor: validIfNotUsedFor, + RememberMeCheckedByDefault: *sessions.RememberMeCheckedByDefault, + SSOSharedWithDefault: sessions.SSOSharedWithDefault, + } +} + // ToServerConfig converts YAMLConfig to dex server.Config func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) server.Config { cfg := server.Config{ @@ -448,6 +600,8 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s Dir: c.Frontend.Dir, Extra: c.Frontend.Extra, }, + SessionConfig: buildSessionsConfig(c.Sessions), + MFAProviders: buildMFAProviders(c.MFA.Authenticators, c.Issuer, logger), } // Use embedded NetBird-styled templates if no custom dir specified @@ -460,11 +614,6 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s } // Apply expiry settings - if c.Expiry.SigningKeys != "" { - if d, err := parseDuration(c.Expiry.SigningKeys); err == nil { - cfg.RotateKeysAfter = d - } - } if c.Expiry.IDTokens != "" { if d, err := parseDuration(c.Expiry.IDTokens); err == nil { cfg.IDTokensValidFor = d diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 24aed1b99..526d6a17a 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -18,6 +18,7 @@ import ( dexapi "github.com/dexidp/dex/api/v2" "github.com/dexidp/dex/server" + "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" "github.com/dexidp/dex/storage/sql" jose "github.com/go-jose/go-jose/v4" @@ -70,7 +71,7 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) { logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) // Ensure data directory exists - if err := os.MkdirAll(config.DataDir, 0700); err != nil { + if err := os.MkdirAll(config.DataDir, 0o700); err != nil { return nil, fmt.Errorf("failed to create data directory: %w", err) } @@ -101,6 +102,15 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) { return nil, fmt.Errorf("failed to create refresh token policy: %w", err) } + localSignerConfig := signer.LocalConfig{ + KeysRotationPeriod: "6h", + } + + localSigner, err := localSignerConfig.Open(ctx, stor, 24*time.Hour, time.Now, logger) + if err != nil { + return nil, fmt.Errorf("failed to create local signer: %w", err) + } + // Build Dex server config - use Dex's types directly dexConfig := server.Config{ Issuer: issuer, @@ -110,12 +120,12 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) { ContinueOnConnectorFailure: true, Logger: logger, PrometheusRegistry: prometheus.NewRegistry(), - RotateKeysAfter: 6 * time.Hour, IDTokensValidFor: 24 * time.Hour, RefreshTokenPolicy: refreshPolicy, Web: server.WebConfig{ Issuer: "NetBird", }, + Signer: localSigner, } dexSrv, err := server.NewServer(ctx, dexConfig) @@ -167,6 +177,14 @@ func NewProviderFromYAML(ctx context.Context, yamlConfig *YAMLConfig) (*Provider return nil, fmt.Errorf("failed to create refresh token policy: %w", err) } + localSigner, err := getSigner(ctx, stor, yamlConfig, logger) + if err != nil { + stor.Close() + return nil, fmt.Errorf("failed to create local signer: %w", err) + } + + dexConfig.Signer = localSigner + dexSrv, err := server.NewServer(ctx, dexConfig) if err != nil { stor.Close() @@ -182,6 +200,32 @@ func NewProviderFromYAML(ctx context.Context, yamlConfig *YAMLConfig) (*Provider }, nil } +func getSigner(ctx context.Context, stor storage.Storage, yamlConfig *YAMLConfig, logger *slog.Logger) (signer.Signer, error) { + // Parse expiry durations + idTokensValidFor := 24 * time.Hour // default + if yamlConfig.Expiry.IDTokens != "" { + var err error + idTokensValidFor, err = parseDuration(yamlConfig.Expiry.IDTokens) + if err != nil { + return nil, fmt.Errorf("invalid config value %q for id token expiry: %v", yamlConfig.Expiry.IDTokens, err) + } + } + + localSignerConfig := &signer.LocalConfig{ + KeysRotationPeriod: "720h", // 30 Days + } + + if yamlConfig.Expiry.SigningKeys != "" { + if _, err := parseDuration(yamlConfig.Expiry.SigningKeys); err != nil { + return nil, fmt.Errorf("invalid config value %q for signing key expiry: %v", yamlConfig.Expiry.SigningKeys, err) + } + + localSignerConfig.KeysRotationPeriod = yamlConfig.Expiry.SigningKeys + } + + return localSignerConfig.Open(ctx, stor, idTokensValidFor, time.Now, logger) +} + // initializeStorage sets up connectors, passwords, and clients in storage func initializeStorage(ctx context.Context, stor storage.Storage, cfg *YAMLConfig) error { if cfg.EnablePasswordDB { @@ -241,6 +285,8 @@ func ensureStaticClients(ctx context.Context, stor storage.Storage, clients []st old.RedirectURIs = client.RedirectURIs old.Name = client.Name old.Public = client.Public + old.PostLogoutRedirectURIs = client.PostLogoutRedirectURIs + old.MFAChain = client.MFAChain return old, nil }); err != nil { return fmt.Errorf("failed to update client %s: %w", client.ID, err) @@ -253,9 +299,6 @@ func ensureStaticClients(ctx context.Context, stor storage.Storage, clients []st func buildDexConfig(yamlConfig *YAMLConfig, stor storage.Storage, logger *slog.Logger) server.Config { cfg := yamlConfig.ToServerConfig(stor, logger) cfg.PrometheusRegistry = prometheus.NewRegistry() - if cfg.RotateKeysAfter == 0 { - cfg.RotateKeysAfter = 24 * 30 * time.Hour - } if cfg.IDTokensValidFor == 0 { cfg.IDTokensValidFor = 24 * time.Hour } @@ -450,10 +493,34 @@ func (p *Provider) Storage() storage.Storage { return p.storage } +// SetClientsMFAChain updates the MFAChain field on the dashboard and CLI OAuth2 clients. +// Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it. +func (p *Provider) SetClientsMFAChain(ctx context.Context, clientIDs []string, mfaChain []string) error { + for _, clientID := range clientIDs { + if err := p.storage.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { + old.MFAChain = mfaChain + return old, nil + }); err != nil { + return fmt.Errorf("failed to update MFA chain on client %s: %w", clientID, err) + } + } + return nil +} + // Handler returns the Dex server as an http.Handler for embedding in another server. // The handler expects requests with path prefix "/oauth2/". func (p *Provider) Handler() http.Handler { - return p.dexServer + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Dex's /logout endpoint requires id_token_hint for RP-initiated logout with + // post_logout_redirect_uri. If the dashboard calls logout without one, avoid + // rendering Dex's non-actionable Bad Request page and send the user home. + if strings.HasSuffix(r.URL.Path, "/logout") && r.FormValue("id_token_hint") == "" { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + p.dexServer.ServeHTTP(w, r) + }) } // CreateUser creates a new user with the given email, username, and password. diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go index 4ed89fd2e..88828fbbb 100644 --- a/idp/dex/provider_test.go +++ b/idp/dex/provider_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "log/slog" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -144,6 +146,30 @@ func TestEncodeDexUserID_MatchesDexFormat(t *testing.T) { assert.Equal(t, knownEncodedID, reEncoded) } +func TestHandlerRedirectsLogoutWithoutIDTokenHint(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "dex-logout-handler-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + provider, err := NewProvider(ctx, &Config{ + Issuer: "http://localhost:5556/oauth2", + Port: 5556, + DataDir: tmpDir, + }) + require.NoError(t, err) + defer func() { _ = provider.Stop(ctx) }() + + req := httptest.NewRequest(http.MethodGet, "/oauth2/logout?post_logout_redirect_uri=https://example.com", nil) + rec := httptest.NewRecorder() + + provider.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusSeeOther, rec.Code) + require.Equal(t, "/", rec.Header().Get("Location")) +} + func TestCreateUserInTempDB(t *testing.T) { ctx := context.Background() diff --git a/idp/dex/web/templates/home.html b/idp/dex/web/templates/home.html new file mode 100644 index 000000000..be7c938ae --- /dev/null +++ b/idp/dex/web/templates/home.html @@ -0,0 +1,12 @@ +{{ template "header.html" . }} + + + + +{{ template "footer.html" . }} diff --git a/idp/dex/web/templates/logout.html b/idp/dex/web/templates/logout.html new file mode 100644 index 000000000..b623d35af --- /dev/null +++ b/idp/dex/web/templates/logout.html @@ -0,0 +1,14 @@ +{{ template "header.html" . }} + +
+

Logged Out

+

You have been successfully logged out.

+ + {{ if .BackURL }} + + {{ end }} +
+ +{{ template "footer.html" . }} diff --git a/idp/dex/web/templates/password.html b/idp/dex/web/templates/password.html index 1d1b8282e..e1bfa7258 100755 --- a/idp/dex/web/templates/password.html +++ b/idp/dex/web/templates/password.html @@ -18,6 +18,7 @@ id="login" name="login" class="nb-input" + autocomplete="username" placeholder="Enter your {{ .UsernamePrompt | lower }}" {{ if .Username }}value="{{ .Username }}"{{ else }}autofocus{{ end }} required @@ -31,6 +32,7 @@ id="password" name="password" class="nb-input" + autocomplete="current-password" placeholder="Enter your password" {{ if .Invalid }}autofocus{{ end }} required diff --git a/idp/dex/web/templates/totp_verify.html b/idp/dex/web/templates/totp_verify.html new file mode 100644 index 000000000..8286418f0 --- /dev/null +++ b/idp/dex/web/templates/totp_verify.html @@ -0,0 +1,44 @@ +{{ template "header.html" . }} + +
+

Two-factor authentication

+ {{ if not (eq .QRCode "") }} +

Scan the QR code below using your authenticator app, then enter the code.

+
+ QR code +
+ {{ else }} +

Enter the code from your authenticator app.

+ {{ end }} + +
+ {{ if .Invalid }} +
+ Invalid code. Please try again. +
+ {{ end }} + +
+ + +
+ + +
+
+ +{{ template "footer.html" . }} diff --git a/idp/dex/web/templates/webauthn_verify.html b/idp/dex/web/templates/webauthn_verify.html new file mode 100644 index 000000000..be7c938ae --- /dev/null +++ b/idp/dex/web/templates/webauthn_verify.html @@ -0,0 +1,12 @@ +{{ template "header.html" . }} + + + + +{{ template "footer.html" . }} diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index 9b2ec2989..ea94245d5 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -26,6 +26,7 @@ import ( "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" "github.com/netbirdio/netbird/management/server/networks/routers" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -113,30 +114,47 @@ func (s *BaseServer) AccountManager() account.Manager { }) } +func isMFAEnabledForAccount(accounts []*types.Account) bool { + if len(accounts) != 1 { + return false + } + + settings := accounts[0].Settings + return settings != nil && settings.LocalMfaEnabled +} + func (s *BaseServer) IdpManager() idp.Manager { return Create(s, func() idp.Manager { - var idpManager idp.Manager - var err error - // Use embedded IdP service if embedded Dex is configured and enabled. // Legacy IdpManager won't be used anymore even if configured. embeddedEnabled := s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled if embeddedEnabled { - idpManager, err = idp.NewEmbeddedIdPManager(context.Background(), s.Config.EmbeddedIdP, s.Metrics()) + embeddedMgr, err := idp.NewEmbeddedIdPManager(context.Background(), s.Config.EmbeddedIdP, s.Metrics()) if err != nil { log.Fatalf("failed to create embedded IDP service: %v", err) } - return idpManager + + if val := isMFAEnabledForAccount(s.Store().GetAllAccounts(context.Background())); val { + if err := embeddedMgr.SetMFAEnabled(context.Background(), val); err != nil { + log.Errorf("failed to set MFA enabled on embedded IDP: %v", err) + } + } + + return embeddedMgr } // Fall back to external IdP service if s.Config.IdpManagerConfig != nil { - idpManager, err = idp.NewManager(context.Background(), *s.Config.IdpManagerConfig, s.Metrics()) + idpManager, err := idp.NewManager(context.Background(), *s.Config.IdpManagerConfig, s.Metrics()) if err != nil { log.Fatalf("failed to create IDP service: %v", err) } + + return idpManager } - return idpManager + + + return nil }) } diff --git a/management/server/account.go b/management/server/account.go index 45b99839f..364c0c37b 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -386,6 +386,9 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco if err = am.handleInactivityExpirationSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil { return nil, err } + if err = am.handleLocalMfaSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil { + return nil, err + } if oldSettings.DNSDomain != newSettings.DNSDomain { eventMeta := map[string]any{ "old_dns_domain": oldSettings.DNSDomain, @@ -602,6 +605,29 @@ func (am *DefaultAccountManager) handleInactivityExpirationSettings(ctx context. return nil } +func (am *DefaultAccountManager) handleLocalMfaSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) error { + if oldSettings.LocalMfaEnabled == newSettings.LocalMfaEnabled { + return nil + } + + embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager) + if !ok { + return nil + } + + if err := embeddedIdp.SetMFAEnabled(ctx, newSettings.LocalMfaEnabled); err != nil { + return fmt.Errorf("failed to toggle MFA: %w", err) + } + + if newSettings.LocalMfaEnabled { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountLocalMfaEnabled, nil) + } else { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountLocalMfaDisabled, nil) + } + + return nil +} + func (am *DefaultAccountManager) peerLoginExpirationJob(ctx context.Context, accountID string) func() (time.Duration, bool) { return func() (time.Duration, bool) { //nolint diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index 2388115ff..6c781a952 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -236,6 +236,11 @@ const ( // AccountIPv6Disabled indicates that a user disabled IPv6 overlay for the account AccountIPv6Disabled Activity = 122 + // AccountLocalMfaEnabled indicates that a user enabled TOTP MFA for local users + AccountLocalMfaEnabled Activity = 123 + // AccountLocalMfaDisabled indicates that a user disabled TOTP MFA for local users + AccountLocalMfaDisabled Activity = 124 + AccountDeleted Activity = 99999 ) @@ -386,6 +391,9 @@ var activityMap = map[Activity]Code{ AccountPeerExposeEnabled: {"Account peer expose enabled", "account.setting.peer.expose.enable"}, AccountPeerExposeDisabled: {"Account peer expose disabled", "account.setting.peer.expose.disable"}, + AccountLocalMfaEnabled: {"Account local MFA enabled", "account.setting.local.mfa.enable"}, + AccountLocalMfaDisabled: {"Account local MFA disabled", "account.setting.local.mfa.disable"}, + DomainAdded: {"Domain added", "domain.add"}, DomainDeleted: {"Domain deleted", "domain.delete"}, DomainValidated: {"Domain validated", "domain.validate"}, diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index 31820b9fb..209d593bd 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -277,6 +277,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS if req.Settings.AutoUpdateAlways != nil { returnSettings.AutoUpdateAlways = *req.Settings.AutoUpdateAlways } + if req.Settings.LocalMfaEnabled != nil { + returnSettings.LocalMfaEnabled = *req.Settings.LocalMfaEnabled + } if req.Settings.Ipv6EnabledGroups != nil { returnSettings.IPv6EnabledGroups = *req.Settings.Ipv6EnabledGroups } @@ -412,6 +415,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A Ipv6EnabledGroups: &settings.IPv6EnabledGroups, EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled, LocalAuthDisabled: &settings.LocalAuthDisabled, + LocalMfaEnabled: &settings.LocalMfaEnabled, } if settings.NetworkRange.IsValid() { diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index fc1517a30..8db76719c 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -131,6 +131,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateVersion: sr(""), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: true, expectedID: accountID, @@ -157,6 +158,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateVersion: sr(""), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -183,6 +185,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateVersion: sr("latest"), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -209,6 +212,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateVersion: sr(""), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -235,6 +239,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateVersion: sr(""), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -261,6 +266,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateVersion: sr(""), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: false, expectedID: accountID, diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go index 48d3221cc..a1852a8bc 100644 --- a/management/server/idp/embedded.go +++ b/management/server/idp/embedded.go @@ -2,9 +2,11 @@ package idp import ( "context" + "encoding/base64" "errors" "fmt" "net/http" + "os" "strings" "github.com/dexidp/dex/storage" @@ -17,12 +19,13 @@ import ( ) const ( - staticClientDashboard = "netbird-dashboard" - staticClientCLI = "netbird-cli" - defaultCLIRedirectURL1 = "http://localhost:53000/" - defaultCLIRedirectURL2 = "http://localhost:54000/" - defaultScopes = "openid profile email groups" - defaultUserIDClaim = "sub" + staticClientDashboard = "netbird-dashboard" + staticClientCLI = "netbird-cli" + defaultCLIRedirectURL1 = "http://localhost:53000/" + defaultCLIRedirectURL2 = "http://localhost:54000/" + defaultScopes = "openid profile email groups" + defaultUserIDClaim = "sub" + sessionCookieEncryptionKeyEnv = "NB_IDP_SESSION_COOKIE_ENCRYPTION_KEY" ) // EmbeddedIdPConfig contains configuration for the embedded Dex OIDC identity provider @@ -49,6 +52,26 @@ type EmbeddedIdPConfig struct { // Existing local users are preserved and will be able to login again if re-enabled. // Cannot be enabled if no external identity provider connectors are configured. LocalAuthDisabled bool + // MfaSessionMaxLifetime is the maximum MFA session duration from creation (e.g. "24h"). + // Defaults to "24h" if empty. + MfaSessionMaxLifetime string + // MfaSessionIdleTimeout is the idle timeout after which the MFA session expires (e.g. "1h"). + // Defaults to "1h" if empty. + MfaSessionIdleTimeout string + // MfaSessionRememberMe controls the default state of the "remember me" checkbox on the + // login screen. When true, the session cookie persists across browser tabs/restarts so + // MFA is not re-prompted until the session expires. Defaults to false. + MfaSessionRememberMe bool + // SessionCookieEncryptionKey is the optional AES key used to encrypt embedded IdP session cookies. + // It can also be set with NB_IDP_SESSION_COOKIE_ENCRYPTION_KEY. The value must be 16, 24, or 32 + // bytes when provided as a raw string, or base64-encoded to one of those lengths. + SessionCookieEncryptionKey string + // Dashboard Post logout redirect URIs, these are required to tell + // Dex what to allow when an RP-Initiated logout is started by the frontend + // at least one of these must match the dashboard base URL or the dashboard + // DASHBOARD_POST_LOGOUT_URL environment variable + // WARNING: Dex only uses exact match, not wildcards + DashboardPostLogoutRedirectURIs []string // StaticConnectors are additional connectors to seed during initialization StaticConnectors []dex.Connector } @@ -126,6 +149,11 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { // todo: resolve import cycle dashboardRedirectURIs = append(dashboardRedirectURIs, baseURL+"/api/reverse-proxy/callback") + dashboardPostLogoutRedirectURIs := c.DashboardPostLogoutRedirectURIs + // It is safe to assume that most installations will share the location of the + // MGMT api and the dashboard, adding baseURL means less configuration for the instance admin + dashboardPostLogoutRedirectURIs = append(dashboardPostLogoutRedirectURIs, baseURL) + cfg := &dex.YAMLConfig{ Issuer: c.Issuer, Storage: dex.Storage{ @@ -148,10 +176,11 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { EnablePasswordDB: true, StaticClients: []storage.Client{ { - ID: staticClientDashboard, - Name: "NetBird Dashboard", - Public: true, - RedirectURIs: dashboardRedirectURIs, + ID: staticClientDashboard, + Name: "NetBird Dashboard", + Public: true, + RedirectURIs: dashboardRedirectURIs, + PostLogoutRedirectURIs: sanitizePostLogoutRedirectURIs(dashboardPostLogoutRedirectURIs), }, { ID: staticClientCLI, @@ -163,6 +192,12 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { StaticConnectors: c.StaticConnectors, } + // Always initialize MFA providers and sessions so TOTP can be toggled at runtime. + // MFAChain on clients is NOT set here — it's synced from the DB setting on startup. + if err := configureMFA(cfg, c.MfaSessionMaxLifetime, c.MfaSessionIdleTimeout, c.MfaSessionRememberMe, c.SessionCookieEncryptionKey); err != nil { + return nil, err + } + // Add owner user if provided if c.Owner != nil && c.Owner.Email != "" && c.Owner.Hash != "" { username := c.Owner.Username @@ -182,6 +217,100 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { return cfg, nil } +// Due to how the frontend generates the logout, sometimes it appends a trailing slash +// and because Dex only allows exact matches, we need to make sure we always have both +// versions of each provided uri +func sanitizePostLogoutRedirectURIs(uris []string) []string { + result := make([]string, 0) + for _, uri := range uris { + if strings.HasSuffix(uri, "/") { + result = append(result, uri) + result = append(result, strings.TrimSuffix(uri, "/")) + } else { + result = append(result, uri) + result = append(result, uri+"/") + } + } + + return result +} + +func configureMFA(cfg *dex.YAMLConfig, sessionMaxLifetime, sessionIdleTimeout string, rememberMe bool, sessionCookieEncryptionKey string) error { + cfg.MFA.Authenticators = []dex.MFAAuthenticator{{ + ID: "default-totp", + // Has to be caps otherwise it will fail + Type: "TOTP", + Config: map[string]interface{}{ + "issuer": "NetBird", + }, + ConnectorTypes: []string{"local"}, + }} + + if sessionMaxLifetime == "" { + sessionMaxLifetime = "24h" + } + if sessionIdleTimeout == "" { + sessionIdleTimeout = "1h" + } + + cookieEncryptionKey, err := resolveSessionCookieEncryptionKey(sessionCookieEncryptionKey) + if err != nil { + return err + } + + cfg.Sessions = &dex.Sessions{ + CookieName: "netbird-session", + AbsoluteLifetime: sessionMaxLifetime, + ValidIfNotUsedFor: sessionIdleTimeout, + RememberMeCheckedByDefault: &rememberMe, + SSOSharedWithDefault: "all", + CookieEncryptionKey: cookieEncryptionKey, + } + // Absolutely required, otherwise the dex server will omit the MFA configuration entirely + os.Setenv("DEX_SESSIONS_ENABLED", "true") + + // Note: MFAChain on clients is NOT set here. + // It is toggled at runtime via SetMFAEnabled() based on the account settings DB value. + return nil +} + +func resolveSessionCookieEncryptionKey(configuredKey string) (string, error) { + key := strings.TrimSpace(configuredKey) + if key == "" { + key = strings.TrimSpace(os.Getenv(sessionCookieEncryptionKeyEnv)) + } + if key == "" { + return "", nil + } + + if validSessionCookieEncryptionKeyLength(len([]byte(key))) { + return key, nil + } + + for _, encoding := range []*base64.Encoding{ + base64.StdEncoding, + base64.RawStdEncoding, + base64.URLEncoding, + base64.RawURLEncoding, + } { + decoded, err := encoding.DecodeString(key) + if err == nil && validSessionCookieEncryptionKeyLength(len(decoded)) { + return string(decoded), nil + } + } + + return "", fmt.Errorf("invalid embedded IdP session cookie encryption key: %s (or sessionCookieEncryptionKey) must be 16, 24, or 32 bytes as a raw string or base64-encoded to one of those lengths; got %d raw bytes", sessionCookieEncryptionKeyEnv, len([]byte(key))) +} + +func validSessionCookieEncryptionKeyLength(length int) bool { + switch length { + case 16, 24, 32: + return true + default: + return false + } +} + // Compile-time check that EmbeddedIdPManager implements Manager interface var _ Manager = (*EmbeddedIdPManager)(nil) @@ -215,6 +344,7 @@ type EmbeddedIdPManager struct { provider *dex.Provider appMetrics telemetry.AppMetrics config EmbeddedIdPConfig + mfaEnabled bool } // NewEmbeddedIdPManager creates a new instance of EmbeddedIdPManager from a configuration. @@ -641,6 +771,27 @@ func (m *EmbeddedIdPManager) IsLocalAuthDisabled() bool { return m.config.LocalAuthDisabled } +// SetMFAEnabled enables or disables TOTP MFA for local users by updating the MFAChain on OAuth2 clients. +func (m *EmbeddedIdPManager) SetMFAEnabled(ctx context.Context, enabled bool) error { + var mfaChain []string + if enabled { + mfaChain = []string{"default-totp"} + } + if err := m.provider.SetClientsMFAChain(ctx, []string{ + staticClientCLI, + staticClientDashboard, + }, mfaChain); err != nil { + return fmt.Errorf("failed to set MFA enabled=%v: %w", enabled, err) + } + m.mfaEnabled = enabled + return nil +} + +// IsMFAEnabled returns whether TOTP MFA is currently enabled for local users. +func (m *EmbeddedIdPManager) IsMFAEnabled() bool { + return m.mfaEnabled +} + // HasNonLocalConnectors checks if there are any identity provider connectors other than local. func (m *EmbeddedIdPManager) HasNonLocalConnectors(ctx context.Context) (bool, error) { return m.provider.HasNonLocalConnectors(ctx) diff --git a/management/server/idp/embedded_test.go b/management/server/idp/embedded_test.go index 4dda483fb..09dc67614 100644 --- a/management/server/idp/embedded_test.go +++ b/management/server/idp/embedded_test.go @@ -2,6 +2,7 @@ package idp import ( "context" + "encoding/base64" "os" "path/filepath" "testing" @@ -313,6 +314,72 @@ func TestEmbeddedIdPManager_UpdateUserPassword(t *testing.T) { }) } +func TestEmbeddedIdPConfig_ToYAMLConfig_SessionCookieEncryptionKey(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, "") + + rawKey := "0123456789abcdef0123456789abcdef" + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + SessionCookieEncryptionKey: base64.StdEncoding.EncodeToString([]byte(rawKey)), + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(t.TempDir(), "dex.db"), + }, + }, + } + + yamlConfig, err := config.ToYAMLConfig() + require.NoError(t, err) + require.NotNil(t, yamlConfig.Sessions) + assert.Equal(t, rawKey, yamlConfig.Sessions.CookieEncryptionKey) +} + +func TestResolveSessionCookieEncryptionKey(t *testing.T) { + rawKey := "0123456789abcdef0123456789abcdef" + + t.Run("uses raw configured key", func(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, "") + + key, err := resolveSessionCookieEncryptionKey(rawKey) + require.NoError(t, err) + assert.Equal(t, rawKey, key) + }) + + t.Run("uses base64 configured key", func(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, "") + + key, err := resolveSessionCookieEncryptionKey(base64.StdEncoding.EncodeToString([]byte(rawKey))) + require.NoError(t, err) + assert.Equal(t, rawKey, key) + }) + + t.Run("falls back to env var", func(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, rawKey) + + key, err := resolveSessionCookieEncryptionKey("") + require.NoError(t, err) + assert.Equal(t, rawKey, key) + }) + + t.Run("empty key disables encryption", func(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, "") + + key, err := resolveSessionCookieEncryptionKey("") + require.NoError(t, err) + assert.Empty(t, key) + }) + + t.Run("rejects invalid key length", func(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, "") + + _, err := resolveSessionCookieEncryptionKey("32") + require.Error(t, err) + assert.Contains(t, err.Error(), sessionCookieEncryptionKeyEnv) + }) +} + func TestEmbeddedIdPManager_GetLocalKeysLocation(t *testing.T) { ctx := context.Background() diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 973101ce3..065a0d306 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -236,7 +236,6 @@ func (s *SqlStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([ Where(accountAndPeerIDQueryCondition, accountID, peerID). Order("created_at DESC"). Find(&jobs).Error - if err != nil { log.WithContext(ctx).Errorf("failed to fetch jobs from store: %s", err) return nil, err @@ -463,7 +462,6 @@ func (s *SqlStore) SavePeer(ctx context.Context, accountID string, peer *nbpeer. return nil }) - if err != nil { return err } @@ -1514,6 +1512,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_jwt_groups_enabled, settings_jwt_groups_claim_name, settings_jwt_allow_groups, settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, + settings_local_mfa_enabled, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1535,6 +1534,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sNetworkRangeV6 sql.NullString sIPv6EnabledGroups sql.NullString sLazyConnectionEnabled sql.NullBool + sLocalMFAEnabled sql.NullBool sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString @@ -1557,6 +1557,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sJWTGroupsEnabled, &sJWTGroupsClaimName, &sJWTAllowGroups, &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, + &sLocalMFAEnabled, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1619,6 +1620,9 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sLazyConnectionEnabled.Valid { account.Settings.LazyConnectionEnabled = sLazyConnectionEnabled.Bool } + if sLocalMFAEnabled.Valid { + account.Settings.LocalMfaEnabled = sLocalMFAEnabled.Bool + } if sJWTAllowGroups.Valid { _ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups) } @@ -3061,7 +3065,6 @@ func (s *SqlStore) AddPeerToAllGroup(ctx context.Context, accountID string, peer GroupID: groupID, PeerID: peerID, }).Error - if err != nil { return status.Errorf(status.Internal, "error adding peer to group 'All': %v", err) } @@ -3081,7 +3084,6 @@ func (s *SqlStore) AddPeerToGroup(ctx context.Context, accountID, peerID, groupI Columns: []clause.Column{{Name: "group_id"}, {Name: "peer_id"}}, DoNothing: true, }).Create(peer).Error - if err != nil { log.WithContext(ctx).Errorf("failed to add peer %s to group %s for account %s: %v", peerID, groupID, accountID, err) return status.Errorf(status.Internal, "failed to add peer to group") @@ -3094,7 +3096,6 @@ func (s *SqlStore) AddPeerToGroup(ctx context.Context, accountID, peerID, groupI func (s *SqlStore) RemovePeerFromGroup(ctx context.Context, peerID string, groupID string) error { err := s.db. Delete(&types.GroupPeer{}, "group_id = ? AND peer_id = ?", groupID, peerID).Error - if err != nil { log.WithContext(ctx).Errorf("failed to remove peer %s from group %s: %v", peerID, groupID, err) return status.Errorf(status.Internal, "failed to remove peer from group") @@ -3107,7 +3108,6 @@ func (s *SqlStore) RemovePeerFromGroup(ctx context.Context, peerID string, group func (s *SqlStore) RemovePeerFromAllGroups(ctx context.Context, peerID string) error { err := s.db. Delete(&types.GroupPeer{}, "peer_id = ?", peerID).Error - if err != nil { log.WithContext(ctx).Errorf("failed to remove peer %s from all groups: %v", peerID, err) return status.Errorf(status.Internal, "failed to remove peer from all groups") @@ -4964,7 +4964,6 @@ func (s *SqlStore) UpdateService(ctx context.Context, service *rpservice.Service return nil }) - if err != nil { log.WithContext(ctx).Errorf("failed to update service to store: %v", err) return status.Errorf(status.Internal, "failed to update service to store") @@ -5620,7 +5619,6 @@ func (s *SqlStore) getClusterUnanimousCapability(ctx context.Context, clusterAdd Where("cluster_address = ? AND status = ? AND last_seen > ?", clusterAddr, "connected", time.Now().Add(-proxyActiveThreshold)). Scan(&result).Error - if err != nil { log.WithContext(ctx).Errorf("query cluster capability %s for %s: %v", column, clusterAddr, err) return nil @@ -5662,7 +5660,6 @@ func (s *SqlStore) getClusterCapability(ctx context.Context, clusterAddr, column Where("cluster_address = ? AND status = ? AND last_seen > ?", clusterAddr, "connected", time.Now().Add(-proxyActiveThreshold)). Scan(&result).Error - if err != nil { log.WithContext(ctx).Errorf("query cluster capability %s for %s: %v", column, clusterAddr, err) return nil diff --git a/management/server/types/settings.go b/management/server/types/settings.go index 264a018d4..97ffa5e76 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -80,6 +80,10 @@ type Settings struct { // LocalAuthDisabled indicates if local (email/password) authentication is disabled. // This is a runtime-only field, not stored in the database. LocalAuthDisabled bool `gorm:"-"` + + // LocalMfaEnabled indicates if TOTP MFA is enabled for local users. + // Only applicable when the embedded IDP is enabled. + LocalMfaEnabled bool } // Copy copies the Settings struct @@ -108,6 +112,7 @@ func (s *Settings) Copy() *Settings { IPv6EnabledGroups: slices.Clone(s.IPv6EnabledGroups), EmbeddedIdpEnabled: s.EmbeddedIdpEnabled, LocalAuthDisabled: s.LocalAuthDisabled, + LocalMfaEnabled: s.LocalMfaEnabled, } if s.Extra != nil { settings.Extra = s.Extra.Copy() diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 8e6ee54cc..82fca0782 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -381,6 +381,10 @@ components: type: boolean readOnly: true example: false + local_mfa_enabled: + description: Enables or disables TOTP multi-factor authentication for local users. Only applicable when the embedded identity provider is enabled. + type: boolean + example: false ipv6_enabled_groups: description: List of group IDs whose peers receive IPv6 overlay addresses. Peers not in any of these groups will not be allocated an IPv6 address. New accounts default to the All group. type: array diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index f8ea07be7..4b94ea01c 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1486,6 +1486,9 @@ type AccountSettings struct { // LocalAuthDisabled Indicates whether local (email/password) authentication is disabled. When true, users can only authenticate via external identity providers. This is a read-only field. LocalAuthDisabled *bool `json:"local_auth_disabled,omitempty"` + // LocalMfaEnabled Enables or disables TOTP multi-factor authentication for local users. Only applicable when the embedded identity provider is enabled. + LocalMfaEnabled *bool `json:"local_mfa_enabled,omitempty"` + // NetworkRange Allows to define a custom network range for the account in CIDR format NetworkRange *string `json:"network_range,omitempty"` From afb83b3049c3c0b5b578f051e277562c6b8e513d Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 11 May 2026 16:58:49 +0900 Subject: [PATCH 68/80] [client] Use unique temp file and clean up on failure when writing ssh config (#6064) --- client/ssh/config/manager.go | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go index 01822ead6..b58bf2233 100644 --- a/client/ssh/config/manager.go +++ b/client/ssh/config/manager.go @@ -229,18 +229,31 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string { func (m *Manager) writeSSHConfig(sshConfig string) error { sshConfigPath := filepath.Join(m.sshConfigDir, m.sshConfigFile) - sshConfigPathTmp := sshConfigPath + ".tmp" if err := os.MkdirAll(m.sshConfigDir, 0755); err != nil { return fmt.Errorf("create SSH config directory %s: %w", m.sshConfigDir, err) } - if err := writeFileWithTimeout(sshConfigPathTmp, []byte(sshConfig), 0644); err != nil { - return fmt.Errorf("write SSH config file %s: %w", sshConfigPath, err) + tmp, err := os.CreateTemp(m.sshConfigDir, m.sshConfigFile+".*.tmp") + if err != nil { + return fmt.Errorf("create temp SSH config: %w", err) + } + tmpPath := tmp.Name() + defer func() { + if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) { + log.Debugf("remove temp SSH config %s: %v", tmpPath, err) + } + }() + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp SSH config %s: %w", tmpPath, err) } - if err := os.Rename(sshConfigPathTmp, sshConfigPath); err != nil { - return fmt.Errorf("rename ssh config %s -> %s: %w", sshConfigPathTmp, sshConfigPath, err) + if err := writeFileWithTimeout(tmpPath, []byte(sshConfig), 0644); err != nil { + return fmt.Errorf("write SSH config file %s: %w", tmpPath, err) + } + + if err := os.Rename(tmpPath, sshConfigPath); err != nil { + return fmt.Errorf("rename SSH config %s -> %s: %w", tmpPath, sshConfigPath, err) } log.Infof("Created NetBird SSH client config: %s", sshConfigPath) From a852b3bd34f82c3d548874db4eb2b1b186320225 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 11 May 2026 16:59:13 +0900 Subject: [PATCH 69/80] [client, proxy] Harden uspfilter conntrack and share TCP relay (#5936) --- .../firewall/uspfilter/conntrack/cap_test.go | 125 ++++++ client/firewall/uspfilter/conntrack/common.go | 46 ++ .../uspfilter/conntrack/defaults_desktop.go | 11 + .../uspfilter/conntrack/defaults_mobile.go | 13 + client/firewall/uspfilter/conntrack/icmp.go | 50 ++- client/firewall/uspfilter/conntrack/tcp.go | 398 +++++++++++++----- .../uspfilter/conntrack/tcp_rst_bugs_test.go | 100 +++++ .../conntrack/tcp_state_bugs_test.go | 235 +++++++++++ client/firewall/uspfilter/conntrack/udp.go | 52 ++- client/firewall/uspfilter/filter.go | 42 +- client/firewall/uspfilter/forwarder/icmp.go | 31 +- client/firewall/uspfilter/forwarder/tcp.go | 75 +--- client/firewall/uspfilter/forwarder/udp.go | 16 +- client/firewall/uspfilter/log/log.go | 137 +++--- client/firewall/uspfilter/nat.go | 21 +- client/ssh/client/client.go | 26 +- client/ssh/common.go | 60 --- client/ssh/proxy/proxy.go | 5 +- client/ssh/server/port_forwarding.go | 4 +- client/ssh/server/server.go | 33 +- proxy/internal/tcp/peekedconn.go | 6 + proxy/internal/tcp/relay.go | 156 ------- proxy/internal/tcp/relay_test.go | 15 +- proxy/internal/tcp/router.go | 8 +- util/netrelay/relay.go | 238 +++++++++++ util/netrelay/relay_test.go | 221 ++++++++++ 26 files changed, 1629 insertions(+), 495 deletions(-) create mode 100644 client/firewall/uspfilter/conntrack/cap_test.go create mode 100644 client/firewall/uspfilter/conntrack/defaults_desktop.go create mode 100644 client/firewall/uspfilter/conntrack/defaults_mobile.go create mode 100644 client/firewall/uspfilter/conntrack/tcp_rst_bugs_test.go create mode 100644 client/firewall/uspfilter/conntrack/tcp_state_bugs_test.go delete mode 100644 proxy/internal/tcp/relay.go create mode 100644 util/netrelay/relay.go create mode 100644 util/netrelay/relay_test.go diff --git a/client/firewall/uspfilter/conntrack/cap_test.go b/client/firewall/uspfilter/conntrack/cap_test.go new file mode 100644 index 000000000..ee6b72e7f --- /dev/null +++ b/client/firewall/uspfilter/conntrack/cap_test.go @@ -0,0 +1,125 @@ +package conntrack + +import ( + "net/netip" + "testing" + + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/require" +) + +func TestTCPCapEvicts(t *testing.T) { + t.Setenv(EnvTCPMaxEntries, "4") + + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + require.Equal(t, 4, tracker.maxEntries) + + src := netip.MustParseAddr("100.64.0.1") + dst := netip.MustParseAddr("100.64.0.2") + + for i := 0; i < 10; i++ { + tracker.TrackOutbound(src, dst, uint16(10000+i), 80, TCPSyn, 0) + } + require.LessOrEqual(t, len(tracker.connections), 4, + "TCP table must not exceed the configured cap") + require.Greater(t, len(tracker.connections), 0, + "some entries must remain after eviction") + + // The most recently admitted flow must be present: eviction must make + // room for new entries, not silently drop them. + require.Contains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(10009), DstPort: 80}, + "newest TCP flow must be admitted after eviction") + // A pre-cap flow must have been evicted to fit the last one. + require.NotContains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(10000), DstPort: 80}, + "oldest TCP flow should have been evicted") +} + +func TestTCPCapPrefersTombstonedForEviction(t *testing.T) { + t.Setenv(EnvTCPMaxEntries, "3") + + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + src := netip.MustParseAddr("100.64.0.1") + dst := netip.MustParseAddr("100.64.0.2") + + // Fill to cap with 3 live connections. + for i := 0; i < 3; i++ { + tracker.TrackOutbound(src, dst, uint16(20000+i), 80, TCPSyn, 0) + } + require.Len(t, tracker.connections, 3) + + // Tombstone one by sending RST through IsValidInbound. + tombstonedKey := ConnKey{SrcIP: src, DstIP: dst, SrcPort: 20001, DstPort: 80} + require.True(t, tracker.IsValidInbound(dst, src, 80, 20001, TCPRst|TCPAck, 0)) + require.True(t, tracker.connections[tombstonedKey].IsTombstone()) + + // Another live connection forces eviction. The tombstone must go first. + tracker.TrackOutbound(src, dst, uint16(29999), 80, TCPSyn, 0) + + _, tombstonedStillPresent := tracker.connections[tombstonedKey] + require.False(t, tombstonedStillPresent, + "tombstoned entry should be evicted before live entries") + require.LessOrEqual(t, len(tracker.connections), 3) + + // Both live pre-cap entries must survive: eviction must prefer the + // tombstone, not just satisfy the size bound by dropping any entry. + require.Contains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(20000), DstPort: 80}, + "live entries must not be evicted while a tombstone exists") + require.Contains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(20002), DstPort: 80}, + "live entries must not be evicted while a tombstone exists") +} + +func TestUDPCapEvicts(t *testing.T) { + t.Setenv(EnvUDPMaxEntries, "5") + + tracker := NewUDPTracker(DefaultUDPTimeout, logger, flowLogger) + defer tracker.Close() + require.Equal(t, 5, tracker.maxEntries) + + src := netip.MustParseAddr("100.64.0.1") + dst := netip.MustParseAddr("100.64.0.2") + + for i := 0; i < 12; i++ { + tracker.TrackOutbound(src, dst, uint16(30000+i), 53, 0) + } + require.LessOrEqual(t, len(tracker.connections), 5) + require.Greater(t, len(tracker.connections), 0) + + require.Contains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(30011), DstPort: 53}, + "newest UDP flow must be admitted after eviction") + require.NotContains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(30000), DstPort: 53}, + "oldest UDP flow should have been evicted") +} + +func TestICMPCapEvicts(t *testing.T) { + t.Setenv(EnvICMPMaxEntries, "3") + + tracker := NewICMPTracker(DefaultICMPTimeout, logger, flowLogger) + defer tracker.Close() + require.Equal(t, 3, tracker.maxEntries) + + src := netip.MustParseAddr("100.64.0.1") + dst := netip.MustParseAddr("100.64.0.2") + + echoReq := layers.CreateICMPv4TypeCode(uint8(layers.ICMPv4TypeEchoRequest), 0) + for i := 0; i < 8; i++ { + tracker.TrackOutbound(src, dst, uint16(i), echoReq, nil, 64) + } + require.LessOrEqual(t, len(tracker.connections), 3) + require.Greater(t, len(tracker.connections), 0) + + require.Contains(t, tracker.connections, + ICMPConnKey{SrcIP: src, DstIP: dst, ID: uint16(7)}, + "newest ICMP flow must be admitted after eviction") + require.NotContains(t, tracker.connections, + ICMPConnKey{SrcIP: src, DstIP: dst, ID: uint16(0)}, + "oldest ICMP flow should have been evicted") +} diff --git a/client/firewall/uspfilter/conntrack/common.go b/client/firewall/uspfilter/conntrack/common.go index 88e90317c..e497a0bff 100644 --- a/client/firewall/uspfilter/conntrack/common.go +++ b/client/firewall/uspfilter/conntrack/common.go @@ -3,15 +3,61 @@ package conntrack import ( "net" "net/netip" + "os" "strconv" "sync/atomic" "time" "github.com/google/uuid" + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" ) +// evictSampleSize bounds how many map entries we scan per eviction call. +// Keeps eviction O(1) even at cap under sustained load; the sampled-LRU +// heuristic is good enough for a conntrack table that only overflows under +// abuse. +const evictSampleSize = 8 + +// envDuration parses an os.Getenv(name) as a time.Duration. Falls back to +// def on empty or invalid; logs a warning on invalid. +func envDuration(logger *nblog.Logger, name string, def time.Duration) time.Duration { + v := os.Getenv(name) + if v == "" { + return def + } + d, err := time.ParseDuration(v) + if err != nil { + logger.Warn3("invalid %s=%q: %v, using default", name, v, err) + return def + } + if d <= 0 { + logger.Warn2("invalid %s=%q: must be positive, using default", name, v) + return def + } + return d +} + +// envInt parses an os.Getenv(name) as an int. Falls back to def on empty, +// invalid, or non-positive. Logs a warning on invalid input. +func envInt(logger *nblog.Logger, name string, def int) int { + v := os.Getenv(name) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + switch { + case err != nil: + logger.Warn3("invalid %s=%q: %v, using default", name, v, err) + return def + case n <= 0: + logger.Warn2("invalid %s=%q: must be positive, using default", name, v) + return def + } + return n +} + // BaseConnTrack provides common fields and locking for all connection types type BaseConnTrack struct { FlowId uuid.UUID diff --git a/client/firewall/uspfilter/conntrack/defaults_desktop.go b/client/firewall/uspfilter/conntrack/defaults_desktop.go new file mode 100644 index 000000000..2f07f5984 --- /dev/null +++ b/client/firewall/uspfilter/conntrack/defaults_desktop.go @@ -0,0 +1,11 @@ +//go:build !ios && !android + +package conntrack + +// Default per-tracker entry caps on desktop/server platforms. These mirror +// typical Linux netfilter nf_conntrack_max territory with ample headroom. +const ( + DefaultMaxTCPEntries = 65536 + DefaultMaxUDPEntries = 16384 + DefaultMaxICMPEntries = 2048 +) diff --git a/client/firewall/uspfilter/conntrack/defaults_mobile.go b/client/firewall/uspfilter/conntrack/defaults_mobile.go new file mode 100644 index 000000000..c9e05d229 --- /dev/null +++ b/client/firewall/uspfilter/conntrack/defaults_mobile.go @@ -0,0 +1,13 @@ +//go:build ios || android + +package conntrack + +// Default per-tracker entry caps on mobile platforms. iOS network extensions +// are capped at ~50 MB; Android runs under aggressive memory pressure. These +// values keep conntrack footprint well under 5 MB worst case (TCPConnTrack +// is ~200 B plus map overhead). +const ( + DefaultMaxTCPEntries = 4096 + DefaultMaxUDPEntries = 2048 + DefaultMaxICMPEntries = 512 +) diff --git a/client/firewall/uspfilter/conntrack/icmp.go b/client/firewall/uspfilter/conntrack/icmp.go index a48215ca9..3c96548b5 100644 --- a/client/firewall/uspfilter/conntrack/icmp.go +++ b/client/firewall/uspfilter/conntrack/icmp.go @@ -50,6 +50,9 @@ type ICMPConnTrack struct { ICMPCode uint8 } +// EnvICMPMaxEntries caps the ICMP conntrack table size. +const EnvICMPMaxEntries = "NB_CONNTRACK_ICMP_MAX" + // ICMPTracker manages ICMP connection states type ICMPTracker struct { logger *nblog.Logger @@ -58,6 +61,7 @@ type ICMPTracker struct { cleanupTicker *time.Ticker tickerCancel context.CancelFunc mutex sync.RWMutex + maxEntries int flowLogger nftypes.FlowLogger } @@ -171,6 +175,7 @@ func NewICMPTracker(timeout time.Duration, logger *nblog.Logger, flowLogger nfty timeout: timeout, cleanupTicker: time.NewTicker(ICMPCleanupInterval), tickerCancel: cancel, + maxEntries: envInt(logger, EnvICMPMaxEntries, DefaultMaxICMPEntries), flowLogger: flowLogger, } @@ -257,7 +262,9 @@ func (t *ICMPTracker) track( // non echo requests don't need tracking if typ != uint8(layers.ICMPv4TypeEchoRequest) { - t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo) + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo) + } t.sendStartEvent(direction, srcIP, dstIP, typ, code, ruleId, size) return } @@ -276,10 +283,15 @@ func (t *ICMPTracker) track( conn.UpdateCounters(direction, size) t.mutex.Lock() + if t.maxEntries > 0 && len(t.connections) >= t.maxEntries { + t.evictOneLocked() + } t.connections[key] = conn t.mutex.Unlock() - t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo) + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo) + } t.sendEvent(nftypes.TypeStart, conn, ruleId) } @@ -323,6 +335,34 @@ func (t *ICMPTracker) cleanupRoutine(ctx context.Context) { } } +// evictOneLocked removes one entry to make room. Caller must hold t.mutex. +// Bounded sample scan: picks the oldest among up to evictSampleSize entries. +func (t *ICMPTracker) evictOneLocked() { + var candKey ICMPConnKey + var candSeen int64 + haveCand := false + sampled := 0 + + for k, c := range t.connections { + seen := c.lastSeen.Load() + if !haveCand || seen < candSeen { + candKey = k + candSeen = seen + haveCand = true + } + sampled++ + if sampled >= evictSampleSize { + break + } + } + if haveCand { + if evicted := t.connections[candKey]; evicted != nil { + t.sendEvent(nftypes.TypeEnd, evicted, nil) + } + delete(t.connections, candKey) + } +} + func (t *ICMPTracker) cleanup() { t.mutex.Lock() defer t.mutex.Unlock() @@ -331,8 +371,10 @@ func (t *ICMPTracker) cleanup() { if conn.timeoutExceeded(t.timeout) { delete(t.connections, key) - t.logger.Trace5("Removed ICMP connection %s (timeout) [in: %d Pkts/%d B out: %d Pkts/%d B]", - key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace5("Removed ICMP connection %s (timeout) [in: %d Pkts/%d B out: %d Pkts/%d B]", + key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + } t.sendEvent(nftypes.TypeEnd, conn, nil) } } diff --git a/client/firewall/uspfilter/conntrack/tcp.go b/client/firewall/uspfilter/conntrack/tcp.go index 335a3abab..9edc9af22 100644 --- a/client/firewall/uspfilter/conntrack/tcp.go +++ b/client/firewall/uspfilter/conntrack/tcp.go @@ -38,6 +38,27 @@ const ( TCPHandshakeTimeout = 60 * time.Second // TCPCleanupInterval is how often we check for stale connections TCPCleanupInterval = 5 * time.Minute + // FinWaitTimeout bounds FIN_WAIT_1 / FIN_WAIT_2 / CLOSING states. + // Matches Linux netfilter nf_conntrack_tcp_timeout_fin_wait. + FinWaitTimeout = 60 * time.Second + // CloseWaitTimeout bounds CLOSE_WAIT. Matches Linux default; apps + // holding CloseWait longer than this should bump the env var. + CloseWaitTimeout = 60 * time.Second + // LastAckTimeout bounds LAST_ACK. Matches Linux default. + LastAckTimeout = 30 * time.Second +) + +// Env vars to override per-state teardown timeouts. Values parsed by +// time.ParseDuration (e.g. "120s", "2m"). Invalid values fall back to the +// defaults above with a warning. +const ( + EnvTCPFinWaitTimeout = "NB_CONNTRACK_TCP_FIN_WAIT_TIMEOUT" + EnvTCPCloseWaitTimeout = "NB_CONNTRACK_TCP_CLOSE_WAIT_TIMEOUT" + EnvTCPLastAckTimeout = "NB_CONNTRACK_TCP_LAST_ACK_TIMEOUT" + + // EnvTCPMaxEntries caps the TCP conntrack table size. Oldest entries + // (tombstones first) are evicted when the cap is reached. + EnvTCPMaxEntries = "NB_CONNTRACK_TCP_MAX" ) // TCPState represents the state of a TCP connection @@ -133,14 +154,18 @@ func (t *TCPConnTrack) SetTombstone() { // TCPTracker manages TCP connection states type TCPTracker struct { - logger *nblog.Logger - connections map[ConnKey]*TCPConnTrack - mutex sync.RWMutex - cleanupTicker *time.Ticker - tickerCancel context.CancelFunc - timeout time.Duration - waitTimeout time.Duration - flowLogger nftypes.FlowLogger + logger *nblog.Logger + connections map[ConnKey]*TCPConnTrack + mutex sync.RWMutex + cleanupTicker *time.Ticker + tickerCancel context.CancelFunc + timeout time.Duration + waitTimeout time.Duration + finWaitTimeout time.Duration + closeWaitTimeout time.Duration + lastAckTimeout time.Duration + maxEntries int + flowLogger nftypes.FlowLogger } // NewTCPTracker creates a new TCP connection tracker @@ -155,13 +180,17 @@ func NewTCPTracker(timeout time.Duration, logger *nblog.Logger, flowLogger nftyp ctx, cancel := context.WithCancel(context.Background()) tracker := &TCPTracker{ - logger: logger, - connections: make(map[ConnKey]*TCPConnTrack), - cleanupTicker: time.NewTicker(TCPCleanupInterval), - tickerCancel: cancel, - timeout: timeout, - waitTimeout: waitTimeout, - flowLogger: flowLogger, + logger: logger, + connections: make(map[ConnKey]*TCPConnTrack), + cleanupTicker: time.NewTicker(TCPCleanupInterval), + tickerCancel: cancel, + timeout: timeout, + waitTimeout: waitTimeout, + finWaitTimeout: envDuration(logger, EnvTCPFinWaitTimeout, FinWaitTimeout), + closeWaitTimeout: envDuration(logger, EnvTCPCloseWaitTimeout, CloseWaitTimeout), + lastAckTimeout: envDuration(logger, EnvTCPLastAckTimeout, LastAckTimeout), + maxEntries: envInt(logger, EnvTCPMaxEntries, DefaultMaxTCPEntries), + flowLogger: flowLogger, } go tracker.cleanupRoutine(ctx) @@ -209,6 +238,12 @@ func (t *TCPTracker) track(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, fla if exists || flags&TCPSyn == 0 { return } + // Reject illegal SYN combinations (SYN+FIN, SYN+RST, …) so they don't + // create spurious conntrack entries. Not mandated by RFC 9293 but a + // common hardening (Linux netfilter/nftables rejects these too). + if !isValidFlagCombination(flags) { + return + } conn := &TCPConnTrack{ BaseConnTrack: BaseConnTrack{ @@ -225,20 +260,65 @@ func (t *TCPTracker) track(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, fla conn.state.Store(int32(TCPStateNew)) conn.DNATOrigPort.Store(uint32(origPort)) - if origPort != 0 { - t.logger.Trace4("New %s TCP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort) - } else { - t.logger.Trace2("New %s TCP connection: %s", direction, key) + if t.logger.Enabled(nblog.LevelTrace) { + if origPort != 0 { + t.logger.Trace4("New %s TCP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort) + } else { + t.logger.Trace2("New %s TCP connection: %s", direction, key) + } } t.updateState(key, conn, flags, direction, size) t.mutex.Lock() + if t.maxEntries > 0 && len(t.connections) >= t.maxEntries { + t.evictOneLocked() + } t.connections[key] = conn t.mutex.Unlock() t.sendEvent(nftypes.TypeStart, conn, ruleID) } +// evictOneLocked removes one entry to make room. Caller must hold t.mutex. +// Bounded scan: samples up to evictSampleSize pseudo-random entries (Go map +// iteration order is randomized), preferring a tombstone. If no tombstone +// found in the sample, evicts the oldest among the sampled entries. O(1) +// worst case — cheap enough to run on every insert at cap during abuse. +func (t *TCPTracker) evictOneLocked() { + var candKey ConnKey + var candSeen int64 + haveCand := false + sampled := 0 + + for k, c := range t.connections { + if c.IsTombstone() { + delete(t.connections, k) + return + } + seen := c.lastSeen.Load() + if !haveCand || seen < candSeen { + candKey = k + candSeen = seen + haveCand = true + } + sampled++ + if sampled >= evictSampleSize { + break + } + } + if haveCand { + if evicted := t.connections[candKey]; evicted != nil { + // TypeEnd is already emitted at the state transition to + // TimeWait and when a connection is tombstoned. Only emit + // here when we're reaping a still-active flow. + if evicted.GetState() != TCPStateTimeWait && !evicted.IsTombstone() { + t.sendEvent(nftypes.TypeEnd, evicted, nil) + } + } + delete(t.connections, candKey) + } +} + // IsValidInbound checks if an inbound TCP packet matches a tracked connection func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, flags uint8, size int) bool { key := ConnKey{ @@ -256,12 +336,19 @@ func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort ui return false } + // Reject illegal flag combinations regardless of state. These never belong + // to a legitimate flow and must not advance or tear down state. + if !isValidFlagCombination(flags) { + if t.logger.Enabled(nblog.LevelWarn) { + t.logger.Warn3("TCP illegal flag combination %x for connection %s (state %s)", flags, key, conn.GetState()) + } + return false + } + currentState := conn.GetState() if !t.isValidStateForFlags(currentState, flags) { - t.logger.Warn3("TCP state %s is not valid with flags %x for connection %s", currentState, flags, key) - // allow all flags for established for now - if currentState == TCPStateEstablished { - return true + if t.logger.Enabled(nblog.LevelWarn) { + t.logger.Warn3("TCP state %s is not valid with flags %x for connection %s", currentState, flags, key) } return false } @@ -270,116 +357,208 @@ func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort ui return true } -// updateState updates the TCP connection state based on flags +// updateState updates the TCP connection state based on flags. func (t *TCPTracker) updateState(key ConnKey, conn *TCPConnTrack, flags uint8, packetDir nftypes.Direction, size int) { - conn.UpdateLastSeen() conn.UpdateCounters(packetDir, size) + // Malformed flag combinations must not refresh lastSeen or drive state, + // otherwise spoofed packets keep a dead flow alive past its timeout. + if !isValidFlagCombination(flags) { + return + } + + conn.UpdateLastSeen() + currentState := conn.GetState() if flags&TCPRst != 0 { - if conn.CompareAndSwapState(currentState, TCPStateClosed) { - conn.SetTombstone() - t.logger.Trace6("TCP connection reset: %s (dir: %s) [in: %d Pkts/%d B, out: %d Pkts/%d B]", - key, packetDir, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) - t.sendEvent(nftypes.TypeEnd, conn, nil) - } + // Hardening beyond RFC 9293 §3.10.7.4: without sequence tracking we + // cannot apply the RFC 5961 in-window RST check, so we conservatively + // reject RSTs that the spec would accept (TIME-WAIT with in-window + // SEQ, SynSent from same direction as own SYN, etc.). + t.handleRst(key, conn, currentState, packetDir) return } - var newState TCPState - switch currentState { - case TCPStateNew: - if flags&TCPSyn != 0 && flags&TCPAck == 0 { - if conn.Direction == nftypes.Egress { - newState = TCPStateSynSent - } else { - newState = TCPStateSynReceived - } - } + newState := nextState(currentState, conn.Direction, packetDir, flags) + if newState == 0 || !conn.CompareAndSwapState(currentState, newState) { + return + } + t.onTransition(key, conn, currentState, newState, packetDir) +} - case TCPStateSynSent: - if flags&TCPSyn != 0 && flags&TCPAck != 0 { - if packetDir != conn.Direction { - newState = TCPStateEstablished - } else { - // Simultaneous open - newState = TCPStateSynReceived - } - } +// handleRst processes a RST segment. Late RSTs in TimeWait and spoofed RSTs +// from the SYN direction are ignored; otherwise the flow is tombstoned. +func (t *TCPTracker) handleRst(key ConnKey, conn *TCPConnTrack, currentState TCPState, packetDir nftypes.Direction) { + // TimeWait exists to absorb late segments; don't let a late RST + // tombstone the entry and break same-4-tuple reuse. + if currentState == TCPStateTimeWait { + return + } + // A RST from the same direction as the SYN cannot be a legitimate + // response and must not tear down a half-open connection. + if currentState == TCPStateSynSent && packetDir == conn.Direction { + return + } + if !conn.CompareAndSwapState(currentState, TCPStateClosed) { + return + } + conn.SetTombstone() + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace6("TCP connection reset: %s (dir: %s) [in: %d Pkts/%d B, out: %d Pkts/%d B]", + key, packetDir, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + } + t.sendEvent(nftypes.TypeEnd, conn, nil) +} - case TCPStateSynReceived: - if flags&TCPAck != 0 && flags&TCPSyn == 0 { - if packetDir == conn.Direction { - newState = TCPStateEstablished - } - } +// stateTransition describes one state's transition logic. It receives the +// packet's flags plus whether the packet direction matches the connection's +// origin direction (same=true means same side as the SYN initiator). Return 0 +// for no transition. +type stateTransition func(flags uint8, connDir nftypes.Direction, same bool) TCPState - case TCPStateEstablished: - if flags&TCPFin != 0 { - if packetDir == conn.Direction { - newState = TCPStateFinWait1 - } else { - newState = TCPStateCloseWait - } - } +// stateTable maps each state to its transition function. Centralized here so +// nextState stays trivial and each rule is easy to read in isolation. +var stateTable = map[TCPState]stateTransition{ + TCPStateNew: transNew, + TCPStateSynSent: transSynSent, + TCPStateSynReceived: transSynReceived, + TCPStateEstablished: transEstablished, + TCPStateFinWait1: transFinWait1, + TCPStateFinWait2: transFinWait2, + TCPStateClosing: transClosing, + TCPStateCloseWait: transCloseWait, + TCPStateLastAck: transLastAck, +} - case TCPStateFinWait1: - if packetDir != conn.Direction { - switch { - case flags&TCPFin != 0 && flags&TCPAck != 0: - newState = TCPStateClosing - case flags&TCPFin != 0: - newState = TCPStateClosing - case flags&TCPAck != 0: - newState = TCPStateFinWait2 - } - } +// nextState returns the target TCP state for the given current state and +// packet, or 0 if the packet does not trigger a transition. +func nextState(currentState TCPState, connDir, packetDir nftypes.Direction, flags uint8) TCPState { + fn, ok := stateTable[currentState] + if !ok { + return 0 + } + return fn(flags, connDir, packetDir == connDir) +} - case TCPStateFinWait2: - if flags&TCPFin != 0 { - newState = TCPStateTimeWait +func transNew(flags uint8, connDir nftypes.Direction, _ bool) TCPState { + if flags&TCPSyn != 0 && flags&TCPAck == 0 { + if connDir == nftypes.Egress { + return TCPStateSynSent } + return TCPStateSynReceived + } + return 0 +} - case TCPStateClosing: - if flags&TCPAck != 0 { - newState = TCPStateTimeWait +func transSynSent(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPSyn != 0 && flags&TCPAck != 0 { + if same { + return TCPStateSynReceived // simultaneous open } + return TCPStateEstablished + } + return 0 +} - case TCPStateCloseWait: - if flags&TCPFin != 0 { - newState = TCPStateLastAck - } +func transSynReceived(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPAck != 0 && flags&TCPSyn == 0 && same { + return TCPStateEstablished + } + return 0 +} - case TCPStateLastAck: - if flags&TCPAck != 0 { - newState = TCPStateClosed - } +func transEstablished(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPFin == 0 { + return 0 + } + if same { + return TCPStateFinWait1 + } + return TCPStateCloseWait +} + +// transFinWait1 handles the active-close peer response. A FIN carrying our +// ACK piggybacked goes straight to TIME-WAIT (RFC 9293 §3.10.7.4, FIN-WAIT-1: +// "if our FIN has been ACKed... enter the TIME-WAIT state"); a lone FIN moves +// to CLOSING; a pure ACK of our FIN moves to FIN-WAIT-2. +func transFinWait1(flags uint8, _ nftypes.Direction, same bool) TCPState { + if same { + return 0 + } + if flags&TCPFin != 0 && flags&TCPAck != 0 { + return TCPStateTimeWait + } + switch { + case flags&TCPFin != 0: + return TCPStateClosing + case flags&TCPAck != 0: + return TCPStateFinWait2 + } + return 0 +} + +// transFinWait2 ignores own-side FIN retransmits; only the peer's FIN advances. +func transFinWait2(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPFin != 0 && !same { + return TCPStateTimeWait + } + return 0 +} + +// transClosing completes a simultaneous close on the peer's ACK. +func transClosing(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPAck != 0 && !same { + return TCPStateTimeWait + } + return 0 +} + +// transCloseWait only advances to LastAck when WE send FIN, ignoring peer retransmits. +func transCloseWait(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPFin != 0 && same { + return TCPStateLastAck + } + return 0 +} + +// transLastAck closes the flow only on the peer's ACK (not our own ACK retransmits). +func transLastAck(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPAck != 0 && !same { + return TCPStateClosed + } + return 0 +} + +// onTransition handles logging and flow-event emission after a successful +// state transition. TimeWait and Closed are terminal for flow accounting. +func (t *TCPTracker) onTransition(key ConnKey, conn *TCPConnTrack, from, to TCPState, packetDir nftypes.Direction) { + traceOn := t.logger.Enabled(nblog.LevelTrace) + if traceOn { + t.logger.Trace4("TCP connection %s transitioned from %s to %s (dir: %s)", key, from, to, packetDir) } - if newState != 0 && conn.CompareAndSwapState(currentState, newState) { - t.logger.Trace4("TCP connection %s transitioned from %s to %s (dir: %s)", key, currentState, newState, packetDir) - - switch newState { - case TCPStateTimeWait: + switch to { + case TCPStateTimeWait: + if traceOn { t.logger.Trace5("TCP connection %s completed [in: %d Pkts/%d B, out: %d Pkts/%d B]", key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) - t.sendEvent(nftypes.TypeEnd, conn, nil) - - case TCPStateClosed: - conn.SetTombstone() + } + t.sendEvent(nftypes.TypeEnd, conn, nil) + case TCPStateClosed: + conn.SetTombstone() + if traceOn { t.logger.Trace5("TCP connection %s closed gracefully [in: %d Pkts/%d, B out: %d Pkts/%d B]", key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) - t.sendEvent(nftypes.TypeEnd, conn, nil) } + t.sendEvent(nftypes.TypeEnd, conn, nil) } } -// isValidStateForFlags checks if the TCP flags are valid for the current connection state +// isValidStateForFlags checks if the TCP flags are valid for the current +// connection state. Caller must have already verified the flag combination is +// legal via isValidFlagCombination. func (t *TCPTracker) isValidStateForFlags(state TCPState, flags uint8) bool { - if !isValidFlagCombination(flags) { - return false - } if flags&TCPRst != 0 { if state == TCPStateSynSent { return flags&TCPAck != 0 @@ -449,15 +628,24 @@ func (t *TCPTracker) cleanup() { timeout = t.waitTimeout case TCPStateEstablished: timeout = t.timeout + case TCPStateFinWait1, TCPStateFinWait2, TCPStateClosing: + timeout = t.finWaitTimeout + case TCPStateCloseWait: + timeout = t.closeWaitTimeout + case TCPStateLastAck: + timeout = t.lastAckTimeout default: + // SynSent / SynReceived / New timeout = TCPHandshakeTimeout } if conn.timeoutExceeded(timeout) { delete(t.connections, key) - t.logger.Trace6("Cleaned up timed-out TCP connection %s (%s) [in: %d Pkts/%d, B out: %d Pkts/%d B]", - key, conn.GetState(), conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace6("Cleaned up timed-out TCP connection %s (%s) [in: %d Pkts/%d, B out: %d Pkts/%d B]", + key, conn.GetState(), conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + } // event already handled by state change if currentState != TCPStateTimeWait { diff --git a/client/firewall/uspfilter/conntrack/tcp_rst_bugs_test.go b/client/firewall/uspfilter/conntrack/tcp_rst_bugs_test.go new file mode 100644 index 000000000..81d4f5710 --- /dev/null +++ b/client/firewall/uspfilter/conntrack/tcp_rst_bugs_test.go @@ -0,0 +1,100 @@ +package conntrack + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/require" +) + +// RST hygiene tests: the tracker currently closes the flow on any RST that +// matches the 4-tuple, regardless of direction or state. These tests cover +// the minimum checks we want (no SEQ tracking). + +func TestTCPRstInSynSentWrongDirection(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPSyn, 0) + conn := tracker.connections[key] + require.Equal(t, TCPStateSynSent, conn.GetState()) + + // A RST arriving in the same direction as the SYN (i.e. TrackOutbound) + // cannot be a legitimate response. It must not close the connection. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPRst|TCPAck, 0) + require.Equal(t, TCPStateSynSent, conn.GetState(), + "RST in same direction as SYN must not close connection") + require.False(t, conn.IsTombstone()) +} + +func TestTCPRstInTimeWaitIgnored(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Drive to TIME-WAIT via active close. + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0)) + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + + conn := tracker.connections[key] + require.Equal(t, TCPStateTimeWait, conn.GetState()) + require.False(t, conn.IsTombstone(), "TIME-WAIT must not be tombstoned") + + // Late RST during TIME-WAIT must not tombstone the entry (TIME-WAIT + // exists to absorb late segments). + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPRst, 0) + require.Equal(t, TCPStateTimeWait, conn.GetState(), + "RST in TIME-WAIT must not transition state") + require.False(t, conn.IsTombstone(), + "RST in TIME-WAIT must not tombstone the entry") +} + +func TestTCPIllegalFlagCombos(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + conn := tracker.connections[key] + + // Illegal combos must be rejected and must not change state. + combos := []struct { + name string + flags uint8 + }{ + {"SYN+RST", TCPSyn | TCPRst}, + {"FIN+RST", TCPFin | TCPRst}, + {"SYN+FIN", TCPSyn | TCPFin}, + {"SYN+FIN+RST", TCPSyn | TCPFin | TCPRst}, + } + + for _, c := range combos { + t.Run(c.name, func(t *testing.T) { + before := conn.GetState() + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, c.flags, 0) + require.False(t, valid, "illegal flag combo must be rejected: %s", c.name) + require.Equal(t, before, conn.GetState(), + "illegal flag combo must not change state") + require.False(t, conn.IsTombstone()) + }) + } +} diff --git a/client/firewall/uspfilter/conntrack/tcp_state_bugs_test.go b/client/firewall/uspfilter/conntrack/tcp_state_bugs_test.go new file mode 100644 index 000000000..32112cd58 --- /dev/null +++ b/client/firewall/uspfilter/conntrack/tcp_state_bugs_test.go @@ -0,0 +1,235 @@ +package conntrack + +import ( + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// These tests exercise cases where the TCP state machine currently advances +// on retransmitted or wrong-direction segments and tears the flow down +// prematurely. They are expected to fail until the direction checks are added. + +func TestTCPCloseWaitRetransmittedPeerFIN(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // Peer sends FIN -> CloseWait (our app has not yet closed). + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + require.True(t, valid) + conn := tracker.connections[key] + require.Equal(t, TCPStateCloseWait, conn.GetState()) + + // Peer retransmits their FIN (ACK may have been delayed). We have NOT + // sent our FIN yet, so state must remain CloseWait. + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + require.True(t, valid, "retransmitted peer FIN must still be accepted") + require.Equal(t, TCPStateCloseWait, conn.GetState(), + "retransmitted peer FIN must not advance CloseWait to LastAck") + + // Our app finally closes -> LastAck. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + require.Equal(t, TCPStateLastAck, conn.GetState()) + + // Peer ACK closes. + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.True(t, valid) + require.Equal(t, TCPStateClosed, conn.GetState()) +} + +func TestTCPFinWait2RetransmittedOwnFIN(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // We initiate close. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.True(t, valid) + conn := tracker.connections[key] + require.Equal(t, TCPStateFinWait2, conn.GetState()) + + // Stray retransmit of our own FIN (same direction as originator) must + // NOT advance FinWait2 to TimeWait; only the peer's FIN should. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + require.Equal(t, TCPStateFinWait2, conn.GetState(), + "own FIN retransmit must not advance FinWait2 to TimeWait") + + // Peer FIN -> TimeWait. + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + require.True(t, valid) + require.Equal(t, TCPStateTimeWait, conn.GetState()) +} + +func TestTCPLastAckDirectionCheck(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // Drive to LastAck: peer FIN -> CloseWait, our FIN -> LastAck. + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + conn := tracker.connections[key] + require.Equal(t, TCPStateLastAck, conn.GetState()) + + // Our own ACK retransmit (same direction as originator) must NOT close. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + require.Equal(t, TCPStateLastAck, conn.GetState(), + "own ACK retransmit in LastAck must not transition to Closed") + + // Peer's ACK -> Closed. + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0)) + require.Equal(t, TCPStateClosed, conn.GetState()) +} + +func TestTCPFinWait1OwnAckDoesNotAdvance(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + conn := tracker.connections[key] + require.Equal(t, TCPStateFinWait1, conn.GetState()) + + // Our own ACK retransmit (same direction as originator) must not advance. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + require.Equal(t, TCPStateFinWait1, conn.GetState(), + "own ACK in FinWait1 must not advance to FinWait2") +} + +func TestTCPPerStateTeardownTimeouts(t *testing.T) { + // Verify cleanup reaps entries in each teardown state at the configured + // per-state timeout, not at the single handshake timeout. + t.Setenv(EnvTCPFinWaitTimeout, "50ms") + t.Setenv(EnvTCPCloseWaitTimeout, "80ms") + t.Setenv(EnvTCPLastAckTimeout, "30ms") + + dstIP := netip.MustParseAddr("100.64.0.2") + dstPort := uint16(80) + + // Drives a connection to the target state, forces its lastSeen well + // beyond the configured timeout, runs cleanup, and asserts reaping. + cases := []struct { + name string + // drive takes a fresh tracker and returns the conn key after + // transitioning the flow into the intended teardown state. + drive func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) + }{ + { + name: "FinWait1", + drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) { + establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort) + tr.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) // → FinWait1 + return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateFinWait1 + }, + }, + { + name: "FinWait2", + drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) { + establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort) + tr.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) // FinWait1 + require.True(t, tr.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0)) // → FinWait2 + return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateFinWait2 + }, + }, + { + name: "CloseWait", + drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) { + establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort) + require.True(t, tr.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)) // → CloseWait + return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateCloseWait + }, + }, + { + name: "LastAck", + drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) { + establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort) + require.True(t, tr.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)) // CloseWait + tr.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) // → LastAck + return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateLastAck + }, + }, + } + + // Use a unique source port per subtest so nothing aliases. + port := uint16(12345) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + require.Equal(t, 50*time.Millisecond, tracker.finWaitTimeout) + require.Equal(t, 80*time.Millisecond, tracker.closeWaitTimeout) + require.Equal(t, 30*time.Millisecond, tracker.lastAckTimeout) + + srcIP := netip.MustParseAddr("100.64.0.1") + port++ + key, wantState := c.drive(t, tracker, srcIP, port) + conn := tracker.connections[key] + require.NotNil(t, conn) + require.Equal(t, wantState, conn.GetState()) + + // Age the entry past the largest per-state timeout. + conn.lastSeen.Store(time.Now().Add(-500 * time.Millisecond).UnixNano()) + tracker.cleanup() + _, exists := tracker.connections[key] + require.False(t, exists, "%s entry should be reaped", c.name) + }) + } +} + +func TestTCPEstablishedPSHACKInFinStates(t *testing.T) { + // Verifies FIN|PSH|ACK and bare ACK keepalives are not dropped in FIN + // teardown states, which some stacks emit during close. + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // Peer FIN -> CloseWait. + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)) + + // Peer pushes trailing data + FIN|PSH|ACK (legal). + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPPush|TCPAck, 100), + "FIN|PSH|ACK in CloseWait must be accepted") + + // Bare ACK keepalive from peer in CloseWait must be accepted. + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0), + "bare ACK in CloseWait must be accepted") +} diff --git a/client/firewall/uspfilter/conntrack/udp.go b/client/firewall/uspfilter/conntrack/udp.go index a3b6a418b..335c5832a 100644 --- a/client/firewall/uspfilter/conntrack/udp.go +++ b/client/firewall/uspfilter/conntrack/udp.go @@ -17,6 +17,9 @@ const ( DefaultUDPTimeout = 30 * time.Second // UDPCleanupInterval is how often we check for stale connections UDPCleanupInterval = 15 * time.Second + + // EnvUDPMaxEntries caps the UDP conntrack table size. + EnvUDPMaxEntries = "NB_CONNTRACK_UDP_MAX" ) // UDPConnTrack represents a UDP connection state @@ -34,6 +37,7 @@ type UDPTracker struct { cleanupTicker *time.Ticker tickerCancel context.CancelFunc mutex sync.RWMutex + maxEntries int flowLogger nftypes.FlowLogger } @@ -51,6 +55,7 @@ func NewUDPTracker(timeout time.Duration, logger *nblog.Logger, flowLogger nftyp timeout: timeout, cleanupTicker: time.NewTicker(UDPCleanupInterval), tickerCancel: cancel, + maxEntries: envInt(logger, EnvUDPMaxEntries, DefaultMaxUDPEntries), flowLogger: flowLogger, } @@ -117,13 +122,18 @@ func (t *UDPTracker) track(srcIP netip.Addr, dstIP netip.Addr, srcPort uint16, d conn.UpdateCounters(direction, size) t.mutex.Lock() + if t.maxEntries > 0 && len(t.connections) >= t.maxEntries { + t.evictOneLocked() + } t.connections[key] = conn t.mutex.Unlock() - if origPort != 0 { - t.logger.Trace4("New %s UDP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort) - } else { - t.logger.Trace2("New %s UDP connection: %s", direction, key) + if t.logger.Enabled(nblog.LevelTrace) { + if origPort != 0 { + t.logger.Trace4("New %s UDP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort) + } else { + t.logger.Trace2("New %s UDP connection: %s", direction, key) + } } t.sendEvent(nftypes.TypeStart, conn, ruleID) } @@ -151,6 +161,34 @@ func (t *UDPTracker) IsValidInbound(srcIP netip.Addr, dstIP netip.Addr, srcPort return true } +// evictOneLocked removes one entry to make room. Caller must hold t.mutex. +// Bounded sample: picks the oldest among up to evictSampleSize entries. +func (t *UDPTracker) evictOneLocked() { + var candKey ConnKey + var candSeen int64 + haveCand := false + sampled := 0 + + for k, c := range t.connections { + seen := c.lastSeen.Load() + if !haveCand || seen < candSeen { + candKey = k + candSeen = seen + haveCand = true + } + sampled++ + if sampled >= evictSampleSize { + break + } + } + if haveCand { + if evicted := t.connections[candKey]; evicted != nil { + t.sendEvent(nftypes.TypeEnd, evicted, nil) + } + delete(t.connections, candKey) + } +} + // cleanupRoutine periodically removes stale connections func (t *UDPTracker) cleanupRoutine(ctx context.Context) { defer t.cleanupTicker.Stop() @@ -173,8 +211,10 @@ func (t *UDPTracker) cleanup() { if conn.timeoutExceeded(t.timeout) { delete(t.connections, key) - t.logger.Trace5("Removed UDP connection %s (timeout) [in: %d Pkts/%d B, out: %d Pkts/%d B]", - key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace5("Removed UDP connection %s (timeout) [in: %d Pkts/%d B, out: %d Pkts/%d B]", + key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + } t.sendEvent(nftypes.TypeEnd, conn, nil) } } diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index 5ecd08abf..91866dcab 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -787,7 +787,9 @@ func (m *Manager) filterOutbound(packetData []byte, size int) bool { srcIP, dstIP := m.extractIPs(d) if !srcIP.IsValid() { - m.logger.Error1("Unknown network layer: %v", d.decoded[0]) + if m.logger.Enabled(nblog.LevelError) { + m.logger.Error1("Unknown network layer: %v", d.decoded[0]) + } return false } @@ -901,7 +903,9 @@ func (m *Manager) clampTCPMSS(packetData []byte, d *decoder) bool { return false } - m.logger.Trace2("Clamped TCP MSS from %d to %d", currentMSS, mssClampValue) + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("Clamped TCP MSS from %d to %d", currentMSS, mssClampValue) + } return true } @@ -1044,11 +1048,13 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { // TODO: pass fragments of routed packets to forwarder if fragment { - if d.decoded[0] == layers.LayerTypeIPv4 { - m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v", - srcIP, dstIP, d.ip4.Id, d.ip4.Flags) - } else { - m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP) + if m.logger.Enabled(nblog.LevelTrace) { + if d.decoded[0] == layers.LayerTypeIPv4 { + m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v", + srcIP, dstIP, d.ip4.Id, d.ip4.Flags) + } else { + m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP) + } } return false } @@ -1091,8 +1097,10 @@ func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packet pnum := getProtocolFromPacket(d) srcPort, dstPort := getPortsFromPacket(d) - m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, pnum, srcIP, srcPort, dstIP, dstPort) + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + ruleID, pnum, srcIP, srcPort, dstIP, dstPort) + } m.flowLogger.StoreEvent(nftypes.EventFields{ FlowID: uuid.New(), @@ -1142,8 +1150,10 @@ func (m *Manager) handleForwardedLocalTraffic(packetData []byte) bool { func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { // Drop if routing is disabled if !m.routingEnabled.Load() { - m.logger.Trace2("Dropping routed packet (routing disabled): src=%s dst=%s", - srcIP, dstIP) + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("Dropping routed packet (routing disabled): src=%s dst=%s", + srcIP, dstIP) + } return true } @@ -1160,8 +1170,10 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe if !pass { proto := getProtocolFromPacket(d) - m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, proto, srcIP, srcPort, dstIP, dstPort) + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + ruleID, proto, srcIP, srcPort, dstIP, dstPort) + } m.flowLogger.StoreEvent(nftypes.EventFields{ FlowID: uuid.New(), @@ -1287,7 +1299,9 @@ func getPortsFromPacket(d *decoder) (srcPort, dstPort uint16) { // It returns true, true if the packet is a fragment and valid. func (m *Manager) isValidPacket(d *decoder, packetData []byte) (bool, bool) { if err := d.decodePacket(packetData); err != nil { - m.logger.Trace1("couldn't decode packet, err: %s", err) + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace1("couldn't decode packet, err: %s", err) + } return false, false } diff --git a/client/firewall/uspfilter/forwarder/icmp.go b/client/firewall/uspfilter/forwarder/icmp.go index 3922c2052..d6d4e705e 100644 --- a/client/firewall/uspfilter/forwarder/icmp.go +++ b/client/firewall/uspfilter/forwarder/icmp.go @@ -13,6 +13,7 @@ import ( "gvisor.dev/gvisor/pkg/tcpip/header" "gvisor.dev/gvisor/pkg/tcpip/stack" + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" ) @@ -97,8 +98,10 @@ func (f *Forwarder) forwardICMPPacket(id stack.TransportEndpointID, payload []by return nil, fmt.Errorf("write ICMP packet: %w", err) } - f.logger.Trace3("forwarder: Forwarded ICMP packet %v type %v code %v", - epID(id), icmpType, icmpCode) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace3("forwarder: Forwarded ICMP packet %v type %v code %v", + epID(id), icmpType, icmpCode) + } return conn, nil } @@ -121,12 +124,14 @@ func (f *Forwarder) handleICMPViaSocket(flowID uuid.UUID, id stack.TransportEndp txBytes := f.handleEchoResponse(conn, id, v6) rtt := time.Since(sendTime).Round(10 * time.Microsecond) - proto := "ICMP" - if v6 { - proto = "ICMPv6" + if f.logger.Enabled(nblog.LevelTrace) { + proto := "ICMP" + if v6 { + proto = "ICMPv6" + } + f.logger.Trace5("forwarder: Forwarded %s echo reply %v type %v code %v (rtt=%v, raw socket)", + proto, epID(id), icmpType, icmpCode, rtt) } - f.logger.Trace5("forwarder: Forwarded %s echo reply %v type %v code %v (rtt=%v, raw socket)", - proto, epID(id), icmpType, icmpCode, rtt) f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes)) } @@ -224,13 +229,17 @@ func (f *Forwarder) handleICMPViaPing(flowID uuid.UUID, id stack.TransportEndpoi } rtt := time.Since(pingStart).Round(10 * time.Microsecond) - f.logger.Trace3("forwarder: Forwarded ICMP echo request %v type %v code %v", - epID(id), icmpType, icmpCode) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace3("forwarder: Forwarded ICMP echo request %v type %v code %v", + epID(id), icmpType, icmpCode) + } txBytes := f.synthesizeEchoReply(id, icmpData) - f.logger.Trace4("forwarder: Forwarded ICMP echo reply %v type %v code %v (rtt=%v, ping binary)", - epID(id), icmpType, icmpCode, rtt) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace4("forwarder: Forwarded ICMP echo reply %v type %v code %v (rtt=%v, ping binary)", + epID(id), icmpType, icmpCode, rtt) + } f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes)) } diff --git a/client/firewall/uspfilter/forwarder/tcp.go b/client/firewall/uspfilter/forwarder/tcp.go index 8844463f5..c65ebcde0 100644 --- a/client/firewall/uspfilter/forwarder/tcp.go +++ b/client/firewall/uspfilter/forwarder/tcp.go @@ -1,11 +1,8 @@ package forwarder import ( - "context" - "io" "net" "strconv" - "sync" "github.com/google/uuid" @@ -15,7 +12,9 @@ import ( "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" "gvisor.dev/gvisor/pkg/waiter" + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" + "github.com/netbirdio/netbird/util/netrelay" ) // handleTCP is called by the TCP forwarder for new connections. @@ -37,7 +36,9 @@ func (f *Forwarder) handleTCP(r *tcp.ForwarderRequest) { outConn, err := (&net.Dialer{}).DialContext(f.ctx, "tcp", dialAddr) if err != nil { r.Complete(true) - f.logger.Trace2("forwarder: dial error for %v: %v", epID(id), err) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace2("forwarder: dial error for %v: %v", epID(id), err) + } return } @@ -60,64 +61,22 @@ func (f *Forwarder) handleTCP(r *tcp.ForwarderRequest) { inConn := gonet.NewTCPConn(&wq, ep) success = true - f.logger.Trace1("forwarder: established TCP connection %v", epID(id)) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace1("forwarder: established TCP connection %v", epID(id)) + } go f.proxyTCP(id, inConn, outConn, ep, flowID) } func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn, outConn net.Conn, ep tcpip.Endpoint, flowID uuid.UUID) { + // netrelay.Relay copies bidirectionally with proper half-close propagation + // and fully closes both conns before returning. + bytesFromInToOut, bytesFromOutToIn := netrelay.Relay(f.ctx, inConn, outConn, netrelay.Options{ + Logger: f.logger, + }) - ctx, cancel := context.WithCancel(f.ctx) - defer cancel() - - go func() { - <-ctx.Done() - // Close connections and endpoint. - if err := inConn.Close(); err != nil && !isClosedError(err) { - f.logger.Debug1("forwarder: inConn close error: %v", err) - } - if err := outConn.Close(); err != nil && !isClosedError(err) { - f.logger.Debug1("forwarder: outConn close error: %v", err) - } - - ep.Close() - }() - - var wg sync.WaitGroup - wg.Add(2) - - var ( - bytesFromInToOut int64 // bytes from client to server (tx for client) - bytesFromOutToIn int64 // bytes from server to client (rx for client) - errInToOut error - errOutToIn error - ) - - go func() { - bytesFromInToOut, errInToOut = io.Copy(outConn, inConn) - cancel() - wg.Done() - }() - - go func() { - - bytesFromOutToIn, errOutToIn = io.Copy(inConn, outConn) - cancel() - wg.Done() - }() - - wg.Wait() - - if errInToOut != nil { - if !isClosedError(errInToOut) { - f.logger.Error2("proxyTCP: copy error (in → out) for %s: %v", epID(id), errInToOut) - } - } - if errOutToIn != nil { - if !isClosedError(errOutToIn) { - f.logger.Error2("proxyTCP: copy error (out → in) for %s: %v", epID(id), errOutToIn) - } - } + // Close the netstack endpoint after both conns are drained. + ep.Close() var rxPackets, txPackets uint64 if tcpStats, ok := ep.Stats().(*tcp.Stats); ok { @@ -126,7 +85,9 @@ func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn txPackets = tcpStats.SegmentsReceived.Value() } - f.logger.Trace5("forwarder: Removed TCP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, bytesFromOutToIn, txPackets, bytesFromInToOut) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace5("forwarder: Removed TCP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, bytesFromOutToIn, txPackets, bytesFromInToOut) + } f.sendTCPEvent(nftypes.TypeEnd, flowID, id, uint64(bytesFromOutToIn), uint64(bytesFromInToOut), rxPackets, txPackets) } diff --git a/client/firewall/uspfilter/forwarder/udp.go b/client/firewall/uspfilter/forwarder/udp.go index c92fa1f32..d840ef06b 100644 --- a/client/firewall/uspfilter/forwarder/udp.go +++ b/client/firewall/uspfilter/forwarder/udp.go @@ -125,7 +125,9 @@ func (f *udpForwarder) cleanup() { delete(f.conns, idle.id) f.Unlock() - f.logger.Trace1("forwarder: cleaned up idle UDP connection %v", epID(idle.id)) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace1("forwarder: cleaned up idle UDP connection %v", epID(idle.id)) + } } } } @@ -144,7 +146,9 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) bool { _, exists := f.udpForwarder.conns[id] f.udpForwarder.RUnlock() if exists { - f.logger.Trace1("forwarder: existing UDP connection for %v", epID(id)) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace1("forwarder: existing UDP connection for %v", epID(id)) + } return true } @@ -206,7 +210,9 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) bool { f.udpForwarder.Unlock() success = true - f.logger.Trace1("forwarder: established UDP connection %v", epID(id)) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace1("forwarder: established UDP connection %v", epID(id)) + } go f.proxyUDP(connCtx, pConn, id, ep) return true @@ -265,7 +271,9 @@ func (f *Forwarder) proxyUDP(ctx context.Context, pConn *udpPacketConn, id stack txPackets = udpStats.PacketsReceived.Value() } - f.logger.Trace5("forwarder: Removed UDP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, rxBytes, txPackets, txBytes) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace5("forwarder: Removed UDP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, rxBytes, txPackets, txBytes) + } f.udpForwarder.Lock() delete(f.udpForwarder.conns, id) diff --git a/client/firewall/uspfilter/log/log.go b/client/firewall/uspfilter/log/log.go index c6ca55e70..03e7d4809 100644 --- a/client/firewall/uspfilter/log/log.go +++ b/client/firewall/uspfilter/log/log.go @@ -53,16 +53,17 @@ var levelStrings = map[Level]string{ } type logMessage struct { - level Level - format string - arg1 any - arg2 any - arg3 any - arg4 any - arg5 any - arg6 any - arg7 any - arg8 any + level Level + argCount uint8 + format string + arg1 any + arg2 any + arg3 any + arg4 any + arg5 any + arg6 any + arg7 any + arg8 any } // Logger is a high-performance, non-blocking logger @@ -107,6 +108,13 @@ func (l *Logger) SetLevel(level Level) { log.Debugf("Set uspfilter logger loglevel to %v", levelStrings[level]) } +// Enabled reports whether the given level is currently logged. Callers on the +// hot path should guard log sites with this to avoid boxing arguments into +// any when the level is off. +func (l *Logger) Enabled(level Level) bool { + return l.level.Load() >= uint32(level) +} + func (l *Logger) Error(format string) { if l.level.Load() >= uint32(LevelError) { select { @@ -155,7 +163,7 @@ func (l *Logger) Trace(format string) { func (l *Logger) Error1(format string, arg1 any) { if l.level.Load() >= uint32(LevelError) { select { - case l.msgChannel <- logMessage{level: LevelError, format: format, arg1: arg1}: + case l.msgChannel <- logMessage{level: LevelError, argCount: 1, format: format, arg1: arg1}: default: } } @@ -164,7 +172,16 @@ func (l *Logger) Error1(format string, arg1 any) { func (l *Logger) Error2(format string, arg1, arg2 any) { if l.level.Load() >= uint32(LevelError) { select { - case l.msgChannel <- logMessage{level: LevelError, format: format, arg1: arg1, arg2: arg2}: + case l.msgChannel <- logMessage{level: LevelError, argCount: 2, format: format, arg1: arg1, arg2: arg2}: + default: + } + } +} + +func (l *Logger) Warn2(format string, arg1, arg2 any) { + if l.level.Load() >= uint32(LevelWarn) { + select { + case l.msgChannel <- logMessage{level: LevelWarn, argCount: 2, format: format, arg1: arg1, arg2: arg2}: default: } } @@ -173,7 +190,7 @@ func (l *Logger) Error2(format string, arg1, arg2 any) { func (l *Logger) Warn3(format string, arg1, arg2, arg3 any) { if l.level.Load() >= uint32(LevelWarn) { select { - case l.msgChannel <- logMessage{level: LevelWarn, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: + case l.msgChannel <- logMessage{level: LevelWarn, argCount: 3, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: default: } } @@ -182,7 +199,7 @@ func (l *Logger) Warn3(format string, arg1, arg2, arg3 any) { func (l *Logger) Warn4(format string, arg1, arg2, arg3, arg4 any) { if l.level.Load() >= uint32(LevelWarn) { select { - case l.msgChannel <- logMessage{level: LevelWarn, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}: + case l.msgChannel <- logMessage{level: LevelWarn, argCount: 4, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}: default: } } @@ -191,7 +208,7 @@ func (l *Logger) Warn4(format string, arg1, arg2, arg3, arg4 any) { func (l *Logger) Debug1(format string, arg1 any) { if l.level.Load() >= uint32(LevelDebug) { select { - case l.msgChannel <- logMessage{level: LevelDebug, format: format, arg1: arg1}: + case l.msgChannel <- logMessage{level: LevelDebug, argCount: 1, format: format, arg1: arg1}: default: } } @@ -200,7 +217,7 @@ func (l *Logger) Debug1(format string, arg1 any) { func (l *Logger) Debug2(format string, arg1, arg2 any) { if l.level.Load() >= uint32(LevelDebug) { select { - case l.msgChannel <- logMessage{level: LevelDebug, format: format, arg1: arg1, arg2: arg2}: + case l.msgChannel <- logMessage{level: LevelDebug, argCount: 2, format: format, arg1: arg1, arg2: arg2}: default: } } @@ -209,16 +226,59 @@ func (l *Logger) Debug2(format string, arg1, arg2 any) { func (l *Logger) Debug3(format string, arg1, arg2, arg3 any) { if l.level.Load() >= uint32(LevelDebug) { select { - case l.msgChannel <- logMessage{level: LevelDebug, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: + case l.msgChannel <- logMessage{level: LevelDebug, argCount: 3, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: default: } } } +// Debugf is the variadic shape. Dispatches to Debug/Debug1/Debug2/Debug3 +// to avoid allocating an args slice on the fast path when the arg count is +// known (0-3). Args beyond 3 land on the general variadic path; callers on +// the hot path should prefer DebugN for known counts. +func (l *Logger) Debugf(format string, args ...any) { + if l.level.Load() < uint32(LevelDebug) { + return + } + switch len(args) { + case 0: + l.Debug(format) + case 1: + l.Debug1(format, args[0]) + case 2: + l.Debug2(format, args[0], args[1]) + case 3: + l.Debug3(format, args[0], args[1], args[2]) + default: + l.sendVariadic(LevelDebug, format, args) + } +} + +// sendVariadic packs a slice of arguments into a logMessage and non-blocking +// enqueues it. Used for arg counts beyond the fixed-arity fast paths. Args +// beyond the 8-arg slot limit are dropped so callers don't produce silently +// empty log lines via uint8 wraparound in argCount. +func (l *Logger) sendVariadic(level Level, format string, args []any) { + const maxArgs = 8 + n := len(args) + if n > maxArgs { + n = maxArgs + } + msg := logMessage{level: level, argCount: uint8(n), format: format} + slots := [maxArgs]*any{&msg.arg1, &msg.arg2, &msg.arg3, &msg.arg4, &msg.arg5, &msg.arg6, &msg.arg7, &msg.arg8} + for i := 0; i < n; i++ { + *slots[i] = args[i] + } + select { + case l.msgChannel <- msg: + default: + } +} + func (l *Logger) Trace1(format string, arg1 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 1, format: format, arg1: arg1}: default: } } @@ -227,7 +287,7 @@ func (l *Logger) Trace1(format string, arg1 any) { func (l *Logger) Trace2(format string, arg1, arg2 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 2, format: format, arg1: arg1, arg2: arg2}: default: } } @@ -236,7 +296,7 @@ func (l *Logger) Trace2(format string, arg1, arg2 any) { func (l *Logger) Trace3(format string, arg1, arg2, arg3 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 3, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: default: } } @@ -245,7 +305,7 @@ func (l *Logger) Trace3(format string, arg1, arg2, arg3 any) { func (l *Logger) Trace4(format string, arg1, arg2, arg3, arg4 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 4, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}: default: } } @@ -254,7 +314,7 @@ func (l *Logger) Trace4(format string, arg1, arg2, arg3, arg4 any) { func (l *Logger) Trace5(format string, arg1, arg2, arg3, arg4, arg5 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 5, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5}: default: } } @@ -263,7 +323,7 @@ func (l *Logger) Trace5(format string, arg1, arg2, arg3, arg4, arg5 any) { func (l *Logger) Trace6(format string, arg1, arg2, arg3, arg4, arg5, arg6 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 6, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6}: default: } } @@ -273,7 +333,7 @@ func (l *Logger) Trace6(format string, arg1, arg2, arg3, arg4, arg5, arg6 any) { func (l *Logger) Trace8(format string, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6, arg7: arg7, arg8: arg8}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 8, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6, arg7: arg7, arg8: arg8}: default: } } @@ -286,35 +346,8 @@ func (l *Logger) formatMessage(buf *[]byte, msg logMessage) { *buf = append(*buf, levelStrings[msg.level]...) *buf = append(*buf, ' ') - // Count non-nil arguments for switch - argCount := 0 - if msg.arg1 != nil { - argCount++ - if msg.arg2 != nil { - argCount++ - if msg.arg3 != nil { - argCount++ - if msg.arg4 != nil { - argCount++ - if msg.arg5 != nil { - argCount++ - if msg.arg6 != nil { - argCount++ - if msg.arg7 != nil { - argCount++ - if msg.arg8 != nil { - argCount++ - } - } - } - } - } - } - } - } - var formatted string - switch argCount { + switch msg.argCount { case 0: formatted = msg.format case 1: diff --git a/client/firewall/uspfilter/nat.go b/client/firewall/uspfilter/nat.go index 0d411c21e..5d51c1538 100644 --- a/client/firewall/uspfilter/nat.go +++ b/client/firewall/uspfilter/nat.go @@ -11,6 +11,7 @@ import ( "github.com/google/gopacket/layers" firewall "github.com/netbirdio/netbird/client/firewall/manager" + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" ) var ( @@ -262,11 +263,15 @@ func (m *Manager) translateOutboundDNAT(packetData []byte, d *decoder) bool { } if err := m.rewritePacketIP(packetData, d, translatedIP, false); err != nil { - m.logger.Error1("failed to rewrite packet destination: %v", err) + if m.logger.Enabled(nblog.LevelError) { + m.logger.Error1("failed to rewrite packet destination: %v", err) + } return false } - m.logger.Trace2("DNAT: %s -> %s", dstIP, translatedIP) + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("DNAT: %s -> %s", dstIP, translatedIP) + } return true } @@ -283,11 +288,15 @@ func (m *Manager) translateInboundReverse(packetData []byte, d *decoder) bool { } if err := m.rewritePacketIP(packetData, d, originalIP, true); err != nil { - m.logger.Error1("failed to rewrite packet source: %v", err) + if m.logger.Enabled(nblog.LevelError) { + m.logger.Error1("failed to rewrite packet source: %v", err) + } return false } - m.logger.Trace2("Reverse DNAT: %s -> %s", srcIP, originalIP) + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("Reverse DNAT: %s -> %s", srcIP, originalIP) + } return true } @@ -612,7 +621,9 @@ func (m *Manager) applyPortRule(packetData []byte, d *decoder, srcIP, dstIP neti } if err := rewriteFn(packetData, d, rule.targetPort, destinationPortOffset); err != nil { - m.logger.Error1("failed to rewrite port: %v", err) + if m.logger.Enabled(nblog.LevelError) { + m.logger.Error1("failed to rewrite port: %v", err) + } return false } d.dnatOrigPort = rule.origPort diff --git a/client/ssh/client/client.go b/client/ssh/client/client.go index 7f72a72cf..ebf8eb794 100644 --- a/client/ssh/client/client.go +++ b/client/ssh/client/client.go @@ -25,6 +25,7 @@ import ( nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/ssh/detection" "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/netrelay" ) const ( @@ -536,7 +537,7 @@ func (c *Client) LocalPortForward(ctx context.Context, localAddr, remoteAddr str continue } - go c.handleLocalForward(localConn, remoteAddr) + go c.handleLocalForward(ctx, localConn, remoteAddr) } }() @@ -548,7 +549,7 @@ func (c *Client) LocalPortForward(ctx context.Context, localAddr, remoteAddr str } // handleLocalForward handles a single local port forwarding connection -func (c *Client) handleLocalForward(localConn net.Conn, remoteAddr string) { +func (c *Client) handleLocalForward(ctx context.Context, localConn net.Conn, remoteAddr string) { defer func() { if err := localConn.Close(); err != nil { log.Debugf("local port forwarding: close local connection: %v", err) @@ -571,7 +572,7 @@ func (c *Client) handleLocalForward(localConn net.Conn, remoteAddr string) { } }() - nbssh.BidirectionalCopy(log.NewEntry(log.StandardLogger()), localConn, channel) + netrelay.Relay(ctx, localConn, channel, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())}) } // RemotePortForward sets up remote port forwarding, binding on remote and forwarding to localAddr @@ -653,16 +654,19 @@ func (c *Client) handleRemoteForwardChannels(ctx context.Context, localAddr stri select { case <-ctx.Done(): return - case newChan := <-channelRequests: + case newChan, ok := <-channelRequests: + if !ok { + return + } if newChan != nil { - go c.handleRemoteForwardChannel(newChan, localAddr) + go c.handleRemoteForwardChannel(ctx, newChan, localAddr) } } } } // handleRemoteForwardChannel handles a single forwarded-tcpip channel -func (c *Client) handleRemoteForwardChannel(newChan ssh.NewChannel, localAddr string) { +func (c *Client) handleRemoteForwardChannel(ctx context.Context, newChan ssh.NewChannel, localAddr string) { channel, reqs, err := newChan.Accept() if err != nil { return @@ -675,8 +679,14 @@ func (c *Client) handleRemoteForwardChannel(newChan ssh.NewChannel, localAddr st go ssh.DiscardRequests(reqs) - localConn, err := net.Dial("tcp", localAddr) + // Bound the dial so a black-holed localAddr can't pin the accepted SSH + // channel open indefinitely; the relay itself runs under the outer ctx. + dialCtx, cancelDial := context.WithTimeout(ctx, 10*time.Second) + var dialer net.Dialer + localConn, err := dialer.DialContext(dialCtx, "tcp", localAddr) + cancelDial() if err != nil { + log.Debugf("remote port forwarding: dial %s: %v", localAddr, err) return } defer func() { @@ -685,7 +695,7 @@ func (c *Client) handleRemoteForwardChannel(newChan ssh.NewChannel, localAddr st } }() - nbssh.BidirectionalCopy(log.NewEntry(log.StandardLogger()), localConn, channel) + netrelay.Relay(ctx, localConn, channel, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())}) } // tcpipForwardMsg represents the structure for tcpip-forward requests diff --git a/client/ssh/common.go b/client/ssh/common.go index f6aec5f9c..92e647b7d 100644 --- a/client/ssh/common.go +++ b/client/ssh/common.go @@ -194,63 +194,3 @@ func buildAddressList(hostname string, remote net.Addr) []string { return addresses } -// BidirectionalCopy copies data bidirectionally between two io.ReadWriter connections. -// It waits for both directions to complete before returning. -// The caller is responsible for closing the connections. -func BidirectionalCopy(logger *log.Entry, rw1, rw2 io.ReadWriter) { - done := make(chan struct{}, 2) - - go func() { - if _, err := io.Copy(rw2, rw1); err != nil && !isExpectedCopyError(err) { - logger.Debugf("copy error (1->2): %v", err) - } - done <- struct{}{} - }() - - go func() { - if _, err := io.Copy(rw1, rw2); err != nil && !isExpectedCopyError(err) { - logger.Debugf("copy error (2->1): %v", err) - } - done <- struct{}{} - }() - - <-done - <-done -} - -func isExpectedCopyError(err error) bool { - return errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) -} - -// BidirectionalCopyWithContext copies data bidirectionally between two io.ReadWriteCloser connections. -// It waits for both directions to complete or for context cancellation before returning. -// Both connections are closed when the function returns. -func BidirectionalCopyWithContext(logger *log.Entry, ctx context.Context, conn1, conn2 io.ReadWriteCloser) { - done := make(chan struct{}, 2) - - go func() { - if _, err := io.Copy(conn2, conn1); err != nil && !isExpectedCopyError(err) { - logger.Debugf("copy error (1->2): %v", err) - } - done <- struct{}{} - }() - - go func() { - if _, err := io.Copy(conn1, conn2); err != nil && !isExpectedCopyError(err) { - logger.Debugf("copy error (2->1): %v", err) - } - done <- struct{}{} - }() - - select { - case <-ctx.Done(): - case <-done: - select { - case <-ctx.Done(): - case <-done: - } - } - - _ = conn1.Close() - _ = conn2.Close() -} diff --git a/client/ssh/proxy/proxy.go b/client/ssh/proxy/proxy.go index eb659fe21..73b50122c 100644 --- a/client/ssh/proxy/proxy.go +++ b/client/ssh/proxy/proxy.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/client/proto" nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/ssh/detection" + "github.com/netbirdio/netbird/util/netrelay" "github.com/netbirdio/netbird/version" ) @@ -352,7 +353,7 @@ func (p *SSHProxy) directTCPIPHandler(_ *ssh.Server, _ *cryptossh.ServerConn, ne } go cryptossh.DiscardRequests(clientReqs) - nbssh.BidirectionalCopyWithContext(log.NewEntry(log.StandardLogger()), sshCtx, clientChan, backendChan) + netrelay.Relay(sshCtx, clientChan, backendChan, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())}) } func (p *SSHProxy) sftpSubsystemHandler(s ssh.Session, jwtToken string) { @@ -591,7 +592,7 @@ func (p *SSHProxy) handleForwardedChannel(sshCtx ssh.Context, sshConn *cryptossh } go cryptossh.DiscardRequests(clientReqs) - nbssh.BidirectionalCopyWithContext(log.NewEntry(log.StandardLogger()), sshCtx, clientChan, backendChan) + netrelay.Relay(sshCtx, clientChan, backendChan, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())}) } func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string) (*cryptossh.Client, error) { diff --git a/client/ssh/server/port_forwarding.go b/client/ssh/server/port_forwarding.go index f5ac66fca..a47fdb48a 100644 --- a/client/ssh/server/port_forwarding.go +++ b/client/ssh/server/port_forwarding.go @@ -17,7 +17,7 @@ import ( log "github.com/sirupsen/logrus" cryptossh "golang.org/x/crypto/ssh" - nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/util/netrelay" ) const privilegedPortThreshold = 1024 @@ -357,7 +357,7 @@ func (s *Server) handleRemoteForwardConnection(ctx ssh.Context, conn net.Conn, h return } - nbssh.BidirectionalCopyWithContext(logger, ctx, conn, channel) + netrelay.Relay(ctx, conn, channel, netrelay.Options{Logger: logger}) } // openForwardChannel creates an SSH forwarded-tcpip channel diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index de40d3091..6735e0f3b 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -8,9 +8,9 @@ import ( "fmt" "io" "net" - "strconv" "net/netip" "slices" + "strconv" "strings" "sync" "time" @@ -27,6 +27,7 @@ import ( "github.com/netbirdio/netbird/client/ssh/detection" "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/auth/jwt" + "github.com/netbirdio/netbird/util/netrelay" "github.com/netbirdio/netbird/version" ) @@ -53,6 +54,10 @@ const ( DefaultJWTMaxTokenAge = 10 * 60 ) +// directTCPIPDialTimeout bounds how long relayDirectTCPIP waits on a dial to +// the forwarded destination before rejecting the SSH channel. +const directTCPIPDialTimeout = 30 * time.Second + var ( ErrPrivilegedUserDisabled = errors.New(msgPrivilegedUserDisabled) ErrUserNotFound = errors.New("user not found") @@ -933,5 +938,29 @@ func (s *Server) directTCPIPHandler(srv *ssh.Server, conn *cryptossh.ServerConn, s.addConnectionPortForward(ctx.User(), ctx.RemoteAddr(), forwardAddr) logger.Infof("local port forwarding: %s", hostPort) - ssh.DirectTCPIPHandler(srv, conn, newChan, ctx) + s.relayDirectTCPIP(ctx, newChan, payload.Host, int(payload.Port), logger) +} + +// relayDirectTCPIP is a netrelay-based replacement for gliderlabs' +// DirectTCPIPHandler. The upstream handler closes both sides on the first +// EOF; netrelay.Relay propagates CloseWrite so each direction drains on its +// own terms. +func (s *Server) relayDirectTCPIP(ctx ssh.Context, newChan cryptossh.NewChannel, host string, port int, logger *log.Entry) { + dest := net.JoinHostPort(host, strconv.Itoa(port)) + + dialer := net.Dialer{Timeout: directTCPIPDialTimeout} + dconn, err := dialer.DialContext(ctx, "tcp", dest) + if err != nil { + _ = newChan.Reject(cryptossh.ConnectionFailed, err.Error()) + return + } + + ch, reqs, err := newChan.Accept() + if err != nil { + _ = dconn.Close() + return + } + go cryptossh.DiscardRequests(reqs) + + netrelay.Relay(ctx, dconn, ch, netrelay.Options{Logger: logger}) } diff --git a/proxy/internal/tcp/peekedconn.go b/proxy/internal/tcp/peekedconn.go index 26f3e5c7c..23a348352 100644 --- a/proxy/internal/tcp/peekedconn.go +++ b/proxy/internal/tcp/peekedconn.go @@ -25,6 +25,12 @@ func (c *peekedConn) Read(b []byte) (int, error) { return c.reader.Read(b) } +// halfCloser matches connections that support shutting down the write +// side while keeping the read side open (e.g. *net.TCPConn). +type halfCloser interface { + CloseWrite() error +} + // CloseWrite delegates to the underlying connection if it supports // half-close (e.g. *net.TCPConn). Without this, embedding net.Conn // as an interface hides the concrete type's CloseWrite method, making diff --git a/proxy/internal/tcp/relay.go b/proxy/internal/tcp/relay.go deleted file mode 100644 index 39949818d..000000000 --- a/proxy/internal/tcp/relay.go +++ /dev/null @@ -1,156 +0,0 @@ -package tcp - -import ( - "context" - "errors" - "io" - "net" - "sync" - "time" - - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/proxy/internal/netutil" -) - -// errIdleTimeout is returned when a relay connection is closed due to inactivity. -var errIdleTimeout = errors.New("idle timeout") - -// DefaultIdleTimeout is the default idle timeout for TCP relay connections. -// A zero value disables idle timeout checking. -const DefaultIdleTimeout = 5 * time.Minute - -// halfCloser is implemented by connections that support half-close -// (e.g. *net.TCPConn). When one copy direction finishes, we signal -// EOF to the remote by closing the write side while keeping the read -// side open so the other direction can drain. -type halfCloser interface { - CloseWrite() error -} - -// copyBufPool avoids allocating a new 32KB buffer per io.Copy call. -var copyBufPool = sync.Pool{ - New: func() any { - buf := make([]byte, 32*1024) - return &buf - }, -} - -// Relay copies data bidirectionally between src and dst until both -// sides are done or the context is canceled. When idleTimeout is -// non-zero, each direction's read is deadline-guarded; if no data -// flows within the timeout the connection is torn down. When one -// direction finishes, it half-closes the write side of the -// destination (if supported) to signal EOF, allowing the other -// direction to drain gracefully before the full connection teardown. -func Relay(ctx context.Context, logger *log.Entry, src, dst net.Conn, idleTimeout time.Duration) (srcToDst, dstToSrc int64) { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - go func() { - <-ctx.Done() - _ = src.Close() - _ = dst.Close() - }() - - var wg sync.WaitGroup - wg.Add(2) - - var errSrcToDst, errDstToSrc error - - go func() { - defer wg.Done() - srcToDst, errSrcToDst = copyWithIdleTimeout(dst, src, idleTimeout) - halfClose(dst) - cancel() - }() - - go func() { - defer wg.Done() - dstToSrc, errDstToSrc = copyWithIdleTimeout(src, dst, idleTimeout) - halfClose(src) - cancel() - }() - - wg.Wait() - - if errors.Is(errSrcToDst, errIdleTimeout) || errors.Is(errDstToSrc, errIdleTimeout) { - logger.Debug("relay closed due to idle timeout") - } - if errSrcToDst != nil && !isExpectedCopyError(errSrcToDst) { - logger.Debugf("relay copy error (src→dst): %v", errSrcToDst) - } - if errDstToSrc != nil && !isExpectedCopyError(errDstToSrc) { - logger.Debugf("relay copy error (dst→src): %v", errDstToSrc) - } - - return srcToDst, dstToSrc -} - -// copyWithIdleTimeout copies from src to dst using a pooled buffer. -// When idleTimeout > 0 it sets a read deadline on src before each -// read and treats a timeout as an idle-triggered close. -func copyWithIdleTimeout(dst io.Writer, src io.Reader, idleTimeout time.Duration) (int64, error) { - bufp := copyBufPool.Get().(*[]byte) - defer copyBufPool.Put(bufp) - - if idleTimeout <= 0 { - return io.CopyBuffer(dst, src, *bufp) - } - - conn, ok := src.(net.Conn) - if !ok { - return io.CopyBuffer(dst, src, *bufp) - } - - buf := *bufp - var total int64 - for { - if err := conn.SetReadDeadline(time.Now().Add(idleTimeout)); err != nil { - return total, err - } - nr, readErr := src.Read(buf) - if nr > 0 { - n, err := checkedWrite(dst, buf[:nr]) - total += n - if err != nil { - return total, err - } - } - if readErr != nil { - if netutil.IsTimeout(readErr) { - return total, errIdleTimeout - } - return total, readErr - } - } -} - -// checkedWrite writes buf to dst and returns the number of bytes written. -// It guards against short writes and negative counts per io.Copy convention. -func checkedWrite(dst io.Writer, buf []byte) (int64, error) { - nw, err := dst.Write(buf) - if nw < 0 || nw > len(buf) { - nw = 0 - } - if err != nil { - return int64(nw), err - } - if nw != len(buf) { - return int64(nw), io.ErrShortWrite - } - return int64(nw), nil -} - -func isExpectedCopyError(err error) bool { - return errors.Is(err, errIdleTimeout) || netutil.IsExpectedError(err) -} - -// halfClose attempts to half-close the write side of the connection. -// If the connection does not support half-close, this is a no-op. -func halfClose(conn net.Conn) { - if hc, ok := conn.(halfCloser); ok { - // Best-effort; the full close will follow shortly. - _ = hc.CloseWrite() - } -} diff --git a/proxy/internal/tcp/relay_test.go b/proxy/internal/tcp/relay_test.go index e42d65b9d..f83a0d155 100644 --- a/proxy/internal/tcp/relay_test.go +++ b/proxy/internal/tcp/relay_test.go @@ -13,8 +13,13 @@ import ( "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/proxy/internal/netutil" + "github.com/netbirdio/netbird/util/netrelay" ) +func testRelay(ctx context.Context, logger *log.Entry, src, dst net.Conn, idleTimeout time.Duration) (int64, int64) { + return netrelay.Relay(ctx, src, dst, netrelay.Options{IdleTimeout: idleTimeout, Logger: logger}) +} + func TestRelay_BidirectionalCopy(t *testing.T) { srcClient, srcServer := net.Pipe() dstClient, dstServer := net.Pipe() @@ -41,7 +46,7 @@ func TestRelay_BidirectionalCopy(t *testing.T) { srcClient.Close() }() - s2d, d2s := Relay(ctx, logger, srcServer, dstServer, 0) + s2d, d2s := testRelay(ctx, logger, srcServer, dstServer, 0) assert.Equal(t, int64(len(srcData)), s2d, "bytes src→dst") assert.Equal(t, int64(len(dstData)), d2s, "bytes dst→src") @@ -58,7 +63,7 @@ func TestRelay_ContextCancellation(t *testing.T) { done := make(chan struct{}) go func() { - Relay(ctx, logger, srcServer, dstServer, 0) + testRelay(ctx, logger, srcServer, dstServer, 0) close(done) }() @@ -85,7 +90,7 @@ func TestRelay_OneSideClosed(t *testing.T) { done := make(chan struct{}) go func() { - Relay(ctx, logger, srcServer, dstServer, 0) + testRelay(ctx, logger, srcServer, dstServer, 0) close(done) }() @@ -129,7 +134,7 @@ func TestRelay_LargeTransfer(t *testing.T) { dstClient.Close() }() - s2d, _ := Relay(ctx, logger, srcServer, dstServer, 0) + s2d, _ := testRelay(ctx, logger, srcServer, dstServer, 0) assert.Equal(t, int64(len(data)), s2d, "should transfer all bytes") require.NoError(t, <-errCh) } @@ -182,7 +187,7 @@ func TestRelay_IdleTimeout(t *testing.T) { done := make(chan struct{}) var s2d, d2s int64 go func() { - s2d, d2s = Relay(ctx, logger, srcServer, dstServer, 200*time.Millisecond) + s2d, d2s = testRelay(ctx, logger, srcServer, dstServer, 200*time.Millisecond) close(done) }() diff --git a/proxy/internal/tcp/router.go b/proxy/internal/tcp/router.go index 9f8660aeb..05beb658b 100644 --- a/proxy/internal/tcp/router.go +++ b/proxy/internal/tcp/router.go @@ -16,6 +16,7 @@ import ( "github.com/netbirdio/netbird/proxy/internal/accesslog" "github.com/netbirdio/netbird/proxy/internal/restrict" "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/util/netrelay" ) // defaultDialTimeout is the fallback dial timeout when no per-route @@ -528,11 +529,14 @@ func (r *Router) relayTCP(ctx context.Context, conn net.Conn, sni SNIHost, route idleTimeout := route.SessionIdleTimeout if idleTimeout <= 0 { - idleTimeout = DefaultIdleTimeout + idleTimeout = netrelay.DefaultIdleTimeout } start := time.Now() - s2d, d2s := Relay(svcCtx, entry, conn, backend, idleTimeout) + s2d, d2s := netrelay.Relay(svcCtx, conn, backend, netrelay.Options{ + IdleTimeout: idleTimeout, + Logger: entry, + }) elapsed := time.Since(start) if obs != nil { diff --git a/util/netrelay/relay.go b/util/netrelay/relay.go new file mode 100644 index 000000000..de44d5bcd --- /dev/null +++ b/util/netrelay/relay.go @@ -0,0 +1,238 @@ +// Package netrelay provides a bidirectional byte-copy helper for TCP-like +// connections with correct half-close propagation. +// +// When one direction reads EOF, the write side of the opposite connection is +// half-closed (CloseWrite) so the peer sees FIN, then the second direction is +// allowed to drain to its own EOF before both connections are fully closed. +// This preserves TCP half-close semantics (e.g. shutdown(SHUT_WR)) that the +// naive "cancel-both-on-first-EOF" pattern breaks. +package netrelay + +import ( + "context" + "errors" + "io" + "net" + "sync" + "sync/atomic" + "syscall" + "time" +) + +// DebugLogger is the minimal interface netrelay uses to surface teardown +// errors. Both *logrus.Entry and *nblog.Logger (via its Debugf method) +// satisfy it, so callers can pass whichever they already use without an +// adapter. Debugf is the only required method; callers with richer +// loggers just expose this one shape here. +type DebugLogger interface { + Debugf(format string, args ...any) +} + +// DefaultIdleTimeout is a reasonable default for Options.IdleTimeout. Callers +// that want an idle timeout but have no specific preference can use this. +const DefaultIdleTimeout = 5 * time.Minute + +// halfCloser is implemented by connections that support half-close +// (e.g. *net.TCPConn, *gonet.TCPConn). +type halfCloser interface { + CloseWrite() error +} + +var copyBufPool = sync.Pool{ + New: func() any { + buf := make([]byte, 32*1024) + return &buf + }, +} + +// Options configures Relay behavior. The zero value is valid: no idle timeout, +// no logging. +type Options struct { + // IdleTimeout tears down the session if no bytes flow in either + // direction within this window. It is a connection-wide watchdog, so a + // long unidirectional transfer on one side keeps the other side alive. + // Zero disables idle tracking. + IdleTimeout time.Duration + // Logger receives debug-level copy/idle errors. Nil suppresses logging. + // Any logger with Debug/Debugf methods is accepted (logrus.Entry, + // uspfilter's nblog.Logger, etc.). + Logger DebugLogger +} + +// Relay copies bytes in both directions between a and b until both directions +// EOF or ctx is canceled. On each direction's EOF it half-closes the +// opposite conn's write side (best effort) so the peer sees FIN while the +// other direction drains. Both conns are fully closed when Relay returns. +// +// a and b only need to implement io.ReadWriteCloser; connections that also +// implement CloseWrite (e.g. *net.TCPConn, ssh.Channel) get proper half-close +// propagation. Options.IdleTimeout, when set, is enforced by a connection-wide +// watchdog that tracks reads in either direction. +// +// Return values are byte counts: aToB (a.Read → b.Write) and bToA (b.Read → +// a.Write). Errors are logged via Options.Logger when set; they are not +// returned because a relay always terminates on some kind of EOF/cancel. +func Relay(ctx context.Context, a, b io.ReadWriteCloser, opts Options) (aToB, bToA int64) { + ctx, cancel := context.WithCancel(ctx) + closeDone := make(chan struct{}) + defer func() { + cancel() + <-closeDone + }() + + go func() { + <-ctx.Done() + _ = a.Close() + _ = b.Close() + close(closeDone) + }() + + // Both sides must support CloseWrite to propagate half-close. If either + // doesn't, a direction's EOF can't be signaled to the peer and the other + // direction would block forever waiting for data; in that case we fall + // back to the cancel-both-on-first-EOF behavior. + _, aHC := a.(halfCloser) + _, bHC := b.(halfCloser) + halfCloseSupported := aHC && bHC + + var ( + lastActivity atomic.Int64 + idleHit atomic.Bool + ) + lastActivity.Store(time.Now().UnixNano()) + + if opts.IdleTimeout > 0 { + go watchdog(ctx, cancel, &lastActivity, &idleHit, opts.IdleTimeout) + } + + var wg sync.WaitGroup + wg.Add(2) + + var errAToB, errBToA error + + go func() { + defer wg.Done() + aToB, errAToB = copyTracked(b, a, &lastActivity) + if halfCloseSupported && isCleanEOF(errAToB) { + halfClose(b) + } else { + cancel() + } + }() + + go func() { + defer wg.Done() + bToA, errBToA = copyTracked(a, b, &lastActivity) + if halfCloseSupported && isCleanEOF(errBToA) { + halfClose(a) + } else { + cancel() + } + }() + + wg.Wait() + + if opts.Logger != nil { + if idleHit.Load() { + opts.Logger.Debugf("relay closed due to idle timeout") + } + if errAToB != nil && !isExpectedCopyError(errAToB) { + opts.Logger.Debugf("relay copy error (a→b): %v", errAToB) + } + if errBToA != nil && !isExpectedCopyError(errBToA) { + opts.Logger.Debugf("relay copy error (b→a): %v", errBToA) + } + } + + return aToB, bToA +} + +// watchdog enforces a connection-wide idle timeout. It cancels ctx when no +// activity has been seen on either direction for idle. It exits as soon as +// ctx is canceled so it doesn't outlive the relay. +func watchdog(ctx context.Context, cancel context.CancelFunc, lastActivity *atomic.Int64, idleHit *atomic.Bool, idle time.Duration) { + // Cap the tick at 50ms so detection latency stays bounded regardless of + // how large idle is, and fall back to idle/2 when that is smaller so + // very short timeouts (mainly in tests) are still caught promptly. + tick := min(idle/2, 50*time.Millisecond) + if tick <= 0 { + tick = time.Millisecond + } + t := time.NewTicker(tick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + last := time.Unix(0, lastActivity.Load()) + if time.Since(last) >= idle { + idleHit.Store(true) + cancel() + return + } + } + } +} + +// copyTracked copies from src to dst using a pooled buffer, updating +// lastActivity on every successful read so a shared watchdog can enforce a +// connection-wide idle timeout. +func copyTracked(dst io.Writer, src io.Reader, lastActivity *atomic.Int64) (int64, error) { + bufp := copyBufPool.Get().(*[]byte) + defer copyBufPool.Put(bufp) + + buf := *bufp + var total int64 + for { + nr, readErr := src.Read(buf) + if nr > 0 { + lastActivity.Store(time.Now().UnixNano()) + n, werr := checkedWrite(dst, buf[:nr]) + total += n + if werr != nil { + return total, werr + } + } + if readErr != nil { + return total, readErr + } + } +} + +func checkedWrite(dst io.Writer, buf []byte) (int64, error) { + nw, err := dst.Write(buf) + if nw < 0 || nw > len(buf) { + nw = 0 + } + if err != nil { + return int64(nw), err + } + if nw != len(buf) { + return int64(nw), io.ErrShortWrite + } + return int64(nw), nil +} + +func halfClose(conn io.ReadWriteCloser) { + if hc, ok := conn.(halfCloser); ok { + _ = hc.CloseWrite() + } +} + +// isCleanEOF reports whether a copy terminated on a graceful end-of-stream. +// Only in that case is it correct to propagate the EOF via CloseWrite on the +// peer; any other error means the flow is broken and both directions should +// tear down. +func isCleanEOF(err error) bool { + return err == nil || errors.Is(err, io.EOF) +} + +func isExpectedCopyError(err error) bool { + return errors.Is(err, net.ErrClosed) || + errors.Is(err, context.Canceled) || + errors.Is(err, io.EOF) || + errors.Is(err, syscall.ECONNRESET) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNABORTED) +} diff --git a/util/netrelay/relay_test.go b/util/netrelay/relay_test.go new file mode 100644 index 000000000..0cb86eb0d --- /dev/null +++ b/util/netrelay/relay_test.go @@ -0,0 +1,221 @@ +package netrelay + +import ( + "io" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// tcpPair returns two connected loopback TCP conns. +func tcpPair(t *testing.T) (*net.TCPConn, *net.TCPConn) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + type result struct { + c *net.TCPConn + err error + } + ch := make(chan result, 1) + go func() { + c, err := ln.Accept() + if err != nil { + ch <- result{nil, err} + return + } + ch <- result{c.(*net.TCPConn), nil} + }() + + dial, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + r := <-ch + require.NoError(t, r.err) + return dial.(*net.TCPConn), r.c +} + +// TestRelayHalfClose exercises the shutdown(SHUT_WR) scenario that the naive +// cancel-both-on-first-EOF pattern breaks. Client A shuts down its write +// side; B must still be able to write a full response and A must receive +// all of it before its read returns EOF. +func TestRelayHalfClose(t *testing.T) { + // Real peer pairs for each side of the relay. We relay between relayA + // and relayB. Peer A talks through relayA; peer B talks through relayB. + peerA, relayA := tcpPair(t) + relayB, peerB := tcpPair(t) + + defer peerA.Close() + defer peerB.Close() + + // Bound blocking reads/writes so a broken relay fails the test instead of + // hanging the test process. + deadline := time.Now().Add(5 * time.Second) + require.NoError(t, peerA.SetDeadline(deadline)) + require.NoError(t, peerB.SetDeadline(deadline)) + + ctx := t.Context() + + done := make(chan struct{}) + go func() { + Relay(ctx, relayA, relayB, Options{}) + close(done) + }() + + // Peer A sends a request, then half-closes its write side. + req := []byte("request-payload") + _, err := peerA.Write(req) + require.NoError(t, err) + require.NoError(t, peerA.CloseWrite()) + + // Peer B reads the request to EOF (FIN must have propagated). + got, err := io.ReadAll(peerB) + require.NoError(t, err) + require.Equal(t, req, got) + + // Peer B writes its response; peer A must receive all of it even though + // peer A's write side is already closed. + resp := make([]byte, 64*1024) + for i := range resp { + resp[i] = byte(i) + } + _, err = peerB.Write(resp) + require.NoError(t, err) + require.NoError(t, peerB.Close()) + + gotResp, err := io.ReadAll(peerA) + require.NoError(t, err) + require.Equal(t, resp, gotResp) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("relay did not return") + } +} + +// TestRelayFullDuplex verifies bidirectional copy in the simple case. +func TestRelayFullDuplex(t *testing.T) { + peerA, relayA := tcpPair(t) + relayB, peerB := tcpPair(t) + defer peerA.Close() + defer peerB.Close() + + // Bound blocking reads/writes so a broken relay fails the test instead of + // hanging the test process. + deadline := time.Now().Add(5 * time.Second) + require.NoError(t, peerA.SetDeadline(deadline)) + require.NoError(t, peerB.SetDeadline(deadline)) + + ctx := t.Context() + + done := make(chan struct{}) + go func() { + Relay(ctx, relayA, relayB, Options{}) + close(done) + }() + + type result struct { + got []byte + err error + } + resA := make(chan result, 1) + resB := make(chan result, 1) + + msgAB := []byte("hello-from-a") + msgBA := []byte("hello-from-b") + + go func() { + if _, err := peerA.Write(msgAB); err != nil { + resA <- result{err: err} + return + } + buf := make([]byte, len(msgBA)) + _, err := io.ReadFull(peerA, buf) + resA <- result{got: buf, err: err} + _ = peerA.Close() + }() + + go func() { + if _, err := peerB.Write(msgBA); err != nil { + resB <- result{err: err} + return + } + buf := make([]byte, len(msgAB)) + _, err := io.ReadFull(peerB, buf) + resB <- result{got: buf, err: err} + _ = peerB.Close() + }() + + a, b := <-resA, <-resB + require.NoError(t, a.err) + require.Equal(t, msgBA, a.got) + require.NoError(t, b.err) + require.Equal(t, msgAB, b.got) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("relay did not return") + } +} + +// TestRelayNoHalfCloseFallback ensures Relay terminates when the underlying +// conns don't support CloseWrite (e.g. net.Pipe). Without the fallback to +// cancel-both-on-first-EOF, the second direction would block forever. +func TestRelayNoHalfCloseFallback(t *testing.T) { + a1, a2 := net.Pipe() + b1, b2 := net.Pipe() + defer a1.Close() + defer b1.Close() + + ctx := t.Context() + done := make(chan struct{}) + go func() { + Relay(ctx, a2, b2, Options{}) + close(done) + }() + + // Close peer A's side; a2's Read will return EOF. + require.NoError(t, a1.Close()) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("relay did not terminate when half-close is unsupported") + } +} + +// TestRelayIdleTimeout ensures the idle watchdog tears down a silent flow. +func TestRelayIdleTimeout(t *testing.T) { + peerA, relayA := tcpPair(t) + relayB, peerB := tcpPair(t) + defer peerA.Close() + defer peerB.Close() + + ctx := t.Context() + + const idle = 150 * time.Millisecond + + start := time.Now() + done := make(chan struct{}) + go func() { + Relay(ctx, relayA, relayB, Options{IdleTimeout: idle}) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("relay did not close on idle") + } + + elapsed := time.Since(start) + require.GreaterOrEqual(t, elapsed, idle, + "relay must not close before the idle timeout elapses") + require.Less(t, elapsed, idle+500*time.Millisecond, + "relay should close shortly after the idle timeout") +} From 6b08e89c7bb318f51c24b467a34fe05e13e17fcf Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 11 May 2026 16:59:33 +0900 Subject: [PATCH 70/80] [relay] Preserve non-standard port in WS dialer URL prep (#6061) --- shared/relay/client/dialer/ws/ws.go | 32 ++++++---- shared/relay/client/dialer/ws/ws_test.go | 76 ++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 shared/relay/client/dialer/ws/ws_test.go diff --git a/shared/relay/client/dialer/ws/ws.go b/shared/relay/client/dialer/ws/ws.go index 301486514..8a13ba126 100644 --- a/shared/relay/client/dialer/ws/ws.go +++ b/shared/relay/client/dialer/ws/ws.go @@ -9,7 +9,6 @@ import ( "net" "net/http" "net/url" - "strings" "github.com/coder/websocket" log "github.com/sirupsen/logrus" @@ -35,13 +34,7 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, var underlying net.Conn opts := createDialOptions(serverName, &underlying) - parsedURL, err := url.Parse(wsURL) - if err != nil { - return nil, err - } - parsedURL.Path = relay.WebSocketURLPath - - wsConn, resp, err := websocket.Dial(ctx, parsedURL.String(), opts) + wsConn, resp, err := websocket.Dial(ctx, wsURL, opts) if err != nil { if errors.Is(err, context.Canceled) { return nil, err @@ -57,12 +50,27 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, return conn, nil } +// prepareURL rewrites a rel://host[:port] or rels://host[:port] address into a +// ws://host[:port]/relay or wss://host[:port]/relay URL, preserving any +// non-standard port from the input. func prepareURL(address string) (string, error) { - if !strings.HasPrefix(address, "rel:") && !strings.HasPrefix(address, "rels:") { - return "", fmt.Errorf("unsupported scheme: %s", address) + parsed, err := url.Parse(address) + if err != nil { + return "", fmt.Errorf("parse relay address %q: %w", address, err) } - - return strings.Replace(address, "rel", "ws", 1), nil + switch parsed.Scheme { + case "rel": + parsed.Scheme = "ws" + case "rels": + parsed.Scheme = "wss" + default: + return "", fmt.Errorf("unsupported scheme: %s", parsed.Scheme) + } + if parsed.Host == "" { + return "", fmt.Errorf("missing host in relay address %q", address) + } + parsed.Path = relay.WebSocketURLPath + return parsed.String(), nil } // httpClientNbDialer builds the http client used by the websocket library. diff --git a/shared/relay/client/dialer/ws/ws_test.go b/shared/relay/client/dialer/ws/ws_test.go new file mode 100644 index 000000000..7357adbc0 --- /dev/null +++ b/shared/relay/client/dialer/ws/ws_test.go @@ -0,0 +1,76 @@ +package ws + +import ( + "testing" +) + +func TestPrepareURL(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "rel scheme with non-standard port", + input: "rel://test-domain-2:45678", + want: "ws://test-domain-2:45678/relay", + }, + { + name: "rels scheme with non-standard port", + input: "rels://test-domain-2:45678", + want: "wss://test-domain-2:45678/relay", + }, + { + name: "rel scheme without port", + input: "rel://test-domain-2", + want: "ws://test-domain-2/relay", + }, + { + name: "rels scheme without port", + input: "rels://test-domain-2", + want: "wss://test-domain-2/relay", + }, + { + name: "rel scheme with IP and port", + input: "rel://1.2.3.4:45678", + want: "ws://1.2.3.4:45678/relay", + }, + { + name: "rel scheme with hostname starting with rel", + input: "rel://relay.example.com:45678", + want: "ws://relay.example.com:45678/relay", + }, + { + name: "rel scheme with IPv6 and port", + input: "rel://[2001:db8::1]:45678", + want: "ws://[2001:db8::1]:45678/relay", + }, + { + name: "rels scheme with IPv6 loopback and port", + input: "rels://[::1]:45678", + want: "wss://[::1]:45678/relay", + }, + { + name: "unsupported scheme", + input: "http://test-domain-2:45678", + wantErr: true, + }, + { + name: "no scheme", + input: "test-domain-2:45678", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := prepareURL(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("prepareURL(%q) err = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("prepareURL(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} From a4114a5e453bbbe287610ed0510de79ea057905d Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 11 May 2026 17:00:23 +0900 Subject: [PATCH 71/80] [client] Skip DNS upstream failover on definitive EDE (#6089) --- .github/workflows/golangci-lint.yml | 2 +- client/internal/dns/upstream.go | 99 +++++++++++++++++++- client/internal/dns/upstream_test.go | 129 +++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- util/capture/text.go | 33 ++++++- 6 files changed, 261 insertions(+), 8 deletions(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 62dfe9bce..7b7b32ec0 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -19,7 +19,7 @@ jobs: - name: codespell uses: codespell-project/actions-codespell@v2 with: - ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA + ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals skip: go.mod,go.sum,**/proxy/web/** golangci: strategy: diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go index a26536f6e..39064f26c 100644 --- a/client/internal/dns/upstream.go +++ b/client/internal/dns/upstream.go @@ -30,6 +30,27 @@ import ( var currentMTU uint16 = iface.DefaultMTU +// nonRetryableEDECodes lists EDE info codes (RFC 8914) for which a SERVFAIL +// from one upstream means another upstream would return the same answer: +// DNSSEC validation outcomes and policy-based blocks. Transient errors +// (network, cached, not ready) are not included. +var nonRetryableEDECodes = map[uint16]struct{}{ + dns.ExtendedErrorCodeUnsupportedDNSKEYAlgorithm: {}, + dns.ExtendedErrorCodeUnsupportedDSDigestType: {}, + dns.ExtendedErrorCodeDNSSECIndeterminate: {}, + dns.ExtendedErrorCodeDNSBogus: {}, + dns.ExtendedErrorCodeSignatureExpired: {}, + dns.ExtendedErrorCodeSignatureNotYetValid: {}, + dns.ExtendedErrorCodeDNSKEYMissing: {}, + dns.ExtendedErrorCodeRRSIGsMissing: {}, + dns.ExtendedErrorCodeNoZoneKeyBitSet: {}, + dns.ExtendedErrorCodeNSECMissing: {}, + dns.ExtendedErrorCodeBlocked: {}, + dns.ExtendedErrorCodeCensored: {}, + dns.ExtendedErrorCodeFiltered: {}, + dns.ExtendedErrorCodeProhibited: {}, +} + // privateClientIface is the subset of the WireGuard interface needed by GetClientPrivate. type privateClientIface interface { Name() string @@ -250,6 +271,18 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, w dns.Re var t time.Duration var err error + // Advertise EDNS0 so the upstream may include Extended DNS Errors + // (RFC 8914) in failure responses; we use those to short-circuit + // failover for definitive answers like DNSSEC validation failures. + // Operate on a copy so the inbound request is unchanged: a client that + // did not advertise EDNS0 must not see an OPT in the response. + hadEdns := r.IsEdns0() != nil + reqUp := r + if !hadEdns { + reqUp = r.Copy() + reqUp.SetEdns0(upstreamUDPSize(), false) + } + var startTime time.Time var upstreamProto *upstreamProtocolResult func() { @@ -257,7 +290,7 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, w dns.Re defer cancel() ctx, upstreamProto = contextWithupstreamProtocolResult(ctx) startTime = time.Now() - rm, t, err = u.upstreamClient.exchange(ctx, upstream.String(), r) + rm, t, err = u.upstreamClient.exchange(ctx, upstream.String(), reqUp) }() if err != nil { @@ -269,13 +302,49 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, w dns.Re } if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused { + if code, ok := nonRetryableEDE(rm); ok { + resutil.SetMeta(w, "ede", edeName(code)) + if !hadEdns { + stripOPT(rm) + } + u.writeSuccessResponse(w, rm, upstream, r.Question[0].Name, t, upstreamProto, logger) + return nil + } return &upstreamFailure{upstream: upstream, reason: dns.RcodeToString[rm.Rcode]} } + if !hadEdns { + stripOPT(rm) + } u.writeSuccessResponse(w, rm, upstream, r.Question[0].Name, t, upstreamProto, logger) return nil } +// upstreamUDPSize returns the EDNS0 UDP buffer size we advertise to upstreams, +// derived from the tunnel MTU and bounded against underflow. +func upstreamUDPSize() uint16 { + if currentMTU > ipUDPHeaderSize { + return currentMTU - ipUDPHeaderSize + } + return dns.MinMsgSize +} + +// stripOPT removes any OPT pseudo-RRs from the response's Extra section so +// the response complies with RFC 6891 when the client did not advertise EDNS0. +func stripOPT(rm *dns.Msg) { + if len(rm.Extra) == 0 { + return + } + out := rm.Extra[:0] + for _, rr := range rm.Extra { + if _, ok := rr.(*dns.OPT); ok { + continue + } + out = append(out, rr) + } + rm.Extra = out +} + func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, startTime time.Time) *upstreamFailure { if !errors.Is(err, context.DeadlineExceeded) && !isTimeout(err) { return &upstreamFailure{upstream: upstream, reason: err.Error()} @@ -337,6 +406,34 @@ func formatFailures(failures []upstreamFailure) string { return strings.Join(parts, ", ") } +// nonRetryableEDE returns the first non-retryable EDE code carried in the +// response, if any. +func nonRetryableEDE(rm *dns.Msg) (uint16, bool) { + opt := rm.IsEdns0() + if opt == nil { + return 0, false + } + for _, o := range opt.Option { + ede, ok := o.(*dns.EDNS0_EDE) + if !ok { + continue + } + if _, ok := nonRetryableEDECodes[ede.InfoCode]; ok { + return ede.InfoCode, true + } + } + return 0, false +} + +// edeName returns a human-readable name for an EDE code, falling back to +// the numeric code when unknown. +func edeName(code uint16) string { + if name, ok := dns.ExtendedErrorCodeToString[code]; ok { + return name + } + return fmt.Sprintf("EDE %d", code) +} + // ProbeAvailability tests all upstream servers simultaneously and // disables the resolver if none work func (u *upstreamResolverBase) ProbeAvailability(ctx context.Context) { diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go index 1797fdad8..d6aec05ca 100644 --- a/client/internal/dns/upstream_test.go +++ b/client/internal/dns/upstream_test.go @@ -770,3 +770,132 @@ func TestExchangeWithFallback_TCPTruncatesToClientSize(t *testing.T) { assert.Less(t, len(rm2.Answer), 20, "small EDNS0 client should get fewer records") assert.True(t, rm2.Truncated, "response should be truncated for small buffer client") } + +func msgWithEDE(rcode int, codes ...uint16) *dns.Msg { + m := new(dns.Msg) + m.Response = true + m.Rcode = rcode + if len(codes) == 0 { + return m + } + opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}} + opt.SetUDPSize(dns.MinMsgSize) + for _, c := range codes { + opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: c}) + } + m.Extra = append(m.Extra, opt) + return m +} + +func TestNonRetryableEDE(t *testing.T) { + tests := []struct { + name string + msg *dns.Msg + wantOK bool + wantCode uint16 + }{ + {name: "no edns0", msg: msgWithEDE(dns.RcodeServerFailure)}, + { + name: "opt without ede", + msg: func() *dns.Msg { + m := msgWithEDE(dns.RcodeServerFailure) + opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}} + opt.Option = append(opt.Option, &dns.EDNS0_NSID{Code: dns.EDNS0NSID}) + m.Extra = []dns.RR{opt} + return m + }(), + }, + {name: "ede dnsbogus", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeDNSBogus), wantOK: true, wantCode: dns.ExtendedErrorCodeDNSBogus}, + {name: "ede signature expired", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeSignatureExpired), wantOK: true, wantCode: dns.ExtendedErrorCodeSignatureExpired}, + {name: "ede blocked", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeBlocked), wantOK: true, wantCode: dns.ExtendedErrorCodeBlocked}, + {name: "ede prohibited", msg: msgWithEDE(dns.RcodeRefused, dns.ExtendedErrorCodeProhibited), wantOK: true, wantCode: dns.ExtendedErrorCodeProhibited}, + {name: "ede cached error retryable", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeCachedError)}, + {name: "ede network error retryable", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeNetworkError)}, + {name: "ede not ready retryable", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeNotReady)}, + { + name: "first non-retryable wins", + msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeNetworkError, dns.ExtendedErrorCodeDNSBogus), + wantOK: true, + wantCode: dns.ExtendedErrorCodeDNSBogus, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, ok := nonRetryableEDE(tc.msg) + assert.Equal(t, tc.wantOK, ok, "ok should match") + if tc.wantOK { + assert.Equal(t, tc.wantCode, code, "code should match") + } + }) + } +} + +func TestEDEName(t *testing.T) { + assert.Equal(t, "DNSSEC Bogus", edeName(dns.ExtendedErrorCodeDNSBogus)) + assert.Equal(t, "Signature Expired", edeName(dns.ExtendedErrorCodeSignatureExpired)) + assert.Equal(t, "EDE 9999", edeName(9999), "unknown code falls back to numeric") +} + +func TestStripOPT(t *testing.T) { + rm := &dns.Msg{ + Extra: []dns.RR{ + &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}, + &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)}, + }, + } + stripOPT(rm) + assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept") + _, isOPT := rm.Extra[0].(*dns.OPT) + assert.False(t, isOPT, "remaining record must not be OPT") +} + +func TestUpstreamResolver_NonRetryableEDEShortCircuits(t *testing.T) { + upstream1 := netip.MustParseAddrPort("192.0.2.1:53") + upstream2 := netip.MustParseAddrPort("192.0.2.2:53") + + servfailWithEDE := msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeDNSBogus) + successResp := buildMockResponse(dns.RcodeSuccess, "192.0.2.100") + + var queried []string + tracking := &trackingMockClient{ + inner: &mockUpstreamResolverPerServer{ + responses: map[string]mockUpstreamResponse{ + upstream1.String(): {msg: servfailWithEDE}, + upstream2.String(): {msg: successResp}, + }, + rtt: time.Millisecond, + }, + queriedUpstreams: &queried, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resolver := &upstreamResolverBase{ + ctx: ctx, + upstreamClient: tracking, + upstreamServers: []netip.AddrPort{upstream1, upstream2}, + upstreamTimeout: UpstreamTimeout, + } + + var written *dns.Msg + w := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + written = m + return nil + }, + } + + // Client query without EDNS0 must not see an OPT in the response. + q := new(dns.Msg).SetQuestion("example.com.", dns.TypeA) + resolver.ServeDNS(w, q) + + require.NotNil(t, written, "response must be written") + assert.Equal(t, dns.RcodeServerFailure, written.Rcode, "SERVFAIL must propagate") + assert.Len(t, queried, 1, "only first upstream should be queried") + assert.Equal(t, upstream1.String(), queried[0]) + for _, rr := range written.Extra { + _, isOPT := rr.(*dns.OPT) + assert.False(t, isOPT, "synthetic OPT must not leak to a non-EDNS0 client") + } +} diff --git a/go.mod b/go.mod index 84aeab941..5704887ce 100644 --- a/go.mod +++ b/go.mod @@ -72,7 +72,7 @@ require ( github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 github.com/mdlayher/socket v0.5.1 github.com/mdp/qrterminal/v3 v3.2.1 - github.com/miekg/dns v1.1.59 + github.com/miekg/dns v1.1.72 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 diff --git a/go.sum b/go.sum index 851d1ce66..42652169c 100644 --- a/go.sum +++ b/go.sum @@ -455,8 +455,8 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mholt/acmez/v2 v2.0.1 h1:3/3N0u1pLjMK4sNEAFSI+bcvzbPhRpY383sy1kLHJ6k= github.com/mholt/acmez/v2 v2.0.1/go.mod h1:fX4c9r5jYwMyMsC+7tkYRxHibkOTgta5DIFGoe67e1U= -github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= -github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= diff --git a/util/capture/text.go b/util/capture/text.go index fbb26654e..a6a6dd28b 100644 --- a/util/capture/text.go +++ b/util/capture/text.go @@ -12,6 +12,7 @@ import ( "github.com/google/gopacket" "github.com/google/gopacket/layers" + "github.com/miekg/dns" ) // TextWriter writes human-readable one-line-per-packet summaries. @@ -594,19 +595,45 @@ func formatDNSResponse(d *layers.DNS, rd string, plen int) string { anCount := d.ANCount nsCount := d.NSCount arCount := d.ARCount + ede := formatEDE(d) if d.ResponseCode != layers.DNSResponseCodeNoErr { - return fmt.Sprintf("%04x %d/%d/%d %s (%d)", d.ID, anCount, nsCount, arCount, d.ResponseCode, plen) + return fmt.Sprintf("%04x %d/%d/%d %s%s (%d)", d.ID, anCount, nsCount, arCount, d.ResponseCode, ede, plen) } if anCount > 0 && len(d.Answers) > 0 { rr := d.Answers[0] if rdata := shortRData(&rr); rdata != "" { - return fmt.Sprintf("%04x %d/%d/%d %s %s (%d)", d.ID, anCount, nsCount, arCount, rr.Type, rdata, plen) + return fmt.Sprintf("%04x %d/%d/%d %s %s%s (%d)", d.ID, anCount, nsCount, arCount, rr.Type, rdata, ede, plen) } } - return fmt.Sprintf("%04x %d/%d/%d (%d)", d.ID, anCount, nsCount, arCount, plen) + return fmt.Sprintf("%04x %d/%d/%d%s (%d)", d.ID, anCount, nsCount, arCount, ede, plen) +} + +// dnsOPTCodeEDE is the EDNS0 option code for Extended DNS Errors (RFC 8914). +const dnsOPTCodeEDE layers.DNSOptionCode = layers.DNSOptionCode(dns.EDNS0EDE) + +// formatEDE returns " EDE=Name" for the first Extended DNS Error option +// found in the response, or empty string if none is present. +func formatEDE(d *layers.DNS) string { + for _, rr := range d.Additionals { + if rr.Type != layers.DNSTypeOPT { + continue + } + for _, opt := range rr.OPT { + if opt.Code != dnsOPTCodeEDE || len(opt.Data) < 2 { + continue + } + info := binary.BigEndian.Uint16(opt.Data[:2]) + name, ok := dns.ExtendedErrorCodeToString[info] + if !ok { + name = fmt.Sprintf("%d", info) + } + return " EDE=" + name + } + } + return "" } func shortRData(rr *layers.DNSResourceRecord) string { From 08f52f4517fe0a33b9e68a9298ff0489302e4823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 11 May 2026 11:02:39 +0200 Subject: [PATCH 72/80] [client/server] Allow clearing pre-shared key via SetConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon ignored an empty OptionalPreSharedKey, so a UI/CLI request to clear the pre-shared key was silently dropped. Pass the pointer through unconditionally — profilemanager already handles the empty-string case. --- client/server/server.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/server/server.go b/client/server/server.go index 17e4fcf0b..55d166fdb 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -341,9 +341,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques } if msg.OptionalPreSharedKey != nil { - if *msg.OptionalPreSharedKey != "" { - config.PreSharedKey = msg.OptionalPreSharedKey - } + config.PreSharedKey = msg.OptionalPreSharedKey } if msg.CleanDNSLabels { From 9aef31ff53779b94e063603223da337ef323aed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 11 May 2026 11:20:22 +0200 Subject: [PATCH 73/80] [client/ui] Replace fyne UI with Wails (rename ui-wails to ui) Removes the legacy fyne-based client/ui implementation and renames the Wails replacement (client/ui-wails) to take its place at client/ui. Go imports, frontend bindings, CI workflows, goreleaser configs and the windows .syso icon path are updated to follow the rename. --- .../netbird-systemtray-connected-dark.png | Bin 5272 -> 0 bytes .../netbird-systemtray-connected-macos.png | Bin 3690 -> 0 bytes .../assets/netbird-systemtray-connected.png | Bin 5287 -> 0 bytes .../netbird-systemtray-connecting-dark.png | Bin 5434 -> 0 bytes .../netbird-systemtray-connecting-macos.png | Bin 3725 -> 0 bytes .../assets/netbird-systemtray-connecting.png | Bin 5412 -> 0 bytes .../netbird-systemtray-disconnected-macos.png | Bin 3474 -> 0 bytes .../netbird-systemtray-disconnected.png | Bin 4800 -> 0 bytes .../assets/netbird-systemtray-error-dark.png | Bin 5279 -> 0 bytes .../assets/netbird-systemtray-error-macos.png | Bin 3555 -> 0 bytes .../assets/netbird-systemtray-error.png | Bin 5260 -> 0 bytes ...tbird-systemtray-update-connected-dark.png | Bin 4867 -> 0 bytes ...bird-systemtray-update-connected-macos.png | Bin 3328 -> 0 bytes .../netbird-systemtray-update-connected.png | Bin 4842 -> 0 bytes ...rd-systemtray-update-disconnected-dark.png | Bin 5275 -> 0 bytes ...d-systemtray-update-disconnected-macos.png | Bin 3747 -> 0 bytes ...netbird-systemtray-update-disconnected.png | Bin 5298 -> 0 bytes client/ui-wails/assets/netbird.png | Bin 4800 -> 0 bytes client/ui-wails/icons.go | 54 - client/ui-wails/signal_unix.go | 33 - client/ui-wails/signal_windows.go | 81 - client/{ui-wails => ui}/.gitignore | 0 client/ui/Netbird.icns | Bin 80549 -> 0 bytes client/{ui-wails => ui}/README.md | 0 client/{ui-wails => ui}/Taskfile.yml | 0 client/ui/assets/connected.png | Bin 4743 -> 0 bytes client/ui/assets/disconnected.png | Bin 10530 -> 0 bytes client/ui/assets/netbird-disconnected.ico | Bin 5056 -> 0 bytes client/ui/assets/netbird-disconnected.png | Bin 7537 -> 0 bytes .../netbird-systemtray-connected-dark.ico | Bin 105144 -> 0 bytes .../netbird-systemtray-connected-macos.png | Bin 3858 -> 3690 bytes .../assets/netbird-systemtray-connected.ico | Bin 105151 -> 0 bytes .../netbird-systemtray-connecting-dark.ico | Bin 105128 -> 0 bytes .../netbird-systemtray-connecting-macos.png | Bin 3843 -> 3725 bytes .../assets/netbird-systemtray-connecting.ico | Bin 105091 -> 0 bytes .../netbird-systemtray-disconnected-macos.png | Bin 3491 -> 3474 bytes .../netbird-systemtray-disconnected.ico | Bin 104575 -> 0 bytes .../assets/netbird-systemtray-error-dark.ico | Bin 105062 -> 0 bytes .../assets/netbird-systemtray-error-macos.png | Bin 3837 -> 3555 bytes client/ui/assets/netbird-systemtray-error.ico | Bin 105013 -> 0 bytes ...tbird-systemtray-update-connected-dark.ico | Bin 104704 -> 0 bytes ...bird-systemtray-update-connected-macos.png | Bin 3570 -> 3328 bytes .../netbird-systemtray-update-connected.ico | Bin 104698 -> 0 bytes ...rd-systemtray-update-disconnected-dark.ico | Bin 105086 -> 0 bytes ...d-systemtray-update-disconnected-macos.png | Bin 3816 -> 3747 bytes ...netbird-systemtray-update-disconnected.ico | Bin 105115 -> 0 bytes client/ui/assets/netbird.ico | Bin 106176 -> 0 bytes client/{ui-wails => ui}/assets/svg/_base.svg | 0 .../{ui-wails => ui}/assets/svg/appicon.svg | 0 .../assets/svg/connected-macos.svg | 0 .../{ui-wails => ui}/assets/svg/connected.svg | 0 .../assets/svg/connecting-macos.svg | 0 .../assets/svg/connecting.svg | 0 .../assets/svg/disconnected-macos.svg | 0 .../assets/svg/disconnected.svg | 0 .../assets/svg/error-macos.svg | 0 client/{ui-wails => ui}/assets/svg/error.svg | 0 .../assets/svg/update-connected-macos.svg | 0 .../assets/svg/update-connected.svg | 0 .../assets/svg/update-disconnected-macos.svg | 0 .../assets/svg/update-disconnected.svg | 0 client/{ui-wails => ui}/build/Taskfile.yml | 0 .../appicon.icon/Assets/wails_icon_vector.svg | 0 .../build/appicon.icon/icon.json | 0 client/{ui-wails => ui}/build/appicon.png | Bin client/ui/build/banner.bmp | Bin 26494 -> 0 bytes client/ui/build/build-ui-linux.sh | 5 - client/{ui-wails => ui}/build/config.yml | 0 .../build/darwin/Info.dev.plist | 0 .../{ui-wails => ui}/build/darwin/Info.plist | 0 .../build/darwin/Taskfile.yml | 0 .../{ui-wails => ui}/build/darwin/icons.icns | Bin .../build/docker/Dockerfile.cross | 0 .../build/docker/Dockerfile.server | 0 .../{ui-wails => ui}/build/linux/Taskfile.yml | 0 .../build/linux/appimage/build.sh | 0 client/{ui-wails => ui}/build/linux/desktop | 0 .../build/linux/netbird-ui.desktop | 0 .../build/linux/netbird.desktop | 0 .../build/linux/nfpm/nfpm.yaml | 0 .../build/linux/nfpm/scripts/postinstall.sh | 0 .../build/linux/nfpm/scripts/postremove.sh | 0 .../build/linux/nfpm/scripts/preinstall.sh | 0 .../build/linux/nfpm/scripts/preremove.sh | 0 client/ui/build/netbird.desktop | 8 - .../build/windows/Taskfile.yml | 0 .../{ui-wails => ui}/build/windows/icon.ico | Bin .../{ui-wails => ui}/build/windows/info.json | 0 .../build/windows/msix/app_manifest.xml | 0 .../build/windows/msix/template.xml | 0 .../build/windows/nsis/project.nsi | 0 .../build/windows/nsis/wails_tools.nsh | 0 .../build/windows/wails.exe.manifest | 0 client/ui/client_ui.go | 1773 ----------------- client/ui/const.go | 16 - client/ui/debug.go | 727 ------- client/ui/event/event.go | 176 -- client/ui/event_handler.go | 326 --- client/ui/font_bsd.go | 30 - client/ui/font_darwin.go | 18 - client/ui/font_linux.go | 7 - client/ui/font_windows.go | 90 - .../frontend/Inter Font License.txt | 0 .../netbird/client/ui}/services/connection.ts | 0 .../netbird/client/ui}/services/debug.ts | 0 .../netbird/client/ui}/services/forwarding.ts | 0 .../netbird/client/ui}/services/index.ts | 0 .../netbird/client/ui}/services/models.ts | 0 .../netbird/client/ui}/services/networks.ts | 0 .../netbird/client/ui}/services/peers.ts | 0 .../netbird/client/ui}/services/profiles.ts | 0 .../netbird/client/ui}/services/settings.ts | 0 .../netbird/client/ui}/services/update.ts | 0 .../wailsapp/wails/v3/internal/eventcreate.ts | 0 .../wailsapp/wails/v3/internal/eventdata.d.ts | 0 .../v3/pkg/services/notifications/index.ts | 0 .../v3/pkg/services/notifications/models.ts | 0 .../notifications/notificationservice.ts | 0 client/{ui-wails => ui}/frontend/index.html | 0 client/{ui-wails => ui}/frontend/package.json | 0 .../{ui-wails => ui}/frontend/pnpm-lock.yaml | 0 .../frontend/postcss.config.js | 0 .../frontend/public/Inter-Medium.ttf | Bin .../frontend/public/react.svg | 0 .../frontend/public/style.css | 0 .../frontend/public/wails.png | Bin client/{ui-wails => ui}/frontend/src/App.tsx | 0 .../{ui-wails => ui}/frontend/src/Layout.tsx | 0 .../frontend/src/components/Button.tsx | 0 .../frontend/src/components/Card.tsx | 0 .../frontend/src/components/Input.tsx | 0 .../frontend/src/components/Switch.tsx | 0 .../frontend/src/components/Tabs.tsx | 0 .../frontend/src/hooks/useStatus.ts | 0 .../{ui-wails => ui}/frontend/src/index.css | 0 .../{ui-wails => ui}/frontend/src/lib/cn.ts | 0 client/{ui-wails => ui}/frontend/src/main.tsx | 0 .../frontend/src/pages/Debug.tsx | 0 .../frontend/src/pages/Login.tsx | 0 .../frontend/src/pages/LoginUrl.tsx | 0 .../frontend/src/pages/Networks.tsx | 0 .../frontend/src/pages/Peers.tsx | 0 .../frontend/src/pages/Profiles.tsx | 0 .../frontend/src/pages/QuickActions.tsx | 0 .../frontend/src/pages/Settings.tsx | 0 .../frontend/src/pages/Status.tsx | 0 .../frontend/src/pages/Update.tsx | 0 .../frontend/src/vite-env.d.ts | 0 .../frontend/tailwind.config.ts | 0 .../{ui-wails => ui}/frontend/tsconfig.json | 0 .../{ui-wails => ui}/frontend/vite.config.ts | 0 client/{ui-wails => ui}/grpc.go | 0 client/ui/icons.go | 60 +- client/ui/icons_windows.go | 44 - client/{ui-wails => ui}/main.go | 0 client/ui/manifest.xml | 17 - client/ui/netbird-ui.rb.tmpl | 39 - client/ui/network.go | 695 ------- client/ui/notifier/notifier.go | 27 - client/ui/notifier/notifier_other.go | 9 - client/ui/notifier/notifier_windows.go | 88 - client/ui/process/process.go | 38 - client/ui/process/process_nonwindows.go | 26 - client/ui/process/process_windows.go | 24 - client/ui/profile.go | 719 ------- client/ui/quickactions.go | 349 ---- client/ui/quickactions_assets.go | 23 - client/{ui-wails => ui}/services/conn.go | 0 .../{ui-wails => ui}/services/connection.go | 0 client/{ui-wails => ui}/services/debug.go | 0 .../{ui-wails => ui}/services/forwarding.go | 0 client/{ui-wails => ui}/services/network.go | 0 client/{ui-wails => ui}/services/peers.go | 0 client/{ui-wails => ui}/services/profile.go | 0 client/{ui-wails => ui}/services/settings.go | 0 client/{ui-wails => ui}/services/update.go | 0 client/ui/signal_unix.go | 65 +- client/ui/signal_windows.go | 164 +- client/{ui-wails => ui}/tray.go | 0 client/{ui-wails => ui}/tray_linux.go | 0 client/{ui-wails => ui}/tray_watcher_linux.go | 0 client/{ui-wails => ui}/tray_watcher_other.go | 0 client/ui/update.go | 140 -- client/ui/update_notwindows.go | 7 - client/ui/update_windows.go | 44 - client/{ui-wails => ui}/xembed_host_linux.go | 0 client/{ui-wails => ui}/xembed_host_other.go | 0 client/{ui-wails => ui}/xembed_tray_linux.c | 0 client/{ui-wails => ui}/xembed_tray_linux.h | 0 189 files changed, 82 insertions(+), 5840 deletions(-) delete mode 100644 client/ui-wails/assets/netbird-systemtray-connected-dark.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-connected-macos.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-connected.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-connecting-dark.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-connecting-macos.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-connecting.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-disconnected-macos.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-disconnected.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-error-dark.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-error-macos.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-error.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-update-connected-dark.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-update-connected-macos.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-update-connected.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-update-disconnected-dark.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-update-disconnected-macos.png delete mode 100644 client/ui-wails/assets/netbird-systemtray-update-disconnected.png delete mode 100644 client/ui-wails/assets/netbird.png delete mode 100644 client/ui-wails/icons.go delete mode 100644 client/ui-wails/signal_unix.go delete mode 100644 client/ui-wails/signal_windows.go rename client/{ui-wails => ui}/.gitignore (100%) delete mode 100644 client/ui/Netbird.icns rename client/{ui-wails => ui}/README.md (100%) rename client/{ui-wails => ui}/Taskfile.yml (100%) delete mode 100644 client/ui/assets/connected.png delete mode 100644 client/ui/assets/disconnected.png delete mode 100644 client/ui/assets/netbird-disconnected.ico delete mode 100644 client/ui/assets/netbird-disconnected.png delete mode 100644 client/ui/assets/netbird-systemtray-connected-dark.ico delete mode 100644 client/ui/assets/netbird-systemtray-connected.ico delete mode 100644 client/ui/assets/netbird-systemtray-connecting-dark.ico delete mode 100644 client/ui/assets/netbird-systemtray-connecting.ico delete mode 100644 client/ui/assets/netbird-systemtray-disconnected.ico delete mode 100644 client/ui/assets/netbird-systemtray-error-dark.ico delete mode 100644 client/ui/assets/netbird-systemtray-error.ico delete mode 100644 client/ui/assets/netbird-systemtray-update-connected-dark.ico delete mode 100644 client/ui/assets/netbird-systemtray-update-connected.ico delete mode 100644 client/ui/assets/netbird-systemtray-update-disconnected-dark.ico delete mode 100644 client/ui/assets/netbird-systemtray-update-disconnected.ico delete mode 100644 client/ui/assets/netbird.ico rename client/{ui-wails => ui}/assets/svg/_base.svg (100%) rename client/{ui-wails => ui}/assets/svg/appicon.svg (100%) rename client/{ui-wails => ui}/assets/svg/connected-macos.svg (100%) rename client/{ui-wails => ui}/assets/svg/connected.svg (100%) rename client/{ui-wails => ui}/assets/svg/connecting-macos.svg (100%) rename client/{ui-wails => ui}/assets/svg/connecting.svg (100%) rename client/{ui-wails => ui}/assets/svg/disconnected-macos.svg (100%) rename client/{ui-wails => ui}/assets/svg/disconnected.svg (100%) rename client/{ui-wails => ui}/assets/svg/error-macos.svg (100%) rename client/{ui-wails => ui}/assets/svg/error.svg (100%) rename client/{ui-wails => ui}/assets/svg/update-connected-macos.svg (100%) rename client/{ui-wails => ui}/assets/svg/update-connected.svg (100%) rename client/{ui-wails => ui}/assets/svg/update-disconnected-macos.svg (100%) rename client/{ui-wails => ui}/assets/svg/update-disconnected.svg (100%) rename client/{ui-wails => ui}/build/Taskfile.yml (100%) rename client/{ui-wails => ui}/build/appicon.icon/Assets/wails_icon_vector.svg (100%) rename client/{ui-wails => ui}/build/appicon.icon/icon.json (100%) rename client/{ui-wails => ui}/build/appicon.png (100%) delete mode 100644 client/ui/build/banner.bmp delete mode 100644 client/ui/build/build-ui-linux.sh rename client/{ui-wails => ui}/build/config.yml (100%) rename client/{ui-wails => ui}/build/darwin/Info.dev.plist (100%) rename client/{ui-wails => ui}/build/darwin/Info.plist (100%) rename client/{ui-wails => ui}/build/darwin/Taskfile.yml (100%) rename client/{ui-wails => ui}/build/darwin/icons.icns (100%) rename client/{ui-wails => ui}/build/docker/Dockerfile.cross (100%) rename client/{ui-wails => ui}/build/docker/Dockerfile.server (100%) rename client/{ui-wails => ui}/build/linux/Taskfile.yml (100%) rename client/{ui-wails => ui}/build/linux/appimage/build.sh (100%) rename client/{ui-wails => ui}/build/linux/desktop (100%) rename client/{ui-wails => ui}/build/linux/netbird-ui.desktop (100%) rename client/{ui-wails => ui}/build/linux/netbird.desktop (100%) rename client/{ui-wails => ui}/build/linux/nfpm/nfpm.yaml (100%) rename client/{ui-wails => ui}/build/linux/nfpm/scripts/postinstall.sh (100%) rename client/{ui-wails => ui}/build/linux/nfpm/scripts/postremove.sh (100%) rename client/{ui-wails => ui}/build/linux/nfpm/scripts/preinstall.sh (100%) rename client/{ui-wails => ui}/build/linux/nfpm/scripts/preremove.sh (100%) delete mode 100644 client/ui/build/netbird.desktop rename client/{ui-wails => ui}/build/windows/Taskfile.yml (100%) rename client/{ui-wails => ui}/build/windows/icon.ico (100%) rename client/{ui-wails => ui}/build/windows/info.json (100%) rename client/{ui-wails => ui}/build/windows/msix/app_manifest.xml (100%) rename client/{ui-wails => ui}/build/windows/msix/template.xml (100%) rename client/{ui-wails => ui}/build/windows/nsis/project.nsi (100%) rename client/{ui-wails => ui}/build/windows/nsis/wails_tools.nsh (100%) rename client/{ui-wails => ui}/build/windows/wails.exe.manifest (100%) delete mode 100644 client/ui/client_ui.go delete mode 100644 client/ui/const.go delete mode 100644 client/ui/debug.go delete mode 100644 client/ui/event/event.go delete mode 100644 client/ui/event_handler.go delete mode 100644 client/ui/font_bsd.go delete mode 100644 client/ui/font_darwin.go delete mode 100644 client/ui/font_linux.go delete mode 100644 client/ui/font_windows.go rename client/{ui-wails => ui}/frontend/Inter Font License.txt (100%) rename client/{ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails => ui/frontend/bindings/github.com/netbirdio/netbird/client/ui}/services/connection.ts (100%) rename client/{ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails => ui/frontend/bindings/github.com/netbirdio/netbird/client/ui}/services/debug.ts (100%) rename client/{ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails => ui/frontend/bindings/github.com/netbirdio/netbird/client/ui}/services/forwarding.ts (100%) rename client/{ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails => ui/frontend/bindings/github.com/netbirdio/netbird/client/ui}/services/index.ts (100%) rename client/{ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails => ui/frontend/bindings/github.com/netbirdio/netbird/client/ui}/services/models.ts (100%) rename client/{ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails => ui/frontend/bindings/github.com/netbirdio/netbird/client/ui}/services/networks.ts (100%) rename client/{ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails => ui/frontend/bindings/github.com/netbirdio/netbird/client/ui}/services/peers.ts (100%) rename client/{ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails => ui/frontend/bindings/github.com/netbirdio/netbird/client/ui}/services/profiles.ts (100%) rename client/{ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails => ui/frontend/bindings/github.com/netbirdio/netbird/client/ui}/services/settings.ts (100%) rename client/{ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails => ui/frontend/bindings/github.com/netbirdio/netbird/client/ui}/services/update.ts (100%) rename client/{ui-wails => ui}/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts (100%) rename client/{ui-wails => ui}/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts (100%) rename client/{ui-wails => ui}/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts (100%) rename client/{ui-wails => ui}/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts (100%) rename client/{ui-wails => ui}/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.ts (100%) rename client/{ui-wails => ui}/frontend/index.html (100%) rename client/{ui-wails => ui}/frontend/package.json (100%) rename client/{ui-wails => ui}/frontend/pnpm-lock.yaml (100%) rename client/{ui-wails => ui}/frontend/postcss.config.js (100%) rename client/{ui-wails => ui}/frontend/public/Inter-Medium.ttf (100%) rename client/{ui-wails => ui}/frontend/public/react.svg (100%) rename client/{ui-wails => ui}/frontend/public/style.css (100%) rename client/{ui-wails => ui}/frontend/public/wails.png (100%) rename client/{ui-wails => ui}/frontend/src/App.tsx (100%) rename client/{ui-wails => ui}/frontend/src/Layout.tsx (100%) rename client/{ui-wails => ui}/frontend/src/components/Button.tsx (100%) rename client/{ui-wails => ui}/frontend/src/components/Card.tsx (100%) rename client/{ui-wails => ui}/frontend/src/components/Input.tsx (100%) rename client/{ui-wails => ui}/frontend/src/components/Switch.tsx (100%) rename client/{ui-wails => ui}/frontend/src/components/Tabs.tsx (100%) rename client/{ui-wails => ui}/frontend/src/hooks/useStatus.ts (100%) rename client/{ui-wails => ui}/frontend/src/index.css (100%) rename client/{ui-wails => ui}/frontend/src/lib/cn.ts (100%) rename client/{ui-wails => ui}/frontend/src/main.tsx (100%) rename client/{ui-wails => ui}/frontend/src/pages/Debug.tsx (100%) rename client/{ui-wails => ui}/frontend/src/pages/Login.tsx (100%) rename client/{ui-wails => ui}/frontend/src/pages/LoginUrl.tsx (100%) rename client/{ui-wails => ui}/frontend/src/pages/Networks.tsx (100%) rename client/{ui-wails => ui}/frontend/src/pages/Peers.tsx (100%) rename client/{ui-wails => ui}/frontend/src/pages/Profiles.tsx (100%) rename client/{ui-wails => ui}/frontend/src/pages/QuickActions.tsx (100%) rename client/{ui-wails => ui}/frontend/src/pages/Settings.tsx (100%) rename client/{ui-wails => ui}/frontend/src/pages/Status.tsx (100%) rename client/{ui-wails => ui}/frontend/src/pages/Update.tsx (100%) rename client/{ui-wails => ui}/frontend/src/vite-env.d.ts (100%) rename client/{ui-wails => ui}/frontend/tailwind.config.ts (100%) rename client/{ui-wails => ui}/frontend/tsconfig.json (100%) rename client/{ui-wails => ui}/frontend/vite.config.ts (100%) rename client/{ui-wails => ui}/grpc.go (100%) delete mode 100644 client/ui/icons_windows.go rename client/{ui-wails => ui}/main.go (100%) delete mode 100644 client/ui/manifest.xml delete mode 100644 client/ui/netbird-ui.rb.tmpl delete mode 100644 client/ui/network.go delete mode 100644 client/ui/notifier/notifier.go delete mode 100644 client/ui/notifier/notifier_other.go delete mode 100644 client/ui/notifier/notifier_windows.go delete mode 100644 client/ui/process/process.go delete mode 100644 client/ui/process/process_nonwindows.go delete mode 100644 client/ui/process/process_windows.go delete mode 100644 client/ui/profile.go delete mode 100644 client/ui/quickactions.go delete mode 100644 client/ui/quickactions_assets.go rename client/{ui-wails => ui}/services/conn.go (100%) rename client/{ui-wails => ui}/services/connection.go (100%) rename client/{ui-wails => ui}/services/debug.go (100%) rename client/{ui-wails => ui}/services/forwarding.go (100%) rename client/{ui-wails => ui}/services/network.go (100%) rename client/{ui-wails => ui}/services/peers.go (100%) rename client/{ui-wails => ui}/services/profile.go (100%) rename client/{ui-wails => ui}/services/settings.go (100%) rename client/{ui-wails => ui}/services/update.go (100%) rename client/{ui-wails => ui}/tray.go (100%) rename client/{ui-wails => ui}/tray_linux.go (100%) rename client/{ui-wails => ui}/tray_watcher_linux.go (100%) rename client/{ui-wails => ui}/tray_watcher_other.go (100%) delete mode 100644 client/ui/update.go delete mode 100644 client/ui/update_notwindows.go delete mode 100644 client/ui/update_windows.go rename client/{ui-wails => ui}/xembed_host_linux.go (100%) rename client/{ui-wails => ui}/xembed_host_other.go (100%) rename client/{ui-wails => ui}/xembed_tray_linux.c (100%) rename client/{ui-wails => ui}/xembed_tray_linux.h (100%) diff --git a/client/ui-wails/assets/netbird-systemtray-connected-dark.png b/client/ui-wails/assets/netbird-systemtray-connected-dark.png deleted file mode 100644 index f18a929a0c46fe6eb534932bb8af7dee80a15698..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5272 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 b)IkE9NkTHqSjM4s{{jzZW@B1u>>2kzO3uwf diff --git a/client/ui-wails/assets/netbird-systemtray-connected-macos.png b/client/ui-wails/assets/netbird-systemtray-connected-macos.png deleted file mode 100644 index d29a7ade8821d87b12982d8b051ab5294a7a329d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3690 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 OIuXWJMzz=7AN~`y+wr{s diff --git a/client/ui-wails/assets/netbird-systemtray-connected.png b/client/ui-wails/assets/netbird-systemtray-connected.png deleted file mode 100644 index 4258a5c1c42a47842c09fb65c1b93e1df74584ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5287 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 diff --git a/client/ui-wails/assets/netbird-systemtray-connecting-macos.png b/client/ui-wails/assets/netbird-systemtray-connecting-macos.png deleted file mode 100644 index 306c6ddf555e6049c57ce8bcf98eee80bd2db730..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3725 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 diff --git a/client/ui-wails/assets/netbird-systemtray-connecting.png b/client/ui-wails/assets/netbird-systemtray-connecting.png deleted file mode 100644 index 4f607c997df29ed0104e0e824155f65a1098a3a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5412 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 diff --git a/client/ui-wails/assets/netbird-systemtray-disconnected-macos.png b/client/ui-wails/assets/netbird-systemtray-disconnected-macos.png deleted file mode 100644 index 48cfa7c609bbf6e7cff2b3cec89a7eea90d31c47..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3474 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 F{|k6OYw7?1 diff --git a/client/ui-wails/assets/netbird-systemtray-disconnected.png b/client/ui-wails/assets/netbird-systemtray-disconnected.png deleted file mode 100644 index a92e9ed4cd17d7998a819f1b80f7551b7004fd6e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4800 zcmb7Ii93|t`+sH(W63_YRD>pLwiih;wi0ERykTUrW(ir!GKPxRn(UM{$?_V@n=K5* zj4dfNL$+RwEM*_Yn3>;ruiroLeXi@d&U5bToco;fxj*;kKG*Y?wdFNl?vvaA0PtQn zGqD8#5bF~JaB{F3=is-VtcL4>nNtV=@CYBjAfVu-`0mGRtMr`c+(I7 z8q#@=?y>`bki&HoL;DEOuNh>(DPlHZ%WmT4&_9p_bN7gsL3nNsO>9-I+oDLXYtN`s z;D#zkk}m2thsEX*;~VFtZN z2jaXl4++07?SIpCzzH4i_%H`#(o2Zg2}gld>hlEso)z$zQ_3MTw8w3RW1 zhV-12~?tK6#z4J9sIRDuXqLWA%)(UIzXu^ z7A|26o5&q=l!&mGpgA}w3EO~Ke>$&+GTdYLa!rlxw(^vzel%y7ClQG zh4k7E=ODv#Hi9-2uH&@7SEkad%dg_LTA02FMn<5yGw}y zT_)3GK|y&xHz;z?K6HoDGknx2dQ=O^$1&ydX?(loQ1u5|q_6Np!ma#)V%LF1hLV&e z{NKqR@96yOUq*R`Y0Fs4--ALU$^fCJ{l!qi_pmX}GKul>@>g*Ts#Ux0BlOUTFpFi#mdxE|NO+6geL!Y+e)7gA~MUsax&)OdUk75I1H=Kb_er zv)lXV1s8bi^<0TsyEfC4nV!5iJw2J=otLNDxi|M*RHHdM$QCL)@cuB~pa{8rw86Q7 z;Z)IO9pHO^Q0xNRB}&jGMUJ80CrF0`ugQkFzvh;IvSlvkWCy3q2j=QfrhDND&v(LEMwsq8aUPv{4va3Do++FV4|FN>L?@CC3!Tu0|Vv97n_wxF$ z!e~)lb&lzYKygz|c@^?tuWeoWtzGXRPe;Ups=hxV=6nopjB(yn`X-?>F2h;p>HNz1 zKI4<Qef*G}f8vlAiL zZD{#f#cPhuu!0QUOTaNBmrE8%FxgR9>~LS<>O*{CtZPy3!!(EzgGq=W!lz znbCwCx#r8po#kW_*XxgUj5}Vqb?SchjG(l1{*B#Lrsj&_-)3q}!lOKR6~o=ZYdiO2 zs=}dTDxH!CX)_4+v~$+3760iH&SzksKRMC)1v{zxlm9JN9_*TTvE2rDr4?V*9cf2JoQ60_!{^L8 zCzvooCHp#u^ZSYA)j;LFq?jg~+D?teectC10vOX&9Nb&z($_H(*0*XI>^z<4w`@0) z*YC#(2Um9e4p{QknZJre;e^p?|8z$${;<<~lTZBEQ=OZ3#M>zu@oEM^Z10*y7u~Yr z_;V)D&qH<5B@FyS5x4GBae*Js{s(8IT5$N5%i6Vg8^{NT$aP|z?m2mb<*X8=ig*^N zmPNeYNwGy5omM%Bi!7bHuLG@^8Vj7pdB8Bu!(OXPD5PeLeCcxwt9yUUubPEd|Q6=YV=CD zh<4?fKcIzMp~DYdffJdx%mp_;`t^J|*U>dep3m*<=zM`9#H1C2LtnQz_QODpkm-<# zgBoN1=-x3e}w;Q4mxec{4%*fLkw8$7gjAg5GC)8<76wuzyvbg|lVY z?iEd~ClNRFF)~BRRa-h%VU}2>vf2%Ojq3Mcg3+^@s2+JB;)1f}w?TYKB>^NeE336BMp}{hsuX7(~vH>N!Nu zdZ(F4HNkW^SFD}4gi+)P;`CW}6d4~7t1{YYOQCM}P}pqWKfPGrfOL2;=WhquMOL>Y zoW1%fs}JVLlxUW!FSN|q5!{(Dsh?Y!Nly;V9`MIx50bDsA{k%Ma%v|}JYcz7Z^^SS z&8^U&)bv480tn0v2gP9)r%LWCR~bRq_ZVEM^i?%SU3^qkz0c7r+$Ao?^{Jk~#(ts1 z{m0DasD#31p$7|rw_sjQ#uva8aDJ~O+RNs>(2`61F~W&&K56vIxJjbt_-M0_Xnr5_ zUi98r2wbJC==+;YcxjosnPFr~2~oN>_z8hOP&hx7(5Fykn+vMOiK3F@q(H$P`R(wm zdkfv=y)rBiO?7>=s*I|Y}zGqjaQhmIJwWT#od{n zDwk7WU)IOUp`CubYa09hlTuDebkmF0eU0p6{|ocQO8>Yb5?TC^3vKiX1237K`H3Dc zFnQL1j3y5lk-3a#J%W)~#+Oir*SYL2w=i%6M-mAdStc6!=pmqY?g}ND5iqg%?`_l# z5p!*FjVj3bwD=kct2l7kvrrM%(@A=!IF?3TO(w#AbhGd?(|&%X0Fe>+WCATwLHt?o zO^9(AP`zShpiuX7YyKveKq@Y*apGeow=Ugy;S9~;9Mepc?jC(|)xS4BiMopEbe`JV zlA^M7N#OH6ZgmU9uM?`|8vYA~nC309`LXCJalG-QT!)M`{#3Y#z+UfYuX#5NdYE7h@?4zF4*V4=~j!X=K(an=Qk;|`DpHgy)oybbxy zMkM-lU$mg53QNzJ zv$(5u0Cw9wEpIfOE9vqZ?EbjjN>bTE^Dt4g35B}`1H>-9*O6{mqc7iusx5e*AN9xq zPzK@c?K;x$SjJMo{$ajiFX?mVx2m4}8@4R)YyitI4%N>O<203tQL1&2_PV!L08IVdMCNQsWM_wwpl+x_5y$YbWok zy>38?u$N#=O}kS{#A#IGYifb}f2%zsS8!*i&_7sfx2n3uvO)oiJ@+z4(PY@s&G!$m zT`JW#4qPvnsrVY*eqSF>R=Uh%#U_+?Y#seDYp-9Aks)^!PZ%ikoWv*)qO^J%C%*1T znl|j`9)Z}U)gJpu(w8M1A-oamj(vP36=D_6lJxg6<$rS;X;cb|_i4(WE(QMCTwHK> zVeS^`9w>l8mzG8ldX)E<+EnZ10hLASa5e^pnSVQGImDq)sVM`LeZres!(?>Lq>iX} zk~Vzh6)OSbzgQb;SrzhocpJV}8GS*oQVv}xBk*KdQ>qX@m&H1J;Mln=RGYS{&ZsCx zN~=|Zz0>+LbApXU0zcGOr|~=iH00G95H1h4H5m#!F@l z>;3yp#68RTn_*!6d>c3I7vb!cWxyY9_?X96R11zy+CdtntRYc0bL9Bs|Iq2SK;huH z-!vFVb=Te#{r=v%4wSv*jU6F|*BUZCG&1liIsEo9S;ko!CL{S($h7;_@bs^J)Gel| zJfKBtKwk3`jBpuk5!^o{_N4e?&g&$}s4ih1)<(S)mim^t-Z9m_`5l@<_jQBTBkESu z=_l!3fjQ(;BGCs$?rTV4b~nDr7G#1Icb$e+V+H3!CCQ;zJh(Ff{a$vkSj{~ATq6oM zwf&Lc6au}gGfcw1-XcQS=T9i+B6r&krH0y4TQ!##MNv3Ud7x6Q1{aX-D(KiRTF*f$N6Ha>Wv-)s?u^Z?Xi z!y3}K8SmeFzN7COjwc>@tAmA%nw9h|WP@DXDMC}&)YOM4?Du!SPnZRVYNG-Dll`Br!p5klpe3|N3z^XF7hE#mx^9XOIcTsO8U`h zYOUbW@ZsDLHZzg0%Z%Qry?fT|2!Ggp^;EjAg|0(?Z`Zs0Gv>GGB z8s)3TUQ$y@gQzsfAch^89CknlE9W2BLxdtchEnpdUA*omTAQ3YDw(C7m*-{eeO1R~ zKMbdFqfA&L7Ec2G{Cn}HMBY!kr-{^H87lbsGzp8j{a^3=3V42TMIV!V!J$8eP7$ZI zy?6-XR3{HX?I2}ssS&1inv{_9lW;c;A}w5g_gGY>EyAEB{EQwghA5Z5^Qi#>Ix?>Y)F}5aFf*2q7w9m5!_`m1vRi#|cd8@AgtIg&YnZR(6z`g6v1Pt} zY0Ks5t4D!;^k|DRAg`f#b`Tb~q&p%cO;$Q_9R}cIP6s)#Ldbn}mp!%tNpOZ?UgTk| zOf^B?p<-hE}-Mn->Nc7p~s21 zfUF;Vu-fOE{(9`2U8x!_O{681UN7NPN8U~Lg?5uvPo#!{W!<}sL_hJdc(j?79%3{1 zivGyG>w_4}f=5R9mGr-~KYQFa1w}MFjUMofJ5?F=M)jR#)u@wb54a6=j(*N(tGQB7+pE3x?65@t}SEThpFW{mfG|9}5_@8@$r_nz~d^PKaX^E}^k?v1mzvy>2(7X<)70%v7@ z1^^)7D+GWGgO7{BC7$2|5omQj1OUW#Y~2vxN$&0~k&rW%ra)P@;xCYZd70Rn06;~O z82_>m0LbX!%uVoNkcIJ{kg;zmA}rz9N{#P7pQqW@-Wsa%?0)&iEg;TQFd*cd==pDYvyJMOs#g&A^ zsJw=yR?j%(eN5Bi>w4=DiM^l`|NqOo4UT=;ym{E5-3gzaU=Uom`lUBE(qdOjZ(ZbN z>JE*}ej_ws9~~3a+XyxyN%mJqytT46qCCS$yV zz1XfuR17a0yehZgx*k@a1Ou+%O=u@wY6~ceqxn5+3~>|2kHlB|hO3^uRvV-i&0-NsUe z6I|~5<)QQ)*q*#48RbzXqeGg1Byc*wv}QIEE(@&8eCaA|v#E1YqC6KdWmOWYjf}H- zI?y?js-fbwa5=cQ=SaX#HNY96{3pkuYKVcBKA_O|(?Z!2O6s<=o2hlVbWdbzV3wzK zNBjw?7m>xUejA}o*Gh}LE;)0U*JTO?yn2ieU!M#k)F1EQ2*ho%-icacx2sRq95Hi& z1s?|8E)W6U1hSML%{590$XGpBj_-yu)VWYHil<2?)a5QVEVGwI7L+B+0Wu*=qnJ{A z!u6*LKs!hwoM?=Vz<*sY;xFF$8)xP;s+p$vW9+bzPWvynD-2gmVGO%F*II&1NHXY% z@-n^;%@Jm+kCu=Pi*JVK*5-MwMEzTyCb`yNe%(p-p`I}Qg2zp9y9DC`MPfw&Zy(S2 zK%$tI52oer^2*yOP5CJ6;i0{hm3^9!KoYGUtDvQNOY2HZxcQ2^+Bx<-`F88a>5@f8`j(Ud{b^I^_eRYtJ`+lPgkVssM2_SbkOPk zAKKNlg^JiuB=&knkTd5v@8u)cF^z}EdjBZsF#XEATR8r-&bc9~#|pEGlGG|Q{H1n7 z3NcWAJX>m_-k{NPPi_wIBK)RV&H<0(hU8vxdHYL;hA~9{oHQxTOUI2GeY_o6)X5cB zTf3HsOV1y@K)B6xtLNNxlPa@=SDSJ(l0+f4m`LuTaakhmEQJ`>ZxGJKS$@k(azgoe zBrB|2Fb3&qr{mw`Qs=knhjnHh-l*{rl5019+PR$i!-&^SotO{fJ2m@a3m$=irit5% z{I^)(PCffnyzZRv!Hs{v>gGdd{tPU!PSm_E;#oo4m-Fbonr}v~_Fh>cmC;PqHF#Z5 zT*X+K{@RaoHFH{CR^%rio?e3~u^%cKe?PRyuGsi^pXyk=PTuP)SJgP|^;oxqbe|*S z-Yfo4jPls|Yhs_!hj+szqP^UHuR#x*E+Jq7vlsEnXHsdaJjgn6!flf z;FoiPA|sey&+1rSkZ)gWCFAwIVkl<(Q1!yLvK{81>pM&MW5#cv zM?&iT8I1w(IP7~R1){B(2syBN^0k??EPEk{<+QXp*vRWuy-N)_Le)lDt7YjB>B40oTTnCYJ*GadK(?d@}5)$_{g zPeUayXCHaRS+t|DSKedX8cmRJa%*2=>;nDhX(8h$y^bL#+Ms&3Pc3i+A*ZCo>?OFj zyxJNte^wHVCp5ni6dZ>n+&oFp4tb(xFM8!_z6b66(?u~>H;0R&47Zq{lc2*UJ8L!; zY8@oFIs=uA$4h4FO*eo1eOx@&sep1`DBHIg{YH4=q<(KrW6vt#>ht5cK^{|Azh@Z?6$HLngoaSIH3NCg<}N@53Sw8R-e0+^IE09e{jj zUx9cc+8p!+jsJF}&$KlL(%OFmA5}!zmUKJFxzL*i)0);0< z-!IRGED<74{~npNtMEw!UUb3+W-AH1oYm%=`oXkL1W0w89lGtNVxm3cFx;T6d?)|t zoGQL^B@ftrTHOK(kFg0fzGwKZ^eERmIm_ursRyPfG`duRBOZio(tvD6=nF=*Metp6 zr2Qu+r>^#TiEOjrm$qN0B+_=4M(?wN&Q5Vz<6Pgrl^>e0DU*(gG_9ZeN%Bcb9gM>W zd8oeNNqvzg9@@-qoS%hfG@WnkUL|mKlPyo*dU%P4`MDx2SG>6T3zJ$CTblJs5OW^Nbm_A@+6Q2 z93A_j3SY_b=9T;hF^YG2`M6`gZZ#7d961 zf-pHp=U=Tu?k}TjrQI7ZKG?8Tj=JFYIAIZqzjB`QDu_wzZ2x^@SWJ}`@Wh6`>%qxQ zdHUDVFbk3QoI+y~bL zDH=Te7@DBwHjdO2JP=W;Z72ZYbtmzF|FXqSPsE%NgZQnUN$s+QCdmZUFk1*|51+kG zPs83MpMrtWqZ4v$p)<*NSN+|0I(5q{j5QQ#U(ks4plQ+>D`BsO zOETfQqiqbs^fw6Q{V&|W5VmKW%Ttr#X+YRFN9jE;GDroH67HXhz^oT=@bTFq5AhvQ z_lwRc(+RKAfQ>Fy2z{3r&X_^mUWLd96{ltIKFQix7Z+T33hSg?H69wQQn$|B@O)`J zl9~o)WV^2tTi={DREiGt*6cU`5fW)RJwdSuip6O>`wTW9v+bD;cH(k;+o?R?dx^9c z2g@1~Jo2ro=I=8JR-++@gF3U~OEfiS8_bRt8hvp1Q?g3WgpmiDt>vp;4vdt7C(U_Z z-oV~TtflYf>ucdql}xWbM(XEVJ^?lj3C{i5T3_e{1wDlMZoWob_z_(0ez&~ea_r|S zT;CXUv{ek{dC*yd)GV0w{^>G3A>x}Mv~&>b?NuajT<&1-eQ1B>e6bGKw*ej97DKVP zsp{sU0@B7Yh#nEGo zt1i^D7JrnR)4hN|gYJ)r=Fj++CjZbCoIykx)F7?&xaa@MV*T6xblmRD8h|CAVHw&| z0*>X)kgZqbe}L~(!CZN#J4 z&o%{zXby#FKMb-7c?o2H_>eTftf4sx#ur~?!-_yYR!sBky`sc5J_{i#BMiUv}92#zQ$7yQDJ(17&Ve?!~63xmb9K#Q&rNz zn9YG*{Z0t)5zvDA9mlW#`e?(CnuJ96t~0&b)gZRtlVIiK1C*UEoFsKczDg<fHnpGhcbj(k|SUzHyY%?7or?l%_m$=^-<6vSs?bh1Y#Q+Wt&JNhD@*wzz z;z}X%F;g*&>{npMh*^pSQ15~ZoIf~6ME$s6xf(v4ovyX0e0`%;7?HB^t+Dq=zt4rr zT}DpO3So?yQ2D2{9E+5!ca}yW4IaDIq9yN?z^+I}#frMcjlCmqLe}e+GSe^lZHD2Y zdtN&uJ}L3V!L9lG8Vz!Nmum;Z47vMaD>~VfrKXjb zh9xp>k!2lNur08F!nSxf_ONB4sn{YY2sy_fuaAKft0C%R(9(${dQYl9G}p{9*`ex%{6Z%iN9iwn&XMyYIAnqv@Qs4XFUh!g_<~t48vcCwAl`lPv=OmqLN# zCF+n?ubml9z+G^;h0w6NvllBAjlp&HQY>H53#0;W|kL@fz#EdRwvsFcj!rS}?o|LewKnU)hu4P4h!LPv_kQrjpiS!aRK!bOb z$y8S1HXxl5=4T;pBRwX2_{#gZG1B-gI{n!@P0lZIx^iB=HR>S@OlEcZA^1)+R)-22 zVV(k%&ksS#1SJR#8wqWyB#6PIYM}=pgSJ>b!38Yqk93JnNgdS6v(w259%Te|gkDo8 z9WadFtL2mAW(=d*<*7reWU<%F=R-ANG*^(yK%jo=5NuSq|Jd2Efjv-W#HNrmKWI7d zzx*<&_U%uhtLKHtjHfzI7E<#6>=7lUpa-FnFxrQf71!m}J-f@1_Kx5p0=8I>`^Kj% zA4xeQN^k(!w;Aj}Mu^at7ISMiUaCU$5x^|32g~~465RhtCXMtE#)>G$PE!%|rLv!U z!q3=1r*cTnoSbN8=+NY?OE2|=KRW@t*@GmIUUrRqiH8=dgTd2BamW7!KI(BR{f;upi6or!&YL6#EVkmI|;;Fol z7*QZMZfu97BEPuN(HsO!;OPTVXc8T>k(mP4J>Z7wUN{uCx#D73zF`HFr{l>JJb8&FkzzYR@J~#g(aYLP^x-M>VpXx zYr-1R18WQ*UO0rZm*MLl-ZC5WYd>5DRA~lPn)1(yLO;_9X^o(Y_Io=MX;1|n0IGyd z%D}b@5p92iQY6DSWYk9{6*m@z23XfFOPee5^?kPHn&^@s!^kw%t*U@}aMmQ*o;7ra r5k*picP9Cv&}#n{ME;MzbfH9%Yt6w_1;1p|EeV{3oq3t58~J|#D{a8Y diff --git a/client/ui-wails/assets/netbird-systemtray-error-macos.png b/client/ui-wails/assets/netbird-systemtray-error-macos.png deleted file mode 100644 index 580fe647c29bce036a816fd20f7ce0a6ac0824cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3555 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 OdBMWYyyBea{eJ@L!Le-s diff --git a/client/ui-wails/assets/netbird-systemtray-error.png b/client/ui-wails/assets/netbird-systemtray-error.png deleted file mode 100644 index 722342989aeee5ef415db850382f16f5aab10fc0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5260 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 Z(%>Cos#cWtK%X^)%YJvqTKk}k{{iS8*lPd) diff --git a/client/ui-wails/assets/netbird-systemtray-update-connected-dark.png b/client/ui-wails/assets/netbird-systemtray-update-connected-dark.png deleted file mode 100644 index 52ae621ac0f35138d31b68201d70940a61d947e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4867 zcma)Ac{J4F*MDXV#y(^#gtCk!W2v7l&DfJ@gsd59p{&^{vq+Yav{_0RN>Yh7OTze} zjY0{f&}7Xr#yXlA-tWBszvsN)bLM>SbMNQ5+jH+d-+8j|u8tC-J468h5>5y0jsgIK zNEkp0K`;O4hi9OdNYp`}7y!h!@qaLI=dL0j5_8ni20Z<&JOeEd!PYL;06fbT8eyy4AI9+CmHjg5#$5Z-T)>ub~?SKqf~F16MOn%?cV!cWf&0@Kt1u9O>sBs z7OX{()js(#i>N0I$bdYi_;lSx&`N}TjQ|c(@d*(5lCN>hN(tIm_DK*0 z#`bkVWE)=v!0c-oF`d}(hmLOK{40oD_6N>jk=nJ|qEk8rM}kEMX9yhdnGa@lJXv>1 z+N*#AeJ z%%pkAqqqL>!`qRuN;Ef^f_uYp_i9HlJrjJrIc>?U^4c*leHh*1$@-qqD1))n;9&K(+5m~RZX5)>^!c#Q=7@Bb(Y!O;j2{VzyD!h}D zITI;A90HY}uE+f*XussTY@f~^nBW!MaCv^81;*f`ga>)TwjUcBEbTBc>a(rpp%pZx za`w%8?swQllIeG+2`cuycNzV;>dJ}DHLbJKdodLwZMioepQ7XeMaicW!1YtBy^ozJ`I#qcYAhlrONwG2i?g(c%WHL9b}tXA9{k|NR?|FX@S!!JA_S?mtEVsY2#;^Md;FIz)-fD_bzM@&D5Sj%y( zGyYi_
  • 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 JPi+EI{|6dE%h&(_ diff --git a/client/ui-wails/assets/netbird-systemtray-update-connected-macos.png b/client/ui-wails/assets/netbird-systemtray-update-connected-macos.png deleted file mode 100644 index 8b7b9f131f7581a4fc9d86c7d248735b2e6bce39..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3328 zcmb_ecTiLL77i$jipUBIN>C{Zh%`Y!x*{#olp-J_MIsV9QeueNATF|i^b(XN3Ita` zgiu3!^eQVTC6EMxpp+z#01-mSyJ2VEy!XfZZ!>e}cfRjC=R4<~+&gpQZ`oQ1ACWu) z0)d1fmZtU~5ML1QI&=_Vgx>4%4ry;odm9iaN*)A?{v8C`0jTI7AW(!l2(;`80_o*| zKoTK&O}7mIf&adhg((R1XOf%-fDVUPx`qKhV!VqF^eXQZKnjLKY|I3g1rCZF5w1ye zg#p?K#Pqu3V*-^(kC5&E8ojP>v=^i?`KkT)*Zt8)hs>olKo&qW96fxk_qb&;DDdFy zp|!ZrL(-qGIZd=gtc);ML_Ull&C9?AX7zU~%y~oqlfRCm8jtDxo?bG7x_uo;9Z;aZ zZcDH~{T|=O5R|1!&B%i)XwvWTUpZRMP2GanRzKV^=ZHUQuwKLu zq>d76AY0L8ADArdgBOcDCC1l|r~KZ{1N@Ub(-5d3OcT`!NrvPufsZ}nQGLpEo^EX* z4>W+zYQa(ht;x3*bpJl9BU2E(ncmpTQ$Hpxfe+t&h3nNW4Yqb96dTrEMtR57A{&ap zV>+^#2KyC<9C!8wxog51Ta$NYH#pr$!5Cp#jlNYBJ8p=?Mb{J>i^e5>&>dJ`<({P| zT5K0eQ51t^8S=p-!3ZxEF#VooRT>&e?`YJRFX)$kHUv#2L$~(Y0;DK5$qadP|1^6M z@nxDp;>dC?wzahtop_zuAoy}nx~*TDS;M+7@_`}G&2mO$Obm^*hlz!)R#VxD zcWQK8Eak7MFQ^}3*AaU$6IpOV$0M(5J3RkcuK>5ly_8`0DYh%2!=CHx0!~}#ts(fM&BrzxrlBYilF48FR7LUBVDrwob6X`K=|VHCU>71^Ui%K^Hb zu~E6%MsQ;WTSxMd>7>GdsqQm+sG8Ilx3&b{43=9oHL_1-hbGQ&w>wNU>>DS9XkSw{ zLN<}HoTBWAnR;xnc)D2TE7r4}G@uQclhpIvefqje~4zR^j#$ckN486)Bda!u@NzR5- z<9tCY_7nx(GHc+ehDqPi$#!_$CG6CSh|A}n8H8-mj(`&yz_Tw|zgD4B=-fN3WD@H* zT&z{Mq~-8rJ<~&%(t=V8)#;e`qgRzIY;{f)bCMoN%?0(SB4-ApTO?ImmB=0G4Y_G- zKRV`hizM4FC~N7o4DI>CF!Ipk+jZtzqj+`~G4siUP){Cz^9(~-IUau*!8nHh?LKP$ z_#+3JIGEWppdf(C+bfuc$n6;nD2dYJ9O->izqy9PBrcf|JmDY(Tb0; z6z`2tQ8=c9xhKBnD~_JrLU?UokWY8_Uy3KY-#}1C+d9&azKaDR6k@CW z5h8pZxhzdYw68bt3h}q9vJ_4q5PR}TfAz$y!#FM;EM|!A^F6(GG7<}l2mkr$!`7CO zfaT1strb5yqP=bF=VNEjYPwJt!RT0u$e!3Xgqa%Qv2_jc|rp5Z?CC zh-RcL2nYK7w^4ce5sKk)moUP@y`m?Gi2@)+(T=22Y(qMkz+GE$9|ID)iA}BFas7Cc zNJKr)qi@k!Po)&d7#+cXg7u+78dkI(4GZZqB$#&TwG$+8Ps)mSw<^H0M^;PiM-ijYA{f7#S&~AspFngHFA|r zBT%Y}g%wzpf7uQLE&EA^yeL~2uI1t749}RN+speU_P?1qb z<$QqPU8nz$@ISfU0Pe=A+>oLAbd&Dvc^pq^xothIBt9B68Mcuqo&i@{VS8jmc9zZ* z%a8|s_H78QvzSjidlSV(+~xviWN)Z;ljjG6u>xv`r}u@1z_~$RO#MC|Dp{xu%lJD+ zs41yE9QsP~DyO4u2*YD|yZ1q)aaNl=_1Nl3kMW#Aui%!K`|1Tv;`VAS0E=N z7HtUKOkDvNG(bGfM~-SO62l1|&G2jeCI!v#Zy9Y+nclA6oLr6y8@_9%FXh^b)ZtVn zNxG1`1Vf-GwpM%EW|AhK#dy8JFH)}L^0OgO&Skl1xfPK=_@!LYxO208J2zYb{Tx>K zY69?x-A>DChJdpU^TMK3Uf6`ufxW$7&yWvvq}?SIC;jpQnA^AOJD>^c0(5N_rY4)qFupy&PQ0dRmc)HSqK)U{O9 zuQ;mf>ZxDV)6%%4uCAx9PF^WH`#%MN!BChF;{O*^*8TbhC{X-Qhj3WngRpR~z>xp0 exvZskSzYh4=HE?@Jz^&C?h#^UYg%dI8S`(A!F@6S diff --git a/client/ui-wails/assets/netbird-systemtray-update-connected.png b/client/ui-wails/assets/netbird-systemtray-update-connected.png deleted file mode 100644 index 90bb0b7f1975c585b48a5088710678521a236ccd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4842 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 d(wW0Lv-y*A*FK-s$qgR9OY2{tqh82QdHu diff --git a/client/ui-wails/assets/netbird-systemtray-update-disconnected-macos.png b/client/ui-wails/assets/netbird-systemtray-update-disconnected-macos.png deleted file mode 100644 index b6afa3937ae8e93102d69d1669c16f40844e8343..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3747 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 OSX)cSE7j({5B>{<{Ua~{ diff --git a/client/ui-wails/assets/netbird-systemtray-update-disconnected.png b/client/ui-wails/assets/netbird-systemtray-update-disconnected.png deleted file mode 100644 index 3adc3903436f78a5b7eab298c5e78d52a51d2fa7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5298 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 z 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 V>cxt`kmog7Ub4Mdd%@$u{{j=u(k1`^ diff --git a/client/ui/assets/netbird-disconnected.ico b/client/ui/assets/netbird-disconnected.ico deleted file mode 100644 index 812e9d283d096d823824fd66e6533359fb375b4a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5056 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 M);HIyz`Mr%AJn`~GXMYp diff --git a/client/ui/assets/netbird-disconnected.png b/client/ui/assets/netbird-disconnected.png deleted file mode 100644 index 79d4775eab038831d38abcc2845f671adc7a1b4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7537 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 diff --git a/client/ui/assets/netbird-systemtray-connected-macos.png b/client/ui/assets/netbird-systemtray-connected-macos.png index ead210250d7a4b25461aa0311fcb6c13cec75057..d29a7ade8821d87b12982d8b051ab5294a7a329d 100644 GIT binary patch literal 3690 zcmcInXH=8fx(;m^^?(Y}j3f+2K#)+R1%y#yAan%;1VWW+C;}lw+Q=x~3?R}`5C%|+ zND~r3lqN;0p#(w+Jti@M5C%lf$GL0Wb?=Y!@0@R~z4m^e=Y96`?zQ%}_u9!eRwe=` z&z}T=KmrI;qgx;lSI}|$nHNA#y@nrmQeLLFEI}abMGz?d4-jY{fZ{hmpa@kEXv-Z0 zg6D!jVj+cXH}wI6+tb{{2n2eSE~5@aCqhh}!hm^%j~f>#zfc?i`NI*G#{64+yr)kJ zG^9HD1J1e#qic4US=#(UgtUohA5+iZprhz_(phnnH#$7(FD`VwIh*v4D-ti#f6e0R zJ8KH&(&6@(afe=c`v>n%4gFPHOA4a`Li8O0vDYgeceq}W25kO&Uwpq9RZuF+3Y&+3 z8Jfe|BYcgFykVU=Fts(?JI|pC2tTdObH|W1cB-%d0hQH0MiSlkj^@W1rMRRud39+ay?0`+e{==9* zi`Zpdo#zu`{!AFEmKP+))TmGM&p29Nh%RslXYJLqiCO%e@K(*!8+*;o#RhwtSw&I5 z_O%p|sO72-)b+1ZqSf+e3?kmDonD`$XoXyNWA|m`5S^`;t%;KFPiV}{ns}A!%{WJL z{TNa@Dx2b(ke^e%A`!&d0VWjS<{gNks&ADwiD&CYYkw(&uP$_CLg0I<@62Gs*Hzfek)LP~ zIJvn$?z<6bB;$ZaH`Oqf4Z}Wd|Ly>}3&TA^s$FR)iGy`IMlM~&hjh--sZ-GU-OZc3 ztP1@B3J#~OyCdKd5XVlObBgS`BhUGQI)(i+{SRd*1ly_sbv>*I*dKpIg!?K)799dYUvqE=k~`w;U^@X0IYDw+^wAMwa>nfgp%CHH^lOGAY^L)~A}eET)2`#2r0A4nOTrW}!!MCDPj1VcuG#;4dD-uns zXS)-r@wYv8b!a8ljbir}M0FLZyR5K(-hsRDJcIC@ph@ZRhAbCV@M7pq@YSI?UvMzE z6X(QtaQINe7&dWuHYj^LlX2f6xP4`+TZ^n~9$!JKxsPNOX4!Bn52=&eOXJ*xJO4tD zAofe8+!Qq2Bjj;SrNO!?y2JJj<8pQRe!ZzH%IF6<`q}zH)&nwrvjtu@+*iWf^yjXP zy*?-R-j;UFNqa^V0(+~_zG~|rG{}*!(5ttIyq4(p?25e>U|+{g}QQeoMCjDVrt&X27lFy!J_Q;;_OLk|0T`cyeQ#j3box5;AF;7{>oH)1Dt&fWSqoCIEW&`?lnug>0^Y1c4RdX{ z?~S%%I4OPDOI(naC8ccWL9GPrRoK=_KnWM@cczZN}q)n}2QBouLUyVB0iNro#sKzh$0Jl=*Lplc;!2#~}+|h9* zat7ous((Vjc_S^IGk{%QTp$`Z8hcU>+eg1E-}P@d67TcT{?u9oRb#4zeezf2 z*FD34&+DZ$o4OG2UmF^}A2cOJKKkp2`i)t?)b8#F8BsycOzM6kDA_fishml9MH{sMfjPI0`X*1Dy_gPB&q3hgeF$j$s56ZeJe z(U{y)-&48QLl>)FCwBS29JYR%JDZ;7KF2xF9tlkK)0VYJD$ybvfq$~<2@_-TIa?Bv zwJh#gsSMmOA|y6{6hh_d#93G6!U9}Vw8tK29GprOZZMQNgDRyBjtbJMm=|YN8^A7Q zx!{qyB(1UC<<|ivp3ChQXN^cahkUte$9!Y5;~V?#?b>9mdp+da20qjg_|k4U!TCuN zI;(l?ltZj9Nqw6tM7x;YinuX^o<2t|&5&*%mDY&!wi4|{1vU_ zaLz+hNw8EL@|dP*eR?@Bzcur2W0L~Rl(YX1C5egwh;8(+v9lOZC85#q%Zl9WM_}E< z)J#arRpW#Egydb3cm6uF>wu>2*8y7uZp=V3xHLvYOXXwHKEE^W82_NmRG&HnpmOMB zvDr94lWlqwNFyI}Mqh)8jsN0;)Kv~NyY(DlQlAl>r@plmNWe1$X(dY5GT5j zJ06gmC;kF{={E@hG$G=ie>Wq;fXojb)1Ou6*u8)!_;HM8I=bfA=&?t+c|7X45bzbP zwXRwiFwxix4_oePy?>3uf+3r;Kyf+BEij^_srN4}MAyAVm{WgPrtis53_cZUR^nWC zq2OZUjtRs4s_dfuEQX)D1Y(o}bwW;lyO|G!9-s=~)_4Qq4*A>&gmj*qG$5qIRlQvZ z2@x$^>3aaG!gD7SsA!(gbAuvTjX&8v-lOLjDJ<@#L+)xzeed|Y-%Hnj-+2BrZxfsT zBd#Xr9f^;^iolZ#Bo}V%81C&6ei!a_|1Qvh)K%5BE~{!@R=r}UstZ@uforNOsj9+N zRTrq0kpCw@1$+DZME!d~ZS$E@K%nry4&nZ&yJ6uTsF44wQPYI0slwGX{>Mb{{&vdo OIuXWJMzz=7AN~`y+wr{s literal 3858 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 diff --git a/client/ui/assets/netbird-systemtray-connected.ico b/client/ui/assets/netbird-systemtray-connected.ico deleted file mode 100644 index c16bec3f51e8e41d8263b2b4b5dc2095b51e1261..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105151 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

    6A!aevjJ&cU%ypida~ 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 diff --git a/client/ui/assets/netbird-systemtray-connecting-dark.ico b/client/ui/assets/netbird-systemtray-connecting-dark.ico deleted file mode 100644 index 615d40f075b41f3b3c9241611cc4b249d0851899..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105128 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 OF~=dJ2Ok^g7W{u+(t)V} diff --git a/client/ui/assets/netbird-systemtray-connecting-macos.png b/client/ui/assets/netbird-systemtray-connecting-macos.png index 0fe7fa0dbe50bcf8c08e45c7cf13b7e6e2348335..306c6ddf555e6049c57ce8bcf98eee80bd2db730 100644 GIT binary patch literal 3725 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 literal 3843 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 OY{ZbUgZ~;x5BxvcE4?TH diff --git a/client/ui/assets/netbird-systemtray-disconnected-macos.png b/client/ui/assets/netbird-systemtray-disconnected-macos.png index 36b9a488f16673b1fcb1c87037d3d1606dfb1e5b..48cfa7c609bbf6e7cff2b3cec89a7eea90d31c47 100644 GIT binary patch literal 3474 zcmcImdpwiv|G$am5OOHVDW{0ErRd41B*}=T;F@)JLheS`P2unf-Jy~oc$6+Jq z&Excy#~w2yrw$L>vXwdeP2b<^_xk<*`2G34U$5)FuIqh$KJWMEeqXQqy6=oDjaT!5IKV!grd4m=KZ1o9}do{4Y6Q27vg(0FaUf0Bb@h|PYGd=xX|5v{m31dvuJ#T&+Z{V`Uk8uyq$9p6MHThNZ;szyIASf!lU}xJ zpy%!0j@q+qCF|_rq(&k4nv8+q!Vv~X7zb5^L!Y6-m%&>e7+ei zUYq;^#TytRtWoN$wX9N=goYmR-oi8aO2*rMU?`){am$!o5HuaGJ-mOFE*9+*LMG}CGEvn-U^gs%2BOH&s+HZe_AU~u(vhz@iW7Z$*eLZ3*3+^rh- zNh|;@9CJ6CJ5EMeg~DN$w07$p5P$e>NH)Elck|}f#TbNVT)z1#4BE%Z-gLFMG-CXD zYO+ep=g*_xnyfe{OtAd>x>3m|UNQ}*%{5H6R3Lxn#|Ix=a#UT4D`R?nq2J^M8{(WJ zs?9~bYl3~#9(sCpX<^jI<*}ojH*Y>?>2?u3`rq5bDJiD@lq- z#Isy?t?bV_s?n4@*Pu1=9#1@|s3?J7U2^-x0&Py0i(YUf(xaFm;Tt74q6}3+g0`L_ zBdm_MddNoQAaJHbo)ptr8*9W?nGET%y0T?~ddGA~_OibSHe%Fyf?XH?SGecAX57LY#g;BhI|KKOl{+bc66w7%fZlRyWXgZZj zcOuMsdHk-E+!Utv5!=Y}OB&mGkD&I4=MzXM;sL7%@jcWBD-XAERVrZZ z6&835Cx-q8zcw6O5UOeY9k4;p-{&w&q+o?+F7w< zuf8G$@i7h*DuYer%9XB;vsPItMd$7QIPr{f^!~lA@=y$Z7i>QUbBRwie1Ek75$;mDhU*Al9TUzjK zOTz8XI2F!9rSIlbA4Miw4lZiJ;^gy9`cF&hwLO9NKn>PVc`K&%3639?#aFEgMes&( zN0ky)XN+(#QzIK*MMe8jjs}XOtdkU`#sl%OKM)BBEF?0nHi_9CP z&2}H^ndFrlWylEhanNc9LAosJ=}FUY%DqzCPWp6k1>6qVEZnu-CnuBIm+VtCi#Rms zK!~2+IZ#gq>DYDBJ%P0V>$p862TO5$D9=sLOS%P){BUso8bnl+Jo8lNg6F!*M=lcv zOR3yLW|FV}@O`K7iCP9d`K>lKv?ohmMmfUk8U0N zz8*oc=?|6PsA~IZz*01s=ZA6Op{EuaJj(^ru}y@H-!6|-t<)DOyb2AOwREGD4uJWl z!J-;Hr1OQ-RDQs>?`PL$23 z!0g}Q1Ki6;M1pYZ^%l3gYndgm9wO^`>Z>41WEs=UbxN;or9J^0K#9cJQ$oA>Pd``F zt5^tq?AxD3X_bDnCDif&hvY1=*>smX%$xUSR4i}g@asdCB`V_|D?k1=n^=W5iy$3` z*Da&zv(=GKB>HD(QOt)}(rGvL=fPaX*BK}xT0>K>EeP+CkhrGmIe>nW-moq==PDZ3 zu$&0NRj~2_{rRY?VP)vuXHlO|hqLvuI9Z3(pUk^;gH$L5m`$IA_o;+C!M(i+OtzQdR{fMqFUc%I zy*p1W&7~Lr9h3SDB?;lQ%~@G`2Y?Nn^rGEKratq3ZM<51u9TTPOBO>%K{v+rlyQ5~Fz+{9ml?;ooyN8C4P}vU#o1*K2h3@2Qa_ zay{qVAAdIQ^4`laJH^;HnAE^YQ7*pXy*~i!)o7EH(}GmC@SSg6H0!)2T`(4Ru@Nj; z$SE-*LO<=AZq@zU@&FbI!W^3TGRn9=m-X-A=0NkV67g1=yw*ekrr;4$*&I)ReiE5J z69N7}8*Elo7hjM?pKAm&dPxqm16N5x<)$8T9VZ2Zv1S~jnUfXkS~h%H;t7Nab}Y}B zTvaRmxLJ}kc(@Z?Kv#cn+lVyd6(N>OwNU$%rUoWf*dY@Oy{>^5fC5fQ|O7Y-{m`JESL1_I}YzsLIgc-FVRM&aRGw;QFXS_4qw zIX?V-tA@AQvz~!er8;&;t5zp7T`0=*JV~FY`u+-hej2aKa@6>s-no;-A8G>}M%!&E zp$olf;B4g%h3KfDWazu)^=H!+bv7nAG-zjmNvCz)Kqs9+r;Kc<%t%eV^HWrez_Z^^ zz!hZH6kcqjhlu)$tw%vpLn}*VVh`@`Re9y3^CDr9{l5NZKu5u~m^4by`f$AAw0%L1 zSAVR#${lFf`<>@tE9qpDTaJj7PB%p7`@zDyB~&X6L`t=OjDu!EpAP0rtQ|LU zW?XrRwc~6rPp88exa{8WBKCVB4C!h{?Bf>h6v+0S*7#){M0lL|H*`)4FhpOWcECep z2Y>dmx4SU0N!?<+n%v^1?vt)?=-_=Qc2R>{NpAUNT;G!pb=0HLW4|6gs_qt5!>o&| ze00Z@uHCeVo#NYP))K9|J+}#sqSp)3vl+7Z&0%Ioyos<-yE^|_B7?0SjRW1dTMbLW zdG^1J8h48SMQuM4iJM?wmfR~-dsP(E#IpGzaHibbDVi8pCzl)a;gE0}-KRQq0)L^3 zPN@5fs@(LQ04V?5DNg}wvKUs{-XJ!1c9Qzv<=XQNUf#aeCE-1*9$n;iL*%URt3wYY z{c^F!G<#_k4f<&y1$Pm>F!Cra4P4*2nYxyoW%LOzHg;;V?aA9XbOy=jB>|e_K>Ndx zl2=tj;&dTb!dQ0${2GqHrE2_8%fFS?L=DwQk{-}BcixBp+>g4DxvhRhGt=~oHhr*A zS2)#+v2l+HxET{@?jIE>>;MypiJ39P)EIKo6>{1f@|(G-i4g>14uMQ9ywmtU35bY* zkf6B#KcTW=Z>ccB;C~ciLJ)z`F*gyB|2yZnsrhk;`SBC~7LkqOX9{xwI~&IfmFIm^ F{|k6OYw7?1 literal 3491 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 VFbHp#EYMSdj14UGKVEZ=`9J&Jr*QxP diff --git a/client/ui/assets/netbird-systemtray-disconnected.ico b/client/ui/assets/netbird-systemtray-disconnected.ico deleted file mode 100644 index dcb9f4bf83dcfcb18d4858d5cf553895b092eca0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 104575 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 Uw;PvXstfzveZt6u;jD=N1N46qGXMYp diff --git a/client/ui/assets/netbird-systemtray-error-dark.ico b/client/ui/assets/netbird-systemtray-error-dark.ico deleted file mode 100644 index 083816188d695c0a083ba7dcd78c1f31e9f06080..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105062 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 OdBMWYyyBea{eJ@L!Le-s literal 3837 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 diff --git a/client/ui/assets/netbird-systemtray-update-connected-macos.png b/client/ui/assets/netbird-systemtray-update-connected-macos.png index 8a6b2f2db85a6b6e312015ce634ec49096c45059..8b7b9f131f7581a4fc9d86c7d248735b2e6bce39 100644 GIT binary patch literal 3328 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 literal 3570 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 MdJg!tzfZ*f0jyzlfdBvi diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico b/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico deleted file mode 100644 index 123237f665722d0194d825033775bc90616a6a3a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105086 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 WuRrb?z6NGGiIKzHh8`Q@9rAy92WzMR diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-macos.png b/client/ui/assets/netbird-systemtray-update-disconnected-macos.png index 8b190034eac6588e58a36365880d83d9ea8d8db0..b6afa3937ae8e93102d69d1669c16f40844e8343 100644 GIT binary patch literal 3747 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 OSX)cSE7j({5B>{<{Ua~{ literal 3816 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 diff --git a/client/ui/assets/netbird.ico b/client/ui/assets/netbird.ico deleted file mode 100644 index 2bab8a503d92aad4dd9bb5eccc02f044762a7f6b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 106176 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 Dp19ST diff --git a/client/ui/build/build-ui-linux.sh b/client/ui/build/build-ui-linux.sh deleted file mode 100644 index eab08214d..000000000 --- a/client/ui/build/build-ui-linux.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -sudo apt update -sudo apt remove gir1.2-appindicator3-0.1 -sudo apt install -y libayatana-appindicator3-dev -go build \ No newline at end of file diff --git a/client/ui-wails/build/config.yml b/client/ui/build/config.yml similarity index 100% rename from client/ui-wails/build/config.yml rename to client/ui/build/config.yml diff --git a/client/ui-wails/build/darwin/Info.dev.plist b/client/ui/build/darwin/Info.dev.plist similarity index 100% rename from client/ui-wails/build/darwin/Info.dev.plist rename to client/ui/build/darwin/Info.dev.plist diff --git a/client/ui-wails/build/darwin/Info.plist b/client/ui/build/darwin/Info.plist similarity index 100% rename from client/ui-wails/build/darwin/Info.plist rename to client/ui/build/darwin/Info.plist diff --git a/client/ui-wails/build/darwin/Taskfile.yml b/client/ui/build/darwin/Taskfile.yml similarity index 100% rename from client/ui-wails/build/darwin/Taskfile.yml rename to client/ui/build/darwin/Taskfile.yml diff --git a/client/ui-wails/build/darwin/icons.icns b/client/ui/build/darwin/icons.icns similarity index 100% rename from client/ui-wails/build/darwin/icons.icns rename to client/ui/build/darwin/icons.icns diff --git a/client/ui-wails/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross similarity index 100% rename from client/ui-wails/build/docker/Dockerfile.cross rename to client/ui/build/docker/Dockerfile.cross diff --git a/client/ui-wails/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server similarity index 100% rename from client/ui-wails/build/docker/Dockerfile.server rename to client/ui/build/docker/Dockerfile.server diff --git a/client/ui-wails/build/linux/Taskfile.yml b/client/ui/build/linux/Taskfile.yml similarity index 100% rename from client/ui-wails/build/linux/Taskfile.yml rename to client/ui/build/linux/Taskfile.yml diff --git a/client/ui-wails/build/linux/appimage/build.sh b/client/ui/build/linux/appimage/build.sh similarity index 100% rename from client/ui-wails/build/linux/appimage/build.sh rename to client/ui/build/linux/appimage/build.sh diff --git a/client/ui-wails/build/linux/desktop b/client/ui/build/linux/desktop similarity index 100% rename from client/ui-wails/build/linux/desktop rename to client/ui/build/linux/desktop diff --git a/client/ui-wails/build/linux/netbird-ui.desktop b/client/ui/build/linux/netbird-ui.desktop similarity index 100% rename from client/ui-wails/build/linux/netbird-ui.desktop rename to client/ui/build/linux/netbird-ui.desktop diff --git a/client/ui-wails/build/linux/netbird.desktop b/client/ui/build/linux/netbird.desktop similarity index 100% rename from client/ui-wails/build/linux/netbird.desktop rename to client/ui/build/linux/netbird.desktop diff --git a/client/ui-wails/build/linux/nfpm/nfpm.yaml b/client/ui/build/linux/nfpm/nfpm.yaml similarity index 100% rename from client/ui-wails/build/linux/nfpm/nfpm.yaml rename to client/ui/build/linux/nfpm/nfpm.yaml diff --git a/client/ui-wails/build/linux/nfpm/scripts/postinstall.sh b/client/ui/build/linux/nfpm/scripts/postinstall.sh similarity index 100% rename from client/ui-wails/build/linux/nfpm/scripts/postinstall.sh rename to client/ui/build/linux/nfpm/scripts/postinstall.sh diff --git a/client/ui-wails/build/linux/nfpm/scripts/postremove.sh b/client/ui/build/linux/nfpm/scripts/postremove.sh similarity index 100% rename from client/ui-wails/build/linux/nfpm/scripts/postremove.sh rename to client/ui/build/linux/nfpm/scripts/postremove.sh diff --git a/client/ui-wails/build/linux/nfpm/scripts/preinstall.sh b/client/ui/build/linux/nfpm/scripts/preinstall.sh similarity index 100% rename from client/ui-wails/build/linux/nfpm/scripts/preinstall.sh rename to client/ui/build/linux/nfpm/scripts/preinstall.sh diff --git a/client/ui-wails/build/linux/nfpm/scripts/preremove.sh b/client/ui/build/linux/nfpm/scripts/preremove.sh similarity index 100% rename from client/ui-wails/build/linux/nfpm/scripts/preremove.sh rename to client/ui/build/linux/nfpm/scripts/preremove.sh diff --git a/client/ui/build/netbird.desktop b/client/ui/build/netbird.desktop deleted file mode 100644 index b3a1b92dc..000000000 --- a/client/ui/build/netbird.desktop +++ /dev/null @@ -1,8 +0,0 @@ -[Desktop Entry] -Name=Netbird -Exec=/usr/bin/netbird-ui -Icon=netbird -Type=Application -Terminal=false -Categories=Utility; -Keywords=netbird; diff --git a/client/ui-wails/build/windows/Taskfile.yml b/client/ui/build/windows/Taskfile.yml similarity index 100% rename from client/ui-wails/build/windows/Taskfile.yml rename to client/ui/build/windows/Taskfile.yml diff --git a/client/ui-wails/build/windows/icon.ico b/client/ui/build/windows/icon.ico similarity index 100% rename from client/ui-wails/build/windows/icon.ico rename to client/ui/build/windows/icon.ico diff --git a/client/ui-wails/build/windows/info.json b/client/ui/build/windows/info.json similarity index 100% rename from client/ui-wails/build/windows/info.json rename to client/ui/build/windows/info.json diff --git a/client/ui-wails/build/windows/msix/app_manifest.xml b/client/ui/build/windows/msix/app_manifest.xml similarity index 100% rename from client/ui-wails/build/windows/msix/app_manifest.xml rename to client/ui/build/windows/msix/app_manifest.xml diff --git a/client/ui-wails/build/windows/msix/template.xml b/client/ui/build/windows/msix/template.xml similarity index 100% rename from client/ui-wails/build/windows/msix/template.xml rename to client/ui/build/windows/msix/template.xml diff --git a/client/ui-wails/build/windows/nsis/project.nsi b/client/ui/build/windows/nsis/project.nsi similarity index 100% rename from client/ui-wails/build/windows/nsis/project.nsi rename to client/ui/build/windows/nsis/project.nsi diff --git a/client/ui-wails/build/windows/nsis/wails_tools.nsh b/client/ui/build/windows/nsis/wails_tools.nsh similarity index 100% rename from client/ui-wails/build/windows/nsis/wails_tools.nsh rename to client/ui/build/windows/nsis/wails_tools.nsh diff --git a/client/ui-wails/build/windows/wails.exe.manifest b/client/ui/build/windows/wails.exe.manifest similarity index 100% rename from client/ui-wails/build/windows/wails.exe.manifest rename to client/ui/build/windows/wails.exe.manifest diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go deleted file mode 100644 index 28f98ae59..000000000 --- a/client/ui/client_ui.go +++ /dev/null @@ -1,1773 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - _ "embed" - "errors" - "flag" - "fmt" - "net/url" - "os" - "os/exec" - "os/user" - "path" - "runtime" - "strconv" - "strings" - "sync" - "time" - "unicode" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - "github.com/cenkalti/backoff/v4" - log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - - "github.com/netbirdio/netbird/client/iface" - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/client/ui/desktop" - "github.com/netbirdio/netbird/client/ui/event" - "github.com/netbirdio/netbird/client/ui/notifier" - "github.com/netbirdio/netbird/client/ui/process" - "github.com/netbirdio/netbird/util" - - "github.com/netbirdio/netbird/version" -) - -const ( - defaultFailTimeout = 3 * time.Second - failFastTimeout = time.Second -) - -const ( - censoredPreSharedKey = "**********" - maxSSHJWTCacheTTL = 86_400 // 24 hours in seconds -) - -func main() { - flags := parseFlags() - - // Initialize file logging if needed. - var logFile string - if flags.saveLogsInFile { - file, err := initLogFile() - if err != nil { - log.Errorf("error while initializing log: %v", err) - return - } - logFile = file - } else { - _ = util.InitLog("trace", util.LogConsole) - } - - // Create the Fyne application. - a := app.NewWithID("NetBird") - a.SetIcon(fyne.NewStaticResource("netbird", iconDisconnected)) - - // Show error message window if needed. - if flags.errorMsg != "" { - showErrorMessage(flags.errorMsg) - return - } - - // Create the service client (this also builds the settings or networks UI if requested). - client := newServiceClient(&newServiceClientArgs{ - addr: flags.daemonAddr, - logFile: logFile, - app: a, - showSettings: flags.showSettings, - showNetworks: flags.showNetworks, - showLoginURL: flags.showLoginURL, - showDebug: flags.showDebug, - showProfiles: flags.showProfiles, - showQuickActions: flags.showQuickActions, - showUpdate: flags.showUpdate, - showUpdateVersion: flags.showUpdateVersion, - }) - - // Watch for theme/settings changes to update the icon. - go watchSettingsChanges(a, client) - - // Run in window mode if any UI flag was set. - if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions || flags.showUpdate { - a.Run() - return - } - - // Check for another running process. - pid, running, err := process.IsAnotherProcessRunning() - if err != nil { - log.Errorf("error while checking process: %v", err) - return - } - if running { - log.Infof("another process is running with pid %d, sending signal to show window", pid) - if err := sendShowWindowSignal(pid); err != nil { - log.Errorf("send signal to running instance: %v", err) - } - return - } - - client.setupSignalHandler(client.ctx) - - client.setDefaultFonts() - systray.Run(client.onTrayReady, client.onTrayExit) -} - -type cliFlags struct { - daemonAddr string - showSettings bool - showNetworks bool - showProfiles bool - showDebug bool - showLoginURL bool - showQuickActions bool - errorMsg string - saveLogsInFile bool - showUpdate bool - showUpdateVersion string -} - -// parseFlags reads and returns all needed command-line flags. -func parseFlags() *cliFlags { - var flags cliFlags - - defaultDaemonAddr := "unix:///var/run/netbird.sock" - if runtime.GOOS == "windows" { - defaultDaemonAddr = "tcp://127.0.0.1:41731" - } - flag.StringVar(&flags.daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]") - flag.BoolVar(&flags.showSettings, "settings", false, "run settings window") - flag.BoolVar(&flags.showNetworks, "networks", false, "run networks window") - flag.BoolVar(&flags.showProfiles, "profiles", false, "run profiles window") - flag.BoolVar(&flags.showDebug, "debug", false, "run debug window") - flag.BoolVar(&flags.showQuickActions, "quick-actions", false, "run quick actions window") - flag.StringVar(&flags.errorMsg, "error-msg", "", "displays an error message window") - flag.BoolVar(&flags.saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir())) - flag.BoolVar(&flags.showLoginURL, "login-url", false, "show login URL in a popup window") - flag.BoolVar(&flags.showUpdate, "update", false, "show update progress window") - flag.StringVar(&flags.showUpdateVersion, "update-version", "", "version to update to") - flag.Parse() - return &flags -} - -// initLogFile initializes logging into a file. -func initLogFile() (string, error) { - logFile := path.Join(os.TempDir(), fmt.Sprintf("netbird-ui-%d.log", os.Getpid())) - return logFile, util.InitLog("trace", logFile) -} - -// watchSettingsChanges listens for Fyne theme/settings changes and updates the client icon. -func watchSettingsChanges(a fyne.App, client *serviceClient) { - a.Settings().AddListener(func(settings fyne.Settings) { - client.updateIcon() - }) -} - -// showErrorMessage displays an error message in a simple window. -func showErrorMessage(msg string) { - a := app.New() - w := a.NewWindow("NetBird Error") - label := widget.NewLabel(msg) - label.Wrapping = fyne.TextWrapWord - w.SetContent(label) - w.Resize(fyne.NewSize(400, 100)) - w.Show() - a.Run() -} - -//go:embed assets/netbird-systemtray-connected-macos.png -var iconConnectedMacOS []byte - -//go:embed assets/netbird-systemtray-disconnected-macos.png -var iconDisconnectedMacOS []byte - -//go:embed assets/netbird-systemtray-update-disconnected-macos.png -var iconUpdateDisconnectedMacOS []byte - -//go:embed assets/netbird-systemtray-update-connected-macos.png -var iconUpdateConnectedMacOS []byte - -//go:embed assets/netbird-systemtray-connecting-macos.png -var iconConnectingMacOS []byte - -//go:embed assets/netbird-systemtray-error-macos.png -var iconErrorMacOS []byte - -//go:embed assets/connected.png -var iconConnectedDot []byte - -//go:embed assets/disconnected.png -var iconDisconnectedDot []byte - -type serviceClient struct { - ctx context.Context - cancel context.CancelFunc - addr string - conn proto.DaemonServiceClient - connLock sync.Mutex - - eventHandler *eventHandler - - profileManager *profilemanager.ProfileManager - - icAbout []byte - icConnected []byte - icConnectedDot []byte - icDisconnected []byte - icDisconnectedDot []byte - icUpdateConnected []byte - icUpdateDisconnected []byte - icConnecting []byte - icError []byte - - // systray menu items - mStatus *systray.MenuItem - mUp *systray.MenuItem - mDown *systray.MenuItem - mSettings *systray.MenuItem - mProfile *profileMenu - mAbout *systray.MenuItem - mGitHub *systray.MenuItem - mVersionUI *systray.MenuItem - mVersionDaemon *systray.MenuItem - mUpdate *systray.MenuItem - mQuit *systray.MenuItem - mNetworks *systray.MenuItem - mAllowSSH *systray.MenuItem - mAutoConnect *systray.MenuItem - mEnableRosenpass *systray.MenuItem - mLazyConnEnabled *systray.MenuItem - mBlockInbound *systray.MenuItem - mNotifications *systray.MenuItem - mAdvancedSettings *systray.MenuItem - mCreateDebugBundle *systray.MenuItem - mExitNode *systray.MenuItem - - // application with main windows. - app fyne.App - notifier notifier.Notifier - wSettings fyne.Window - showAdvancedSettings bool - sendNotification bool - - // input elements for settings form - iMngURL *widget.Entry - iLogFile *widget.Entry - iPreSharedKey *widget.Entry - iInterfaceName *widget.Entry - iInterfacePort *widget.Entry - iMTU *widget.Entry - - // switch elements for settings form - sRosenpassPermissive *widget.Check - sNetworkMonitor *widget.Check - sDisableDNS *widget.Check - sDisableClientRoutes *widget.Check - sDisableServerRoutes *widget.Check - sBlockLANAccess *widget.Check - sEnableSSHRoot *widget.Check - sEnableSSHSFTP *widget.Check - sEnableSSHLocalPortForward *widget.Check - sEnableSSHRemotePortForward *widget.Check - sDisableSSHAuth *widget.Check - iSSHJWTCacheTTL *widget.Entry - - // observable settings over corresponding iMngURL and iPreSharedKey values. - managementURL string - preSharedKey string - - RosenpassPermissive bool - interfaceName string - interfacePort int - mtu uint16 - networkMonitor bool - disableDNS bool - disableClientRoutes bool - disableServerRoutes bool - blockLANAccess bool - enableSSHRoot bool - enableSSHSFTP bool - enableSSHLocalPortForward bool - enableSSHRemotePortForward bool - disableSSHAuth bool - sshJWTCacheTTL int - - connected bool - daemonVersion string - updateIndicationLock sync.Mutex - isUpdateIconActive bool - isEnforcedUpdate bool - lastNotifiedVersion string - settingsEnabled bool - profilesEnabled bool - networksEnabled bool - showNetworks bool - wNetworks fyne.Window - wProfiles fyne.Window - wQuickActions fyne.Window - - eventManager *event.Manager - - exitNodeMu sync.Mutex - mExitNodeItems []menuHandler - exitNodeRetryCancel context.CancelFunc - mExitNodeSeparator *systray.MenuItem - mExitNodeDeselectAll *systray.MenuItem - logFile string - wLoginURL fyne.Window - wUpdateProgress fyne.Window - updateContextCancel context.CancelFunc - - connectCancel context.CancelFunc -} - -type menuHandler struct { - *systray.MenuItem - cancel context.CancelFunc -} - -type newServiceClientArgs struct { - addr string - logFile string - app fyne.App - showSettings bool - showNetworks bool - showDebug bool - showLoginURL bool - showProfiles bool - showQuickActions bool - showUpdate bool - showUpdateVersion string -} - -// newServiceClient instance constructor -// -// This constructor also builds the UI elements for the settings window. -func newServiceClient(args *newServiceClientArgs) *serviceClient { - ctx, cancel := context.WithCancel(context.Background()) - s := &serviceClient{ - ctx: ctx, - cancel: cancel, - addr: args.addr, - app: args.app, - notifier: notifier.New(args.app), - logFile: args.logFile, - sendNotification: false, - - showAdvancedSettings: args.showSettings, - showNetworks: args.showNetworks, - networksEnabled: true, - } - - s.eventHandler = newEventHandler(s) - s.profileManager = profilemanager.NewProfileManager() - s.setNewIcons() - - switch { - case args.showSettings: - s.showSettingsUI() - case args.showNetworks: - s.showNetworksUI() - case args.showLoginURL: - s.showLoginURL() - case args.showDebug: - s.showDebugUI() - case args.showProfiles: - s.showProfilesUI() - case args.showQuickActions: - s.showQuickActionsUI() - case args.showUpdate: - s.showUpdateProgress(ctx, args.showUpdateVersion) - } - - return s -} - -func (s *serviceClient) setNewIcons() { - s.icAbout = iconAbout - s.icConnectedDot = iconConnectedDot - s.icDisconnectedDot = iconDisconnectedDot - if s.app.Settings().ThemeVariant() == theme.VariantDark { - s.icConnected = iconConnectedDark - s.icDisconnected = iconDisconnected - s.icUpdateConnected = iconUpdateConnectedDark - s.icUpdateDisconnected = iconUpdateDisconnectedDark - s.icConnecting = iconConnectingDark - s.icError = iconErrorDark - } else { - s.icConnected = iconConnected - s.icDisconnected = iconDisconnected - s.icUpdateConnected = iconUpdateConnected - s.icUpdateDisconnected = iconUpdateDisconnected - s.icConnecting = iconConnecting - s.icError = iconError - } -} - -func (s *serviceClient) updateIcon() { - s.setNewIcons() - s.updateIndicationLock.Lock() - if s.connected { - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } - } else { - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - } - s.updateIndicationLock.Unlock() -} - -func (s *serviceClient) showSettingsUI() { - // Check if update settings are disabled by daemon - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - // Continue with default behavior if features can't be retrieved - } else if features != nil && features.DisableUpdateSettings { - log.Warn("Update settings are disabled by daemon") - return - } - - // add settings window UI elements. - s.wSettings = s.app.NewWindow("NetBird Settings") - s.wSettings.SetOnClosed(s.cancel) - - s.iMngURL = widget.NewEntry() - - s.iLogFile = widget.NewEntry() - s.iLogFile.Disable() - s.iPreSharedKey = widget.NewPasswordEntry() - s.iInterfaceName = widget.NewEntry() - s.iInterfacePort = widget.NewEntry() - s.iMTU = widget.NewEntry() - - s.sRosenpassPermissive = widget.NewCheck("Enable Rosenpass permissive mode", nil) - - s.sNetworkMonitor = widget.NewCheck("Restarts NetBird when the network changes", nil) - s.sDisableDNS = widget.NewCheck("Keeps system DNS settings unchanged", nil) - s.sDisableClientRoutes = widget.NewCheck("This peer won't route traffic to other peers", nil) - s.sDisableServerRoutes = widget.NewCheck("This peer won't act as router for others", nil) - s.sBlockLANAccess = widget.NewCheck("Blocks local network access when used as exit node", nil) - s.sEnableSSHRoot = widget.NewCheck("Enable SSH Root Login", nil) - s.sEnableSSHSFTP = widget.NewCheck("Enable SSH SFTP", nil) - s.sEnableSSHLocalPortForward = widget.NewCheck("Enable SSH Local Port Forwarding", nil) - s.sEnableSSHRemotePortForward = widget.NewCheck("Enable SSH Remote Port Forwarding", nil) - s.sDisableSSHAuth = widget.NewCheck("Disable SSH Authentication", nil) - s.iSSHJWTCacheTTL = widget.NewEntry() - - s.wSettings.SetContent(s.getSettingsForm()) - s.wSettings.Resize(fyne.NewSize(600, 400)) - s.wSettings.SetFixedSize(true) - - s.getSrvConfig() - s.wSettings.Show() -} - -func (s *serviceClient) getConnectionForm() *widget.Form { - var activeProfName string - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - } else { - activeProfName = activeProf.Name - } - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Profile", Widget: widget.NewLabel(activeProfName)}, - {Text: "Management URL", Widget: s.iMngURL}, - {Text: "Pre-shared Key", Widget: s.iPreSharedKey}, - {Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive}, - {Text: "Interface Name", Widget: s.iInterfaceName}, - {Text: "Interface Port", Widget: s.iInterfacePort}, - {Text: "MTU", Widget: s.iMTU}, - {Text: "Log File", Widget: s.iLogFile}, - }, - } -} - -func (s *serviceClient) saveSettings() { - // Check if update settings are disabled by daemon - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - // Continue with default behavior if features can't be retrieved - } else if features != nil && features.DisableUpdateSettings { - log.Warn("Configuration updates are disabled by daemon") - dialog.ShowError(fmt.Errorf("configuration updates are disabled by daemon"), s.wSettings) - return - } - - if err := s.validateSettings(); err != nil { - dialog.ShowError(err, s.wSettings) - return - } - - port, mtu, err := s.parseNumericSettings() - if err != nil { - dialog.ShowError(err, s.wSettings) - return - } - - iMngURL := strings.TrimSpace(s.iMngURL.Text) - - if s.hasSettingsChanged(iMngURL, port, mtu) { - if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil { - dialog.ShowError(err, s.wSettings) - return - } - } - - s.wSettings.Close() -} - -func (s *serviceClient) validateSettings() error { - if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey { - if _, err := wgtypes.ParseKey(s.iPreSharedKey.Text); err != nil { - return fmt.Errorf("invalid pre-shared key value") - } - } - return nil -} - -func (s *serviceClient) parseNumericSettings() (int64, int64, error) { - port, err := strconv.ParseInt(s.iInterfacePort.Text, 10, 64) - if err != nil { - return 0, 0, errors.New("invalid interface port") - } - if port < 1 || port > 65535 { - return 0, 0, errors.New("invalid interface port: out of range 1-65535") - } - - var mtu int64 - mtuText := strings.TrimSpace(s.iMTU.Text) - if mtuText != "" { - mtu, err = strconv.ParseInt(mtuText, 10, 64) - if err != nil { - return 0, 0, errors.New("invalid MTU value") - } - if mtu < iface.MinMTU || mtu > iface.MaxMTU { - return 0, 0, fmt.Errorf("MTU must be between %d and %d bytes", iface.MinMTU, iface.MaxMTU) - } - } - - return port, mtu, nil -} - -func (s *serviceClient) hasSettingsChanged(iMngURL string, port, mtu int64) bool { - return s.managementURL != iMngURL || - s.preSharedKey != s.iPreSharedKey.Text || - s.RosenpassPermissive != s.sRosenpassPermissive.Checked || - s.interfaceName != s.iInterfaceName.Text || - s.interfacePort != int(port) || - s.mtu != uint16(mtu) || - s.networkMonitor != s.sNetworkMonitor.Checked || - s.disableDNS != s.sDisableDNS.Checked || - s.disableClientRoutes != s.sDisableClientRoutes.Checked || - s.disableServerRoutes != s.sDisableServerRoutes.Checked || - s.blockLANAccess != s.sBlockLANAccess.Checked || - s.hasSSHChanges() -} - -func (s *serviceClient) applySettingsChanges(iMngURL string, port, mtu int64) error { - s.managementURL = iMngURL - s.preSharedKey = s.iPreSharedKey.Text - s.mtu = uint16(mtu) - - req, err := s.buildSetConfigRequest(iMngURL, port, mtu) - if err != nil { - return fmt.Errorf("build config request: %w", err) - } - - if err := s.sendConfigUpdate(req); err != nil { - return fmt.Errorf("set configuration: %w", err) - } - - return nil -} - -func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (*proto.SetConfigRequest, error) { - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - return nil, fmt.Errorf("get active profile: %w", err) - } - - req := &proto.SetConfigRequest{ - ProfileName: activeProf.Name, - Username: currUser.Username, - } - - if iMngURL != "" { - req.ManagementUrl = iMngURL - } - - req.RosenpassPermissive = &s.sRosenpassPermissive.Checked - req.InterfaceName = &s.iInterfaceName.Text - req.WireguardPort = &port - if mtu > 0 { - req.Mtu = &mtu - } - - req.NetworkMonitor = &s.sNetworkMonitor.Checked - req.DisableDns = &s.sDisableDNS.Checked - req.DisableClientRoutes = &s.sDisableClientRoutes.Checked - req.DisableServerRoutes = &s.sDisableServerRoutes.Checked - req.BlockLanAccess = &s.sBlockLANAccess.Checked - - req.EnableSSHRoot = &s.sEnableSSHRoot.Checked - req.EnableSSHSFTP = &s.sEnableSSHSFTP.Checked - req.EnableSSHLocalPortForwarding = &s.sEnableSSHLocalPortForward.Checked - req.EnableSSHRemotePortForwarding = &s.sEnableSSHRemotePortForward.Checked - req.DisableSSHAuth = &s.sDisableSSHAuth.Checked - - sshJWTCacheTTLText := strings.TrimSpace(s.iSSHJWTCacheTTL.Text) - if sshJWTCacheTTLText != "" { - sshJWTCacheTTL, err := strconv.ParseInt(sshJWTCacheTTLText, 10, 32) - if err != nil { - return nil, errors.New("invalid SSH JWT Cache TTL value") - } - if sshJWTCacheTTL < 0 || sshJWTCacheTTL > maxSSHJWTCacheTTL { - return nil, fmt.Errorf("SSH JWT Cache TTL must be between 0 and %d seconds", maxSSHJWTCacheTTL) - } - sshJWTCacheTTL32 := int32(sshJWTCacheTTL) - req.SshJWTCacheTTL = &sshJWTCacheTTL32 - } - - if s.iPreSharedKey.Text != censoredPreSharedKey { - req.OptionalPreSharedKey = &s.iPreSharedKey.Text - } - - return req, nil -} - -func (s *serviceClient) sendConfigUpdate(req *proto.SetConfigRequest) error { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return fmt.Errorf("get client: %w", err) - } - - _, err = conn.SetConfig(s.ctx, req) - if err != nil { - return fmt.Errorf("set config: %w", err) - } - - // Reconnect if connected to apply the new settings - go func() { - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("get service status: %v", err) - return - } - if status.Status == string(internal.StatusConnected) { - // run down & up - _, err = conn.Down(s.ctx, &proto.DownRequest{}) - if err != nil { - log.Errorf("down service: %v", err) - } - - _, err = conn.Up(s.ctx, &proto.UpRequest{}) - if err != nil { - log.Errorf("up service: %v", err) - return - } - } - }() - - return nil -} - -func (s *serviceClient) getSettingsForm() fyne.CanvasObject { - connectionForm := s.getConnectionForm() - networkForm := s.getNetworkForm() - sshForm := s.getSSHForm() - tabs := container.NewAppTabs( - container.NewTabItem("Connection", connectionForm), - container.NewTabItem("Network", networkForm), - container.NewTabItem("SSH", sshForm), - ) - saveButton := widget.NewButtonWithIcon("Save", theme.ConfirmIcon(), s.saveSettings) - saveButton.Importance = widget.HighImportance - cancelButton := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() { - s.wSettings.Close() - }) - buttonContainer := container.NewHBox( - layout.NewSpacer(), - cancelButton, - saveButton, - ) - return container.NewBorder(nil, buttonContainer, nil, nil, tabs) -} - -func (s *serviceClient) getNetworkForm() *widget.Form { - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Network Monitor", Widget: s.sNetworkMonitor}, - {Text: "Disable DNS", Widget: s.sDisableDNS}, - {Text: "Disable Client Routes", Widget: s.sDisableClientRoutes}, - {Text: "Disable Server Routes", Widget: s.sDisableServerRoutes}, - {Text: "Disable LAN Access", Widget: s.sBlockLANAccess}, - }, - } -} - -func (s *serviceClient) getSSHForm() *widget.Form { - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Enable SSH Root Login", Widget: s.sEnableSSHRoot}, - {Text: "Enable SSH SFTP", Widget: s.sEnableSSHSFTP}, - {Text: "Enable SSH Local Port Forwarding", Widget: s.sEnableSSHLocalPortForward}, - {Text: "Enable SSH Remote Port Forwarding", Widget: s.sEnableSSHRemotePortForward}, - {Text: "Disable SSH Authentication", Widget: s.sDisableSSHAuth}, - {Text: "JWT Cache TTL (seconds, 0=disabled)", Widget: s.iSSHJWTCacheTTL}, - }, - } -} - -func (s *serviceClient) hasSSHChanges() bool { - currentSSHJWTCacheTTL := s.sshJWTCacheTTL - if text := strings.TrimSpace(s.iSSHJWTCacheTTL.Text); text != "" { - val, err := strconv.Atoi(text) - if err != nil { - return true - } - currentSSHJWTCacheTTL = val - } - - return s.enableSSHRoot != s.sEnableSSHRoot.Checked || - s.enableSSHSFTP != s.sEnableSSHSFTP.Checked || - s.enableSSHLocalPortForward != s.sEnableSSHLocalPortForward.Checked || - s.enableSSHRemotePortForward != s.sEnableSSHRemotePortForward.Checked || - s.disableSSHAuth != s.sDisableSSHAuth.Checked || - s.sshJWTCacheTTL != currentSSHJWTCacheTTL -} - -func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginResponse, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf("get daemon client: %w", err) - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - return nil, fmt.Errorf("get active profile: %w", err) - } - - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - loginReq := &proto.LoginRequest{ - IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd", - ProfileName: &activeProf.Name, - Username: &currUser.Username, - } - - profileState, err := s.profileManager.GetProfileState(activeProf.Name) - if err != nil { - log.Debugf("failed to get profile state for login hint: %v", err) - } else if profileState.Email != "" { - loginReq.Hint = &profileState.Email - } - - loginResp, err := conn.Login(ctx, loginReq) - if err != nil { - return nil, fmt.Errorf("login to management: %w", err) - } - - if loginResp.NeedsSSOLogin && openURL { - if err = s.handleSSOLogin(ctx, loginResp, conn); err != nil { - return nil, fmt.Errorf("SSO login: %w", err) - } - } - - return loginResp, nil -} - -func (s *serviceClient) handleSSOLogin(ctx context.Context, loginResp *proto.LoginResponse, conn proto.DaemonServiceClient) error { - if err := openURL(loginResp.VerificationURIComplete); err != nil { - return fmt.Errorf("open browser: %w", err) - } - - resp, err := conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode}) - if err != nil { - return fmt.Errorf("wait for SSO login: %w", err) - } - - if resp.Email != "" { - if err := s.profileManager.SetActiveProfileState(&profilemanager.ProfileState{ - Email: resp.Email, - }); err != nil { - log.Debugf("failed to set profile state: %v", err) - } else { - s.mProfile.refresh() - } - } - - return nil -} - -func (s *serviceClient) menuUpClick(ctx context.Context) error { - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - systray.SetTemplateIcon(iconErrorMacOS, s.icError) - return fmt.Errorf("get daemon client: %w", err) - } - - _, err = s.login(ctx, true) - if err != nil { - return fmt.Errorf("login: %w", err) - } - - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - return fmt.Errorf("get status: %w", err) - } - - if status.Status == string(internal.StatusConnected) { - return nil - } - - if _, err := s.conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - return fmt.Errorf("start connection: %w", err) - } - - return nil -} - -func (s *serviceClient) menuDownClick() error { - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("get daemon client: %w", err) - } - - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - return fmt.Errorf("get status: %w", err) - } - - if status.Status != string(internal.StatusConnected) && status.Status != string(internal.StatusConnecting) { - return nil - } - - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - return fmt.Errorf("stop connection: %w", err) - } - - return nil -} - -func (s *serviceClient) updateStatus() error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return err - } - err = backoff.Retry(func() error { - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("get service status: %v", err) - if s.connected { - s.notifier.Send("Error", "Connection to service lost") - } - s.setDisconnectedStatus() - return err - } - - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - // notify the user when the session has expired - if status.Status == string(internal.StatusSessionExpired) { - s.onSessionExpire() - } - - var systrayIconState bool - - switch { - case status.Status == string(internal.StatusConnected) && !s.connected: - s.connected = true - s.sendNotification = true - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } - systray.SetTooltip("NetBird (Connected)") - s.mStatus.SetTitle("Connected") - s.mStatus.SetIcon(s.icConnectedDot) - s.mUp.Disable() - s.mDown.Enable() - if s.networksEnabled { - s.mNetworks.Enable() - s.mExitNode.Enable() - } - s.startExitNodeRefresh() - systrayIconState = true - case status.Status == string(internal.StatusConnecting): - s.setConnectingStatus() - case status.Status != string(internal.StatusConnected) && s.mUp.Disabled(): - s.setDisconnectedStatus() - systrayIconState = false - } - - // if the daemon version changed (e.g. after a successful update), reset the update indication - if s.daemonVersion != status.DaemonVersion { - if s.daemonVersion != "" { - s.mUpdate.Hide() - s.isUpdateIconActive = false - } - s.daemonVersion = status.DaemonVersion - if !s.isUpdateIconActive { - if systrayIconState { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - } - - daemonVersionTitle := normalizedVersion(s.daemonVersion) - s.mVersionDaemon.SetTitle(fmt.Sprintf("Daemon: %s", daemonVersionTitle)) - s.mVersionDaemon.SetTooltip(fmt.Sprintf("Daemon version: %s", daemonVersionTitle)) - s.mVersionDaemon.Show() - } - - return nil - }, &backoff.ExponentialBackOff{ - InitialInterval: time.Second, - RandomizationFactor: backoff.DefaultRandomizationFactor, - Multiplier: backoff.DefaultMultiplier, - MaxInterval: 300 * time.Millisecond, - MaxElapsedTime: 2 * time.Second, - Stop: backoff.Stop, - Clock: backoff.SystemClock, - }) - if err != nil { - return err - } - - return nil -} - -func (s *serviceClient) setDisconnectedStatus() { - s.connected = false - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - systray.SetTooltip("NetBird (Disconnected)") - s.mStatus.SetTitle("Disconnected") - s.mStatus.SetIcon(s.icDisconnectedDot) - s.mDown.Disable() - s.mUp.Enable() - s.mNetworks.Disable() - s.mExitNode.Disable() - s.cancelExitNodeRetry() - go s.updateExitNodes() -} - -func (s *serviceClient) setConnectingStatus() { - s.connected = false - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - systray.SetTooltip("NetBird (Connecting)") - s.mStatus.SetTitle("Connecting") - s.mUp.Disable() - s.mDown.Enable() - s.mNetworks.Disable() - s.mExitNode.Disable() -} - -func (s *serviceClient) onTrayReady() { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - systray.SetTooltip("NetBird") - - // setup systray menu items - s.mStatus = systray.AddMenuItem("Disconnected", "Disconnected") - s.mStatus.SetIcon(s.icDisconnectedDot) - s.mStatus.Disable() - - profileMenuItem := systray.AddMenuItem("", "") - emailMenuItem := systray.AddMenuItem("", "") - - newProfileMenuArgs := &newProfileMenuArgs{ - ctx: s.ctx, - serviceClient: s, - profileManager: s.profileManager, - eventHandler: s.eventHandler, - profileMenuItem: profileMenuItem, - emailMenuItem: emailMenuItem, - downClickCallback: s.menuDownClick, - upClickCallback: s.menuUpClick, - getSrvClientCallback: s.getSrvClient, - loadSettingsCallback: s.loadSettings, - app: s.app, - } - - s.mProfile = newProfileMenu(*newProfileMenuArgs) - - systray.AddSeparator() - s.mUp = systray.AddMenuItem("Connect", "Connect") - s.mDown = systray.AddMenuItem("Disconnect", "Disconnect") - s.mDown.Disable() - systray.AddSeparator() - - s.mSettings = systray.AddMenuItem("Settings", disabledMenuDescr) - s.mAllowSSH = s.mSettings.AddSubMenuItemCheckbox("Allow SSH", allowSSHMenuDescr, false) - s.mAutoConnect = s.mSettings.AddSubMenuItemCheckbox("Connect on Startup", autoConnectMenuDescr, false) - s.mEnableRosenpass = s.mSettings.AddSubMenuItemCheckbox("Enable Quantum-Resistance", quantumResistanceMenuDescr, false) - s.mLazyConnEnabled = s.mSettings.AddSubMenuItemCheckbox("Enable Lazy Connections", lazyConnMenuDescr, false) - s.mBlockInbound = s.mSettings.AddSubMenuItemCheckbox("Block Inbound Connections", blockInboundMenuDescr, false) - s.mNotifications = s.mSettings.AddSubMenuItemCheckbox("Notifications", notificationsMenuDescr, false) - s.mSettings.AddSeparator() - s.mAdvancedSettings = s.mSettings.AddSubMenuItem("Advanced Settings", advancedSettingsMenuDescr) - s.mCreateDebugBundle = s.mSettings.AddSubMenuItem("Create Debug Bundle", debugBundleMenuDescr) - s.loadSettings() - - // Disable settings menu if update settings are disabled by daemon - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - // Continue with default behavior if features can't be retrieved - } else { - if features != nil && features.DisableUpdateSettings { - s.setSettingsEnabled(false) - } - if features != nil && features.DisableProfiles { - s.mProfile.setEnabled(false) - } - } - - s.exitNodeMu.Lock() - s.mExitNode = systray.AddMenuItem("Exit Node", disabledMenuDescr) - s.mExitNode.Disable() - s.exitNodeMu.Unlock() - - s.mNetworks = systray.AddMenuItem("Networks", networksMenuDescr) - s.mNetworks.Disable() - systray.AddSeparator() - - s.mAbout = systray.AddMenuItem("About", "About") - s.mAbout.SetIcon(s.icAbout) - - s.mGitHub = s.mAbout.AddSubMenuItem("GitHub", "GitHub") - - versionString := normalizedVersion(version.NetbirdVersion()) - s.mVersionUI = s.mAbout.AddSubMenuItem(fmt.Sprintf("GUI: %s", versionString), fmt.Sprintf("GUI Version: %s", versionString)) - s.mVersionUI.Disable() - - s.mVersionDaemon = s.mAbout.AddSubMenuItem("", "") - s.mVersionDaemon.Disable() - s.mVersionDaemon.Hide() - - s.mUpdate = s.mAbout.AddSubMenuItem("Download latest version", latestVersionMenuDescr) - s.mUpdate.Hide() - - systray.AddSeparator() - s.mQuit = systray.AddMenuItem("Quit", quitMenuDescr) - - // update exit node menu in case service is already connected - go s.updateExitNodes() - - go func() { - s.getSrvConfig() - time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon - for { - // Check features before status so menus respect disable flags before being enabled - s.checkAndUpdateFeatures() - - err := s.updateStatus() - if err != nil { - log.Errorf("error while updating status: %v", err) - } - - time.Sleep(2 * time.Second) - } - }() - - s.eventManager = event.NewManager(s.notifier, s.addr) - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - if event.Category == proto.SystemEvent_SYSTEM { - s.updateExitNodes() - } - }) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - // todo use new Category - if windowAction, ok := event.Metadata["progress_window"]; ok { - targetVersion, ok := event.Metadata["version"] - if !ok { - targetVersion = "unknown" - } - log.Debugf("window action: %v", windowAction) - if windowAction == "show" { - if s.updateContextCancel != nil { - s.updateContextCancel() - s.updateContextCancel = nil - } - - subCtx, cancel := context.WithCancel(s.ctx) - go s.eventHandler.runSelfCommand(subCtx, "update", "--update-version", targetVersion) - s.updateContextCancel = cancel - } - } - }) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - if newVersion, ok := event.Metadata["new_version_available"]; ok { - _, enforced := event.Metadata["enforced"] - log.Infof("received new_version_available event: version=%s enforced=%v", newVersion, enforced) - s.onUpdateAvailable(newVersion, enforced) - } - }) - - go s.eventManager.Start(s.ctx) - go s.eventHandler.listen(s.ctx) -} - -func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File { - if s.logFile == "" { - // attach child's streams to parent's streams - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return nil - } - - out, err := os.OpenFile(s.logFile, os.O_WRONLY|os.O_APPEND, 0) - if err != nil { - log.Errorf("Failed to open log file %s: %v", s.logFile, err) - return nil - } - cmd.Stdout = out - cmd.Stderr = out - return out -} - -func normalizedVersion(version string) string { - versionString := version - if unicode.IsDigit(rune(versionString[0])) { - versionString = fmt.Sprintf("v%s", versionString) - } - return versionString -} - -// onTrayExit is called when the tray icon is closed. -func (s *serviceClient) onTrayExit() { - s.cancel() -} - -// getSrvClient connection to the service. -func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonServiceClient, error) { - s.connLock.Lock() - defer s.connLock.Unlock() - if s.conn != nil { - return s.conn, nil - } - - ctx, cancel := context.WithTimeout(s.ctx, timeout) - defer cancel() - - conn, err := grpc.DialContext( - ctx, - strings.TrimPrefix(s.addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock(), - grpc.WithUserAgent(desktop.GetUIUserAgent()), - ) - if err != nil { - return nil, fmt.Errorf("dial service: %w", err) - } - - s.conn = proto.NewDaemonServiceClient(conn) - return s.conn, nil -} - -// setSettingsEnabled enables or disables the settings menu based on the provided state -func (s *serviceClient) setSettingsEnabled(enabled bool) { - if s.mSettings != nil { - if enabled { - s.mSettings.Enable() - } else { - s.mSettings.Hide() - s.mSettings.SetTooltip("Settings are disabled by daemon") - } - } -} - -// checkAndUpdateFeatures checks the current features and updates the UI accordingly -func (s *serviceClient) checkAndUpdateFeatures() { - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - return - } - - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - // Update settings menu based on current features - settingsEnabled := features == nil || !features.DisableUpdateSettings - if s.settingsEnabled != settingsEnabled { - s.settingsEnabled = settingsEnabled - s.setSettingsEnabled(settingsEnabled) - } - - // Update profile menu based on current features - if s.mProfile != nil { - profilesEnabled := features == nil || !features.DisableProfiles - if s.profilesEnabled != profilesEnabled { - s.profilesEnabled = profilesEnabled - s.mProfile.setEnabled(profilesEnabled) - } - } - - // Update networks and exit node menus based on current features - s.networksEnabled = features == nil || !features.DisableNetworks - if s.networksEnabled && s.connected { - s.mNetworks.Enable() - s.mExitNode.Enable() - } else { - s.mNetworks.Disable() - s.mExitNode.Disable() - } -} - -// getFeatures from the daemon to determine which features are enabled/disabled. -func (s *serviceClient) getFeatures() (*proto.GetFeaturesResponse, error) { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return nil, fmt.Errorf("get client for features: %w", err) - } - - features, err := conn.GetFeatures(s.ctx, &proto.GetFeaturesRequest{}) - if err != nil { - return nil, fmt.Errorf("get features from daemon: %w", err) - } - - return features, nil -} - -// getSrvConfig from the service to show it in the settings window. -func (s *serviceClient) getSrvConfig() { - s.managementURL = profilemanager.DefaultManagementURL - - _, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - var cfg *profilemanager.Config - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.Name, - Username: currUser.Username, - }) - if err != nil { - log.Errorf("get config settings from server: %v", err) - return - } - - cfg = protoConfigToConfig(srvCfg) - - if cfg.ManagementURL.String() != "" { - s.managementURL = cfg.ManagementURL.String() - } - s.preSharedKey = cfg.PreSharedKey - s.RosenpassPermissive = cfg.RosenpassPermissive - s.interfaceName = cfg.WgIface - s.interfacePort = cfg.WgPort - s.mtu = cfg.MTU - - s.networkMonitor = *cfg.NetworkMonitor - s.disableDNS = cfg.DisableDNS - s.disableClientRoutes = cfg.DisableClientRoutes - s.disableServerRoutes = cfg.DisableServerRoutes - s.blockLANAccess = cfg.BlockLANAccess - - if cfg.EnableSSHRoot != nil { - s.enableSSHRoot = *cfg.EnableSSHRoot - } - if cfg.EnableSSHSFTP != nil { - s.enableSSHSFTP = *cfg.EnableSSHSFTP - } - if cfg.EnableSSHLocalPortForwarding != nil { - s.enableSSHLocalPortForward = *cfg.EnableSSHLocalPortForwarding - } - if cfg.EnableSSHRemotePortForwarding != nil { - s.enableSSHRemotePortForward = *cfg.EnableSSHRemotePortForwarding - } - if cfg.DisableSSHAuth != nil { - s.disableSSHAuth = *cfg.DisableSSHAuth - } - if cfg.SSHJWTCacheTTL != nil { - s.sshJWTCacheTTL = *cfg.SSHJWTCacheTTL - } - - if s.showAdvancedSettings { - s.iMngURL.SetText(s.managementURL) - s.iPreSharedKey.SetText(cfg.PreSharedKey) - s.iInterfaceName.SetText(cfg.WgIface) - s.iInterfacePort.SetText(strconv.Itoa(cfg.WgPort)) - if cfg.MTU != 0 { - s.iMTU.SetText(strconv.Itoa(int(cfg.MTU))) - } else { - s.iMTU.SetText("") - s.iMTU.SetPlaceHolder(strconv.Itoa(int(iface.DefaultMTU))) - } - s.sRosenpassPermissive.SetChecked(cfg.RosenpassPermissive) - if !cfg.RosenpassEnabled { - s.sRosenpassPermissive.Disable() - } - s.sNetworkMonitor.SetChecked(*cfg.NetworkMonitor) - s.sDisableDNS.SetChecked(cfg.DisableDNS) - s.sDisableClientRoutes.SetChecked(cfg.DisableClientRoutes) - s.sDisableServerRoutes.SetChecked(cfg.DisableServerRoutes) - s.sBlockLANAccess.SetChecked(cfg.BlockLANAccess) - if cfg.EnableSSHRoot != nil { - s.sEnableSSHRoot.SetChecked(*cfg.EnableSSHRoot) - } - if cfg.EnableSSHSFTP != nil { - s.sEnableSSHSFTP.SetChecked(*cfg.EnableSSHSFTP) - } - if cfg.EnableSSHLocalPortForwarding != nil { - s.sEnableSSHLocalPortForward.SetChecked(*cfg.EnableSSHLocalPortForwarding) - } - if cfg.EnableSSHRemotePortForwarding != nil { - s.sEnableSSHRemotePortForward.SetChecked(*cfg.EnableSSHRemotePortForwarding) - } - if cfg.DisableSSHAuth != nil { - s.sDisableSSHAuth.SetChecked(*cfg.DisableSSHAuth) - } - if cfg.SSHJWTCacheTTL != nil { - s.iSSHJWTCacheTTL.SetText(strconv.Itoa(*cfg.SSHJWTCacheTTL)) - } - } - - if s.mNotifications == nil { - return - } - if cfg.DisableNotifications != nil && *cfg.DisableNotifications { - s.mNotifications.Uncheck() - } else { - s.mNotifications.Check() - } - if s.eventManager != nil { - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - } -} - -func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config { - - var config profilemanager.Config - - if cfg.ManagementUrl != "" { - parsed, err := url.Parse(cfg.ManagementUrl) - if err != nil { - log.Errorf("parse management URL: %v", err) - } else { - config.ManagementURL = parsed - } - } - - if cfg.PreSharedKey != "" { - if cfg.PreSharedKey != censoredPreSharedKey { - config.PreSharedKey = cfg.PreSharedKey - } else { - config.PreSharedKey = "" - } - } - if cfg.AdminURL != "" { - parsed, err := url.Parse(cfg.AdminURL) - if err != nil { - log.Errorf("parse admin URL: %v", err) - } else { - config.AdminURL = parsed - } - } - - config.WgIface = cfg.InterfaceName - if cfg.WireguardPort != 0 { - config.WgPort = int(cfg.WireguardPort) - } else { - config.WgPort = iface.DefaultWgPort - } - - if cfg.Mtu != 0 { - config.MTU = uint16(cfg.Mtu) - } else { - config.MTU = iface.DefaultMTU - } - - config.DisableAutoConnect = cfg.DisableAutoConnect - config.ServerSSHAllowed = &cfg.ServerSSHAllowed - config.RosenpassEnabled = cfg.RosenpassEnabled - config.RosenpassPermissive = cfg.RosenpassPermissive - config.DisableNotifications = &cfg.DisableNotifications - config.LazyConnectionEnabled = cfg.LazyConnectionEnabled - config.BlockInbound = cfg.BlockInbound - config.NetworkMonitor = &cfg.NetworkMonitor - config.DisableDNS = cfg.DisableDns - config.DisableClientRoutes = cfg.DisableClientRoutes - config.DisableServerRoutes = cfg.DisableServerRoutes - config.BlockLANAccess = cfg.BlockLanAccess - - config.EnableSSHRoot = &cfg.EnableSSHRoot - config.EnableSSHSFTP = &cfg.EnableSSHSFTP - config.EnableSSHLocalPortForwarding = &cfg.EnableSSHLocalPortForwarding - config.EnableSSHRemotePortForwarding = &cfg.EnableSSHRemotePortForwarding - config.DisableSSHAuth = &cfg.DisableSSHAuth - - ttl := int(cfg.SshJWTCacheTTL) - config.SSHJWTCacheTTL = &ttl - - return &config -} - -func (s *serviceClient) onUpdateAvailable(newVersion string, enforced bool) { - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - s.isEnforcedUpdate = enforced - if enforced { - s.mUpdate.SetTitle("Install version " + newVersion) - } else { - s.lastNotifiedVersion = "" - s.mUpdate.SetTitle("Download latest version") - } - - s.mUpdate.Show() - s.isUpdateIconActive = true - - if s.connected { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } - - if enforced && s.lastNotifiedVersion != newVersion { - s.lastNotifiedVersion = newVersion - s.notifier.Send("Update available", "A new version "+newVersion+" is ready to install") - } -} - -// onSessionExpire sends a notification to the user when the session expires. -func (s *serviceClient) onSessionExpire() { - s.sendNotification = true - if s.sendNotification { - go s.eventHandler.runSelfCommand(s.ctx, "login-url", "true") - s.sendNotification = false - } -} - -// loadSettings loads the settings from the config file and updates the UI elements accordingly. -func (s *serviceClient) loadSettings() { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.Name, - Username: currUser.Username, - }) - if err != nil { - log.Errorf("get config settings from server: %v", err) - return - } - - if cfg.ServerSSHAllowed { - s.mAllowSSH.Check() - } else { - s.mAllowSSH.Uncheck() - } - - if cfg.DisableAutoConnect { - s.mAutoConnect.Uncheck() - } else { - s.mAutoConnect.Check() - } - - if cfg.RosenpassEnabled { - s.mEnableRosenpass.Check() - } else { - s.mEnableRosenpass.Uncheck() - } - - if cfg.LazyConnectionEnabled { - s.mLazyConnEnabled.Check() - } else { - s.mLazyConnEnabled.Uncheck() - } - - if cfg.BlockInbound { - s.mBlockInbound.Check() - } else { - s.mBlockInbound.Uncheck() - } - - if cfg.DisableNotifications { - s.mNotifications.Uncheck() - } else { - s.mNotifications.Check() - } - if s.eventManager != nil { - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - } -} - -// updateConfig updates the configuration parameters -// based on the values selected in the settings window. -func (s *serviceClient) updateConfig() error { - disableAutoStart := !s.mAutoConnect.Checked() - sshAllowed := s.mAllowSSH.Checked() - rosenpassEnabled := s.mEnableRosenpass.Checked() - lazyConnectionEnabled := s.mLazyConnEnabled.Checked() - blockInbound := s.mBlockInbound.Checked() - notificationsDisabled := !s.mNotifications.Checked() - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return err - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return err - } - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return err - } - - req := proto.SetConfigRequest{ - ProfileName: activeProf.Name, - Username: currUser.Username, - DisableAutoConnect: &disableAutoStart, - ServerSSHAllowed: &sshAllowed, - RosenpassEnabled: &rosenpassEnabled, - LazyConnectionEnabled: &lazyConnectionEnabled, - BlockInbound: &blockInbound, - DisableNotifications: ¬ificationsDisabled, - } - - if _, err := conn.SetConfig(s.ctx, &req); err != nil { - log.Errorf("set config settings on server: %v", err) - return err - } - - return nil -} - -// showLoginURL creates a borderless window styled like a pop-up in the top-right corner using s.wLoginURL. -// It also starts a background goroutine that periodically checks if the client is already connected -// and closes the window if so. The goroutine can be cancelled by the returned CancelFunc, and it is -// also cancelled when the window is closed. -func (s *serviceClient) showLoginURL() context.CancelFunc { - - // create a cancellable context for the background check goroutine - ctx, cancel := context.WithCancel(s.ctx) - - resIcon := fyne.NewStaticResource("netbird.png", iconAbout) - - if s.wLoginURL == nil { - s.wLoginURL = s.app.NewWindow("NetBird Session Expired") - s.wLoginURL.Resize(fyne.NewSize(400, 200)) - s.wLoginURL.SetIcon(resIcon) - } - // ensure goroutine is cancelled when the window is closed - s.wLoginURL.SetOnClosed(func() { cancel() }) - // add a description label - label := widget.NewLabel("Your NetBird session has expired.\nPlease re-authenticate to continue using NetBird.") - - btn := widget.NewButtonWithIcon("Re-authenticate", theme.ViewRefreshIcon(), func() { - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - resp, err := s.login(ctx, false) - if err != nil { - log.Errorf("failed to fetch login URL: %v", err) - return - } - verificationURL := resp.VerificationURIComplete - if verificationURL == "" { - verificationURL = resp.VerificationURI - } - - if verificationURL == "" { - log.Error("no verification URL provided in the login response") - return - } - - if err := openURL(verificationURL); err != nil { - log.Errorf("failed to open login URL: %v", err) - return - } - - _, err = conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: resp.UserCode}) - if err != nil { - log.Errorf("Waiting sso login failed with: %v", err) - label.SetText("Waiting login failed, please create \na debug bundle in the settings and contact support.") - return - } - - label.SetText("Re-authentication successful.\nReconnecting") - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("get service status: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - label.SetText("Already connected.\nClosing this window.") - time.Sleep(2 * time.Second) - s.wLoginURL.Close() - return - } - - _, err = conn.Up(ctx, &proto.UpRequest{}) - if err != nil { - label.SetText("Reconnecting failed, please create \na debug bundle in the settings and contact support.") - log.Errorf("Reconnecting failed with: %v", err) - return - } - - label.SetText("Connection successful.\nClosing this window.") - time.Sleep(time.Second) - - s.wLoginURL.Close() - }) - - img := canvas.NewImageFromResource(resIcon) - img.FillMode = canvas.ImageFillContain - img.SetMinSize(fyne.NewSize(64, 64)) - img.Resize(fyne.NewSize(64, 64)) - - // center the content vertically - content := container.NewVBox( - layout.NewSpacer(), - img, - label, - btn, - layout.NewSpacer(), - ) - s.wLoginURL.SetContent(container.NewCenter(content)) - - // start a goroutine to check connection status and close the window if connected - go func() { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return - } - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - continue - } - if status.Status == string(internal.StatusConnected) { - if s.wLoginURL != nil { - s.wLoginURL.Close() - } - return - } - } - } - }() - - s.wLoginURL.Show() - - // return cancel func so callers can stop the background goroutine if desired - return cancel -} - -func openURL(url string) error { - if browser := os.Getenv("BROWSER"); browser != "" { - return exec.Command(browser, url).Start() - } - - var err error - switch runtime.GOOS { - case "windows": - err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - err = exec.Command("open", url).Start() - case "linux", "freebsd": - err = exec.Command("xdg-open", url).Start() - default: - err = fmt.Errorf("unsupported platform") - } - return err -} diff --git a/client/ui/const.go b/client/ui/const.go deleted file mode 100644 index 48619be75..000000000 --- a/client/ui/const.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -const ( - allowSSHMenuDescr = "Allow SSH connections" - autoConnectMenuDescr = "Connect automatically when the service starts" - quantumResistanceMenuDescr = "Enable post-quantum security via Rosenpass" - lazyConnMenuDescr = "[Experimental] Enable lazy connections" - blockInboundMenuDescr = "Block inbound connections to the local machine and routed networks" - notificationsMenuDescr = "Enable notifications" - advancedSettingsMenuDescr = "Advanced settings of the application" - debugBundleMenuDescr = "Create and open debug information bundle" - disabledMenuDescr = "" - networksMenuDescr = "Open the networks management window" - latestVersionMenuDescr = "Download latest version" - quitMenuDescr = "Quit the client app" -) diff --git a/client/ui/debug.go b/client/ui/debug.go deleted file mode 100644 index cf5ac1a75..000000000 --- a/client/ui/debug.go +++ /dev/null @@ -1,727 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "fmt" - "path/filepath" - "strconv" - "sync" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - "github.com/skratchdot/open-golang/open" - "google.golang.org/protobuf/types/known/durationpb" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/proto" - uptypes "github.com/netbirdio/netbird/upload-server/types" -) - -// Initial state for the debug collection -type debugInitialState struct { - wasDown bool - needsRestoreUp bool - logLevel proto.LogLevel - isLevelTrace bool -} - -// Debug collection parameters -type debugCollectionParams struct { - duration time.Duration - anonymize bool - systemInfo bool - upload bool - uploadURL string - enablePersistence bool - capture bool -} - -// UI components for progress tracking -type progressUI struct { - statusLabel *widget.Label - progressBar *widget.ProgressBar - uiControls []fyne.Disableable - window fyne.Window -} - -func (s *serviceClient) showDebugUI() { - w := s.app.NewWindow("NetBird Debug") - w.SetOnClosed(s.cancel) - w.Resize(fyne.NewSize(600, 500)) - w.SetFixedSize(true) - - anonymizeCheck := widget.NewCheck("Anonymize sensitive information (public IPs, domains, ...)", nil) - systemInfoCheck := widget.NewCheck("Include system information (routes, interfaces, ...)", nil) - systemInfoCheck.SetChecked(true) - captureCheck := widget.NewCheck("Include packet capture", nil) - uploadCheck := widget.NewCheck("Upload bundle automatically after creation", nil) - uploadCheck.SetChecked(true) - - uploadURLContainer, uploadURL := s.buildUploadSection(uploadCheck) - - debugModeContainer, runForDurationCheck, durationInput, noteLabel := s.buildDurationSection() - - statusLabel := widget.NewLabel("") - statusLabel.Hide() - progressBar := widget.NewProgressBar() - progressBar.Hide() - createButton := widget.NewButton("Create Debug Bundle", nil) - - uiControls := []fyne.Disableable{ - anonymizeCheck, systemInfoCheck, captureCheck, - uploadCheck, uploadURL, runForDurationCheck, durationInput, createButton, - } - - createButton.OnTapped = s.getCreateHandler( - statusLabel, progressBar, uploadCheck, uploadURL, - anonymizeCheck, systemInfoCheck, captureCheck, - runForDurationCheck, durationInput, uiControls, w, - ) - - content := container.NewVBox( - widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"), - widget.NewLabel(""), - anonymizeCheck, systemInfoCheck, captureCheck, - uploadCheck, uploadURLContainer, - widget.NewLabel(""), - debugModeContainer, noteLabel, - widget.NewLabel(""), - statusLabel, progressBar, createButton, - ) - - w.SetContent(container.NewPadded(content)) - w.Show() -} - -func (s *serviceClient) buildUploadSection(uploadCheck *widget.Check) (*fyne.Container, *widget.Entry) { - uploadURL := widget.NewEntry() - uploadURL.SetText(uptypes.DefaultBundleURL) - uploadURL.SetPlaceHolder("Enter upload URL") - - uploadURLContainer := container.NewVBox(widget.NewLabel("Debug upload URL:"), uploadURL) - - uploadCheck.OnChanged = func(checked bool) { - if checked { - uploadURLContainer.Show() - } else { - uploadURLContainer.Hide() - } - } - return uploadURLContainer, uploadURL -} - -func (s *serviceClient) buildDurationSection() (*fyne.Container, *widget.Check, *widget.Entry, *widget.Label) { - runForDurationCheck := widget.NewCheck("Run with trace logs before creating bundle", nil) - runForDurationCheck.SetChecked(true) - - forLabel := widget.NewLabel("for") - durationInput := widget.NewEntry() - durationInput.SetText("1") - minutesLabel := widget.NewLabel("minute") - durationInput.Validator = func(s string) error { - return validateMinute(s, minutesLabel) - } - - noteLabel := widget.NewLabel("Note: NetBird will be brought up and down during collection") - - runForDurationCheck.OnChanged = func(checked bool) { - if checked { - forLabel.Show() - durationInput.Show() - minutesLabel.Show() - noteLabel.Show() - } else { - forLabel.Hide() - durationInput.Hide() - minutesLabel.Hide() - noteLabel.Hide() - } - } - - modeContainer := container.NewHBox(runForDurationCheck, forLabel, durationInput, minutesLabel) - return modeContainer, runForDurationCheck, durationInput, noteLabel -} - -func validateMinute(s string, minutesLabel *widget.Label) error { - if val, err := strconv.Atoi(s); err != nil || val < 1 { - return fmt.Errorf("must be a number ≥ 1") - } - if s == "1" { - minutesLabel.SetText("minute") - } else { - minutesLabel.SetText("minutes") - } - return nil -} - -// disableUIControls disables the provided UI controls -func disableUIControls(controls []fyne.Disableable) { - for _, control := range controls { - control.Disable() - } -} - -// enableUIControls enables the provided UI controls -func enableUIControls(controls []fyne.Disableable) { - for _, control := range controls { - control.Enable() - } -} - -func (s *serviceClient) getCreateHandler( - statusLabel *widget.Label, - progressBar *widget.ProgressBar, - uploadCheck *widget.Check, - uploadURL *widget.Entry, - anonymizeCheck *widget.Check, - systemInfoCheck *widget.Check, - captureCheck *widget.Check, - runForDurationCheck *widget.Check, - duration *widget.Entry, - uiControls []fyne.Disableable, - w fyne.Window, -) func() { - return func() { - disableUIControls(uiControls) - statusLabel.Show() - - var url string - if uploadCheck.Checked { - url = uploadURL.Text - if url == "" { - statusLabel.SetText("Error: Upload URL is required when upload is enabled") - enableUIControls(uiControls) - return - } - } - - params := &debugCollectionParams{ - anonymize: anonymizeCheck.Checked, - systemInfo: systemInfoCheck.Checked, - capture: captureCheck.Checked, - upload: uploadCheck.Checked, - uploadURL: url, - enablePersistence: true, - } - - runForDuration := runForDurationCheck.Checked - if runForDuration { - minutes, err := time.ParseDuration(duration.Text + "m") - if err != nil { - statusLabel.SetText(fmt.Sprintf("Error: Invalid duration: %v", err)) - enableUIControls(uiControls) - return - } - params.duration = minutes - - statusLabel.SetText(fmt.Sprintf("Running in debug mode for %d minutes...", int(minutes.Minutes()))) - progressBar.Show() - progressBar.SetValue(0) - - go s.handleRunForDuration( - statusLabel, - progressBar, - uiControls, - w, - params, - ) - return - } - - statusLabel.SetText("Creating debug bundle...") - go s.handleDebugCreation( - params, - statusLabel, - uiControls, - w, - ) - } -} - -func (s *serviceClient) handleRunForDuration( - statusLabel *widget.Label, - progressBar *widget.ProgressBar, - uiControls []fyne.Disableable, - w fyne.Window, - params *debugCollectionParams, -) { - progressUI := &progressUI{ - statusLabel: statusLabel, - progressBar: progressBar, - uiControls: uiControls, - window: w, - } - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - handleError(progressUI, fmt.Sprintf("Failed to get client for debug: %v", err)) - return - } - - initialState, err := s.getInitialState(conn) - if err != nil { - handleError(progressUI, err.Error()) - return - } - - defer s.restoreServiceState(conn, initialState) - - if err := s.collectDebugData(conn, initialState, params, progressUI); err != nil { - handleError(progressUI, err.Error()) - return - } - - if err := s.createDebugBundleFromCollection(conn, params, progressUI); err != nil { - handleError(progressUI, err.Error()) - return - } - - progressUI.statusLabel.SetText("Bundle created successfully") -} - -// Get initial state of the service -func (s *serviceClient) getInitialState(conn proto.DaemonServiceClient) (*debugInitialState, error) { - statusResp, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - return nil, fmt.Errorf(" get status: %v", err) - } - - logLevelResp, err := conn.GetLogLevel(s.ctx, &proto.GetLogLevelRequest{}) - if err != nil { - return nil, fmt.Errorf("get log level: %v", err) - } - - wasDown := statusResp.Status != string(internal.StatusConnected) && - statusResp.Status != string(internal.StatusConnecting) - - initialLogLevel := logLevelResp.GetLevel() - initialLevelTrace := initialLogLevel >= proto.LogLevel_TRACE - - return &debugInitialState{ - wasDown: wasDown, - logLevel: initialLogLevel, - isLevelTrace: initialLevelTrace, - }, nil -} - -// Handle progress tracking during collection -func startProgressTracker(ctx context.Context, wg *sync.WaitGroup, duration time.Duration, progress *progressUI) { - progress.progressBar.Show() - progress.progressBar.SetValue(0) - - startTime := time.Now() - endTime := startTime.Add(duration) - wg.Add(1) - - go func() { - defer wg.Done() - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - remaining := time.Until(endTime) - if remaining <= 0 { - remaining = 0 - } - - elapsed := time.Since(startTime) - progressVal := float64(elapsed) / float64(duration) - if progressVal > 1.0 { - progressVal = 1.0 - } - - progress.progressBar.SetValue(progressVal) - progress.statusLabel.SetText(fmt.Sprintf("Running with trace logs... %s remaining", formatDuration(remaining))) - } - } - }() - -} - -func (s *serviceClient) configureServiceForDebug( - conn proto.DaemonServiceClient, - state *debugInitialState, - params *debugCollectionParams, -) { - if state.wasDown { - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to bring service up: %v", err) - } else { - log.Info("Service brought up for debug") - time.Sleep(time.Second * 10) - } - } - - if !state.isLevelTrace { - if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel_TRACE}); err != nil { - log.Warnf("failed to set log level to TRACE: %v", err) - } else { - log.Info("Log level set to TRACE for debug") - } - } - - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - log.Warnf("failed to bring service down: %v", err) - } else { - state.needsRestoreUp = !state.wasDown - time.Sleep(time.Second) - } - - if params.enablePersistence { - if _, err := conn.SetSyncResponsePersistence(s.ctx, &proto.SetSyncResponsePersistenceRequest{ - Enabled: true, - }); err != nil { - log.Warnf("failed to enable sync response persistence: %v", err) - } else { - log.Info("Sync response persistence enabled for debug") - } - } - - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to bring service back up: %v", err) - } else { - state.needsRestoreUp = false - time.Sleep(time.Second * 3) - } - - if _, err := conn.StartCPUProfile(s.ctx, &proto.StartCPUProfileRequest{}); err != nil { - log.Warnf("failed to start CPU profiling: %v", err) - } - - s.startBundleCaptureIfEnabled(conn, params) -} - -func (s *serviceClient) startBundleCaptureIfEnabled(conn proto.DaemonServiceClient, params *debugCollectionParams) { - if !params.capture { - return - } - - const maxCapture = 10 * time.Minute - timeout := params.duration + 30*time.Second - if timeout > maxCapture { - timeout = maxCapture - log.Warnf("packet capture clamped to %s (server maximum)", maxCapture) - } - if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{ - Timeout: durationpb.New(timeout), - }); err != nil { - log.Warnf("failed to start bundle capture: %v", err) - } -} - -func (s *serviceClient) collectDebugData( - conn proto.DaemonServiceClient, - state *debugInitialState, - params *debugCollectionParams, - progress *progressUI, -) error { - ctx, cancel := context.WithTimeout(s.ctx, params.duration) - defer cancel() - var wg sync.WaitGroup - startProgressTracker(ctx, &wg, params.duration, progress) - - s.configureServiceForDebug(conn, state, params) - - wg.Wait() - progress.progressBar.Hide() - progress.statusLabel.SetText("Collecting debug data...") - - if _, err := conn.StopCPUProfile(s.ctx, &proto.StopCPUProfileRequest{}); err != nil { - log.Warnf("failed to stop CPU profiling: %v", err) - } - - if params.capture { - stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { - log.Warnf("failed to stop bundle capture: %v", err) - } - } - - return nil -} - -// Create the debug bundle with collected data -func (s *serviceClient) createDebugBundleFromCollection( - conn proto.DaemonServiceClient, - params *debugCollectionParams, - progress *progressUI, -) error { - progress.statusLabel.SetText("Creating debug bundle with collected logs...") - - request := &proto.DebugBundleRequest{ - Anonymize: params.anonymize, - SystemInfo: params.systemInfo, - } - - if params.upload { - request.UploadURL = params.uploadURL - } - - resp, err := conn.DebugBundle(s.ctx, request) - if err != nil { - return fmt.Errorf("create debug bundle: %v", err) - } - - // Show appropriate dialog based on upload status - localPath := resp.GetPath() - uploadFailureReason := resp.GetUploadFailureReason() - uploadedKey := resp.GetUploadedKey() - - if params.upload { - if uploadFailureReason != "" { - showUploadFailedDialog(progress.window, localPath, uploadFailureReason) - } else { - showUploadSuccessDialog(s.app, progress.window, localPath, uploadedKey) - } - } else { - showBundleCreatedDialog(progress.window, localPath) - } - - enableUIControls(progress.uiControls) - return nil -} - -// Restore service to original state -func (s *serviceClient) restoreServiceState(conn proto.DaemonServiceClient, state *debugInitialState) { - if state.needsRestoreUp { - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to restore up state: %v", err) - } else { - log.Info("Service state restored to up") - } - } - - if state.wasDown { - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - log.Warnf("failed to restore down state: %v", err) - } else { - log.Info("Service state restored to down") - } - } - - if !state.isLevelTrace { - if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: state.logLevel}); err != nil { - log.Warnf("failed to restore log level: %v", err) - } else { - log.Info("Log level restored to original setting") - } - } -} - -// Handle errors during debug collection -func handleError(progress *progressUI, errMsg string) { - log.Errorf("%s", errMsg) - progress.statusLabel.SetText(errMsg) - progress.progressBar.Hide() - enableUIControls(progress.uiControls) -} - -func (s *serviceClient) handleDebugCreation( - params *debugCollectionParams, - statusLabel *widget.Label, - uiControls []fyne.Disableable, - w fyne.Window, -) { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("Failed to get client for debug: %v", err) - statusLabel.SetText(fmt.Sprintf("Error: %v", err)) - enableUIControls(uiControls) - return - } - - if params.capture { - if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{ - Timeout: durationpb.New(30 * time.Second), - }); err != nil { - log.Warnf("failed to start bundle capture: %v", err) - } else { - defer func() { - stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { - log.Warnf("failed to stop bundle capture: %v", err) - } - }() - time.Sleep(2 * time.Second) - } - } - - resp, err := s.createDebugBundle(params.anonymize, params.systemInfo, params.uploadURL) - if err != nil { - log.Errorf("Failed to create debug bundle: %v", err) - statusLabel.SetText(fmt.Sprintf("Error creating bundle: %v", err)) - enableUIControls(uiControls) - return - } - - localPath := resp.GetPath() - uploadFailureReason := resp.GetUploadFailureReason() - uploadedKey := resp.GetUploadedKey() - - if params.upload { - if uploadFailureReason != "" { - showUploadFailedDialog(w, localPath, uploadFailureReason) - } else { - showUploadSuccessDialog(s.app, w, localPath, uploadedKey) - } - } else { - showBundleCreatedDialog(w, localPath) - } - - enableUIControls(uiControls) - statusLabel.SetText("Bundle created successfully") -} - -func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploadURL string) (*proto.DebugBundleResponse, error) { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return nil, fmt.Errorf("get client: %v", err) - } - - request := &proto.DebugBundleRequest{ - Anonymize: anonymize, - SystemInfo: systemInfo, - } - - if uploadURL != "" { - request.UploadURL = uploadURL - } - - resp, err := conn.DebugBundle(s.ctx, request) - if err != nil { - return nil, fmt.Errorf("failed to create debug bundle via daemon: %v", err) - } - - return resp, nil -} - -// formatDuration formats a duration in HH:MM:SS format -func formatDuration(d time.Duration) string { - d = d.Round(time.Second) - h := d / time.Hour - d %= time.Hour - m := d / time.Minute - d %= time.Minute - s := d / time.Second - return fmt.Sprintf("%02d:%02d:%02d", h, m, s) -} - -// createButtonWithAction creates a button with the given label and action -func createButtonWithAction(label string, action func()) *widget.Button { - button := widget.NewButton(label, action) - return button -} - -// showUploadFailedDialog displays a dialog when upload fails -func showUploadFailedDialog(w fyne.Window, localPath, failureReason string) { - content := container.NewVBox( - widget.NewLabel(fmt.Sprintf("Bundle upload failed:\n%s\n\n"+ - "A local copy was saved at:\n%s", failureReason, localPath)), - ) - - customDialog := dialog.NewCustom("Upload Failed", "Cancel", content, w) - - buttonBox := container.NewHBox( - createButtonWithAction("Open file", func() { - log.Infof("Attempting to open local file: %s", localPath) - if openErr := open.Start(localPath); openErr != nil { - log.Errorf("Failed to open local file '%s': %v", localPath, openErr) - dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w) - } - }), - createButtonWithAction("Open folder", func() { - folderPath := filepath.Dir(localPath) - log.Infof("Attempting to open local folder: %s", folderPath) - if openErr := open.Start(folderPath); openErr != nil { - log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr) - dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w) - } - }), - ) - - content.Add(buttonBox) - customDialog.Show() -} - -// showUploadSuccessDialog displays a dialog when upload succeeds -func showUploadSuccessDialog(a fyne.App, w fyne.Window, localPath, uploadedKey string) { - log.Infof("Upload key: %s", uploadedKey) - keyEntry := widget.NewEntry() - keyEntry.SetText(uploadedKey) - keyEntry.Disable() - - content := container.NewVBox( - widget.NewLabel("Bundle uploaded successfully!"), - widget.NewLabel(""), - widget.NewLabel("Upload key:"), - keyEntry, - widget.NewLabel(""), - widget.NewLabel(fmt.Sprintf("Local copy saved at:\n%s", localPath)), - ) - - customDialog := dialog.NewCustom("Upload Successful", "OK", content, w) - - copyBtn := createButtonWithAction("Copy key", func() { - a.Clipboard().SetContent(uploadedKey) - log.Info("Upload key copied to clipboard") - }) - - buttonBox := createButtonBox(localPath, w, copyBtn) - content.Add(buttonBox) - customDialog.Show() -} - -// showBundleCreatedDialog displays a dialog when bundle is created without upload -func showBundleCreatedDialog(w fyne.Window, localPath string) { - content := container.NewVBox( - widget.NewLabel(fmt.Sprintf("Bundle created locally at:\n%s\n\n"+ - "Administrator privileges may be required to access the file.", localPath)), - ) - - customDialog := dialog.NewCustom("Debug Bundle Created", "Cancel", content, w) - - buttonBox := createButtonBox(localPath, w, nil) - content.Add(buttonBox) - customDialog.Show() -} - -func createButtonBox(localPath string, w fyne.Window, elems ...fyne.Widget) *fyne.Container { - box := container.NewHBox() - for _, elem := range elems { - box.Add(elem) - } - - fileBtn := createButtonWithAction("Open file", func() { - log.Infof("Attempting to open local file: %s", localPath) - if openErr := open.Start(localPath); openErr != nil { - log.Errorf("Failed to open local file '%s': %v", localPath, openErr) - dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w) - } - }) - - folderBtn := createButtonWithAction("Open folder", func() { - folderPath := filepath.Dir(localPath) - log.Infof("Attempting to open local folder: %s", folderPath) - if openErr := open.Start(folderPath); openErr != nil { - log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr) - dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w) - } - }) - - box.Add(fileBtn) - box.Add(folderBtn) - - return box -} diff --git a/client/ui/event/event.go b/client/ui/event/event.go deleted file mode 100644 index ea968f60a..000000000 --- a/client/ui/event/event.go +++ /dev/null @@ -1,176 +0,0 @@ -package event - -import ( - "context" - "fmt" - "slices" - "strings" - "sync" - "time" - - "github.com/cenkalti/backoff/v4" - log "github.com/sirupsen/logrus" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/client/ui/desktop" -) - -// Notifier sends desktop notifications. Defined here so the event package -// does not depend on fyne or the platform-specific notifier implementation. -type Notifier interface { - Send(title, body string) -} - -type Handler func(*proto.SystemEvent) - -type Manager struct { - notifier Notifier - addr string - - mu sync.Mutex - ctx context.Context - cancel context.CancelFunc - enabled bool - handlers []Handler -} - -func NewManager(notifier Notifier, addr string) *Manager { - return &Manager{ - notifier: notifier, - addr: addr, - } -} - -func (e *Manager) Start(ctx context.Context) { - e.mu.Lock() - e.ctx, e.cancel = context.WithCancel(ctx) - e.mu.Unlock() - - expBackOff := backoff.WithContext(&backoff.ExponentialBackOff{ - InitialInterval: time.Second, - RandomizationFactor: backoff.DefaultRandomizationFactor, - Multiplier: backoff.DefaultMultiplier, - MaxInterval: 10 * time.Second, - MaxElapsedTime: 0, - Stop: backoff.Stop, - Clock: backoff.SystemClock, - }, ctx) - - if err := backoff.Retry(e.streamEvents, expBackOff); err != nil { - log.Errorf("event stream ended: %v", err) - } -} - -func (e *Manager) streamEvents() error { - e.mu.Lock() - ctx := e.ctx - e.mu.Unlock() - - client, err := getClient(e.addr) - if err != nil { - return fmt.Errorf("create client: %w", err) - } - - stream, err := client.SubscribeEvents(ctx, &proto.SubscribeRequest{}) - if err != nil { - return fmt.Errorf("failed to subscribe to events: %w", err) - } - - log.Info("subscribed to daemon events") - defer func() { - log.Info("unsubscribed from daemon events") - }() - - for { - event, err := stream.Recv() - if err != nil { - return fmt.Errorf("error receiving event: %w", err) - } - e.handleEvent(event) - } -} - -func (e *Manager) Stop() { - e.mu.Lock() - defer e.mu.Unlock() - if e.cancel != nil { - e.cancel() - } -} - -func (e *Manager) SetNotificationsEnabled(enabled bool) { - e.mu.Lock() - defer e.mu.Unlock() - e.enabled = enabled -} - -func (e *Manager) handleEvent(event *proto.SystemEvent) { - e.mu.Lock() - enabled := e.enabled - handlers := slices.Clone(e.handlers) - e.mu.Unlock() - - if event.UserMessage != "" && (enabled || event.Severity == proto.SystemEvent_CRITICAL) { - title := e.getEventTitle(event) - body := event.UserMessage - id := event.Metadata["id"] - if id != "" { - body += fmt.Sprintf(" ID: %s", id) - } - e.notifier.Send(title, body) - } - - for _, handler := range handlers { - go handler(event) - } -} - -func (e *Manager) AddHandler(handler Handler) { - e.mu.Lock() - defer e.mu.Unlock() - e.handlers = append(e.handlers, handler) -} - -func (e *Manager) getEventTitle(event *proto.SystemEvent) string { - var prefix string - switch event.Severity { - case proto.SystemEvent_CRITICAL: - prefix = "Critical" - case proto.SystemEvent_ERROR: - prefix = "Error" - case proto.SystemEvent_WARNING: - prefix = "Warning" - default: - prefix = "Info" - } - - var category string - switch event.Category { - case proto.SystemEvent_DNS: - category = "DNS" - case proto.SystemEvent_NETWORK: - category = "Network" - case proto.SystemEvent_AUTHENTICATION: - category = "Authentication" - case proto.SystemEvent_CONNECTIVITY: - category = "Connectivity" - default: - category = "System" - } - - return fmt.Sprintf("%s: %s", prefix, category) -} - -func getClient(addr string) (proto.DaemonServiceClient, error) { - conn, err := grpc.NewClient( - strings.TrimPrefix(addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithUserAgent(desktop.GetUIUserAgent()), - ) - if err != nil { - return nil, err - } - return proto.NewDaemonServiceClient(conn), nil -} diff --git a/client/ui/event_handler.go b/client/ui/event_handler.go deleted file mode 100644 index 876fcef5f..000000000 --- a/client/ui/event_handler.go +++ /dev/null @@ -1,326 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - - "fyne.io/systray" - log "github.com/sirupsen/logrus" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/version" -) - -type eventHandler struct { - client *serviceClient -} - -func newEventHandler(client *serviceClient) *eventHandler { - return &eventHandler{ - client: client, - } -} - -func (h *eventHandler) listen(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case <-h.client.mUp.ClickedCh: - h.handleConnectClick() - case <-h.client.mDown.ClickedCh: - h.handleDisconnectClick() - case <-h.client.mAllowSSH.ClickedCh: - h.handleAllowSSHClick() - case <-h.client.mAutoConnect.ClickedCh: - h.handleAutoConnectClick() - case <-h.client.mEnableRosenpass.ClickedCh: - h.handleRosenpassClick() - case <-h.client.mLazyConnEnabled.ClickedCh: - h.handleLazyConnectionClick() - case <-h.client.mBlockInbound.ClickedCh: - h.handleBlockInboundClick() - case <-h.client.mAdvancedSettings.ClickedCh: - h.handleAdvancedSettingsClick() - case <-h.client.mCreateDebugBundle.ClickedCh: - h.handleCreateDebugBundleClick() - case <-h.client.mQuit.ClickedCh: - h.handleQuitClick() - return - case <-h.client.mGitHub.ClickedCh: - h.handleGitHubClick() - case <-h.client.mUpdate.ClickedCh: - h.handleUpdateClick() - case <-h.client.mNetworks.ClickedCh: - h.handleNetworksClick() - case <-h.client.mNotifications.ClickedCh: - h.handleNotificationsClick() - case <-systray.TrayOpenedCh: - h.client.updateExitNodes() - } - } -} - -func (h *eventHandler) handleConnectClick() { - h.client.mUp.Disable() - - if h.client.connectCancel != nil { - h.client.connectCancel() - } - - connectCtx, connectCancel := context.WithCancel(h.client.ctx) - h.client.connectCancel = connectCancel - - go func() { - defer connectCancel() - - if err := h.client.menuUpClick(connectCtx); err != nil { - st, ok := status.FromError(err) - if errors.Is(err, context.Canceled) || (ok && st.Code() == codes.Canceled) { - log.Debugf("connect operation cancelled by user") - } else { - h.client.notifier.Send("Error", "Failed to connect") - log.Errorf("connect failed: %v", err) - } - } - - if err := h.client.updateStatus(); err != nil { - log.Debugf("failed to update status after connect: %v", err) - } - }() -} - -func (h *eventHandler) handleDisconnectClick() { - h.client.mDown.Disable() - h.client.cancelExitNodeRetry() - - if h.client.connectCancel != nil { - log.Debugf("cancelling ongoing connect operation") - h.client.connectCancel() - h.client.connectCancel = nil - } - - go func() { - if err := h.client.menuDownClick(); err != nil { - st, ok := status.FromError(err) - if !errors.Is(err, context.Canceled) && !(ok && st.Code() == codes.Canceled) { - h.client.notifier.Send("Error", "Failed to disconnect") - log.Errorf("disconnect failed: %v", err) - } else { - log.Debugf("disconnect cancelled or already disconnecting") - } - } - - if err := h.client.updateStatus(); err != nil { - log.Debugf("failed to update status after disconnect: %v", err) - } - }() -} - -func (h *eventHandler) handleAllowSSHClick() { - h.toggleCheckbox(h.client.mAllowSSH) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mAllowSSH) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update SSH settings") - } - -} - -func (h *eventHandler) handleAutoConnectClick() { - h.toggleCheckbox(h.client.mAutoConnect) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mAutoConnect) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update auto-connect settings") - } -} - -func (h *eventHandler) handleRosenpassClick() { - h.toggleCheckbox(h.client.mEnableRosenpass) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mEnableRosenpass) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update Rosenpass settings") - } -} - -func (h *eventHandler) handleLazyConnectionClick() { - h.toggleCheckbox(h.client.mLazyConnEnabled) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mLazyConnEnabled) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update lazy connection settings") - } -} - -func (h *eventHandler) handleBlockInboundClick() { - h.toggleCheckbox(h.client.mBlockInbound) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mBlockInbound) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update block inbound settings") - } -} - -func (h *eventHandler) handleNotificationsClick() { - h.toggleCheckbox(h.client.mNotifications) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mNotifications) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update notifications settings") - } else if h.client.eventManager != nil { - h.client.eventManager.SetNotificationsEnabled(h.client.mNotifications.Checked()) - } - -} - -func (h *eventHandler) handleAdvancedSettingsClick() { - h.client.mAdvancedSettings.Disable() - go func() { - defer h.client.mAdvancedSettings.Enable() - defer h.client.getSrvConfig() - h.runSelfCommand(h.client.ctx, "settings") - }() -} - -func (h *eventHandler) handleCreateDebugBundleClick() { - h.client.mCreateDebugBundle.Disable() - go func() { - defer h.client.mCreateDebugBundle.Enable() - h.runSelfCommand(h.client.ctx, "debug") - }() -} - -func (h *eventHandler) handleQuitClick() { - systray.Quit() -} - -func (h *eventHandler) handleGitHubClick() { - if err := openURL("https://github.com/netbirdio/netbird"); err != nil { - log.Errorf("failed to open GitHub URL: %v", err) - } -} - -func (h *eventHandler) handleUpdateClick() { - h.client.updateIndicationLock.Lock() - enforced := h.client.isEnforcedUpdate - h.client.updateIndicationLock.Unlock() - - if !enforced { - if err := openURL(version.DownloadUrl()); err != nil { - log.Errorf("failed to open download URL: %v", err) - } - return - } - - // prevent blocking against a busy server - h.client.mUpdate.Disable() - go func() { - defer h.client.mUpdate.Enable() - conn, err := h.client.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get service client for update: %v", err) - _ = openURL(version.DownloadUrl()) - return - } - - resp, err := conn.TriggerUpdate(h.client.ctx, &proto.TriggerUpdateRequest{}) - if err != nil { - log.Errorf("TriggerUpdate failed: %v", err) - _ = openURL(version.DownloadUrl()) - return - } - if !resp.Success { - log.Errorf("TriggerUpdate failed: %s", resp.ErrorMsg) - _ = openURL(version.DownloadUrl()) - return - } - - log.Infof("update triggered via daemon") - }() -} - -func (h *eventHandler) handleNetworksClick() { - h.client.mNetworks.Disable() - go func() { - defer h.client.mNetworks.Enable() - h.runSelfCommand(h.client.ctx, "networks") - }() -} - -func (h *eventHandler) toggleCheckbox(item *systray.MenuItem) { - if item.Checked() { - item.Uncheck() - } else { - item.Check() - } -} - -func (h *eventHandler) updateConfigWithErr() error { - if err := h.client.updateConfig(); err != nil { - return err - } - - return nil -} - -func (h *eventHandler) runSelfCommand(ctx context.Context, command string, args ...string) { - proc, err := os.Executable() - if err != nil { - log.Errorf("error getting executable path: %v", err) - return - } - - // Build the full command arguments - cmdArgs := []string{ - fmt.Sprintf("--%s=true", command), - fmt.Sprintf("--daemon-addr=%s", h.client.addr), - } - cmdArgs = append(cmdArgs, args...) - - cmd := exec.CommandContext(ctx, proc, cmdArgs...) - - if out := h.client.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("error closing log file %s: %v", h.client.logFile, err) - } - }() - } - - log.Printf("running command: %s", cmd.String()) - - if err := cmd.Run(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - log.Printf("command '%s' failed with exit code %d", cmd.String(), exitErr.ExitCode()) - } - return - } - - log.Printf("command '%s' completed successfully", cmd.String()) -} - -func (h *eventHandler) logout(ctx context.Context) error { - client, err := h.client.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("failed to get service client: %w", err) - } - - _, err = client.Logout(ctx, &proto.LogoutRequest{}) - if err != nil { - return fmt.Errorf("logout failed: %w", err) - } - - h.client.getSrvConfig() - - return nil -} diff --git a/client/ui/font_bsd.go b/client/ui/font_bsd.go deleted file mode 100644 index 139f38f40..000000000 --- a/client/ui/font_bsd.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build freebsd || openbsd || netbsd || dragonfly - -package main - -import ( - "os" - "runtime" - - log "github.com/sirupsen/logrus" -) - -func (s *serviceClient) setDefaultFonts() { - paths := []string{ - "/usr/local/share/fonts/TTF/DejaVuSans.ttf", - "/usr/local/share/fonts/dejavu/DejaVuSans.ttf", - "/usr/local/share/noto/NotoSans-Regular.ttf", - "/usr/local/share/fonts/noto/NotoSans-Regular.ttf", - "/usr/local/share/fonts/liberation-fonts-ttf/LiberationSans-Regular.ttf", - } - - for _, fontPath := range paths { - if _, err := os.Stat(fontPath); err == nil { - os.Setenv("FYNE_FONT", fontPath) - log.Debugf("Using font: %s", fontPath) - return - } - } - - log.Errorf("Failed to find any suitable font files for %s", runtime.GOOS) -} diff --git a/client/ui/font_darwin.go b/client/ui/font_darwin.go deleted file mode 100644 index cafb72f59..000000000 --- a/client/ui/font_darwin.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "os" - - log "github.com/sirupsen/logrus" -) - -const defaultFontPath = "/Library/Fonts/Arial Unicode.ttf" - -func (s *serviceClient) setDefaultFonts() { - if _, err := os.Stat(defaultFontPath); err != nil { - log.Errorf("Failed to find default font file: %v", err) - return - } - - os.Setenv("FYNE_FONT", defaultFontPath) -} diff --git a/client/ui/font_linux.go b/client/ui/font_linux.go deleted file mode 100644 index 4aa92494a..000000000 --- a/client/ui/font_linux.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !386 - -package main - -func (s *serviceClient) setDefaultFonts() { - //TODO: Linux Multiple Language Support -} diff --git a/client/ui/font_windows.go b/client/ui/font_windows.go deleted file mode 100644 index 6346a9fb9..000000000 --- a/client/ui/font_windows.go +++ /dev/null @@ -1,90 +0,0 @@ -package main - -import ( - "os" - "path" - "unsafe" - - log "github.com/sirupsen/logrus" - "golang.org/x/sys/windows" -) - -func (s *serviceClient) setDefaultFonts() { - defaultFontPath := s.getWindowsFontFilePath() - - if _, err := os.Stat(defaultFontPath); err != nil { - log.Errorf("Failed to find default font file: %v", err) - return - } - - os.Setenv("FYNE_FONT", defaultFontPath) -} - -func (s *serviceClient) getWindowsFontFilePath() string { - var ( - fontFolder = "C:/Windows/Fonts" - fontMapping = map[string]string{ - "default": "Segoeui.ttf", - "zh-CN": "Segoeui.ttf", - "am-ET": "Ebrima.ttf", - "nirmala": "Nirmala.ttf", - "chr-CHER-US": "Gadugi.ttf", - "zh-HK": "Segoeui.ttf", - "zh-TW": "Segoeui.ttf", - "km-KH": "Leelawui.ttf", - "ko-KR": "Malgun.ttf", - "th-TH": "Leelawui.ttf", - "ti-ET": "Ebrima.ttf", - } - nirMalaLang = []string{ - "as-IN", - "bn-BD", - "bn-IN", - "gu-IN", - "hi-IN", - "kn-IN", - "kok-IN", - "ml-IN", - "mr-IN", - "ne-NP", - "or-IN", - "pa-IN", - "si-LK", - "ta-IN", - "te-IN", - } - ) - - // getUserDefaultLocaleName.Call() panics if the func is not found - defer func() { - if r := recover(); r != nil { - log.Errorf("Recovered from panic: %v", r) - } - }() - - kernel32 := windows.NewLazySystemDLL("kernel32.dll") - getUserDefaultLocaleName := kernel32.NewProc("GetUserDefaultLocaleName") - - buf := make([]uint16, 85) // LOCALE_NAME_MAX_LENGTH is usually 85 - r, _, err := getUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) - // returns 0 on failure, err is always non-nil - // https://learn.microsoft.com/en-us/windows/win32/api/winnls/nf-winnls-getuserdefaultlocalename - if r == 0 { - log.Errorf("GetUserDefaultLocaleName call failed: %v", err) - return path.Join(fontFolder, fontMapping["default"]) - } - - defaultLanguage := windows.UTF16ToString(buf) - - for _, lang := range nirMalaLang { - if defaultLanguage == lang { - return path.Join(fontFolder, fontMapping["nirmala"]) - } - } - - if font, ok := fontMapping[defaultLanguage]; ok { - return path.Join(fontFolder, font) - } - - return path.Join(fontFolder, fontMapping["default"]) -} diff --git a/client/ui-wails/frontend/Inter Font License.txt b/client/ui/frontend/Inter Font License.txt similarity index 100% rename from client/ui-wails/frontend/Inter Font License.txt rename to client/ui/frontend/Inter Font License.txt diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/connection.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts rename to client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/connection.ts diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/debug.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/debug.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/debug.ts rename to client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/debug.ts diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/forwarding.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/forwarding.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/forwarding.ts rename to client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/forwarding.ts diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/index.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/index.ts rename to client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.ts rename to client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/networks.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/networks.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/networks.ts rename to client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/networks.ts diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/peers.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/peers.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/peers.ts rename to client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/peers.ts diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/profiles.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profiles.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/profiles.ts rename to client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profiles.ts diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/settings.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/settings.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/settings.ts rename to client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/settings.ts diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/update.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts rename to client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/update.ts diff --git a/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts rename to client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts diff --git a/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts rename to client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts diff --git a/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts rename to client/ui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts diff --git a/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts rename to client/ui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts diff --git a/client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.ts b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.ts similarity index 100% rename from client/ui-wails/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.ts rename to client/ui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.ts diff --git a/client/ui-wails/frontend/index.html b/client/ui/frontend/index.html similarity index 100% rename from client/ui-wails/frontend/index.html rename to client/ui/frontend/index.html diff --git a/client/ui-wails/frontend/package.json b/client/ui/frontend/package.json similarity index 100% rename from client/ui-wails/frontend/package.json rename to client/ui/frontend/package.json diff --git a/client/ui-wails/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml similarity index 100% rename from client/ui-wails/frontend/pnpm-lock.yaml rename to client/ui/frontend/pnpm-lock.yaml diff --git a/client/ui-wails/frontend/postcss.config.js b/client/ui/frontend/postcss.config.js similarity index 100% rename from client/ui-wails/frontend/postcss.config.js rename to client/ui/frontend/postcss.config.js diff --git a/client/ui-wails/frontend/public/Inter-Medium.ttf b/client/ui/frontend/public/Inter-Medium.ttf similarity index 100% rename from client/ui-wails/frontend/public/Inter-Medium.ttf rename to client/ui/frontend/public/Inter-Medium.ttf diff --git a/client/ui-wails/frontend/public/react.svg b/client/ui/frontend/public/react.svg similarity index 100% rename from client/ui-wails/frontend/public/react.svg rename to client/ui/frontend/public/react.svg diff --git a/client/ui-wails/frontend/public/style.css b/client/ui/frontend/public/style.css similarity index 100% rename from client/ui-wails/frontend/public/style.css rename to client/ui/frontend/public/style.css diff --git a/client/ui-wails/frontend/public/wails.png b/client/ui/frontend/public/wails.png similarity index 100% rename from client/ui-wails/frontend/public/wails.png rename to client/ui/frontend/public/wails.png diff --git a/client/ui-wails/frontend/src/App.tsx b/client/ui/frontend/src/App.tsx similarity index 100% rename from client/ui-wails/frontend/src/App.tsx rename to client/ui/frontend/src/App.tsx diff --git a/client/ui-wails/frontend/src/Layout.tsx b/client/ui/frontend/src/Layout.tsx similarity index 100% rename from client/ui-wails/frontend/src/Layout.tsx rename to client/ui/frontend/src/Layout.tsx diff --git a/client/ui-wails/frontend/src/components/Button.tsx b/client/ui/frontend/src/components/Button.tsx similarity index 100% rename from client/ui-wails/frontend/src/components/Button.tsx rename to client/ui/frontend/src/components/Button.tsx diff --git a/client/ui-wails/frontend/src/components/Card.tsx b/client/ui/frontend/src/components/Card.tsx similarity index 100% rename from client/ui-wails/frontend/src/components/Card.tsx rename to client/ui/frontend/src/components/Card.tsx diff --git a/client/ui-wails/frontend/src/components/Input.tsx b/client/ui/frontend/src/components/Input.tsx similarity index 100% rename from client/ui-wails/frontend/src/components/Input.tsx rename to client/ui/frontend/src/components/Input.tsx diff --git a/client/ui-wails/frontend/src/components/Switch.tsx b/client/ui/frontend/src/components/Switch.tsx similarity index 100% rename from client/ui-wails/frontend/src/components/Switch.tsx rename to client/ui/frontend/src/components/Switch.tsx diff --git a/client/ui-wails/frontend/src/components/Tabs.tsx b/client/ui/frontend/src/components/Tabs.tsx similarity index 100% rename from client/ui-wails/frontend/src/components/Tabs.tsx rename to client/ui/frontend/src/components/Tabs.tsx diff --git a/client/ui-wails/frontend/src/hooks/useStatus.ts b/client/ui/frontend/src/hooks/useStatus.ts similarity index 100% rename from client/ui-wails/frontend/src/hooks/useStatus.ts rename to client/ui/frontend/src/hooks/useStatus.ts diff --git a/client/ui-wails/frontend/src/index.css b/client/ui/frontend/src/index.css similarity index 100% rename from client/ui-wails/frontend/src/index.css rename to client/ui/frontend/src/index.css diff --git a/client/ui-wails/frontend/src/lib/cn.ts b/client/ui/frontend/src/lib/cn.ts similarity index 100% rename from client/ui-wails/frontend/src/lib/cn.ts rename to client/ui/frontend/src/lib/cn.ts diff --git a/client/ui-wails/frontend/src/main.tsx b/client/ui/frontend/src/main.tsx similarity index 100% rename from client/ui-wails/frontend/src/main.tsx rename to client/ui/frontend/src/main.tsx diff --git a/client/ui-wails/frontend/src/pages/Debug.tsx b/client/ui/frontend/src/pages/Debug.tsx similarity index 100% rename from client/ui-wails/frontend/src/pages/Debug.tsx rename to client/ui/frontend/src/pages/Debug.tsx diff --git a/client/ui-wails/frontend/src/pages/Login.tsx b/client/ui/frontend/src/pages/Login.tsx similarity index 100% rename from client/ui-wails/frontend/src/pages/Login.tsx rename to client/ui/frontend/src/pages/Login.tsx diff --git a/client/ui-wails/frontend/src/pages/LoginUrl.tsx b/client/ui/frontend/src/pages/LoginUrl.tsx similarity index 100% rename from client/ui-wails/frontend/src/pages/LoginUrl.tsx rename to client/ui/frontend/src/pages/LoginUrl.tsx diff --git a/client/ui-wails/frontend/src/pages/Networks.tsx b/client/ui/frontend/src/pages/Networks.tsx similarity index 100% rename from client/ui-wails/frontend/src/pages/Networks.tsx rename to client/ui/frontend/src/pages/Networks.tsx diff --git a/client/ui-wails/frontend/src/pages/Peers.tsx b/client/ui/frontend/src/pages/Peers.tsx similarity index 100% rename from client/ui-wails/frontend/src/pages/Peers.tsx rename to client/ui/frontend/src/pages/Peers.tsx diff --git a/client/ui-wails/frontend/src/pages/Profiles.tsx b/client/ui/frontend/src/pages/Profiles.tsx similarity index 100% rename from client/ui-wails/frontend/src/pages/Profiles.tsx rename to client/ui/frontend/src/pages/Profiles.tsx diff --git a/client/ui-wails/frontend/src/pages/QuickActions.tsx b/client/ui/frontend/src/pages/QuickActions.tsx similarity index 100% rename from client/ui-wails/frontend/src/pages/QuickActions.tsx rename to client/ui/frontend/src/pages/QuickActions.tsx diff --git a/client/ui-wails/frontend/src/pages/Settings.tsx b/client/ui/frontend/src/pages/Settings.tsx similarity index 100% rename from client/ui-wails/frontend/src/pages/Settings.tsx rename to client/ui/frontend/src/pages/Settings.tsx diff --git a/client/ui-wails/frontend/src/pages/Status.tsx b/client/ui/frontend/src/pages/Status.tsx similarity index 100% rename from client/ui-wails/frontend/src/pages/Status.tsx rename to client/ui/frontend/src/pages/Status.tsx diff --git a/client/ui-wails/frontend/src/pages/Update.tsx b/client/ui/frontend/src/pages/Update.tsx similarity index 100% rename from client/ui-wails/frontend/src/pages/Update.tsx rename to client/ui/frontend/src/pages/Update.tsx diff --git a/client/ui-wails/frontend/src/vite-env.d.ts b/client/ui/frontend/src/vite-env.d.ts similarity index 100% rename from client/ui-wails/frontend/src/vite-env.d.ts rename to client/ui/frontend/src/vite-env.d.ts diff --git a/client/ui-wails/frontend/tailwind.config.ts b/client/ui/frontend/tailwind.config.ts similarity index 100% rename from client/ui-wails/frontend/tailwind.config.ts rename to client/ui/frontend/tailwind.config.ts diff --git a/client/ui-wails/frontend/tsconfig.json b/client/ui/frontend/tsconfig.json similarity index 100% rename from client/ui-wails/frontend/tsconfig.json rename to client/ui/frontend/tsconfig.json diff --git a/client/ui-wails/frontend/vite.config.ts b/client/ui/frontend/vite.config.ts similarity index 100% rename from client/ui-wails/frontend/vite.config.ts rename to client/ui/frontend/vite.config.ts diff --git a/client/ui-wails/grpc.go b/client/ui/grpc.go similarity index 100% rename from client/ui-wails/grpc.go rename to client/ui/grpc.go diff --git a/client/ui/icons.go b/client/ui/icons.go index 874f24fdd..28d4582cc 100644 --- a/client/ui/icons.go +++ b/client/ui/icons.go @@ -1,16 +1,15 @@ -//go:build !(linux && 386) && !windows +//go:build !android && !ios && !freebsd && !js package main -import ( - _ "embed" -) +import _ "embed" -//go:embed assets/netbird.png -var iconAbout []byte - -//go:embed assets/netbird-disconnected.png -var iconAboutDisconnected []byte +// Tray icons embedded from the legacy Fyne UI's asset set. Each pair is a +// light-mode PNG and its dark-mode variant; macOS template variants +// (*-macos.png) live alongside for menubar use. Windows uses the same +// PNGs — multi-resolution .ico files looked promising on disk but +// Wails3's Shell_NotifyIcon NIM_MODIFY never redrew them on the running +// tray; PNG single-frame works. //go:embed assets/netbird-systemtray-connected.png var iconConnected []byte @@ -21,26 +20,35 @@ var iconConnectedDark []byte //go:embed assets/netbird-systemtray-disconnected.png var iconDisconnected []byte -//go:embed assets/netbird-systemtray-update-disconnected.png -var iconUpdateDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected-dark.png -var iconUpdateDisconnectedDark []byte - -//go:embed assets/netbird-systemtray-update-connected.png -var iconUpdateConnected []byte - -//go:embed assets/netbird-systemtray-update-connected-dark.png -var iconUpdateConnectedDark []byte - //go:embed assets/netbird-systemtray-connecting.png var iconConnecting []byte -//go:embed assets/netbird-systemtray-connecting-dark.png -var iconConnectingDark []byte - //go:embed assets/netbird-systemtray-error.png var iconError []byte -//go:embed assets/netbird-systemtray-error-dark.png -var iconErrorDark []byte +//go:embed assets/netbird-systemtray-update-connected.png +var iconUpdateConnected []byte + +//go:embed assets/netbird-systemtray-update-disconnected.png +var iconUpdateDisconnected []byte + +//go:embed assets/netbird-systemtray-connected-macos.png +var iconConnectedMacOS []byte + +//go:embed assets/netbird-systemtray-disconnected-macos.png +var iconDisconnectedMacOS []byte + +//go:embed assets/netbird-systemtray-connecting-macos.png +var iconConnectingMacOS []byte + +//go:embed assets/netbird-systemtray-error-macos.png +var iconErrorMacOS []byte + +//go:embed assets/netbird-systemtray-update-connected-macos.png +var iconUpdateConnectedMacOS []byte + +//go:embed assets/netbird-systemtray-update-disconnected-macos.png +var iconUpdateDisconnectedMacOS []byte + +//go:embed assets/netbird.png +var iconWindow []byte diff --git a/client/ui/icons_windows.go b/client/ui/icons_windows.go deleted file mode 100644 index bd57b2690..000000000 --- a/client/ui/icons_windows.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - _ "embed" -) - -//go:embed assets/netbird.ico -var iconAbout []byte - -//go:embed assets/netbird-disconnected.ico -var iconAboutDisconnected []byte - -//go:embed assets/netbird-systemtray-connected.ico -var iconConnected []byte - -//go:embed assets/netbird-systemtray-connected-dark.ico -var iconConnectedDark []byte - -//go:embed assets/netbird-systemtray-disconnected.ico -var iconDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected.ico -var iconUpdateDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected-dark.ico -var iconUpdateDisconnectedDark []byte - -//go:embed assets/netbird-systemtray-update-connected.ico -var iconUpdateConnected []byte - -//go:embed assets/netbird-systemtray-update-connected-dark.ico -var iconUpdateConnectedDark []byte - -//go:embed assets/netbird-systemtray-connecting.ico -var iconConnecting []byte - -//go:embed assets/netbird-systemtray-connecting-dark.ico -var iconConnectingDark []byte - -//go:embed assets/netbird-systemtray-error.ico -var iconError []byte - -//go:embed assets/netbird-systemtray-error-dark.ico -var iconErrorDark []byte diff --git a/client/ui-wails/main.go b/client/ui/main.go similarity index 100% rename from client/ui-wails/main.go rename to client/ui/main.go diff --git a/client/ui/manifest.xml b/client/ui/manifest.xml deleted file mode 100644 index c71a407e5..000000000 --- a/client/ui/manifest.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - Netbird UI application - - - - - - - - \ No newline at end of file diff --git a/client/ui/netbird-ui.rb.tmpl b/client/ui/netbird-ui.rb.tmpl deleted file mode 100644 index 06971909d..000000000 --- a/client/ui/netbird-ui.rb.tmpl +++ /dev/null @@ -1,39 +0,0 @@ -{{ $projectName := env.Getenv "PROJECT" }}{{ $amdFilePath := env.Getenv "AMD" }}{{ $armFilePath := env.Getenv "ARM" }} -{{ $amdURL := env.Getenv "AMD_URL" }}{{ $armURL := env.Getenv "ARM_URL" }} -{{ $amdFile := filepath.Base $amdFilePath }}{{ $armFile := filepath.Base $armFilePath }}{{ $amdFileBytes := file.Read $amdFilePath }} -{{ $armFileBytes := file.Read $armFilePath }}# Netbird's UI Client Cask Formula -cask "{{ $projectName }}" do - version "{{ env.Getenv "VERSION" }}" - - if Hardware::CPU.intel? - url "{{ $amdURL }}" - sha256 "{{ crypto.SHA256 $amdFileBytes }}" - app "netbird_ui_darwin", target: "Netbird UI.app" - else - url "{{ $armURL }}" - sha256 "{{ crypto.SHA256 $armFileBytes }}" - app "netbird_ui_darwin", target: "Netbird UI.app" - end - - depends_on formula: "netbird" - - postflight do - set_permissions "/Applications/Netbird UI.app/installer.sh", '0755' - set_permissions "/Applications/Netbird UI.app/uninstaller.sh", '0755' - end - - postflight do - system_command "#{appdir}/Netbird UI.app/installer.sh", - args: ["#{version}"], - sudo: true - end - - uninstall_preflight do - system_command "#{appdir}/Netbird UI.app/uninstaller.sh", - sudo: false - end - - name "Netbird UI" - desc "Netbird UI Client" - homepage "https://www.netbird.io/" -end diff --git a/client/ui/network.go b/client/ui/network.go deleted file mode 100644 index 571e871bb..000000000 --- a/client/ui/network.go +++ /dev/null @@ -1,695 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "fmt" - "runtime" - "sort" - "strings" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/proto" -) - -const ( - allNetworksText = "All networks" - overlappingNetworksText = "Overlapping networks" - exitNodeNetworksText = "Exit-node networks" - allNetworks filter = "all" - overlappingNetworks filter = "overlapping" - exitNodeNetworks filter = "exit-node" - getClientFMT = "get client: %v" -) - -type filter string - -func (s *serviceClient) showNetworksUI() { - s.wNetworks = s.app.NewWindow("Networks") - s.wNetworks.SetOnClosed(s.cancel) - - allGrid := container.New(layout.NewGridLayout(3)) - go s.updateNetworks(allGrid, allNetworks) - overlappingGrid := container.New(layout.NewGridLayout(3)) - exitNodeGrid := container.New(layout.NewGridLayout(3)) - routeCheckContainer := container.NewVBox() - tabs := container.NewAppTabs( - container.NewTabItem(allNetworksText, allGrid), - container.NewTabItem(overlappingNetworksText, overlappingGrid), - container.NewTabItem(exitNodeNetworksText, exitNodeGrid), - ) - tabs.OnSelected = func(item *container.TabItem) { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - } - tabs.OnUnselected = func(item *container.TabItem) { - grid, _ := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - grid.Objects = nil - } - - routeCheckContainer.Add(tabs) - scrollContainer := container.NewVScroll(routeCheckContainer) - scrollContainer.SetMinSize(fyne.NewSize(200, 300)) - - buttonBox := container.NewHBox( - layout.NewSpacer(), - widget.NewButton("Refresh", func() { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - widget.NewButton("Select all", func() { - _, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - s.selectAllFilteredNetworks(f) - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - widget.NewButton("Deselect All", func() { - _, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - s.deselectAllFilteredNetworks(f) - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - layout.NewSpacer(), - ) - - content := container.NewBorder(nil, buttonBox, nil, nil, scrollContainer) - - s.wNetworks.SetContent(content) - s.wNetworks.Show() - - s.startAutoRefresh(10*time.Second, tabs, allGrid, overlappingGrid, exitNodeGrid) -} - -func (s *serviceClient) updateNetworks(grid *fyne.Container, f filter) { - grid.Objects = nil - grid.Refresh() - idHeader := widget.NewLabelWithStyle(" ID", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - networkHeader := widget.NewLabelWithStyle("Range/Domains", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - resolvedIPsHeader := widget.NewLabelWithStyle("Resolved IPs", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - - grid.Add(idHeader) - grid.Add(networkHeader) - grid.Add(resolvedIPsHeader) - - filteredRoutes, err := s.getFilteredNetworks(f) - if err != nil { - return - } - - sortNetworksByIDs(filteredRoutes) - - for _, route := range filteredRoutes { - r := route - - checkBox := widget.NewCheck(r.GetID(), func(checked bool) { - s.selectNetwork(r.ID, checked) - }) - checkBox.Checked = route.Selected - checkBox.Resize(fyne.NewSize(20, 20)) - checkBox.Refresh() - - grid.Add(checkBox) - network := r.GetRange() - domains := r.GetDomains() - - if len(domains) == 0 { - grid.Add(widget.NewLabel(network)) - grid.Add(widget.NewLabel("")) - continue - } - - // our selectors are only for display - noopFunc := func(_ string) { - // do nothing - } - - domainsSelector := widget.NewSelect(domains, noopFunc) - domainsSelector.Selected = domains[0] - grid.Add(domainsSelector) - - var resolvedIPsList []string - for domain, ipList := range r.GetResolvedIPs() { - resolvedIPsList = append(resolvedIPsList, fmt.Sprintf("%s: %s", domain, strings.Join(ipList.GetIps(), ", "))) - } - - if len(resolvedIPsList) == 0 { - grid.Add(widget.NewLabel("")) - continue - } - - // TODO: limit width within the selector display - resolvedIPsSelector := widget.NewSelect(resolvedIPsList, noopFunc) - resolvedIPsSelector.Selected = resolvedIPsList[0] - resolvedIPsSelector.Resize(fyne.NewSize(100, 100)) - grid.Add(resolvedIPsSelector) - } - - s.wNetworks.Content().Refresh() - grid.Refresh() -} - -func (s *serviceClient) getFilteredNetworks(f filter) ([]*proto.Network, error) { - routes, err := s.fetchNetworks() - if err != nil { - log.Errorf(getClientFMT, err) - s.showError(fmt.Errorf(getClientFMT, err)) - return nil, err - } - switch f { - case overlappingNetworks: - return getOverlappingNetworks(routes), nil - case exitNodeNetworks: - return getExitNodeNetworks(routes), nil - default: - } - return routes, nil -} - -func getOverlappingNetworks(routes []*proto.Network) []*proto.Network { - var filteredRoutes []*proto.Network - existingRange := make(map[string][]*proto.Network) - for _, route := range routes { - if len(route.Domains) > 0 { - continue - } - if r, exists := existingRange[route.GetRange()]; exists { - r = append(r, route) - existingRange[route.GetRange()] = r - } else { - existingRange[route.GetRange()] = []*proto.Network{route} - } - } - for _, r := range existingRange { - if len(r) > 1 { - filteredRoutes = append(filteredRoutes, r...) - } - } - return filteredRoutes -} - -func getExitNodeNetworks(routes []*proto.Network) []*proto.Network { - var filteredRoutes []*proto.Network - for _, route := range routes { - if route.Range == "0.0.0.0/0" { - filteredRoutes = append(filteredRoutes, route) - } - } - return filteredRoutes -} - -func sortNetworksByIDs(routes []*proto.Network) { - sort.Slice(routes, func(i, j int) bool { - return strings.ToLower(routes[i].GetID()) < strings.ToLower(routes[j].GetID()) - }) -} - -func (s *serviceClient) fetchNetworks() ([]*proto.Network, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf(getClientFMT, err) - } - - resp, err := conn.ListNetworks(s.ctx, &proto.ListNetworksRequest{}) - if err != nil { - return nil, fmt.Errorf("failed to list routes: %v", err) - } - - return resp.Routes, nil -} - -func (s *serviceClient) selectNetwork(id string, checked bool) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - s.showError(fmt.Errorf(getClientFMT, err)) - return - } - - req := &proto.SelectNetworksRequest{ - NetworkIDs: []string{id}, - Append: checked, - } - - if checked { - if _, err := conn.SelectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to select network: %v", err) - s.showError(fmt.Errorf("failed to select network: %v", err)) - return - } - log.Infof("Network '%s' selected", id) - } else { - if _, err := conn.DeselectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to deselect network: %v", err) - s.showError(fmt.Errorf("failed to deselect network: %v", err)) - return - } - log.Infof("Network '%s' deselected", id) - } -} - -func (s *serviceClient) selectAllFilteredNetworks(f filter) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - return - } - - req := s.getNetworksRequest(f, true) - if _, err := conn.SelectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to select all networks: %v", err) - s.showError(fmt.Errorf("failed to select all networks: %v", err)) - return - } - - log.Debug("All networks selected") -} - -func (s *serviceClient) deselectAllFilteredNetworks(f filter) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - return - } - - req := s.getNetworksRequest(f, false) - if _, err := conn.DeselectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to deselect all networks: %v", err) - s.showError(fmt.Errorf("failed to deselect all networks: %v", err)) - return - } - - log.Debug("All networks deselected") -} - -func (s *serviceClient) getNetworksRequest(f filter, appendRoute bool) *proto.SelectNetworksRequest { - req := &proto.SelectNetworksRequest{} - if f == allNetworks { - req.All = true - } else { - routes, err := s.getFilteredNetworks(f) - if err != nil { - return nil - } - for _, route := range routes { - req.NetworkIDs = append(req.NetworkIDs, route.GetID()) - } - req.Append = appendRoute - } - return req -} - -func (s *serviceClient) showError(err error) { - wrappedMessage := wrapText(err.Error(), 50) - - dialog.ShowError(fmt.Errorf("%s", wrappedMessage), s.wNetworks) -} - -func (s *serviceClient) startAutoRefresh(interval time.Duration, tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) { - ticker := time.NewTicker(interval) - go func() { - for range ticker.C { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodesGrid) - } - }() - - s.wNetworks.SetOnClosed(func() { - ticker.Stop() - s.cancel() - }) -} - -func (s *serviceClient) updateNetworksBasedOnDisplayTab(tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) { - grid, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodesGrid) - s.wNetworks.Content().Refresh() - s.updateNetworks(grid, f) -} - -// startExitNodeRefresh initiates exit node menu refresh after connecting. -// On Windows, TrayOpenedCh is not supported by the systray library, so we use -// a background poller to keep exit nodes in sync while connected. -// On macOS/Linux, TrayOpenedCh handles refreshes on each tray open. -func (s *serviceClient) startExitNodeRefresh() { - s.cancelExitNodeRetry() - - if runtime.GOOS == "windows" { - ctx, cancel := context.WithCancel(s.ctx) - s.exitNodeMu.Lock() - s.exitNodeRetryCancel = cancel - s.exitNodeMu.Unlock() - - go s.pollExitNodes(ctx) - } else { - go s.updateExitNodes() - } -} - -func (s *serviceClient) cancelExitNodeRetry() { - s.exitNodeMu.Lock() - if s.exitNodeRetryCancel != nil { - s.exitNodeRetryCancel() - s.exitNodeRetryCancel = nil - } - s.exitNodeMu.Unlock() -} - -// pollExitNodes periodically refreshes exit nodes while connected. -// Uses a short initial interval to catch routes from the management sync, -// then switches to a longer interval for ongoing updates. -func (s *serviceClient) pollExitNodes(ctx context.Context) { - // Initial fast polling to catch routes as they appear after connect. - for i := 0; i < 5; i++ { - if s.updateExitNodes() { - break - } - select { - case <-ctx.Done(): - return - case <-time.After(2 * time.Second): - } - } - - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - s.updateExitNodes() - } - } -} - -// updateExitNodes fetches exit nodes from the daemon and recreates the menu. -// Returns true if exit nodes were found. -func (s *serviceClient) updateExitNodes() bool { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return false - } - exitNodes, err := s.getExitNodes(conn) - if err != nil { - log.Errorf("get exit nodes: %v", err) - return false - } - - s.exitNodeMu.Lock() - defer s.exitNodeMu.Unlock() - - s.recreateExitNodeMenu(exitNodes) - - if len(s.mExitNodeItems) > 0 { - s.mExitNode.Enable() - return true - } - - s.mExitNode.Disable() - return false -} - -func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) { - for _, node := range s.mExitNodeItems { - node.cancel() - node.Hide() - node.Remove() - } - s.mExitNodeItems = nil - if s.mExitNodeSeparator != nil { - s.mExitNodeSeparator.Remove() - s.mExitNodeSeparator = nil - } - if s.mExitNodeDeselectAll != nil { - s.mExitNodeDeselectAll.Remove() - s.mExitNodeDeselectAll = nil - } - - if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" { - s.mExitNode.Remove() - s.mExitNode = systray.AddMenuItem("Exit Node", disabledMenuDescr) - } - - var showDeselectAll bool - - for _, node := range exitNodes { - if node.Selected { - showDeselectAll = true - } - - menuItem := s.mExitNode.AddSubMenuItemCheckbox( - node.ID, - fmt.Sprintf("Use exit node %s", node.ID), - node.Selected, - ) - - ctx, cancel := context.WithCancel(s.ctx) - s.mExitNodeItems = append(s.mExitNodeItems, menuHandler{ - MenuItem: menuItem, - cancel: cancel, - }) - go s.handleChecked(ctx, node.ID, menuItem) - } - - if showDeselectAll { - s.addExitNodeDeselectAll() - } - -} - -func (s *serviceClient) addExitNodeDeselectAll() { - sep := s.mExitNode.AddSubMenuItem("───────────────", "") - sep.Disable() - s.mExitNodeSeparator = sep - - deselectAllItem := s.mExitNode.AddSubMenuItem("Deselect All", "Deselect All") - s.mExitNodeDeselectAll = deselectAllItem - - go func() { - for { - _, ok := <-deselectAllItem.ClickedCh - if !ok { - return - } - exitNodes, err := s.handleExitNodeMenuDeselectAll() - if err != nil { - log.Warnf("failed to handle deselect all exit nodes: %v", err) - } else { - s.exitNodeMu.Lock() - s.recreateExitNodeMenu(exitNodes) - s.exitNodeMu.Unlock() - } - } - }() -} - -func (s *serviceClient) getExitNodes(conn proto.DaemonServiceClient) ([]*proto.Network, error) { - ctx, cancel := context.WithTimeout(s.ctx, defaultFailTimeout) - defer cancel() - - resp, err := conn.ListNetworks(ctx, &proto.ListNetworksRequest{}) - if err != nil { - return nil, fmt.Errorf("list networks: %v", err) - } - - var exitNodes []*proto.Network - for _, network := range resp.Routes { - if network.Range == "0.0.0.0/0" { - exitNodes = append(exitNodes, network) - } - } - return exitNodes, nil -} - -func (s *serviceClient) handleChecked(ctx context.Context, id string, item *systray.MenuItem) { - for { - select { - case <-ctx.Done(): - return - case _, ok := <-item.ClickedCh: - if !ok { - return - } - if err := s.toggleExitNode(id, item); err != nil { - log.Errorf("failed to toggle exit node: %v", err) - continue - } - } - } -} - -func (s *serviceClient) handleExitNodeMenuDeselectAll() ([]*proto.Network, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf("get client: %v", err) - } - - exitNodes, err := s.getExitNodes(conn) - if err != nil { - return nil, fmt.Errorf("get exit nodes: %v", err) - } - - var ids []string - for _, e := range exitNodes { - if e.Selected { - ids = append(ids, e.ID) - } - } - - // deselect selected exit nodes - if err := s.deselectOtherExitNodes(conn, ids); err != nil { - return nil, err - } - - updatedExitNodes, err := s.getExitNodes(conn) - if err != nil { - return nil, fmt.Errorf("re-fetch exit nodes: %v", err) - } - - return updatedExitNodes, nil -} - -// Add function to toggle exit node selection -func (s *serviceClient) toggleExitNode(nodeID string, item *systray.MenuItem) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("get client: %v", err) - } - - log.Infof("Toggling exit node '%s'", nodeID) - - s.exitNodeMu.Lock() - defer s.exitNodeMu.Unlock() - - exitNodes, err := s.getExitNodes(conn) - if err != nil { - return fmt.Errorf("get exit nodes: %v", err) - } - - var exitNode *proto.Network - // find other selected nodes and ours - ids := make([]string, 0, len(exitNodes)) - for _, node := range exitNodes { - if node.ID == nodeID { - // preserve original state - cp := *node //nolint:govet - exitNode = &cp - - // set desired state for recreation - node.Selected = true - continue - } - if node.Selected { - ids = append(ids, node.ID) - - // set desired state for recreation - node.Selected = false - } - } - - // exit node is the only selected node, deselect it - deselectAll := item.Checked() && len(ids) == 0 - if deselectAll { - ids = append(ids, nodeID) - for _, node := range exitNodes { - if node.ID == nodeID { - // set desired state for recreation - node.Selected = false - } - } - } - - // deselect all other selected exit nodes - if err := s.deselectOtherExitNodes(conn, ids); err != nil { - return err - } - - if !deselectAll { - if err := s.selectNewExitNode(conn, exitNode, nodeID, item); err != nil { - return err - } - } - - // linux/bsd doesn't handle Check/Uncheck well, so we recreate the menu - if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" { - s.recreateExitNodeMenu(exitNodes) - } - - return nil -} - -func (s *serviceClient) deselectOtherExitNodes(conn proto.DaemonServiceClient, ids []string) error { - // deselect all other selected exit nodes - if len(ids) > 0 { - deselectReq := &proto.SelectNetworksRequest{ - NetworkIDs: ids, - } - if _, err := conn.DeselectNetworks(s.ctx, deselectReq); err != nil { - return fmt.Errorf("deselect networks: %v", err) - } - - log.Infof("Deselected exit nodes: %v", ids) - } - - // uncheck all other exit node menu items - for _, i := range s.mExitNodeItems { - i.Uncheck() - log.Infof("Unchecked exit node %v", i) - } - - return nil -} - -func (s *serviceClient) selectNewExitNode(conn proto.DaemonServiceClient, exitNode *proto.Network, nodeID string, item *systray.MenuItem) error { - if exitNode != nil && !exitNode.Selected { - selectReq := &proto.SelectNetworksRequest{ - NetworkIDs: []string{exitNode.ID}, - Append: true, - } - if _, err := conn.SelectNetworks(s.ctx, selectReq); err != nil { - return fmt.Errorf("select network: %v", err) - } - - log.Infof("Selected exit node '%s'", nodeID) - } - - item.Check() - log.Infof("Checked exit node '%s'", nodeID) - - return nil -} - -func getGridAndFilterFromTab(tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) (*fyne.Container, filter) { - switch tabs.Selected().Text { - case overlappingNetworksText: - return overlappingGrid, overlappingNetworks - case exitNodeNetworksText: - return exitNodesGrid, exitNodeNetworks - default: - return allGrid, allNetworks - } -} - -// wrapText inserts newlines into the text to ensure that each line is -// no longer than 'lineLength' runes. -func wrapText(text string, lineLength int) string { - var sb strings.Builder - var currentLineLength int - - for _, runeValue := range text { - sb.WriteRune(runeValue) - currentLineLength++ - - if currentLineLength >= lineLength || runeValue == '\n' { - sb.WriteRune('\n') - currentLineLength = 0 - } - } - - return sb.String() -} diff --git a/client/ui/notifier/notifier.go b/client/ui/notifier/notifier.go deleted file mode 100644 index 8d1cbe4c4..000000000 --- a/client/ui/notifier/notifier.go +++ /dev/null @@ -1,27 +0,0 @@ -// Package notifier sends desktop notifications. On Windows it uses the WinRT -// COM API directly via go-toast/v2 to avoid the PowerShell window flash that -// fyne's default implementation produces. On other platforms it delegates to -// fyne. -package notifier - -import "fyne.io/fyne/v2" - -// Notifier sends desktop notifications. -type Notifier interface { - Send(title, body string) -} - -// New returns a platform-specific Notifier. The fyne app is used as the -// fallback notifier on platforms where no native implementation is wired up, -// and on Windows when the COM path fails to initialize. -func New(app fyne.App) Notifier { - return newNotifier(app) -} - -type fyneNotifier struct { - app fyne.App -} - -func (f *fyneNotifier) Send(title, body string) { - f.app.SendNotification(fyne.NewNotification(title, body)) -} diff --git a/client/ui/notifier/notifier_other.go b/client/ui/notifier/notifier_other.go deleted file mode 100644 index 686d2885f..000000000 --- a/client/ui/notifier/notifier_other.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !windows - -package notifier - -import "fyne.io/fyne/v2" - -func newNotifier(app fyne.App) Notifier { - return &fyneNotifier{app: app} -} diff --git a/client/ui/notifier/notifier_windows.go b/client/ui/notifier/notifier_windows.go deleted file mode 100644 index c7afb43ae..000000000 --- a/client/ui/notifier/notifier_windows.go +++ /dev/null @@ -1,88 +0,0 @@ -package notifier - -import ( - "os" - "path/filepath" - "sync" - - "fyne.io/fyne/v2" - toast "git.sr.ht/~jackmordaunt/go-toast/v2" - "git.sr.ht/~jackmordaunt/go-toast/v2/wintoast" - log "github.com/sirupsen/logrus" -) - -const ( - // appID is the AppUserModelID shown in the Windows Action Center. It - // must match the System.AppUserModel.ID property set on the Start Menu - // shortcut by the MSI (see client/netbird.wxs); otherwise Windows - // groups toasts under a separate, unbranded entry. - appID = "NetBird" - - // appGUID identifies the COM activation callback class. Generated once - // for NetBird; do not change without coordinating an installer bump, - // since old registry entries pointing at the previous GUID would orphan. - appGUID = "{0E1B4DE7-E148-432B-9814-544F941826EC}" -) - -type comNotifier struct { - fallback *fyneNotifier - ready bool - iconPath string -} - -var ( - initOnce sync.Once - initErr error -) - -func newNotifier(app fyne.App) Notifier { - n := &comNotifier{ - fallback: &fyneNotifier{app: app}, - iconPath: resolveIcon(), - } - initOnce.Do(func() { - initErr = wintoast.SetAppData(wintoast.AppData{ - AppID: appID, - GUID: appGUID, - IconPath: n.iconPath, - }) - }) - if initErr != nil { - log.Warnf("toast: register app data failed, falling back to fyne notifications: %v", initErr) - return n.fallback - } - n.ready = true - return n -} - -func (n *comNotifier) Send(title, body string) { - if !n.ready { - n.fallback.Send(title, body) - return - } - notification := toast.Notification{ - AppID: appID, - Title: title, - Body: body, - Icon: n.iconPath, - } - if err := notification.Push(); err != nil { - log.Warnf("toast: push failed, using fyne fallback: %v", err) - n.fallback.Send(title, body) - } -} - -// resolveIcon returns an absolute path to the toast icon, or an empty string -// when no icon can be located. Windows requires a PNG/JPG for the -// AppUserModelId IconUri registry value; .ico is silently ignored. -func resolveIcon() string { - exe, err := os.Executable() - if err != nil { - return "" - } - candidate := filepath.Join(filepath.Dir(exe), "netbird.png") - if _, err := os.Stat(candidate); err == nil { - return candidate - } - return "" -} diff --git a/client/ui/process/process.go b/client/ui/process/process.go deleted file mode 100644 index 28276f416..000000000 --- a/client/ui/process/process.go +++ /dev/null @@ -1,38 +0,0 @@ -package process - -import ( - "os" - "path/filepath" - "strings" - - "github.com/shirou/gopsutil/v3/process" -) - -func IsAnotherProcessRunning() (int32, bool, error) { - processes, err := process.Processes() - if err != nil { - return 0, false, err - } - - pid := os.Getpid() - processName := strings.ToLower(filepath.Base(os.Args[0])) - - for _, p := range processes { - if int(p.Pid) == pid { - continue - } - - runningProcessPath, err := p.Exe() - // most errors are related to short-lived processes - if err != nil { - continue - } - - runningProcessName := strings.ToLower(filepath.Base(runningProcessPath)) - if runningProcessName == processName && isProcessOwnedByCurrentUser(p) { - return p.Pid, true, nil - } - } - - return 0, false, nil -} diff --git a/client/ui/process/process_nonwindows.go b/client/ui/process/process_nonwindows.go deleted file mode 100644 index cf9f6443d..000000000 --- a/client/ui/process/process_nonwindows.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build !windows - -package process - -import ( - "os" - - "github.com/shirou/gopsutil/v3/process" - log "github.com/sirupsen/logrus" -) - -func isProcessOwnedByCurrentUser(p *process.Process) bool { - currentUserID := os.Getuid() - uids, err := p.Uids() - if err != nil { - log.Errorf("get process uids: %v", err) - return false - } - for _, id := range uids { - log.Debugf("checking process uid: %d", id) - if int(id) == currentUserID { - return true - } - } - return false -} diff --git a/client/ui/process/process_windows.go b/client/ui/process/process_windows.go deleted file mode 100644 index 2d211d1a4..000000000 --- a/client/ui/process/process_windows.go +++ /dev/null @@ -1,24 +0,0 @@ -package process - -import ( - "os/user" - - "github.com/shirou/gopsutil/v3/process" - log "github.com/sirupsen/logrus" -) - -func isProcessOwnedByCurrentUser(p *process.Process) bool { - processUsername, err := p.Username() - if err != nil { - log.Errorf("get process username error: %v", err) - return false - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user error: %v", err) - return false - } - - return processUsername == currUser.Username -} diff --git a/client/ui/profile.go b/client/ui/profile.go deleted file mode 100644 index 7ee89e631..000000000 --- a/client/ui/profile.go +++ /dev/null @@ -1,719 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "os/user" - "slices" - "sort" - "sync" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/proto" -) - -// showProfilesUI creates and displays the Profiles window with a list of existing profiles, -// a button to add new profiles, allows removal, and lets the user switch the active profile. -func (s *serviceClient) showProfilesUI() { - - profiles, err := s.getProfiles() - if err != nil { - log.Errorf("get profiles: %v", err) - return - } - - var refresh func() - // List widget for profiles - list := widget.NewList( - func() int { return len(profiles) }, - func() fyne.CanvasObject { - // Each item: Selected indicator, Name, spacer, Select, Logout & Remove buttons - return container.NewHBox( - widget.NewLabel(""), // indicator - widget.NewLabel(""), // profile name - layout.NewSpacer(), - widget.NewButton("Select", nil), - widget.NewButton("Deregister", nil), - widget.NewButton("Remove", nil), - ) - }, - func(i widget.ListItemID, item fyne.CanvasObject) { - // Populate each row - row := item.(*fyne.Container) - indicator := row.Objects[0].(*widget.Label) - nameLabel := row.Objects[1].(*widget.Label) - selectBtn := row.Objects[3].(*widget.Button) - logoutBtn := row.Objects[4].(*widget.Button) - removeBtn := row.Objects[5].(*widget.Button) - - profile := profiles[i] - // Show a checkmark if selected - if profile.IsActive { - indicator.SetText("✓") - } else { - indicator.SetText("") - } - nameLabel.SetText(profile.Name) - - // Configure Select/Active button - selectBtn.SetText(func() string { - if profile.IsActive { - return "Active" - } - return "Select" - }()) - selectBtn.OnTapped = func() { - if profile.IsActive { - return // already active - } - // confirm switch - dialog.ShowConfirm( - "Switch Profile", - fmt.Sprintf("Are you sure you want to switch to '%s'?", profile.Name), - func(confirm bool) { - if !confirm { - return - } - // switch - err = s.switchProfile(profile.Name) - if err != nil { - log.Errorf("failed to switch profile: %v", err) - dialog.ShowError(errors.New("failed to select profile"), s.wProfiles) - return - } - - dialog.ShowInformation( - "Profile Switched", - fmt.Sprintf("Profile '%s' switched successfully", profile.Name), - s.wProfiles, - ) - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("failed to get status after switching profile: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - if err := s.menuDownClick(); err != nil { - log.Errorf("failed to handle down click after switching profile: %v", err) - dialog.ShowError(fmt.Errorf("failed to handle down click"), s.wProfiles) - return - } - } - // update slice flags - refresh() - }, - s.wProfiles, - ) - } - - logoutBtn.Show() - logoutBtn.SetText("Deregister") - logoutBtn.OnTapped = func() { - s.handleProfileLogout(profile.Name, refresh) - } - - // Remove profile - removeBtn.SetText("Remove") - removeBtn.OnTapped = func() { - dialog.ShowConfirm( - "Delete Profile", - fmt.Sprintf("Are you sure you want to delete '%s'?", profile.Name), - func(confirm bool) { - if !confirm { - return - } - - err = s.removeProfile(profile.Name) - if err != nil { - log.Errorf("failed to remove profile: %v", err) - dialog.ShowError(fmt.Errorf("failed to remove profile"), s.wProfiles) - return - } - dialog.ShowInformation( - "Profile Removed", - fmt.Sprintf("Profile '%s' removed successfully", profile.Name), - s.wProfiles, - ) - // update slice - refresh() - }, - s.wProfiles, - ) - } - }, - ) - - refresh = func() { - newProfiles, err := s.getProfiles() - if err != nil { - dialog.ShowError(err, s.wProfiles) - return - } - profiles = newProfiles // update the slice - list.Refresh() // tell Fyne to re-call length/update on every visible row - } - - // Button to add a new profile - newBtn := widget.NewButton("New Profile", func() { - nameEntry := widget.NewEntry() - nameEntry.SetPlaceHolder("Enter Profile Name") - - formItems := []*widget.FormItem{{Text: "Name:", Widget: nameEntry}} - dlg := dialog.NewForm( - "New Profile", - "Create", - "Cancel", - formItems, - func(confirm bool) { - if !confirm { - return - } - name := nameEntry.Text - if name == "" { - dialog.ShowError(errors.New("profile name cannot be empty"), s.wProfiles) - return - } - - // add profile - err = s.addProfile(name) - if err != nil { - log.Errorf("failed to create profile: %v", err) - dialog.ShowError(fmt.Errorf("failed to create profile"), s.wProfiles) - return - } - dialog.ShowInformation( - "Profile Created", - fmt.Sprintf("Profile '%s' created successfully", name), - s.wProfiles, - ) - // update slice - refresh() - }, - s.wProfiles, - ) - // make dialog wider - dlg.Resize(fyne.NewSize(350, 150)) - dlg.Show() - }) - - // Assemble window content - content := container.NewBorder(nil, newBtn, nil, nil, list) - s.wProfiles = s.app.NewWindow("NetBird Profiles") - s.wProfiles.SetContent(content) - s.wProfiles.Resize(fyne.NewSize(400, 300)) - s.wProfiles.SetOnClosed(s.cancel) - - s.wProfiles.Show() -} - -func (s *serviceClient) addProfile(profileName string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - _, err = conn.AddProfile(s.ctx, &proto.AddProfileRequest{ - ProfileName: profileName, - Username: currUser.Username, - }) - - if err != nil { - return fmt.Errorf("add profile: %w", err) - } - - return nil -} - -func (s *serviceClient) switchProfile(profileName string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - if _, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{ - ProfileName: &profileName, - Username: &currUser.Username, - }); err != nil { - return fmt.Errorf("switch profile failed: %w", err) - } - - err = s.profileManager.SwitchProfile(profileName) - if err != nil { - return fmt.Errorf("switch profile: %w", err) - } - - return nil -} - -func (s *serviceClient) removeProfile(profileName string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - _, err = conn.RemoveProfile(s.ctx, &proto.RemoveProfileRequest{ - ProfileName: profileName, - Username: currUser.Username, - }) - if err != nil { - return fmt.Errorf("remove profile: %w", err) - } - - return nil -} - -type Profile struct { - Name string - IsActive bool -} - -func (s *serviceClient) getProfiles() ([]Profile, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - profilesResp, err := conn.ListProfiles(s.ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, - }) - if err != nil { - return nil, fmt.Errorf("list profiles: %w", err) - } - - var profiles []Profile - - for _, profile := range profilesResp.Profiles { - profiles = append(profiles, Profile{ - Name: profile.Name, - IsActive: profile.IsActive, - }) - } - - return profiles, nil -} - -func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback func()) { - dialog.ShowConfirm( - "Deregister", - fmt.Sprintf("Are you sure you want to deregister from '%s'?", profileName), - func(confirm bool) { - if !confirm { - return - } - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get service client: %v", err) - dialog.ShowError(fmt.Errorf("failed to connect to service"), s.wProfiles) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("failed to get current user: %v", err) - dialog.ShowError(fmt.Errorf("failed to get current user"), s.wProfiles) - return - } - - username := currUser.Username - _, err = conn.Logout(s.ctx, &proto.LogoutRequest{ - ProfileName: &profileName, - Username: &username, - }) - if err != nil { - log.Errorf("logout failed: %v", err) - dialog.ShowError(fmt.Errorf("deregister failed"), s.wProfiles) - return - } - - dialog.ShowInformation( - "Deregistered", - fmt.Sprintf("Successfully deregistered from '%s'", profileName), - s.wProfiles, - ) - - refreshCallback() - }, - s.wProfiles, - ) -} - -type subItem struct { - *systray.MenuItem - ctx context.Context - cancel context.CancelFunc -} - -type profileMenu struct { - mu sync.Mutex - ctx context.Context - serviceClient *serviceClient - profileManager *profilemanager.ProfileManager - eventHandler *eventHandler - profileMenuItem *systray.MenuItem - emailMenuItem *systray.MenuItem - profileSubItems []*subItem - manageProfilesSubItem *subItem - logoutSubItem *subItem - profilesState []Profile - downClickCallback func() error - upClickCallback func(context.Context) error - getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error) - loadSettingsCallback func() - app fyne.App -} - -type newProfileMenuArgs struct { - ctx context.Context - serviceClient *serviceClient - profileManager *profilemanager.ProfileManager - eventHandler *eventHandler - profileMenuItem *systray.MenuItem - emailMenuItem *systray.MenuItem - downClickCallback func() error - upClickCallback func(context.Context) error - getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error) - loadSettingsCallback func() - app fyne.App -} - -func newProfileMenu(args newProfileMenuArgs) *profileMenu { - p := profileMenu{ - ctx: args.ctx, - serviceClient: args.serviceClient, - profileManager: args.profileManager, - eventHandler: args.eventHandler, - profileMenuItem: args.profileMenuItem, - emailMenuItem: args.emailMenuItem, - downClickCallback: args.downClickCallback, - upClickCallback: args.upClickCallback, - getSrvClientCallback: args.getSrvClientCallback, - loadSettingsCallback: args.loadSettingsCallback, - app: args.app, - } - - p.emailMenuItem.Disable() - p.emailMenuItem.Hide() - p.refresh() - go p.updateMenu() - - return &p -} - -func (p *profileMenu) getProfiles() ([]Profile, error) { - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf(getClientFMT, err) - } - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - profilesResp, err := conn.ListProfiles(p.ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, - }) - if err != nil { - return nil, fmt.Errorf("list profiles: %w", err) - } - - var profiles []Profile - - for _, profile := range profilesResp.Profiles { - profiles = append(profiles, Profile{ - Name: profile.Name, - IsActive: profile.IsActive, - }) - } - - return profiles, nil -} - -func (p *profileMenu) refresh() { - p.mu.Lock() - defer p.mu.Unlock() - - profiles, err := p.getProfiles() - if err != nil { - log.Errorf("failed to list profiles: %v", err) - return - } - - // Clear existing profile items - p.clear(profiles) - - currUser, err := user.Current() - if err != nil { - log.Errorf("failed to get current user: %v", err) - return - } - - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - activeProf, err := conn.GetActiveProfile(p.ctx, &proto.GetActiveProfileRequest{}) - if err != nil { - log.Errorf("failed to get active profile: %v", err) - return - } - - if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username { - activeProfState, err := p.profileManager.GetProfileState(activeProf.ProfileName) - if err != nil { - log.Warnf("failed to get active profile state: %v", err) - p.emailMenuItem.Hide() - } else if activeProfState.Email != "" { - p.emailMenuItem.SetTitle(fmt.Sprintf("(%s)", activeProfState.Email)) - p.emailMenuItem.Show() - } - } - - for _, profile := range profiles { - item := p.profileMenuItem.AddSubMenuItem(profile.Name, "") - if profile.IsActive { - item.Check() - } - - ctx, cancel := context.WithCancel(context.Background()) - p.profileSubItems = append(p.profileSubItems, &subItem{item, ctx, cancel}) - - go func() { - for { - select { - case <-ctx.Done(): - return // context cancelled - case _, ok := <-item.ClickedCh: - if !ok { - return // channel closed - } - - // Handle profile selection - if profile.IsActive { - log.Infof("Profile '%s' is already active", profile.Name) - return - } - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - _, err = conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{ - ProfileName: &profile.Name, - Username: &currUser.Username, - }) - if err != nil { - log.Errorf("failed to switch profile: %v", err) - // show notification dialog - p.serviceClient.notifier.Send("Error", "Failed to switch profile") - return - } - - err = p.profileManager.SwitchProfile(profile.Name) - if err != nil { - log.Errorf("failed to switch profile '%s': %v", profile.Name, err) - return - } - - log.Infof("Switched to profile '%s'", profile.Name) - - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("failed to get status after switching profile: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - if err := p.downClickCallback(); err != nil { - log.Errorf("failed to handle down click after switching profile: %v", err) - } - } - - if p.serviceClient.connectCancel != nil { - p.serviceClient.connectCancel() - } - - connectCtx, connectCancel := context.WithCancel(p.ctx) - p.serviceClient.connectCancel = connectCancel - - if err := p.upClickCallback(connectCtx); err != nil { - log.Errorf("failed to handle up click after switching profile: %v", err) - } - - connectCancel() - - p.refresh() - p.loadSettingsCallback() - } - } - }() - - } - ctx, cancel := context.WithCancel(context.Background()) - manageItem := p.profileMenuItem.AddSubMenuItem("Manage Profiles", "") - p.manageProfilesSubItem = &subItem{manageItem, ctx, cancel} - - go func() { - for { - select { - case <-ctx.Done(): - return - case _, ok := <-manageItem.ClickedCh: - if !ok { - return - } - p.eventHandler.runSelfCommand(p.ctx, "profiles", "true") - p.refresh() - p.loadSettingsCallback() - } - } - }() - - // Add Logout menu item - ctx2, cancel2 := context.WithCancel(context.Background()) - logoutItem := p.profileMenuItem.AddSubMenuItem("Deregister", "") - p.logoutSubItem = &subItem{logoutItem, ctx2, cancel2} - - go func() { - for { - select { - case <-ctx2.Done(): - return - case _, ok := <-logoutItem.ClickedCh: - if !ok { - return - } - if err := p.eventHandler.logout(p.ctx); err != nil { - log.Errorf("logout failed: %v", err) - p.serviceClient.notifier.Send("Error", "Failed to deregister") - } else { - p.serviceClient.notifier.Send("Success", "Deregistered successfully") - } - } - } - }() - - if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username { - p.profileMenuItem.SetTitle(activeProf.ProfileName) - } else { - p.profileMenuItem.SetTitle(fmt.Sprintf("Profile: %s (User: %s)", activeProf.ProfileName, activeProf.Username)) - p.emailMenuItem.Hide() - } - -} - -func (p *profileMenu) clear(profiles []Profile) { - for _, item := range p.profileSubItems { - item.Remove() - item.cancel() - } - p.profileSubItems = make([]*subItem, 0, len(profiles)) - p.profilesState = profiles - - if p.manageProfilesSubItem != nil { - p.manageProfilesSubItem.Remove() - p.manageProfilesSubItem.cancel() - p.manageProfilesSubItem = nil - } - - if p.logoutSubItem != nil { - p.logoutSubItem.Remove() - p.logoutSubItem.cancel() - p.logoutSubItem = nil - } -} - -// setEnabled enables or disables the profile menu based on the provided state -func (p *profileMenu) setEnabled(enabled bool) { - if p.profileMenuItem != nil { - if enabled { - p.profileMenuItem.Enable() - p.profileMenuItem.SetTooltip("") - } else { - p.profileMenuItem.Hide() - p.profileMenuItem.SetTooltip("Profiles are disabled by daemon") - } - } -} - -func (p *profileMenu) updateMenu() { - // check every second - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // get profilesList - profiles, err := p.getProfiles() - if err != nil { - log.Errorf("failed to list profiles: %v", err) - continue - } - - sort.Slice(profiles, func(i, j int) bool { - return profiles[i].Name < profiles[j].Name - }) - - p.mu.Lock() - state := p.profilesState - p.mu.Unlock() - - sort.Slice(state, func(i, j int) bool { - return state[i].Name < state[j].Name - }) - - if slices.Equal(profiles, state) { - continue - } - - p.refresh() - case <-p.ctx.Done(): - return // context cancelled - - } - } -} diff --git a/client/ui/quickactions.go b/client/ui/quickactions.go deleted file mode 100644 index bf47ac434..000000000 --- a/client/ui/quickactions.go +++ /dev/null @@ -1,349 +0,0 @@ -//go:build !(linux && 386) - -//go:generate fyne bundle -o quickactions_assets.go assets/connected.png -//go:generate fyne bundle -o quickactions_assets.go -append assets/disconnected.png -package main - -import ( - "context" - _ "embed" - "fmt" - "runtime" - "sync/atomic" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/proto" -) - -type quickActionsUiState struct { - connectionStatus string - isToggleButtonEnabled bool - isConnectionChanged bool - toggleAction func() -} - -func newQuickActionsUiState() quickActionsUiState { - return quickActionsUiState{ - connectionStatus: string(internal.StatusIdle), - isToggleButtonEnabled: false, - isConnectionChanged: false, - } -} - -type clientConnectionStatusProvider interface { - connectionStatus(ctx context.Context) (string, error) -} - -type daemonClientConnectionStatusProvider struct { - client proto.DaemonServiceClient -} - -func (d daemonClientConnectionStatusProvider) connectionStatus(ctx context.Context) (string, error) { - childCtx, cancel := context.WithTimeout(ctx, 400*time.Millisecond) - defer cancel() - status, err := d.client.Status(childCtx, &proto.StatusRequest{}) - if err != nil { - return "", err - } - - return status.Status, nil -} - -type clientCommand interface { - execute() error -} - -type connectCommand struct { - connectClient func() error -} - -func (c connectCommand) execute() error { - return c.connectClient() -} - -type disconnectCommand struct { - disconnectClient func() error -} - -func (c disconnectCommand) execute() error { - return c.disconnectClient() -} - -type quickActionsViewModel struct { - provider clientConnectionStatusProvider - connect clientCommand - disconnect clientCommand - uiChan chan quickActionsUiState - isWatchingConnectionStatus atomic.Bool -} - -func newQuickActionsViewModel(ctx context.Context, provider clientConnectionStatusProvider, connect, disconnect clientCommand, uiChan chan quickActionsUiState) { - viewModel := quickActionsViewModel{ - provider: provider, - connect: connect, - disconnect: disconnect, - uiChan: uiChan, - } - - viewModel.isWatchingConnectionStatus.Store(true) - - // base UI status - uiChan <- newQuickActionsUiState() - - // this retrieves the current connection status - // and pushes the UI state that reflects it via uiChan - go viewModel.watchConnectionStatus(ctx) -} - -func (q *quickActionsViewModel) updateUiState(ctx context.Context) { - uiState := newQuickActionsUiState() - connectionStatus, err := q.provider.connectionStatus(ctx) - - if err != nil { - log.Errorf("Status: Error - %v", err) - q.uiChan <- uiState - return - } - - if connectionStatus == string(internal.StatusConnected) { - uiState.toggleAction = func() { - q.executeCommand(q.disconnect) - } - } else { - uiState.toggleAction = func() { - q.executeCommand(q.connect) - } - } - - uiState.isToggleButtonEnabled = true - uiState.connectionStatus = connectionStatus - q.uiChan <- uiState -} - -func (q *quickActionsViewModel) watchConnectionStatus(ctx context.Context) { - ticker := time.NewTicker(1000 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if q.isWatchingConnectionStatus.Load() { - q.updateUiState(ctx) - } - } - } -} - -func (q *quickActionsViewModel) executeCommand(command clientCommand) { - uiState := newQuickActionsUiState() - // newQuickActionsUiState starts with Idle connection status, - // and all that's necessary here is to just disable the toggle button. - uiState.connectionStatus = "" - - q.uiChan <- uiState - - q.isWatchingConnectionStatus.Store(false) - - err := command.execute() - - if err != nil { - log.Errorf("Status: Error - %v", err) - q.isWatchingConnectionStatus.Store(true) - } else { - uiState = newQuickActionsUiState() - uiState.isConnectionChanged = true - q.uiChan <- uiState - } -} - -func getSystemTrayName() string { - os := runtime.GOOS - switch os { - case "darwin": - return "menu bar" - default: - return "system tray" - } -} - -func (s *serviceClient) getNetBirdImage(name string, content []byte) *canvas.Image { - imageSize := fyne.NewSize(64, 64) - - resource := fyne.NewStaticResource(name, content) - image := canvas.NewImageFromResource(resource) - image.FillMode = canvas.ImageFillContain - image.SetMinSize(imageSize) - image.Resize(imageSize) - - return image -} - -type quickActionsUiComponents struct { - content *fyne.Container - toggleConnectionButton *widget.Button - connectedLabelText, disconnectedLabelText string - connectedImage, disconnectedImage *canvas.Image - connectedCircleRes, disconnectedCircleRes fyne.Resource -} - -// applyQuickActionsUiState applies a single UI state to the quick actions window. -// It closes the window and returns true if the connection status has changed, -// in which case the caller should stop processing further states. -func (s *serviceClient) applyQuickActionsUiState( - uiState quickActionsUiState, - components quickActionsUiComponents, -) bool { - if uiState.isConnectionChanged { - fyne.DoAndWait(func() { - s.wQuickActions.Close() - }) - return true - } - - var logo *canvas.Image - var buttonText string - var buttonIcon fyne.Resource - - if uiState.connectionStatus == string(internal.StatusConnected) { - buttonText = components.connectedLabelText - buttonIcon = components.connectedCircleRes - logo = components.connectedImage - } else if uiState.connectionStatus == string(internal.StatusIdle) { - buttonText = components.disconnectedLabelText - buttonIcon = components.disconnectedCircleRes - logo = components.disconnectedImage - } - - fyne.DoAndWait(func() { - if buttonText != "" { - components.toggleConnectionButton.SetText(buttonText) - } - - if buttonIcon != nil { - components.toggleConnectionButton.SetIcon(buttonIcon) - } - - if uiState.isToggleButtonEnabled { - components.toggleConnectionButton.Enable() - } else { - components.toggleConnectionButton.Disable() - } - - components.toggleConnectionButton.OnTapped = func() { - if uiState.toggleAction != nil { - go uiState.toggleAction() - } - } - - components.toggleConnectionButton.Refresh() - - // the second position in the content's object array is the NetBird logo. - if logo != nil { - components.content.Objects[1] = logo - components.content.Refresh() - } - }) - - return false -} - -// showQuickActionsUI displays a simple window with the NetBird logo and a connection toggle button. -func (s *serviceClient) showQuickActionsUI() { - s.wQuickActions = s.app.NewWindow("NetBird") - vmCtx, vmCancel := context.WithCancel(s.ctx) - s.wQuickActions.SetOnClosed(vmCancel) - - client, err := s.getSrvClient(defaultFailTimeout) - - connCmd := connectCommand{ - connectClient: func() error { - return s.menuUpClick(s.ctx) - }, - } - - disConnCmd := disconnectCommand{ - disconnectClient: func() error { - return s.menuDownClick() - }, - } - - if err != nil { - log.Errorf("get service client: %v", err) - return - } - - uiChan := make(chan quickActionsUiState, 1) - newQuickActionsViewModel(vmCtx, daemonClientConnectionStatusProvider{client: client}, connCmd, disConnCmd, uiChan) - - connectedImage := s.getNetBirdImage("netbird.png", iconAbout) - disconnectedImage := s.getNetBirdImage("netbird-disconnected.png", iconAboutDisconnected) - - connectedCircle := canvas.NewImageFromResource(resourceConnectedPng) - disconnectedCircle := canvas.NewImageFromResource(resourceDisconnectedPng) - - connectedLabelText := "Disconnect" - disconnectedLabelText := "Connect" - - toggleConnectionButton := widget.NewButtonWithIcon(disconnectedLabelText, disconnectedCircle.Resource, func() { - // This button's tap function will be set when an ui state arrives via the uiChan channel. - }) - - // Button starts disabled until the first ui state arrives. - toggleConnectionButton.Disable() - - hintLabelText := fmt.Sprintf("You can always access NetBird from your %s.", getSystemTrayName()) - hintLabel := widget.NewLabel(hintLabelText) - - content := container.NewVBox( - layout.NewSpacer(), - disconnectedImage, - layout.NewSpacer(), - container.NewCenter(toggleConnectionButton), - layout.NewSpacer(), - container.NewCenter(hintLabel), - ) - - // this watches for ui state updates. - go func() { - - for { - select { - case <-vmCtx.Done(): - return - case uiState, ok := <-uiChan: - if !ok { - return - } - - closed := s.applyQuickActionsUiState( - uiState, - quickActionsUiComponents{ - content, - toggleConnectionButton, - connectedLabelText, disconnectedLabelText, - connectedImage, disconnectedImage, - connectedCircle.Resource, disconnectedCircle.Resource, - }, - ) - if closed { - return - } - } - } - }() - - s.wQuickActions.SetContent(content) - s.wQuickActions.Resize(fyne.NewSize(400, 200)) - s.wQuickActions.SetFixedSize(true) - s.wQuickActions.Show() -} diff --git a/client/ui/quickactions_assets.go b/client/ui/quickactions_assets.go deleted file mode 100644 index 9ff5e85a2..000000000 --- a/client/ui/quickactions_assets.go +++ /dev/null @@ -1,23 +0,0 @@ -// auto-generated -// Code generated by '$ fyne bundle'. DO NOT EDIT. - -package main - -import ( - _ "embed" - "fyne.io/fyne/v2" -) - -//go:embed assets/connected.png -var resourceConnectedPngData []byte -var resourceConnectedPng = &fyne.StaticResource{ - StaticName: "assets/connected.png", - StaticContent: resourceConnectedPngData, -} - -//go:embed assets/disconnected.png -var resourceDisconnectedPngData []byte -var resourceDisconnectedPng = &fyne.StaticResource{ - StaticName: "assets/disconnected.png", - StaticContent: resourceDisconnectedPngData, -} diff --git a/client/ui-wails/services/conn.go b/client/ui/services/conn.go similarity index 100% rename from client/ui-wails/services/conn.go rename to client/ui/services/conn.go diff --git a/client/ui-wails/services/connection.go b/client/ui/services/connection.go similarity index 100% rename from client/ui-wails/services/connection.go rename to client/ui/services/connection.go diff --git a/client/ui-wails/services/debug.go b/client/ui/services/debug.go similarity index 100% rename from client/ui-wails/services/debug.go rename to client/ui/services/debug.go diff --git a/client/ui-wails/services/forwarding.go b/client/ui/services/forwarding.go similarity index 100% rename from client/ui-wails/services/forwarding.go rename to client/ui/services/forwarding.go diff --git a/client/ui-wails/services/network.go b/client/ui/services/network.go similarity index 100% rename from client/ui-wails/services/network.go rename to client/ui/services/network.go diff --git a/client/ui-wails/services/peers.go b/client/ui/services/peers.go similarity index 100% rename from client/ui-wails/services/peers.go rename to client/ui/services/peers.go diff --git a/client/ui-wails/services/profile.go b/client/ui/services/profile.go similarity index 100% rename from client/ui-wails/services/profile.go rename to client/ui/services/profile.go diff --git a/client/ui-wails/services/settings.go b/client/ui/services/settings.go similarity index 100% rename from client/ui-wails/services/settings.go rename to client/ui/services/settings.go diff --git a/client/ui-wails/services/update.go b/client/ui/services/update.go similarity index 100% rename from client/ui-wails/services/update.go rename to client/ui/services/update.go diff --git a/client/ui/signal_unix.go b/client/ui/signal_unix.go index 99de99f0f..a5a9205c0 100644 --- a/client/ui/signal_unix.go +++ b/client/ui/signal_unix.go @@ -1,76 +1,33 @@ -//go:build !windows && !(linux && 386) +//go:build !windows && !android && !ios && !freebsd && !js package main import ( "context" "os" - "os/exec" "os/signal" "syscall" log "github.com/sirupsen/logrus" ) -// setupSignalHandler sets up a signal handler to listen for SIGUSR1. -// When received, it opens the quick actions window. -func (s *serviceClient) setupSignalHandler(ctx context.Context) { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGUSR1) +// listenForShowSignal opens the main window when the process receives SIGUSR1. +// External tools (the daemon, the installer, or another `netbird-ui` invocation) +// can poke this channel by signalling the running pid. +func listenForShowSignal(ctx context.Context, tray *Tray) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGUSR1) go func() { for { select { case <-ctx.Done(): + signal.Stop(sigCh) return - case <-sigChan: - log.Info("received SIGUSR1 signal, opening quick actions window") - s.openQuickActions() + case <-sigCh: + log.Debug("SIGUSR1 received, showing window") + tray.ShowWindow() } } }() } - -// openQuickActions opens the quick actions window by spawning a new process. -func (s *serviceClient) openQuickActions() { - proc, err := os.Executable() - if err != nil { - log.Errorf("get executable path: %v", err) - return - } - - cmd := exec.CommandContext(s.ctx, proc, - "--quick-actions=true", - "--daemon-addr="+s.addr, - ) - - if out := s.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("close log file %s: %v", s.logFile, err) - } - }() - } - - log.Infof("running command: %s --quick-actions=true --daemon-addr=%s", proc, s.addr) - - if err := cmd.Start(); err != nil { - log.Errorf("start quick actions window: %v", err) - return - } - - go func() { - if err := cmd.Wait(); err != nil { - log.Debugf("quick actions window exited: %v", err) - } - }() -} - -// sendShowWindowSignal sends SIGUSR1 to the specified PID. -func sendShowWindowSignal(pid int32) error { - process, err := os.FindProcess(int(pid)) - if err != nil { - return err - } - return process.Signal(syscall.SIGUSR1) -} diff --git a/client/ui/signal_windows.go b/client/ui/signal_windows.go index 58f46374f..22f1623cf 100644 --- a/client/ui/signal_windows.go +++ b/client/ui/signal_windows.go @@ -5,9 +5,6 @@ package main import ( "context" "errors" - "fmt" - "os" - "os/exec" "time" log "github.com/sirupsen/logrus" @@ -17,155 +14,68 @@ import ( const ( quickActionsTriggerEventName = `Global\NetBirdQuickActionsTriggerEvent` waitTimeout = 5 * time.Second - // SYNCHRONIZE is needed for WaitForSingleObject, EVENT_MODIFY_STATE for ResetEvent. - desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE + desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE + + // WaitForSingleObject returns this when the timeout elapses without the + // object being signalled. golang.org/x/sys/windows does not expose it. + waitTimeoutCode uint32 = 0x00000102 ) -func getEventNameUint16Pointer() (*uint16, error) { - eventNamePtr, err := windows.UTF16PtrFromString(quickActionsTriggerEventName) - if err != nil { - log.Errorf("Failed to convert event name '%s' to UTF16: %v", quickActionsTriggerEventName, err) - return nil, err - } - - return eventNamePtr, nil -} - -// setupSignalHandler sets up signal handling for Windows. -// Windows doesn't support SIGUSR1, so this uses a similar approach using windows.Events. -func (s *serviceClient) setupSignalHandler(ctx context.Context) { - eventNamePtr, err := getEventNameUint16Pointer() +// listenForShowSignal opens the main window when an external process pulses +// the named event Global\NetBirdQuickActionsTriggerEvent. Mirrors the trigger +// the legacy Fyne UI used so the installer and CLI integrations keep working. +func listenForShowSignal(ctx context.Context, tray *Tray) { + namePtr, err := windows.UTF16PtrFromString(quickActionsTriggerEventName) if err != nil { + log.Errorf("trigger event name: %v", err) return } - eventHandle, err := windows.CreateEvent(nil, 1, 0, eventNamePtr) - + handle, err := windows.CreateEvent(nil, 1, 0, namePtr) if err != nil { - if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { - log.Warnf("Quick actions trigger event '%s' already exists. Attempting to open.", quickActionsTriggerEventName) - eventHandle, err = windows.OpenEvent(desiredAccesses, false, eventNamePtr) - if err != nil { - log.Errorf("Failed to open existing quick actions trigger event '%s': %v", quickActionsTriggerEventName, err) - return - } - log.Infof("Successfully opened existing quick actions trigger event '%s'.", quickActionsTriggerEventName) - } else { - log.Errorf("Failed to create quick actions trigger event '%s': %v", quickActionsTriggerEventName, err) + if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + log.Errorf("create trigger event %q: %v", quickActionsTriggerEventName, err) + return + } + handle, err = windows.OpenEvent(desiredAccesses, false, namePtr) + if err != nil { + log.Errorf("open trigger event %q: %v", quickActionsTriggerEventName, err) return } } - if eventHandle == windows.InvalidHandle { - log.Errorf("Obtained an invalid handle for quick actions trigger event '%s'", quickActionsTriggerEventName) + if handle == windows.InvalidHandle { + log.Errorf("invalid handle for trigger event %q", quickActionsTriggerEventName) return } - log.Infof("Quick actions handler waiting for signal on event: %s", quickActionsTriggerEventName) - - go s.waitForEvent(ctx, eventHandle) + go waitForTrigger(ctx, handle, tray) } -func (s *serviceClient) waitForEvent(ctx context.Context, eventHandle windows.Handle) { +func waitForTrigger(ctx context.Context, handle windows.Handle, tray *Tray) { defer func() { - if err := windows.CloseHandle(eventHandle); err != nil { - log.Errorf("Failed to close quick actions event handle '%s': %v", quickActionsTriggerEventName, err) + if err := windows.CloseHandle(handle); err != nil { + log.Errorf("close trigger event handle: %v", err) } }() + timeoutMs := uint32(waitTimeout / time.Millisecond) for { if ctx.Err() != nil { return } - - status, err := windows.WaitForSingleObject(eventHandle, uint32(waitTimeout.Milliseconds())) - - switch status { - case windows.WAIT_OBJECT_0: - log.Info("Received signal on quick actions event. Opening quick actions window.") - - // reset the event so it can be triggered again later (manual reset == 1) - if err := windows.ResetEvent(eventHandle); err != nil { - log.Errorf("Failed to reset quick actions event '%s': %v", quickActionsTriggerEventName, err) - } - - s.openQuickActions() - case uint32(windows.WAIT_TIMEOUT): - - default: - if isDone := logUnexpectedStatus(ctx, status, err); isDone { - return + ev, err := windows.WaitForSingleObject(handle, timeoutMs) + switch { + case err != nil: + log.Errorf("wait trigger event: %v", err) + return + case ev == waitTimeoutCode: + continue + case ev == windows.WAIT_OBJECT_0: + if err := windows.ResetEvent(handle); err != nil { + log.Errorf("reset trigger event: %v", err) } + tray.ShowWindow() } } } - -func logUnexpectedStatus(ctx context.Context, status uint32, err error) bool { - log.Errorf("Unexpected status %d from WaitForSingleObject for quick actions event '%s': %v", - status, quickActionsTriggerEventName, err) - select { - case <-time.After(5 * time.Second): - return false - case <-ctx.Done(): - return true - } -} - -// openQuickActions opens the quick actions window by spawning a new process. -func (s *serviceClient) openQuickActions() { - proc, err := os.Executable() - if err != nil { - log.Errorf("get executable path: %v", err) - return - } - - cmd := exec.CommandContext(s.ctx, proc, - "--quick-actions=true", - "--daemon-addr="+s.addr, - ) - - if out := s.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("close log file %s: %v", s.logFile, err) - } - }() - } - - log.Infof("running command: %s --quick-actions=true --daemon-addr=%s", proc, s.addr) - - if err := cmd.Start(); err != nil { - log.Errorf("error starting quick actions window: %v", err) - return - } - - go func() { - if err := cmd.Wait(); err != nil { - log.Debugf("quick actions window exited: %v", err) - } - }() -} - -func sendShowWindowSignal(pid int32) error { - _, err := os.FindProcess(int(pid)) - if err != nil { - return err - } - - eventNamePtr, err := getEventNameUint16Pointer() - if err != nil { - return err - } - - eventHandle, err := windows.OpenEvent(desiredAccesses, false, eventNamePtr) - if err != nil { - return err - } - - err = windows.SetEvent(eventHandle) - if err != nil { - return fmt.Errorf("error setting event: %w", err) - } - - return nil -} diff --git a/client/ui-wails/tray.go b/client/ui/tray.go similarity index 100% rename from client/ui-wails/tray.go rename to client/ui/tray.go diff --git a/client/ui-wails/tray_linux.go b/client/ui/tray_linux.go similarity index 100% rename from client/ui-wails/tray_linux.go rename to client/ui/tray_linux.go diff --git a/client/ui-wails/tray_watcher_linux.go b/client/ui/tray_watcher_linux.go similarity index 100% rename from client/ui-wails/tray_watcher_linux.go rename to client/ui/tray_watcher_linux.go diff --git a/client/ui-wails/tray_watcher_other.go b/client/ui/tray_watcher_other.go similarity index 100% rename from client/ui-wails/tray_watcher_other.go rename to client/ui/tray_watcher_other.go diff --git a/client/ui/update.go b/client/ui/update.go deleted file mode 100644 index 25c317bdf..000000000 --- a/client/ui/update.go +++ /dev/null @@ -1,140 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/proto" -) - -func (s *serviceClient) showUpdateProgress(ctx context.Context, version string) { - log.Infof("show installer progress window: %s", version) - s.wUpdateProgress = s.app.NewWindow("Automatically updating client") - - statusLabel := widget.NewLabel("Updating...") - infoLabel := widget.NewLabel(fmt.Sprintf("Your client version is older than the auto-update version set in Management.\nUpdating client to: %s.", version)) - content := container.NewVBox(infoLabel, statusLabel) - s.wUpdateProgress.SetContent(content) - s.wUpdateProgress.CenterOnScreen() - s.wUpdateProgress.SetFixedSize(true) - s.wUpdateProgress.SetCloseIntercept(func() { - // this is empty to lock window until result known - }) - s.wUpdateProgress.RequestFocus() - s.wUpdateProgress.Show() - - updateWindowCtx, cancel := context.WithTimeout(ctx, 15*time.Minute) - - // Initialize dot updater - updateText := dotUpdater() - - // Channel to receive the result from RPC call - resultErrCh := make(chan error, 1) - resultOkCh := make(chan struct{}, 1) - - // Start RPC call in background - go func() { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Infof("backend not reachable, upgrade in progress: %v", err) - close(resultOkCh) - return - } - - resp, err := conn.GetInstallerResult(updateWindowCtx, &proto.InstallerResultRequest{}) - if err != nil { - log.Infof("backend stopped responding, upgrade in progress: %v", err) - close(resultOkCh) - return - } - - if !resp.Success { - resultErrCh <- mapInstallError(resp.ErrorMsg) - return - } - - // Success - close(resultOkCh) - }() - - // Update UI with dots and wait for result - go func() { - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - defer cancel() - - // allow closing update window after 10 sec - timerResetCloseInterceptor := time.NewTimer(10 * time.Second) - defer timerResetCloseInterceptor.Stop() - - for { - select { - case <-updateWindowCtx.Done(): - s.showInstallerResult(statusLabel, updateWindowCtx.Err()) - return - case err := <-resultErrCh: - s.showInstallerResult(statusLabel, err) - return - case <-resultOkCh: - log.Info("backend exited, upgrade in progress, closing all UI") - killParentUIProcess() - s.app.Quit() - return - case <-ticker.C: - statusLabel.SetText(updateText()) - case <-timerResetCloseInterceptor.C: - s.wUpdateProgress.SetCloseIntercept(nil) - } - } - }() -} - -func (s *serviceClient) showInstallerResult(statusLabel *widget.Label, err error) { - s.wUpdateProgress.SetCloseIntercept(nil) - switch { - case errors.Is(err, context.DeadlineExceeded): - log.Warn("update watcher timed out") - statusLabel.SetText("Update timed out. Please try again.") - case errors.Is(err, context.Canceled): - log.Info("update watcher canceled") - statusLabel.SetText("Update canceled.") - case err != nil: - log.Errorf("update failed: %v", err) - statusLabel.SetText("Update failed: " + err.Error()) - default: - s.wUpdateProgress.Close() - } -} - -// dotUpdater returns a closure that cycles through dots for a loading animation. -func dotUpdater() func() string { - dotCount := 0 - return func() string { - dotCount = (dotCount + 1) % 4 - return fmt.Sprintf("%s%s", "Updating", strings.Repeat(".", dotCount)) - } -} - -func mapInstallError(msg string) error { - msg = strings.ToLower(strings.TrimSpace(msg)) - - switch { - case strings.Contains(msg, "deadline exceeded"), strings.Contains(msg, "timeout"): - return context.DeadlineExceeded - case strings.Contains(msg, "canceled"), strings.Contains(msg, "cancelled"): - return context.Canceled - case msg == "": - return errors.New("unknown update error") - default: - return errors.New(msg) - } -} diff --git a/client/ui/update_notwindows.go b/client/ui/update_notwindows.go deleted file mode 100644 index 5766f18f7..000000000 --- a/client/ui/update_notwindows.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows && !(linux && 386) - -package main - -func killParentUIProcess() { - // No-op on non-Windows platforms -} diff --git a/client/ui/update_windows.go b/client/ui/update_windows.go deleted file mode 100644 index 1b03936f9..000000000 --- a/client/ui/update_windows.go +++ /dev/null @@ -1,44 +0,0 @@ -//go:build windows - -package main - -import ( - log "github.com/sirupsen/logrus" - "golang.org/x/sys/windows" - - nbprocess "github.com/netbirdio/netbird/client/ui/process" -) - -// killParentUIProcess finds and kills the parent systray UI process on Windows. -// This is a workaround in case the MSI installer fails to properly terminate the UI process. -// The installer should handle this via util:CloseApplication with TerminateProcess, but this -// provides an additional safety mechanism to ensure the UI is closed before the upgrade proceeds. -func killParentUIProcess() { - pid, running, err := nbprocess.IsAnotherProcessRunning() - if err != nil { - log.Warnf("failed to check for parent UI process: %v", err) - return - } - - if !running { - log.Debug("no parent UI process found to kill") - return - } - - log.Infof("killing parent UI process (PID: %d)", pid) - - // Open the process with terminate rights - handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid)) - if err != nil { - log.Warnf("failed to open parent process %d: %v", pid, err) - return - } - defer func() { - _ = windows.CloseHandle(handle) - }() - - // Terminate the process with exit code 0 - if err := windows.TerminateProcess(handle, 0); err != nil { - log.Warnf("failed to terminate parent process %d: %v", pid, err) - } -} diff --git a/client/ui-wails/xembed_host_linux.go b/client/ui/xembed_host_linux.go similarity index 100% rename from client/ui-wails/xembed_host_linux.go rename to client/ui/xembed_host_linux.go diff --git a/client/ui-wails/xembed_host_other.go b/client/ui/xembed_host_other.go similarity index 100% rename from client/ui-wails/xembed_host_other.go rename to client/ui/xembed_host_other.go diff --git a/client/ui-wails/xembed_tray_linux.c b/client/ui/xembed_tray_linux.c similarity index 100% rename from client/ui-wails/xembed_tray_linux.c rename to client/ui/xembed_tray_linux.c diff --git a/client/ui-wails/xembed_tray_linux.h b/client/ui/xembed_tray_linux.h similarity index 100% rename from client/ui-wails/xembed_tray_linux.h rename to client/ui/xembed_tray_linux.h From 09052949a2112bfeaef8ae7a873a2c4004239975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 11 May 2026 11:33:35 +0200 Subject: [PATCH 74/80] [client/ui] Finish ui-wails rename (import paths, fyne deps) Follow-up to the rename commit: the previous commit moved the files but the post-mv string substitutions (Go imports, frontend bindings, CI config paths) were not re-staged so they slipped through. This commit applies those edits and removes the fyne dependencies from go.mod/go.sum now that the legacy fyne UI is gone. --- .github/workflows/golang-test-darwin.yml | 4 +- .github/workflows/golang-test-linux.yml | 4 +- .github/workflows/golang-test-windows.yml | 4 +- .github/workflows/golangci-lint.yml | 6 +-- .github/workflows/release.yml | 8 +-- .golangci.yaml | 4 +- .goreleaser_ui.yaml | 16 +++--- .goreleaser_ui_darwin.yaml | 4 +- client/ui/README.md | 2 +- .../netbird/client/ui/services/connection.ts | 12 ++--- .../netbird/client/ui/services/debug.ts | 6 +-- .../netbird/client/ui/services/forwarding.ts | 2 +- .../netbird/client/ui/services/networks.ts | 6 +-- .../netbird/client/ui/services/peers.ts | 4 +- .../netbird/client/ui/services/profiles.ts | 12 ++--- .../netbird/client/ui/services/settings.ts | 6 +-- .../netbird/client/ui/services/update.ts | 6 +-- .../wailsapp/wails/v3/internal/eventcreate.ts | 2 +- .../wailsapp/wails/v3/internal/eventdata.d.ts | 2 +- client/ui/frontend/src/hooks/useStatus.ts | 4 +- client/ui/frontend/src/pages/Debug.tsx | 4 +- client/ui/frontend/src/pages/Login.tsx | 2 +- client/ui/frontend/src/pages/LoginUrl.tsx | 2 +- client/ui/frontend/src/pages/Networks.tsx | 4 +- client/ui/frontend/src/pages/Peers.tsx | 2 +- client/ui/frontend/src/pages/Profiles.tsx | 4 +- client/ui/frontend/src/pages/QuickActions.tsx | 2 +- client/ui/frontend/src/pages/Settings.tsx | 4 +- client/ui/frontend/src/pages/Status.tsx | 4 +- client/ui/frontend/src/pages/Update.tsx | 2 +- client/ui/main.go | 2 +- client/ui/tray.go | 2 +- go.mod | 24 +-------- go.sum | 50 ------------------- sonar-project.properties | 6 +-- 35 files changed, 78 insertions(+), 150 deletions(-) diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index f1f6a5cc1..fc92795aa 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -43,7 +43,7 @@ jobs: run: git --no-pager diff --exit-code - name: Test - # Exclude client/ui-wails: its main.go uses //go:embed all:frontend/dist, + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, # which fails to compile until the frontend has been built. The Wails UI # has no Go-side unit tests, and its release pipeline runs `pnpm build` # before goreleaser. @@ -51,5 +51,5 @@ jobs: # resolve; the grep then drops the broken package by path. Without -e, # go list aborts with empty stdout and `go test` falls back to the repo # root, which has no Go files. - run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui-wails) + run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui) diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 1bcad5345..1183768fa 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -154,7 +154,7 @@ jobs: run: git --no-pager diff --exit-code - name: Test - # Exclude client/ui-wails: its main.go uses //go:embed all:frontend/dist, + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, # which fails to compile until the frontend has been built. The Wails UI # has no Go-side unit tests, and its release pipeline runs `pnpm build` # before goreleaser. @@ -162,7 +162,7 @@ jobs: # resolve; the grep then drops the broken package by path. Without -e, # go list aborts with empty stdout and `go test` falls back to the repo # root, which has no Go files. - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui-wails) + run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui) test_client_on_docker: name: "Client (Docker) / Unit" diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index c5180ea29..70db96543 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -64,7 +64,7 @@ jobs: - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy - name: Generate test script - # Exclude client/ui-wails: its main.go uses //go:embed all:frontend/dist, + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, # which fails to compile until the frontend has been built. The Wails UI # has no Go-side unit tests, and its release pipeline runs `pnpm build` # before goreleaser. @@ -72,7 +72,7 @@ jobs: # resolve; the Where-Object pipeline then drops the broken package by # path. Without -e, go list aborts with empty stdout. run: | - $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui-wails' } + $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' } $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" $cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 2c386f246..6952fd23c 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -52,14 +52,14 @@ jobs: if: matrix.os == 'ubuntu-latest' run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev - name: Stub Wails frontend bundle - # client/ui-wails/main.go has //go:embed all:frontend/dist. The + # client/ui/main.go has //go:embed all:frontend/dist. The # directory is produced by `pnpm run build` and is gitignored, so # lint-only runs (no frontend toolchain) need a placeholder file # for the embed pattern to match. shell: bash run: | - mkdir -p client/ui-wails/frontend/dist - touch client/ui-wails/frontend/dist/.embed-placeholder + mkdir -p client/ui/frontend/dist + touch client/ui/frontend/dist/.embed-placeholder - name: golangci-lint uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb464b8e4..62ce0ce81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -186,9 +186,9 @@ jobs: - name: Install goversioninfo run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e - name: Generate windows syso amd64 - run: goversioninfo -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso + run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso - name: Generate windows syso arm64 - run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso + run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso - name: Run GoReleaser id: goreleaser uses: goreleaser/goreleaser-action@v4 @@ -380,9 +380,9 @@ jobs: - name: Install goversioninfo run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e - name: Generate windows syso amd64 - run: goversioninfo -64 -icon client/ui-wails/build/windows/icon.ico -manifest client/ui-wails/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui-wails/resources_windows_amd64.syso + run: goversioninfo -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso - name: Generate windows syso arm64 - run: goversioninfo -arm -64 -icon client/ui-wails/build/windows/icon.ico -manifest client/ui-wails/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui-wails/resources_windows_arm64.syso + run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso - name: Run GoReleaser uses: goreleaser/goreleaser-action@v4 diff --git a/.golangci.yaml b/.golangci.yaml index 7883961c3..e350b9de7 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -114,7 +114,7 @@ linters: - linters: - staticcheck text: "QF1012" - # client/ui-wails/main.go uses //go:embed all:frontend/dist; the + # client/ui/main.go uses //go:embed all:frontend/dist; the # directory is populated by `pnpm build` in the release pipeline # and missing at lint time, so the embed parses to "no matching # files found" — surfaced by golangci-lint's typecheck pre-pass. @@ -122,7 +122,7 @@ linters: # (services/, tray.go, grpc.go, ...) still gets linted normally. - linters: - typecheck - path: client/ui-wails/main\.go + path: client/ui/main\.go text: "pattern all:frontend/dist" paths: - third_party$ diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 5721f61c8..a7b6034d0 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -4,11 +4,11 @@ project_name: netbird-ui before: hooks: - - sh -c 'cd client/ui-wails/frontend && pnpm install --frozen-lockfile && pnpm build' + - sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build' builds: - id: netbird-ui - dir: client/ui-wails + dir: client/ui binary: netbird-ui env: - CGO_ENABLED=1 @@ -21,7 +21,7 @@ builds: mod_timestamp: "{{ .CommitTimestamp }}" - id: netbird-ui-windows-amd64 - dir: client/ui-wails + dir: client/ui binary: netbird-ui env: - CGO_ENABLED=1 @@ -36,7 +36,7 @@ builds: mod_timestamp: "{{ .CommitTimestamp }}" - id: netbird-ui-windows-arm64 - dir: client/ui-wails + dir: client/ui binary: netbird-ui env: - CGO_ENABLED=1 @@ -75,9 +75,9 @@ nfpms: scripts: postinstall: "release_files/ui-post-install.sh" contents: - - src: client/ui-wails/build/linux/netbird.desktop + - src: client/ui/build/linux/netbird.desktop dst: /usr/share/applications/netbird.desktop - - src: client/ui-wails/build/appicon.png + - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png dependencies: - netbird @@ -97,9 +97,9 @@ nfpms: scripts: postinstall: "release_files/ui-post-install.sh" contents: - - src: client/ui-wails/build/linux/netbird.desktop + - src: client/ui/build/linux/netbird.desktop dst: /usr/share/applications/netbird.desktop - - src: client/ui-wails/build/appicon.png + - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png dependencies: - netbird diff --git a/.goreleaser_ui_darwin.yaml b/.goreleaser_ui_darwin.yaml index 4ee9b4507..441269aa8 100644 --- a/.goreleaser_ui_darwin.yaml +++ b/.goreleaser_ui_darwin.yaml @@ -4,11 +4,11 @@ project_name: netbird-ui before: hooks: - - sh -c 'cd client/ui-wails/frontend && pnpm install --frozen-lockfile && pnpm build' + - sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build' builds: - id: netbird-ui-darwin - dir: client/ui-wails + dir: client/ui binary: netbird-ui env: - CGO_ENABLED=1 diff --git a/client/ui/README.md b/client/ui/README.md index 82bf5e4bf..c2e6180bb 100644 --- a/client/ui/README.md +++ b/client/ui/README.md @@ -17,7 +17,7 @@ WebView. ## Develop without rebuilding ```bash -cd client/ui-wails +cd client/ui task dev ``` diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/connection.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/connection.ts index d4d2dd761..db3e2b556 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/connection.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/connection.ts @@ -15,17 +15,17 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr import * as $models from "./models.js"; export function Down(): $CancellablePromise { - return $Call.ByID(1062334452); + return $Call.ByID(70044537); } export function Login(p: $models.LoginParams): $CancellablePromise<$models.LoginResult> { - return $Call.ByID(782816741, p).then(($result: any) => { + return $Call.ByID(252661358, p).then(($result: any) => { return $$createType0($result); }); } export function Logout(p: $models.LogoutParams): $CancellablePromise { - return $Call.ByID(4028053230, p); + return $Call.ByID(3824847887, p); } /** @@ -36,15 +36,15 @@ export function Logout(p: $models.LogoutParams): $CancellablePromise { * SSO. Honors $BROWSER first, then falls back to the platform default. */ export function OpenURL(url: string): $CancellablePromise { - return $Call.ByID(4267001345, url); + return $Call.ByID(3786555598, url); } export function Up(p: $models.UpParams): $CancellablePromise { - return $Call.ByID(1178388469, p); + return $Call.ByID(3381092588, p); } export function WaitSSOLogin(p: $models.WaitSSOParams): $CancellablePromise { - return $Call.ByID(3487329509, p); + return $Call.ByID(1751351500, p); } // Private type creation functions diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/debug.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/debug.ts index 4711064bb..578dd20b3 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/debug.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/debug.ts @@ -15,19 +15,19 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr import * as $models from "./models.js"; export function Bundle(p: $models.DebugBundleParams): $CancellablePromise<$models.DebugBundleResult> { - return $Call.ByID(1875836985, p).then(($result: any) => { + return $Call.ByID(617551238, p).then(($result: any) => { return $$createType0($result); }); } export function GetLogLevel(): $CancellablePromise<$models.LogLevel> { - return $Call.ByID(2713455331).then(($result: any) => { + return $Call.ByID(3832950014).then(($result: any) => { return $$createType1($result); }); } export function SetLogLevel(lvl: $models.LogLevel): $CancellablePromise { - return $Call.ByID(2627038775, lvl); + return $Call.ByID(4122411498, lvl); } // Private type creation functions diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/forwarding.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/forwarding.ts index b1c18c3a2..803afe6b0 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/forwarding.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/forwarding.ts @@ -19,7 +19,7 @@ import * as $models from "./models.js"; * reverse proxy. The frontend renders these as the "exposed services" list. */ export function List(): $CancellablePromise<$models.ForwardingRule[]> { - return $Call.ByID(3893357601).then(($result: any) => { + return $Call.ByID(3831092172).then(($result: any) => { return $$createType1($result); }); } diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/networks.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/networks.ts index 37967ff58..82a8d870f 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/networks.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/networks.ts @@ -15,17 +15,17 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr import * as $models from "./models.js"; export function Deselect(p: $models.SelectNetworksParams): $CancellablePromise { - return $Call.ByID(2335193802, p); + return $Call.ByID(3382210947, p); } export function List(): $CancellablePromise<$models.Network[]> { - return $Call.ByID(719769457).then(($result: any) => { + return $Call.ByID(1550842096).then(($result: any) => { return $$createType1($result); }); } export function Select(p: $models.SelectNetworksParams): $CancellablePromise { - return $Call.ByID(3714393053, p); + return $Call.ByID(1339338400, p); } // Private type creation functions diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/peers.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/peers.ts index 98032de6e..f70d8cc7c 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/peers.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/peers.ts @@ -19,7 +19,7 @@ import * as $models from "./models.js"; * Get returns the current daemon status snapshot. */ export function Get(): $CancellablePromise<$models.Status> { - return $Call.ByID(196038193).then(($result: any) => { + return $Call.ByID(3266051360).then(($result: any) => { return $$createType0($result); }); } @@ -39,7 +39,7 @@ export function Get(): $CancellablePromise<$models.Status> { * via exponential backoff. */ export function Watch(): $CancellablePromise { - return $Call.ByID(741320382); + return $Call.ByID(2799871735); } // Private type creation functions diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profiles.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profiles.ts index f41cdba25..9a4577e0b 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profiles.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profiles.ts @@ -15,27 +15,27 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr import * as $models from "./models.js"; export function Add(p: $models.ProfileRef): $CancellablePromise { - return $Call.ByID(701512397, p); + return $Call.ByID(722930578, p); } export function GetActive(): $CancellablePromise<$models.ActiveProfile> { - return $Call.ByID(2605259596).then(($result: any) => { + return $Call.ByID(3458449443).then(($result: any) => { return $$createType0($result); }); } export function List(username: string): $CancellablePromise<$models.Profile[]> { - return $Call.ByID(1745269178, username).then(($result: any) => { + return $Call.ByID(3702185167, username).then(($result: any) => { return $$createType2($result); }); } export function Remove(p: $models.ProfileRef): $CancellablePromise { - return $Call.ByID(2506403914, p); + return $Call.ByID(2365690315, p); } export function Switch(p: $models.ProfileRef): $CancellablePromise { - return $Call.ByID(3405248534, p); + return $Call.ByID(3209858855, p); } /** @@ -43,7 +43,7 @@ export function Switch(p: $models.ProfileRef): $CancellablePromise { * The frontend calls this once at boot and reuses the result. */ export function Username(): $CancellablePromise { - return $Call.ByID(1939223418); + return $Call.ByID(262345647); } // Private type creation functions diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/settings.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/settings.ts index 7dff9029f..bc7b7b2d1 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/settings.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/settings.ts @@ -15,19 +15,19 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr import * as $models from "./models.js"; export function GetConfig(p: $models.ConfigParams): $CancellablePromise<$models.Config> { - return $Call.ByID(2849966711, p).then(($result: any) => { + return $Call.ByID(59246988, p).then(($result: any) => { return $$createType0($result); }); } export function GetFeatures(): $CancellablePromise<$models.Features> { - return $Call.ByID(376812026).then(($result: any) => { + return $Call.ByID(2056724965).then(($result: any) => { return $$createType1($result); }); } export function SetConfig(p: $models.SetConfigParams): $CancellablePromise { - return $Call.ByID(565510651, p); + return $Call.ByID(26939944, p); } // Private type creation functions diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/update.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/update.ts index 728d3aad0..c2bdd85e9 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/update.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/update.ts @@ -15,7 +15,7 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr import * as $models from "./models.js"; export function GetInstallerResult(): $CancellablePromise<$models.UpdateResult> { - return $Call.ByID(2190725314).then(($result: any) => { + return $Call.ByID(2533624807).then(($result: any) => { return $$createType0($result); }); } @@ -28,11 +28,11 @@ export function GetInstallerResult(): $CancellablePromise<$models.UpdateResult> * before the runtime tears down. */ export function Quit(): $CancellablePromise { - return $Call.ByID(27817640); + return $Call.ByID(409602657); } export function Trigger(): $CancellablePromise<$models.UpdateResult> { - return $Call.ByID(2415339649).then(($result: any) => { + return $Call.ByID(166270378).then(($result: any) => { return $$createType0($result); }); } diff --git a/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts index 67cad2058..ac44c000f 100644 --- a/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts +++ b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts @@ -8,7 +8,7 @@ import { Create as $Create } from "@wailsio/runtime"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import * as services$0 from "../../../../netbirdio/netbird/client/ui-wails/services/models.js"; +import * as services$0 from "../../../../netbirdio/netbird/client/ui/services/models.js"; function configure() { Object.freeze(Object.assign($Create.Events, { diff --git a/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts index 063187544..3737f620b 100644 --- a/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts +++ b/client/ui/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -7,7 +7,7 @@ import type { Events } from "@wailsio/runtime"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports -import type * as services$0 from "../../../../netbirdio/netbird/client/ui-wails/services/models.js"; +import type * as services$0 from "../../../../netbirdio/netbird/client/ui/services/models.js"; declare module "@wailsio/runtime" { namespace Events { diff --git a/client/ui/frontend/src/hooks/useStatus.ts b/client/ui/frontend/src/hooks/useStatus.ts index 45a006907..3f65b5a05 100644 --- a/client/ui/frontend/src/hooks/useStatus.ts +++ b/client/ui/frontend/src/hooks/useStatus.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Events } from "@wailsio/runtime"; -import { Peers } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; -import type { Status } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +import { Peers } from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; +import type { Status } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js"; const EVENT_STATUS = "netbird:status"; diff --git a/client/ui/frontend/src/pages/Debug.tsx b/client/ui/frontend/src/pages/Debug.tsx index 929e4325f..28683d640 100644 --- a/client/ui/frontend/src/pages/Debug.tsx +++ b/client/ui/frontend/src/pages/Debug.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { Debug as DebugSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; -import type { DebugBundleResult } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +import { Debug as DebugSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; +import type { DebugBundleResult } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js"; import { Button } from "../components/Button"; import { Input } from "../components/Input"; import { Switch } from "../components/Switch"; diff --git a/client/ui/frontend/src/pages/Login.tsx b/client/ui/frontend/src/pages/Login.tsx index 439096f47..4aaca879a 100644 --- a/client/ui/frontend/src/pages/Login.tsx +++ b/client/ui/frontend/src/pages/Login.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ExternalLink, Loader2, AlertTriangle, X, RotateCcw } from "lucide-react"; -import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; +import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; import { Button } from "../components/Button"; type Phase = "starting" | "browser" | "connecting" | "error"; diff --git a/client/ui/frontend/src/pages/LoginUrl.tsx b/client/ui/frontend/src/pages/LoginUrl.tsx index 6841b92a4..7d9fdd98e 100644 --- a/client/ui/frontend/src/pages/LoginUrl.tsx +++ b/client/ui/frontend/src/pages/LoginUrl.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { ExternalLink } from "lucide-react"; -import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; +import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; import { Button } from "../components/Button"; export default function LoginUrl() { diff --git a/client/ui/frontend/src/pages/Networks.tsx b/client/ui/frontend/src/pages/Networks.tsx index ea3bd055e..860676e3f 100644 --- a/client/ui/frontend/src/pages/Networks.tsx +++ b/client/ui/frontend/src/pages/Networks.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { RefreshCw } from "lucide-react"; -import { Networks as NetworksSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; -import type { Network } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +import { Networks as NetworksSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; +import type { Network } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js"; import { Button } from "../components/Button"; import { Tabs } from "../components/Tabs"; diff --git a/client/ui/frontend/src/pages/Peers.tsx b/client/ui/frontend/src/pages/Peers.tsx index f1522ca87..c22f3d3d9 100644 --- a/client/ui/frontend/src/pages/Peers.tsx +++ b/client/ui/frontend/src/pages/Peers.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from "react"; import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react"; import { useStatus } from "../hooks/useStatus"; -import type { PeerStatus } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +import type { PeerStatus } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js"; import { Card } from "../components/Card"; import { Input } from "../components/Input"; import { cn } from "../lib/cn"; diff --git a/client/ui/frontend/src/pages/Profiles.tsx b/client/ui/frontend/src/pages/Profiles.tsx index 3a1035afa..d6be78890 100644 --- a/client/ui/frontend/src/pages/Profiles.tsx +++ b/client/ui/frontend/src/pages/Profiles.tsx @@ -3,8 +3,8 @@ import { Plus, RefreshCw } from "lucide-react"; import { Profiles as ProfilesSvc, Connection, -} from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; -import type { Profile } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +} from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; +import type { Profile } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js"; import { Button } from "../components/Button"; import { Input } from "../components/Input"; import { Card } from "../components/Card"; diff --git a/client/ui/frontend/src/pages/QuickActions.tsx b/client/ui/frontend/src/pages/QuickActions.tsx index 749b4bf8a..9c851e120 100644 --- a/client/ui/frontend/src/pages/QuickActions.tsx +++ b/client/ui/frontend/src/pages/QuickActions.tsx @@ -1,6 +1,6 @@ import { CheckCircle2, Circle, Loader2, Power } from "lucide-react"; import { useStatus } from "../hooks/useStatus"; -import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; +import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; import { Button } from "../components/Button"; import { cn } from "../lib/cn"; diff --git a/client/ui/frontend/src/pages/Settings.tsx b/client/ui/frontend/src/pages/Settings.tsx index 3781611b6..09f2d01d3 100644 --- a/client/ui/frontend/src/pages/Settings.tsx +++ b/client/ui/frontend/src/pages/Settings.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import { Settings as SettingsSvc, Profiles as ProfilesSvc, -} from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; -import type { Config } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +} from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; +import type { Config } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js"; import { Button } from "../components/Button"; import { Input } from "../components/Input"; import { Switch } from "../components/Switch"; diff --git a/client/ui/frontend/src/pages/Status.tsx b/client/ui/frontend/src/pages/Status.tsx index abeae0476..08a33e561 100644 --- a/client/ui/frontend/src/pages/Status.tsx +++ b/client/ui/frontend/src/pages/Status.tsx @@ -1,8 +1,8 @@ import { CheckCircle2, Circle, Loader2, AlertTriangle, Power, LogIn } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useStatus } from "../hooks/useStatus"; -import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; -import type { SystemEvent } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; +import type { SystemEvent } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js"; import { Button } from "../components/Button"; import { Card } from "../components/Card"; import { cn } from "../lib/cn"; diff --git a/client/ui/frontend/src/pages/Update.tsx b/client/ui/frontend/src/pages/Update.tsx index 6f5c55056..d7e600527 100644 --- a/client/ui/frontend/src/pages/Update.tsx +++ b/client/ui/frontend/src/pages/Update.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; +import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; const TIMEOUT_MS = 15 * 60 * 1000; const POLL_INTERVAL_MS = 2000; diff --git a/client/ui/main.go b/client/ui/main.go index 9bc3d5692..b706761c2 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -13,7 +13,7 @@ import ( "github.com/wailsapp/wails/v3/pkg/events" "github.com/wailsapp/wails/v3/pkg/services/notifications" - "github.com/netbirdio/netbird/client/ui-wails/services" + "github.com/netbirdio/netbird/client/ui/services" "github.com/netbirdio/netbird/util" ) diff --git a/client/ui/tray.go b/client/ui/tray.go index 063a4c5ee..a3f4e0b7f 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -15,7 +15,7 @@ import ( "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/services/notifications" - "github.com/netbirdio/netbird/client/ui-wails/services" + "github.com/netbirdio/netbird/client/ui/services" "github.com/netbirdio/netbird/version" ) diff --git a/go.mod b/go.mod index ec79cddb0..c2e387823 100644 --- a/go.mod +++ b/go.mod @@ -28,9 +28,6 @@ require ( ) require ( - fyne.io/fyne/v2 v2.7.0 - fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 - git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 github.com/awnumar/memguard v0.23.0 github.com/aws/aws-sdk-go-v2 v1.38.3 github.com/aws/aws-sdk-go-v2/config v1.31.6 @@ -140,6 +137,7 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.1 // indirect + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/AppsFlyer/go-sundheit v0.6.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect @@ -188,17 +186,10 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fredbi/uri v1.1.1 // indirect - github.com/fyne-io/gl-js v0.2.0 // indirect - github.com/fyne-io/glfw-js v0.3.0 // indirect - github.com/fyne-io/image v0.1.1 // indirect - github.com/fyne-io/oksvg v0.2.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.7.0 // indirect github.com/go-git/go-git/v5 v5.16.4 // indirect - github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect - github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-ldap/ldap/v3 v3.4.12 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -213,8 +204,6 @@ require ( github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/validate v0.24.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect - github.com/go-text/render v0.2.0 // indirect - github.com/go-text/typesetting v0.2.1 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect @@ -225,8 +214,6 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect - github.com/hack-pad/go-indexeddb v0.3.2 // indirect - github.com/hack-pad/safejs v0.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect @@ -238,13 +225,11 @@ require ( github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect - github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/compress v1.18.3 // indirect @@ -278,8 +263,6 @@ require ( github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect - github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/nxadm/tail v1.4.11 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -300,7 +283,6 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect - github.com/rymdport/portal v0.4.2 // indirect github.com/samber/lo v1.52.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shirou/gopsutil/v4 v4.25.8 // indirect @@ -308,8 +290,6 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/skeema/knownhosts v1.3.2 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect @@ -318,7 +298,6 @@ require ( github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - github.com/yuin/goldmark v1.7.16 // indirect github.com/zeebo/blake3 v0.2.3 // indirect go.mongodb.org/mongo-driver v1.17.9 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -327,7 +306,6 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/image v0.35.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.43.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect diff --git a/go.sum b/go.sum index b313ee2bb..ed64f75e1 100644 --- a/go.sum +++ b/go.sum @@ -11,10 +11,6 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -fyne.io/fyne/v2 v2.7.0 h1:GvZSpE3X0liU/fqstInVvRsaboIVpIWQ4/sfjDGIGGQ= -fyne.io/fyne/v2 v2.7.0/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE= -fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 h1:829+77I4TaMrcg9B3wf+gHhdSgoCVEgH2czlPXPbfj4= -fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= @@ -171,29 +167,17 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= -github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= -github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= -github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= -github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= -github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= -github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= -github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= -github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= -github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -208,10 +192,6 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= @@ -255,12 +235,6 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8Wd github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= -github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= -github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= -github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= -github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= -github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -329,10 +303,6 @@ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f2 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357/go.mod h1:w9Y7gY31krpLmrVU5ZPG9H7l9fZuRu5/3R3S3FMtVQ4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= -github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= -github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= -github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= -github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -378,8 +348,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= -github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -393,8 +361,6 @@ github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7X github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= -github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= @@ -512,10 +478,6 @@ github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470 github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= github.com/netbirdio/wireguard-go v0.0.0-20260107100953-33b7c9d03db0 h1:h/QnNzm7xzHPm+gajcblYUOclrW2FeNeDlUNj6tTWKQ= github.com/netbirdio/wireguard-go v0.0.0-20260107100953-33b7c9d03db0/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= -github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= @@ -583,8 +545,6 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= -github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -619,8 +579,6 @@ github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= -github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= @@ -654,10 +612,6 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -719,8 +673,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= -github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0= @@ -785,8 +737,6 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= -golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= -golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20251113184115-a159579294ab h1:Iqyc+2zr7aGyLuEadIm0KRJP0Wwt+fhlXLa51Fxf1+Q= golang.org/x/mobile v0.0.0-20251113184115-a159579294ab/go.mod h1:Eq3Nh/5pFSWug2ohiudJ1iyU59SO78QFuh4qTTN++I0= diff --git a/sonar-project.properties b/sonar-project.properties index 948557321..6a17114de 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,10 +2,10 @@ sonar.projectKey=netbirdio_netbird sonar.organization=netbirdio # Exclude the React frontend from analysis. The Go side of the Wails UI -# (client/ui-wails/*.go and services/) is intentionally kept in scope — +# (client/ui/*.go and services/) is intentionally kept in scope — # it's regular Go code with the same standards as the rest of the repo. -sonar.exclusions=client/ui-wails/frontend/** +sonar.exclusions=client/ui/frontend/** # Mirror the same paths under coverage so missing coverage on UI code # doesn't drag the overall coverage metric down. -sonar.coverage.exclusions=client/ui-wails/frontend/** \ No newline at end of file +sonar.coverage.exclusions=client/ui/frontend/** \ No newline at end of file From 7f560df9be43311d87412d4affc70f53ec9c1fce Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 11 May 2026 12:01:46 +0200 Subject: [PATCH 75/80] [client/ui] Tray menu opens on click; hide window at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Left-click on the tray icon now opens the menu on every platform — the window is reached through a new "Open NetBird" entry. Only the action that matches the current daemon state is shown: Connect when disconnected, Disconnect when connected. The main window starts hidden and is only surfaced via the tray, single-instance launch, or daemon events. --- client/ui/main.go | 2 +- client/ui/tray.go | 65 +++++++++++++++-------------------------------- 2 files changed, 21 insertions(+), 46 deletions(-) diff --git a/client/ui/main.go b/client/ui/main.go index b706761c2..6fc38a971 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -118,7 +118,7 @@ func main() { Title: "NetBird", Width: 960, Height: 640, - Hidden: false, + Hidden: true, BackgroundColour: application.NewRGB(24, 26, 29), URL: "/", Mac: application.MacWindow{ diff --git a/client/ui/tray.go b/client/ui/tray.go index a3f4e0b7f..f88149d37 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -139,32 +139,15 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe t.applyIcon() t.tray.SetTooltip(trayTooltip) t.tray.SetMenu(t.buildMenu()) - // Tray click handling is platform-specific by design: - // - // On Windows and macOS the OS-level tray protocol cleanly separates left - // and right click. AttachWindow plus an explicit OnClick gives the - // expected "click the icon to toggle the window, right-click to open the - // menu" UX, and the platform never delivers both events at once. - // - // On Linux the tray rides on the org.kde.StatusNotifierItem D-Bus protocol - // (libayatana-appindicator). The SNI Activate signal *is* left-click, but - // several environments — GNOME Shell with the AppIndicator extension is - // the loudest offender — also pop the attached menu on left-click, - // regardless of the ItemIsMenu property the spec defines for that purpose. - // Worse, AttachWindow on its own is enough to trigger this: Wails3's - // SystemTray.applySmartDefaults installs ToggleWindow as the default - // click handler whenever a window is attached, so even without an - // explicit OnClick the window pops up alongside the menu. The result - // looks like a bug to users. - // - // Mirror the legacy Fyne client's behaviour on Linux: skip both - // AttachWindow and OnClick so left-click only opens the menu, and expose - // the window through an explicit "Open NetBird" item. Right-click still - // opens the menu through Wails' default rightClickHandler fallback. - if runtime.GOOS != "linux" { - t.tray.AttachWindow(window) - t.tray.OnClick(func() { t.toggleWindow() }) - } + // Left-click on the tray icon opens the menu on every platform. The + // window is reached through the explicit "Open NetBird" entry. This + // matches macOS NSStatusItem convention (click → menu), the Linux + // StatusNotifierItem spec, and the legacy Fyne client. On Linux, + // AttachWindow plus Wails3's applySmartDefaults would also pop the + // window alongside the menu on environments like GNOME Shell with the + // AppIndicator extension, so we intentionally skip both AttachWindow + // and OnClick here. Right-click still opens the menu through Wails' + // default rightClickHandler fallback. app.Event.On(services.EventStatus, t.onStatusEvent) app.Event.On(services.EventSystem, t.onSystemEvent) @@ -195,16 +178,17 @@ func (t *Tray) buildMenu() *application.Menu { SetEnabled(false) menu.AddSeparator() - // On Linux the tray icon's left-click handler is intentionally unbound - // (see NewTray for the rationale), so expose the window through an - // explicit menu entry. Windows and macOS get the window via left-click. - if runtime.GOOS == "linux" { - menu.Add(menuOpenNetBird).OnClick(func(*application.Context) { t.ShowWindow() }) - menu.AddSeparator() - } + // The tray icon's left-click handler is intentionally unbound (see + // NewTray for the rationale), so expose the window through an explicit + // menu entry on every platform. + menu.Add(menuOpenNetBird).OnClick(func(*application.Context) { t.ShowWindow() }) + menu.AddSeparator() + // Only the action that applies to the current state is visible: Connect + // when disconnected, Disconnect when connected. applyStatus swaps them on + // each daemon status change. t.upItem = menu.Add(menuConnect).OnClick(func(*application.Context) { t.handleConnect() }) t.downItem = menu.Add(menuDisconnect).OnClick(func(*application.Context) { t.handleDisconnect() }) - t.downItem.SetEnabled(false) + t.downItem.SetHidden(true) menu.AddSeparator() @@ -245,17 +229,6 @@ func (t *Tray) buildMenu() *application.Menu { return menu } -func (t *Tray) toggleWindow() { - if t.window == nil { - return - } - if t.window.IsVisible() { - t.window.Hide() - return - } - t.window.Show() -} - func (t *Tray) openRoute(route string) { if t.window == nil { return @@ -466,9 +439,11 @@ func (t *Tray) applyStatus(st services.Status) { t.statusItem.SetEnabled(needsLogin) } if t.upItem != nil { + t.upItem.SetHidden(connected || needsLogin) t.upItem.SetEnabled(!connected && !needsLogin) } if t.downItem != nil { + t.downItem.SetHidden(!connected) t.downItem.SetEnabled(connected) } } From 595dfbb6f190e9fda434486c477ff6d6d3e00819 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 11 May 2026 12:22:47 +0200 Subject: [PATCH 76/80] [client/ui] Distinguish "daemon not running" tray state The status stream emits a synthetic StatusDaemonUnavailable when the gRPC client or stream cannot be established, fired once per outage and cleared on the next real snapshot. The tray maps it to a "Not running" status label, switches the icon to the error variant, hides Connect/Disconnect (neither would work without the daemon), and disables Settings, Networks and Create Debug Bundle so the user is not routed to pages that would just fail to load. --- client/ui/services/peers.go | 23 +++++++++++++++++++++ client/ui/tray.go | 40 +++++++++++++++++++++++++++++-------- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/client/ui/services/peers.go b/client/ui/services/peers.go index 8559f3bf1..2fa2a1b12 100644 --- a/client/ui/services/peers.go +++ b/client/ui/services/peers.go @@ -29,6 +29,12 @@ const ( // started) installing an update — Mode 2 enforced flow. The UI opens the // progress window in response. EventUpdateProgress = "netbird:update:progress" + + // StatusDaemonUnavailable is the synthetic Status the UI emits when the + // daemon's gRPC socket is unreachable (daemon not running, socket + // permission, etc.). Real daemon statuses come straight from + // internal.Status* — none of those collide with this label. + StatusDaemonUnavailable = "DaemonUnavailable" ) // Emitter is what peers.Watch needs from the host application: a simple @@ -198,13 +204,28 @@ func (s *Peers) statusStreamLoop(ctx context.Context) { Clock: backoff.SystemClock, }, ctx) + // unavailable tracks whether we've already signalled the daemon as + // unreachable. The synthetic event is emitted once per outage so the + // tray flips to the "Daemon not running" state, but the exponential + // backoff retries don't re-fire it on every attempt. + unavailable := false + emitUnavailable := func() { + if unavailable { + return + } + unavailable = true + s.emitter.Emit(EventStatus, Status{Status: StatusDaemonUnavailable}) + } + op := func() error { cli, err := s.conn.Client() if err != nil { + emitUnavailable() return fmt.Errorf("get client: %w", err) } stream, err := cli.SubscribeStatus(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) if err != nil { + emitUnavailable() return fmt.Errorf("subscribe status: %w", err) } for { @@ -213,8 +234,10 @@ func (s *Peers) statusStreamLoop(ctx context.Context) { if ctx.Err() != nil { return ctx.Err() } + emitUnavailable() return fmt.Errorf("status stream recv: %w", err) } + unavailable = false st := statusFromProto(resp) log.Infof("backend event: status status=%q peers=%d", st.Status, len(st.Peers)) s.emitter.Emit(EventStatus, st) diff --git a/client/ui/tray.go b/client/ui/tray.go index f88149d37..fd8ab351f 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -26,8 +26,9 @@ const ( trayTooltip = "NetBird" // Top-level menu entries. - menuStatusDisconnected = "Disconnected" - menuOpenNetBird = "Open NetBird" + menuStatusDisconnected = "Disconnected" + menuStatusDaemonUnavailable = "Not running" + menuOpenNetBird = "Open NetBird" menuConnect = "Connect" menuDisconnect = "Disconnect" menuExitNode = "Exit Node" @@ -112,6 +113,8 @@ type Tray struct { downItem *application.MenuItem exitNodeItem *application.MenuItem networksItem *application.MenuItem + settingsItem *application.MenuItem + debugItem *application.MenuItem updateItem *application.MenuItem daemonVersionItem *application.MenuItem @@ -201,8 +204,8 @@ func (t *Tray) buildMenu() *application.Menu { // block-inbound, auto-connect, notifications) and profile switching // all live in the in-window Settings page now. The tray menu only // surfaces the day-to-day actions. - menu.Add(menuSettings).OnClick(func(*application.Context) { t.openRoute("/settings") }) - menu.Add(menuCreateDebugBundle).OnClick(func(*application.Context) { t.openRoute("/debug") }) + t.settingsItem = menu.Add(menuSettings).OnClick(func(*application.Context) { t.openRoute("/settings") }) + t.debugItem = menu.Add(menuCreateDebugBundle).OnClick(func(*application.Context) { t.openRoute("/debug") }) menu.AddSeparator() @@ -432,20 +435,40 @@ func (t *Tray) applyStatus(st services.Status) { t.applyIcon() needsLogin := strings.EqualFold(st.Status, statusNeedsLogin) || strings.EqualFold(st.Status, statusSessionExpired) + daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable) if t.statusItem != nil { // When the daemon needs re-authentication the status row turns // into the actionable Login entry — Connect would only fail. - t.statusItem.SetLabel(st.Status) + // When the daemon socket is unreachable, swap the label to make + // the cause obvious; Connect/Disconnect would just fail. + label := st.Status + if daemonUnavailable { + label = menuStatusDaemonUnavailable + } + t.statusItem.SetLabel(label) t.statusItem.SetEnabled(needsLogin) } if t.upItem != nil { - t.upItem.SetHidden(connected || needsLogin) - t.upItem.SetEnabled(!connected && !needsLogin) + t.upItem.SetHidden(connected || needsLogin || daemonUnavailable) + t.upItem.SetEnabled(!connected && !needsLogin && !daemonUnavailable) } if t.downItem != nil { t.downItem.SetHidden(!connected) t.downItem.SetEnabled(connected) } + // Settings, Networks and Debug Bundle all drive daemon RPCs from + // their respective frontend routes — disable them while the daemon + // socket is unreachable so the user doesn't land on a page that + // would only fail to load. + if t.networksItem != nil { + t.networksItem.SetEnabled(!daemonUnavailable) + } + if t.settingsItem != nil { + t.settingsItem.SetEnabled(!daemonUnavailable) + } + if t.debugItem != nil { + t.debugItem.SetEnabled(!daemonUnavailable) + } } if exitNodesChanged { t.rebuildExitNodes(exitNodes) @@ -517,7 +540,8 @@ func (t *Tray) iconForState() (icon, dark []byte) { t.mu.Unlock() connecting := strings.EqualFold(statusLabel, "Connecting") - errored := strings.EqualFold(statusLabel, "Error") + errored := strings.EqualFold(statusLabel, "Error") || + strings.EqualFold(statusLabel, services.StatusDaemonUnavailable) if runtime.GOOS == "darwin" { switch { From b43a09a1c7fa7172647aa7ce1708a3edb9e050f3 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 11 May 2026 13:22:30 +0200 Subject: [PATCH 77/80] [client/ui] Add tray icon for needs-login/login-failed states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tray now switches to a dedicated lock icon when the daemon reports NeedsLogin, SessionExpired or LoginFailed — the latter mirrors the CLI, which groups these three statuses together as "needs authentication" and prints the same "Run netbird up" prompt. The macOS template variant reuses the existing error-macos PNG because the project's macOS tray PNGs use a 2-color (black + transparent) convention that rsvg-convert of the badge-style SVG sources can't reproduce. The earlier badge-style SVG sketches in assets/svg/ are removed (they were marked as reference only and never matched the shipping PNG design). --- .../netbird-systemtray-needs-login-macos.png | Bin 0 -> 3555 bytes .../assets/netbird-systemtray-needs-login.png | Bin 0 -> 9909 bytes client/ui/assets/svg/_base.svg | 14 -------------- client/ui/assets/svg/appicon.svg | 17 ----------------- client/ui/assets/svg/connected-macos.svg | 10 ---------- client/ui/assets/svg/connected.svg | 14 -------------- client/ui/assets/svg/connecting-macos.svg | 9 --------- client/ui/assets/svg/connecting.svg | 9 --------- client/ui/assets/svg/disconnected-macos.svg | 10 ---------- client/ui/assets/svg/disconnected.svg | 10 ---------- client/ui/assets/svg/error-macos.svg | 11 ----------- .../assets/svg/{error.svg => needs-login.svg} | 5 ++--- .../ui/assets/svg/update-connected-macos.svg | 10 ---------- client/ui/assets/svg/update-connected.svg | 10 ---------- .../assets/svg/update-disconnected-macos.svg | 10 ---------- client/ui/assets/svg/update-disconnected.svg | 10 ---------- client/ui/icons.go | 6 ++++++ client/ui/tray.go | 16 +++++++++++++++- 18 files changed, 23 insertions(+), 148 deletions(-) create mode 100644 client/ui/assets/netbird-systemtray-needs-login-macos.png create mode 100644 client/ui/assets/netbird-systemtray-needs-login.png delete mode 100644 client/ui/assets/svg/_base.svg delete mode 100644 client/ui/assets/svg/appicon.svg delete mode 100644 client/ui/assets/svg/connected-macos.svg delete mode 100644 client/ui/assets/svg/connected.svg delete mode 100644 client/ui/assets/svg/connecting-macos.svg delete mode 100644 client/ui/assets/svg/connecting.svg delete mode 100644 client/ui/assets/svg/disconnected-macos.svg delete mode 100644 client/ui/assets/svg/disconnected.svg delete mode 100644 client/ui/assets/svg/error-macos.svg rename client/ui/assets/svg/{error.svg => needs-login.svg} (71%) delete mode 100644 client/ui/assets/svg/update-connected-macos.svg delete mode 100644 client/ui/assets/svg/update-connected.svg delete mode 100644 client/ui/assets/svg/update-disconnected-macos.svg delete mode 100644 client/ui/assets/svg/update-disconnected.svg diff --git a/client/ui/assets/netbird-systemtray-needs-login-macos.png b/client/ui/assets/netbird-systemtray-needs-login-macos.png new file mode 100644 index 0000000000000000000000000000000000000000..580fe647c29bce036a816fd20f7ce0a6ac0824cb GIT binary patch literal 3555 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 OdBMWYyyBea{eJ@L!Le-s literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-systemtray-needs-login.png b/client/ui/assets/netbird-systemtray-needs-login.png new file mode 100644 index 0000000000000000000000000000000000000000..c4b8b4bf47bb3d1c49de83b29316dbf355d85ab5 GIT binary patch literal 9909 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 - - - - - - - diff --git a/client/ui/assets/svg/appicon.svg b/client/ui/assets/svg/appicon.svg deleted file mode 100644 index 773ad3417..000000000 --- a/client/ui/assets/svg/appicon.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - diff --git a/client/ui/assets/svg/connected-macos.svg b/client/ui/assets/svg/connected-macos.svg deleted file mode 100644 index d1e2ce18c..000000000 --- a/client/ui/assets/svg/connected-macos.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/client/ui/assets/svg/connected.svg b/client/ui/assets/svg/connected.svg deleted file mode 100644 index 687d8e2e5..000000000 --- a/client/ui/assets/svg/connected.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - diff --git a/client/ui/assets/svg/connecting-macos.svg b/client/ui/assets/svg/connecting-macos.svg deleted file mode 100644 index 04d666c5f..000000000 --- a/client/ui/assets/svg/connecting-macos.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/client/ui/assets/svg/connecting.svg b/client/ui/assets/svg/connecting.svg deleted file mode 100644 index d3818055a..000000000 --- a/client/ui/assets/svg/connecting.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/client/ui/assets/svg/disconnected-macos.svg b/client/ui/assets/svg/disconnected-macos.svg deleted file mode 100644 index 06802c9d4..000000000 --- a/client/ui/assets/svg/disconnected-macos.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/client/ui/assets/svg/disconnected.svg b/client/ui/assets/svg/disconnected.svg deleted file mode 100644 index 31eab7970..000000000 --- a/client/ui/assets/svg/disconnected.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/client/ui/assets/svg/error-macos.svg b/client/ui/assets/svg/error-macos.svg deleted file mode 100644 index 4c6d4e76d..000000000 --- a/client/ui/assets/svg/error-macos.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/client/ui/assets/svg/error.svg b/client/ui/assets/svg/needs-login.svg similarity index 71% rename from client/ui/assets/svg/error.svg rename to client/ui/assets/svg/needs-login.svg index 46ac0d762..5c01b48d4 100644 --- a/client/ui/assets/svg/error.svg +++ b/client/ui/assets/svg/needs-login.svg @@ -5,7 +5,6 @@ - - - + + diff --git a/client/ui/assets/svg/update-connected-macos.svg b/client/ui/assets/svg/update-connected-macos.svg deleted file mode 100644 index 774e631e6..000000000 --- a/client/ui/assets/svg/update-connected-macos.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/client/ui/assets/svg/update-connected.svg b/client/ui/assets/svg/update-connected.svg deleted file mode 100644 index 45e22693b..000000000 --- a/client/ui/assets/svg/update-connected.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/client/ui/assets/svg/update-disconnected-macos.svg b/client/ui/assets/svg/update-disconnected-macos.svg deleted file mode 100644 index fe161cc44..000000000 --- a/client/ui/assets/svg/update-disconnected-macos.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/client/ui/assets/svg/update-disconnected.svg b/client/ui/assets/svg/update-disconnected.svg deleted file mode 100644 index 657974005..000000000 --- a/client/ui/assets/svg/update-disconnected.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/client/ui/icons.go b/client/ui/icons.go index 28d4582cc..3fe3ea9ef 100644 --- a/client/ui/icons.go +++ b/client/ui/icons.go @@ -26,6 +26,9 @@ var iconConnecting []byte //go:embed assets/netbird-systemtray-error.png var iconError []byte +//go:embed assets/netbird-systemtray-needs-login.png +var iconNeedsLogin []byte + //go:embed assets/netbird-systemtray-update-connected.png var iconUpdateConnected []byte @@ -44,6 +47,9 @@ var iconConnectingMacOS []byte //go:embed assets/netbird-systemtray-error-macos.png var iconErrorMacOS []byte +//go:embed assets/netbird-systemtray-needs-login-macos.png +var iconNeedsLoginMacOS []byte + //go:embed assets/netbird-systemtray-update-connected-macos.png var iconUpdateConnectedMacOS []byte diff --git a/client/ui/tray.go b/client/ui/tray.go index fd8ab351f..27b8b545c 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -80,6 +80,12 @@ const ( // completed an SSO authentication on this profile. Mirrors // internal.StatusNeedsLogin. statusNeedsLogin = "NeedsLogin" + // statusLoginFailed is what the daemon publishes when a login attempt + // failed with a non-auth error (management unreachable, init error, + // etc.). The CLI groups it with NeedsLogin/SessionExpired and prompts + // the user to run "netbird up", so we mirror that here. Mirrors + // internal.StatusLoginFailed. + statusLoginFailed = "LoginFailed" // External URLs. urlGitHubRepo = "https://github.com/netbirdio/netbird" @@ -434,7 +440,8 @@ func (t *Tray) applyStatus(st services.Status) { if iconChanged { t.applyIcon() needsLogin := strings.EqualFold(st.Status, statusNeedsLogin) || - strings.EqualFold(st.Status, statusSessionExpired) + strings.EqualFold(st.Status, statusSessionExpired) || + strings.EqualFold(st.Status, statusLoginFailed) daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable) if t.statusItem != nil { // When the daemon needs re-authentication the status row turns @@ -542,6 +549,9 @@ func (t *Tray) iconForState() (icon, dark []byte) { connecting := strings.EqualFold(statusLabel, "Connecting") errored := strings.EqualFold(statusLabel, "Error") || strings.EqualFold(statusLabel, services.StatusDaemonUnavailable) + needsLogin := strings.EqualFold(statusLabel, statusNeedsLogin) || + strings.EqualFold(statusLabel, statusSessionExpired) || + strings.EqualFold(statusLabel, statusLoginFailed) if runtime.GOOS == "darwin" { switch { @@ -549,6 +559,8 @@ func (t *Tray) iconForState() (icon, dark []byte) { return iconConnectingMacOS, nil case errored: return iconErrorMacOS, nil + case needsLogin: + return iconNeedsLoginMacOS, nil case connected && hasUpdate: return iconUpdateConnectedMacOS, nil case connected: @@ -565,6 +577,8 @@ func (t *Tray) iconForState() (icon, dark []byte) { return iconConnecting, nil case errored: return iconError, nil + case needsLogin: + return iconNeedsLogin, nil case connected && hasUpdate: return iconUpdateConnected, nil case connected: From 8841b950a2346a4fd6631882573b4bdaa6f8c283 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 11 May 2026 13:43:53 +0200 Subject: [PATCH 78/80] [client/server] Stop retry loop after PermissionDenied login Without marking the error as backoff.Permanent the outer retry re-enters connect(), which resets the daemon state from NeedsLogin to Connecting and makes the tray flicker between the two until the user logs in. --- client/server/server.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/server/server.go b/client/server/server.go index 55d166fdb..a79e49457 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -258,6 +258,15 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil runOperation := func() error { err := s.connect(ctx, profileConfig, statusRecorder, runningChan) if err != nil { + // PermissionDenied means the daemon transitioned to NeedsLogin + // inside connect(). Without backoff.Permanent the outer retry + // re-enters connect(), which resets the state to Connecting and + // makes the tray flicker between NeedsLogin and Connecting until + // the user logs in. Stop retrying and let the state stick. + if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied { + log.Debugf("run client connection exited with PermissionDenied, waiting for login") + return backoff.Permanent(err) + } log.Debugf("run client connection exited with error: %v. Will retry in the background", err) return err } From 28a7eba7564e69a35684e3b496f6908f517851a3 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 11 May 2026 13:54:17 +0200 Subject: [PATCH 79/80] [client/ui] Remove unused xembed_host_other.go stub --- client/ui/xembed_host_other.go | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 client/ui/xembed_host_other.go diff --git a/client/ui/xembed_host_other.go b/client/ui/xembed_host_other.go deleted file mode 100644 index c93d78413..000000000 --- a/client/ui/xembed_host_other.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build !linux || (linux && 386) - -package main - -import ( - "errors" - - "github.com/godbus/dbus/v5" -) - -type xembedHost struct{} - -func newXembedHost(_ *dbus.Conn, _ string, _ dbus.ObjectPath) (*xembedHost, error) { - return nil, errors.New("XEmbed tray not supported on this platform") -} - -func (h *xembedHost) run() {} -func (h *xembedHost) stop() {} From 0c287b6f4df8edfde8ec4cfd8e6b3002315e2cf9 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Mon, 11 May 2026 14:48:37 +0200 Subject: [PATCH 80/80] fix vite dev server --- client/ui/frontend/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ui/frontend/vite.config.ts b/client/ui/frontend/vite.config.ts index f58d1fa4a..502d3b6d2 100644 --- a/client/ui/frontend/vite.config.ts +++ b/client/ui/frontend/vite.config.ts @@ -6,6 +6,7 @@ import wails from "@wailsio/runtime/plugins/vite"; export default defineConfig({ plugins: [react(), wails("./bindings")], server: { + host: "127.0.0.1", port: 9245, strictPort: true, },