From fe370e7d8f6feef4bd6aecb584c514eb09b1b679 Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Wed, 5 Feb 2025 14:03:53 -0800 Subject: [PATCH 01/29] [relay] Use new upstream for nhooyr.io/websocket package (#3287) The nhooyr.io/websocket package was renamed to github.com/coder/websocket when the project was transferred to "coder" as the new maintainer. Use the new import path and update go.mod and go.sum accordingly. Signed-off-by: Christian Stewart --- go.mod | 2 +- go.sum | 4 ++-- relay/client/dialer/ws/conn.go | 2 +- relay/client/dialer/ws/ws.go | 2 +- relay/server/listener/ws/conn.go | 2 +- relay/server/listener/ws/listener.go | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 13adeff09..77d570662 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/c-robinson/iplib v1.0.3 github.com/caddyserver/certmagic v0.21.3 github.com/cilium/ebpf v0.15.0 + github.com/coder/websocket v1.8.12 github.com/coreos/go-iptables v0.7.0 github.com/creack/pty v1.1.18 github.com/davecgh/go-spew v1.1.1 @@ -101,7 +102,6 @@ require ( gorm.io/driver/postgres v1.5.7 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 - nhooyr.io/websocket v1.8.11 ) require ( diff --git a/go.sum b/go.sum index e3670b99e..4b9e90eba 100644 --- a/go.sum +++ b/go.sum @@ -137,6 +137,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/containerd/containerd v1.7.16 h1:7Zsfe8Fkj4Wi2My6DXGQ87hiqIrmOXolm72ZEkFU5Mg= github.com/containerd/containerd v1.7.16/go.mod h1:NL49g7A/Fui7ccmxV6zkBWwqMgmMxFWzujYCc+JLt7k= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= @@ -1264,8 +1266,6 @@ k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8 k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= -nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= -nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/relay/client/dialer/ws/conn.go b/relay/client/dialer/ws/conn.go index 74bcafd82..0086b702b 100644 --- a/relay/client/dialer/ws/conn.go +++ b/relay/client/dialer/ws/conn.go @@ -6,7 +6,7 @@ import ( "net" "time" - "nhooyr.io/websocket" + "github.com/coder/websocket" ) type Conn struct { diff --git a/relay/client/dialer/ws/ws.go b/relay/client/dialer/ws/ws.go index 2adbd2451..b007e24bb 100644 --- a/relay/client/dialer/ws/ws.go +++ b/relay/client/dialer/ws/ws.go @@ -12,7 +12,7 @@ import ( "strings" log "github.com/sirupsen/logrus" - "nhooyr.io/websocket" + "github.com/coder/websocket" "github.com/netbirdio/netbird/relay/server/listener/ws" "github.com/netbirdio/netbird/util/embeddedroots" diff --git a/relay/server/listener/ws/conn.go b/relay/server/listener/ws/conn.go index 12e721fdb..3466b2abd 100644 --- a/relay/server/listener/ws/conn.go +++ b/relay/server/listener/ws/conn.go @@ -9,7 +9,7 @@ import ( "time" log "github.com/sirupsen/logrus" - "nhooyr.io/websocket" + "github.com/coder/websocket" ) const ( diff --git a/relay/server/listener/ws/listener.go b/relay/server/listener/ws/listener.go index 0eb244c77..4597669dc 100644 --- a/relay/server/listener/ws/listener.go +++ b/relay/server/listener/ws/listener.go @@ -9,7 +9,7 @@ import ( "net/http" log "github.com/sirupsen/logrus" - "nhooyr.io/websocket" + "github.com/coder/websocket" ) // URLPath is the path for the websocket connection. From e00a280329f77e9572ec43b0005366916b9bcce7 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 5 Feb 2025 23:04:52 +0100 Subject: [PATCH 02/29] [client] Fix grouping of peer ACLs with different port ranges (#3289) --- client/internal/acl/manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/internal/acl/manager.go b/client/internal/acl/manager.go index a015e0a49..31173a5f7 100644 --- a/client/internal/acl/manager.go +++ b/client/internal/acl/manager.go @@ -507,7 +507,7 @@ func (d *DefaultManager) squashAcceptRules( // getRuleGroupingSelector takes all rule properties except IP address to build selector func (d *DefaultManager) getRuleGroupingSelector(rule *mgmProto.FirewallRule) string { - return fmt.Sprintf("%v:%v:%v:%s", strconv.Itoa(int(rule.Direction)), rule.Action, rule.Protocol, rule.Port) + 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) { From ca9aca9b19b82d2976099c92d9ec7d9de8eb44c4 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 6 Feb 2025 10:20:31 +0100 Subject: [PATCH 03/29] Fix nil pointer exception when load empty list and try to cast it (#3282) --- client/internal/engine.go | 3 +-- client/internal/peer/ice/StunTurn.go | 22 ++++++++++++++++++++++ client/internal/peer/ice/StunTurn_test.go | 13 +++++++++++++ client/internal/peer/ice/agent.go | 3 +-- client/internal/peer/ice/config.go | 4 +--- 5 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 client/internal/peer/ice/StunTurn.go create mode 100644 client/internal/peer/ice/StunTurn_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index 7f7cdf376..335729d92 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -13,7 +13,6 @@ import ( "sort" "strings" "sync" - "sync/atomic" "time" "github.com/hashicorp/go-multierror" @@ -146,7 +145,7 @@ type Engine struct { STUNs []*stun.URI // TURNs is a list of STUN servers used by ICE TURNs []*stun.URI - stunTurn atomic.Value + stunTurn icemaker.StunTurn clientCtx context.Context clientCancel context.CancelFunc diff --git a/client/internal/peer/ice/StunTurn.go b/client/internal/peer/ice/StunTurn.go new file mode 100644 index 000000000..63ee8c713 --- /dev/null +++ b/client/internal/peer/ice/StunTurn.go @@ -0,0 +1,22 @@ +package ice + +import ( + "sync/atomic" + + "github.com/pion/stun/v2" +) + +type StunTurn atomic.Value + +func (s *StunTurn) Load() []*stun.URI { + v := (*atomic.Value)(s).Load() + if v == nil { + return nil + } + + return v.([]*stun.URI) +} + +func (s *StunTurn) Store(value []*stun.URI) { + (*atomic.Value)(s).Store(value) +} diff --git a/client/internal/peer/ice/StunTurn_test.go b/client/internal/peer/ice/StunTurn_test.go new file mode 100644 index 000000000..7233afa6c --- /dev/null +++ b/client/internal/peer/ice/StunTurn_test.go @@ -0,0 +1,13 @@ +package ice + +import ( + "testing" +) + +func TestStunTurn_LoadEmpty(t *testing.T) { + var stStunTurn StunTurn + got := stStunTurn.Load() + if len(got) != 0 { + t.Errorf("StunTurn.Load() = %v, want %v", got, nil) + } +} diff --git a/client/internal/peer/ice/agent.go b/client/internal/peer/ice/agent.go index dc4750f24..af9e60f2d 100644 --- a/client/internal/peer/ice/agent.go +++ b/client/internal/peer/ice/agent.go @@ -5,7 +5,6 @@ import ( "github.com/pion/ice/v3" "github.com/pion/randutil" - "github.com/pion/stun/v2" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal/stdnet" @@ -39,7 +38,7 @@ func NewAgent(iFaceDiscover stdnet.ExternalIFaceDiscover, config Config, candida agentConfig := &ice.AgentConfig{ MulticastDNSMode: ice.MulticastDNSModeDisabled, NetworkTypes: []ice.NetworkType{ice.NetworkTypeUDP4, ice.NetworkTypeUDP6}, - Urls: config.StunTurn.Load().([]*stun.URI), + Urls: config.StunTurn.Load(), CandidateTypes: candidateTypes, InterfaceFilter: stdnet.InterfaceFilter(config.InterfaceBlackList), UDPMux: config.UDPMux, diff --git a/client/internal/peer/ice/config.go b/client/internal/peer/ice/config.go index 8abc842f0..dd854a605 100644 --- a/client/internal/peer/ice/config.go +++ b/client/internal/peer/ice/config.go @@ -1,14 +1,12 @@ package ice import ( - "sync/atomic" - "github.com/pion/ice/v3" ) type Config struct { // StunTurn is a list of STUN and TURN URLs - StunTurn *atomic.Value // []*stun.URI + StunTurn *StunTurn // []*stun.URI // InterfaceBlackList is a list of machine interfaces that should be filtered out by ICE Candidate gathering // (e.g. if eth0 is in the list, host candidate of this interface won't be used) From cee4aeea9e48376707974f301f280b3d813131a9 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 6 Feb 2025 13:36:57 +0100 Subject: [PATCH 04/29] [management] Check groups when counting peers on networks list (#3284) --- management/server/http/handlers/networks/handler.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/management/server/http/handlers/networks/handler.go b/management/server/http/handlers/networks/handler.go index 316b93611..f716348d6 100644 --- a/management/server/http/handlers/networks/handler.go +++ b/management/server/http/handlers/networks/handler.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" s "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/groups" @@ -281,7 +282,12 @@ func (h *handler) collectIDsInNetwork(ctx context.Context, accountID, userID, ne } if len(router.PeerGroups) > 0 { for _, groupID := range router.PeerGroups { - peerCounter += len(groups[groupID].Peers) + group, ok := groups[groupID] + if !ok { + log.WithContext(ctx).Warnf("group %s not found", groupID) + continue + } + peerCounter += len(group.Peers) } } } From b7af53ea40b74f52ac57ec874690e93bd2521abb Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 7 Feb 2025 13:51:17 +0100 Subject: [PATCH 05/29] [management] add logs for grpc API (#3298) --- management/server/grpcserver.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/management/server/grpcserver.go b/management/server/grpcserver.go index eec109ee9..e8e0c422e 100644 --- a/management/server/grpcserver.go +++ b/management/server/grpcserver.go @@ -15,6 +15,7 @@ import ( log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc/codes" + "google.golang.org/grpc/peer" "google.golang.org/grpc/status" "github.com/netbirdio/netbird/encryption" @@ -114,6 +115,18 @@ func NewServer( } func (s *GRPCServer) GetServerKey(ctx context.Context, req *proto.Empty) (*proto.ServerKeyResponse, error) { + ip := "" + p, ok := peer.FromContext(ctx) + if ok { + ip = p.Addr.String() + } + + log.WithContext(ctx).Tracef("GetServerKey request from %s", ip) + start := time.Now() + defer func() { + log.WithContext(ctx).Tracef("GetServerKey from %s took %v", ip, time.Since(start)) + }() + // todo introduce something more meaningful with the key expiration/rotation if s.appMetrics != nil { s.appMetrics.GRPCMetrics().CountGetKeyRequest() @@ -717,6 +730,12 @@ func (s *GRPCServer) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, p // This is used for initiating an Oauth 2 device authorization grant flow // which will be used by our clients to Login func (s *GRPCServer) GetDeviceAuthorizationFlow(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { + log.WithContext(ctx).Tracef("GetDeviceAuthorizationFlow request for pubKey: %s", req.WgPubKey) + start := time.Now() + defer func() { + log.WithContext(ctx).Tracef("GetDeviceAuthorizationFlow for pubKey: %s took %v", req.WgPubKey, time.Since(start)) + }() + peerKey, err := wgtypes.ParseKey(req.GetWgPubKey()) if err != nil { errMSG := fmt.Sprintf("error while parsing peer's Wireguard public key %s on GetDeviceAuthorizationFlow request.", req.WgPubKey) @@ -769,6 +788,12 @@ func (s *GRPCServer) GetDeviceAuthorizationFlow(ctx context.Context, req *proto. // This is used for initiating an Oauth 2 pkce authorization grant flow // which will be used by our clients to Login func (s *GRPCServer) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { + log.WithContext(ctx).Tracef("GetPKCEAuthorizationFlow request for pubKey: %s", req.WgPubKey) + start := time.Now() + defer func() { + log.WithContext(ctx).Tracef("GetPKCEAuthorizationFlow for pubKey %s took %v", req.WgPubKey, time.Since(start)) + }() + peerKey, err := wgtypes.ParseKey(req.GetWgPubKey()) if err != nil { errMSG := fmt.Sprintf("error while parsing peer's Wireguard public key %s on GetPKCEAuthorizationFlow request.", req.WgPubKey) From 05415f72ec827835edd211f332a235f5b8e02ba6 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Fri, 7 Feb 2025 14:11:53 +0100 Subject: [PATCH 06/29] [client] Add experimental support for userspace routing (#3134) --- client/Dockerfile-rootless | 1 + client/cmd/trace.go | 137 +++ client/firewall/create.go | 4 +- client/firewall/create_linux.go | 14 +- client/firewall/iface.go | 4 + client/firewall/iptables/manager_linux.go | 5 + client/firewall/iptables/router_linux.go | 11 +- client/firewall/manager/firewall.go | 2 + client/firewall/nftables/manager_linux.go | 5 + .../firewall/nftables/manager_linux_test.go | 17 +- client/firewall/nftables/router_linux.go | 8 +- client/firewall/uspfilter/allow_netbird.go | 23 +- .../uspfilter/allow_netbird_windows.go | 20 +- client/firewall/uspfilter/common/iface.go | 16 + client/firewall/uspfilter/conntrack/common.go | 21 +- .../uspfilter/conntrack/common_test.go | 36 +- client/firewall/uspfilter/conntrack/icmp.go | 27 +- .../firewall/uspfilter/conntrack/icmp_test.go | 4 +- client/firewall/uspfilter/conntrack/tcp.go | 41 +- .../firewall/uspfilter/conntrack/tcp_test.go | 14 +- client/firewall/uspfilter/conntrack/udp.go | 19 +- .../firewall/uspfilter/conntrack/udp_test.go | 12 +- .../firewall/uspfilter/forwarder/endpoint.go | 81 ++ .../firewall/uspfilter/forwarder/forwarder.go | 166 +++ client/firewall/uspfilter/forwarder/icmp.go | 109 ++ client/firewall/uspfilter/forwarder/tcp.go | 90 ++ client/firewall/uspfilter/forwarder/udp.go | 288 +++++ client/firewall/uspfilter/localip.go | 134 +++ client/firewall/uspfilter/localip_test.go | 270 +++++ client/firewall/uspfilter/log/log.go | 196 ++++ client/firewall/uspfilter/log/ringbuffer.go | 85 ++ client/firewall/uspfilter/rule.go | 22 +- client/firewall/uspfilter/tracer.go | 390 +++++++ client/firewall/uspfilter/uspfilter.go | 516 +++++++-- .../uspfilter/uspfilter_bench_test.go | 122 +- .../uspfilter/uspfilter_filter_test.go | 1014 +++++++++++++++++ client/firewall/uspfilter/uspfilter_test.go | 66 +- client/iface/device.go | 3 + client/iface/device/device_darwin.go | 5 + client/iface/device/device_kernel_unix.go | 6 + client/iface/device/device_netstack.go | 5 + client/iface/device/device_usp_unix.go | 5 + client/iface/device/device_windows.go | 5 + client/iface/device_android.go | 3 + client/iface/iface.go | 7 + client/iface/iface_moc.go | 9 +- client/iface/iwginterface.go | 2 + client/iface/iwginterface_windows.go | 2 + client/internal/acl/manager_test.go | 6 +- client/internal/acl/mocks/iface_mapper.go | 30 + client/internal/dns/server_test.go | 2 +- client/internal/engine.go | 29 +- client/internal/routemanager/manager.go | 5 - client/proto/daemon.pb.go | 690 ++++++++--- client/proto/daemon.proto | 35 + client/proto/daemon_grpc.pb.go | 36 + client/server/debug.go | 17 + client/server/trace.go | 123 ++ go.mod | 4 +- go.sum | 8 +- 60 files changed, 4652 insertions(+), 375 deletions(-) create mode 100644 client/cmd/trace.go create mode 100644 client/firewall/uspfilter/common/iface.go create mode 100644 client/firewall/uspfilter/forwarder/endpoint.go create mode 100644 client/firewall/uspfilter/forwarder/forwarder.go create mode 100644 client/firewall/uspfilter/forwarder/icmp.go create mode 100644 client/firewall/uspfilter/forwarder/tcp.go create mode 100644 client/firewall/uspfilter/forwarder/udp.go create mode 100644 client/firewall/uspfilter/localip.go create mode 100644 client/firewall/uspfilter/localip_test.go create mode 100644 client/firewall/uspfilter/log/log.go create mode 100644 client/firewall/uspfilter/log/ringbuffer.go create mode 100644 client/firewall/uspfilter/tracer.go create mode 100644 client/firewall/uspfilter/uspfilter_filter_test.go create mode 100644 client/server/trace.go diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless index 62bcaf964..78314ba12 100644 --- a/client/Dockerfile-rootless +++ b/client/Dockerfile-rootless @@ -9,6 +9,7 @@ USER netbird:netbird ENV NB_FOREGROUND_MODE=true ENV NB_USE_NETSTACK_MODE=true +ENV NB_ENABLE_NETSTACK_LOCAL_FORWARDING=true ENV NB_CONFIG=config.json ENV NB_DAEMON_ADDR=unix://netbird.sock ENV NB_DISABLE_DNS=true diff --git a/client/cmd/trace.go b/client/cmd/trace.go new file mode 100644 index 000000000..b2ff1f1b5 --- /dev/null +++ b/client/cmd/trace.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "fmt" + "math/rand" + "strings" + + "github.com/spf13/cobra" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" +) + +var traceCmd = &cobra.Command{ + Use: "trace ", + Short: "Trace a packet through the firewall", + Example: ` + netbird debug trace in 192.168.1.10 10.10.0.2 -p tcp --sport 12345 --dport 443 --syn --ack + netbird debug trace out 10.10.0.1 8.8.8.8 -p udp --dport 53 + netbird debug trace in 10.10.0.2 10.10.0.1 -p icmp --type 8 --code 0 + netbird debug trace in 100.64.1.1 self -p tcp --dport 80`, + Args: cobra.ExactArgs(3), + RunE: tracePacket, +} + +func init() { + debugCmd.AddCommand(traceCmd) + + traceCmd.Flags().StringP("protocol", "p", "tcp", "Protocol (tcp/udp/icmp)") + traceCmd.Flags().Uint16("sport", 0, "Source port") + traceCmd.Flags().Uint16("dport", 0, "Destination port") + traceCmd.Flags().Uint8("icmp-type", 0, "ICMP type") + traceCmd.Flags().Uint8("icmp-code", 0, "ICMP code") + traceCmd.Flags().Bool("syn", false, "TCP SYN flag") + traceCmd.Flags().Bool("ack", false, "TCP ACK flag") + traceCmd.Flags().Bool("fin", false, "TCP FIN flag") + traceCmd.Flags().Bool("rst", false, "TCP RST flag") + traceCmd.Flags().Bool("psh", false, "TCP PSH flag") + traceCmd.Flags().Bool("urg", false, "TCP URG flag") +} + +func tracePacket(cmd *cobra.Command, args []string) error { + direction := strings.ToLower(args[0]) + if direction != "in" && direction != "out" { + return fmt.Errorf("invalid direction: use 'in' or 'out'") + } + + protocol := cmd.Flag("protocol").Value.String() + if protocol != "tcp" && protocol != "udp" && protocol != "icmp" { + return fmt.Errorf("invalid protocol: use tcp/udp/icmp") + } + + sport, err := cmd.Flags().GetUint16("sport") + if err != nil { + return fmt.Errorf("invalid source port: %v", err) + } + dport, err := cmd.Flags().GetUint16("dport") + if err != nil { + return fmt.Errorf("invalid destination port: %v", err) + } + + // For TCP/UDP, generate random ephemeral port (49152-65535) if not specified + if protocol != "icmp" { + if sport == 0 { + sport = uint16(rand.Intn(16383) + 49152) + } + if dport == 0 { + dport = uint16(rand.Intn(16383) + 49152) + } + } + + var tcpFlags *proto.TCPFlags + if protocol == "tcp" { + syn, _ := cmd.Flags().GetBool("syn") + ack, _ := cmd.Flags().GetBool("ack") + fin, _ := cmd.Flags().GetBool("fin") + rst, _ := cmd.Flags().GetBool("rst") + psh, _ := cmd.Flags().GetBool("psh") + urg, _ := cmd.Flags().GetBool("urg") + + tcpFlags = &proto.TCPFlags{ + Syn: syn, + Ack: ack, + Fin: fin, + Rst: rst, + Psh: psh, + Urg: urg, + } + } + + icmpType, _ := cmd.Flags().GetUint32("icmp-type") + icmpCode, _ := cmd.Flags().GetUint32("icmp-code") + + conn, err := getClient(cmd) + if err != nil { + return err + } + defer conn.Close() + + client := proto.NewDaemonServiceClient(conn) + resp, err := client.TracePacket(cmd.Context(), &proto.TracePacketRequest{ + SourceIp: args[1], + DestinationIp: args[2], + Protocol: protocol, + SourcePort: uint32(sport), + DestinationPort: uint32(dport), + Direction: direction, + TcpFlags: tcpFlags, + IcmpType: &icmpType, + IcmpCode: &icmpCode, + }) + if err != nil { + return fmt.Errorf("trace failed: %v", status.Convert(err).Message()) + } + + printTrace(cmd, args[1], args[2], protocol, sport, dport, resp) + return nil +} + +func printTrace(cmd *cobra.Command, src, dst, proto string, sport, dport uint16, resp *proto.TracePacketResponse) { + cmd.Printf("Packet trace %s:%d -> %s:%d (%s)\n\n", src, sport, dst, dport, strings.ToUpper(proto)) + + for _, stage := range resp.Stages { + if stage.ForwardingDetails != nil { + cmd.Printf("%s: %s [%s]\n", stage.Name, stage.Message, *stage.ForwardingDetails) + } else { + cmd.Printf("%s: %s\n", stage.Name, stage.Message) + } + } + + disposition := map[bool]string{ + true: "\033[32mALLOWED\033[0m", // Green + false: "\033[31mDENIED\033[0m", // Red + }[resp.FinalDisposition] + + cmd.Printf("\nFinal disposition: %s\n", disposition) +} diff --git a/client/firewall/create.go b/client/firewall/create.go index 9466f4b4d..37ea5ceb3 100644 --- a/client/firewall/create.go +++ b/client/firewall/create.go @@ -14,13 +14,13 @@ import ( ) // NewFirewall creates a firewall manager instance -func NewFirewall(iface IFaceMapper, _ *statemanager.Manager) (firewall.Manager, error) { +func NewFirewall(iface IFaceMapper, _ *statemanager.Manager, disableServerRoutes bool) (firewall.Manager, error) { if !iface.IsUserspaceBind() { return nil, fmt.Errorf("not implemented for this OS: %s", runtime.GOOS) } // use userspace packet filtering firewall - fm, err := uspfilter.Create(iface) + fm, err := uspfilter.Create(iface, disableServerRoutes) if err != nil { return nil, err } diff --git a/client/firewall/create_linux.go b/client/firewall/create_linux.go index 076d08ec2..be1b37916 100644 --- a/client/firewall/create_linux.go +++ b/client/firewall/create_linux.go @@ -33,12 +33,12 @@ const SKIP_NFTABLES_ENV = "NB_SKIP_NFTABLES_CHECK" // FWType is the type for the firewall type type FWType int -func NewFirewall(iface IFaceMapper, stateManager *statemanager.Manager) (firewall.Manager, error) { +func NewFirewall(iface IFaceMapper, stateManager *statemanager.Manager, disableServerRoutes bool) (firewall.Manager, error) { // on the linux system we try to user nftables or iptables // in any case, because we need to allow netbird interface traffic // so we use AllowNetbird traffic from these firewall managers // for the userspace packet filtering firewall - fm, err := createNativeFirewall(iface, stateManager) + fm, err := createNativeFirewall(iface, stateManager, disableServerRoutes) if !iface.IsUserspaceBind() { return fm, err @@ -47,10 +47,10 @@ func NewFirewall(iface IFaceMapper, stateManager *statemanager.Manager) (firewal if err != nil { log.Warnf("failed to create native firewall: %v. Proceeding with userspace", err) } - return createUserspaceFirewall(iface, fm) + return createUserspaceFirewall(iface, fm, disableServerRoutes) } -func createNativeFirewall(iface IFaceMapper, stateManager *statemanager.Manager) (firewall.Manager, error) { +func createNativeFirewall(iface IFaceMapper, stateManager *statemanager.Manager, routes bool) (firewall.Manager, error) { fm, err := createFW(iface) if err != nil { return nil, fmt.Errorf("create firewall: %s", err) @@ -77,12 +77,12 @@ func createFW(iface IFaceMapper) (firewall.Manager, error) { } } -func createUserspaceFirewall(iface IFaceMapper, fm firewall.Manager) (firewall.Manager, error) { +func createUserspaceFirewall(iface IFaceMapper, fm firewall.Manager, disableServerRoutes bool) (firewall.Manager, error) { var errUsp error if fm != nil { - fm, errUsp = uspfilter.CreateWithNativeFirewall(iface, fm) + fm, errUsp = uspfilter.CreateWithNativeFirewall(iface, fm, disableServerRoutes) } else { - fm, errUsp = uspfilter.Create(iface) + fm, errUsp = uspfilter.Create(iface, disableServerRoutes) } if errUsp != nil { diff --git a/client/firewall/iface.go b/client/firewall/iface.go index f349f9210..d842abaa1 100644 --- a/client/firewall/iface.go +++ b/client/firewall/iface.go @@ -1,6 +1,8 @@ package firewall import ( + wgdevice "golang.zx2c4.com/wireguard/device" + "github.com/netbirdio/netbird/client/iface/device" ) @@ -10,4 +12,6 @@ type IFaceMapper interface { Address() device.WGAddress IsUserspaceBind() bool SetFilter(device.PacketFilter) error + GetDevice() *device.FilteredDevice + GetWGDevice() *wgdevice.Device } diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 75f082fc4..679f288e3 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -213,6 +213,11 @@ func (m *Manager) AllowNetbird() error { // Flush doesn't need to be implemented for this manager func (m *Manager) Flush() error { return nil } +// SetLogLevel sets the log level for the firewall manager +func (m *Manager) SetLogLevel(log.Level) { + // not supported +} + func getConntrackEstablished() []string { return []string{"-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"} } diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go index a47d3ffe6..6522daa3f 100644 --- a/client/firewall/iptables/router_linux.go +++ b/client/firewall/iptables/router_linux.go @@ -135,7 +135,16 @@ func (r *router) AddRouteFiltering( } rule := genRouteFilteringRuleSpec(params) - if err := r.iptablesClient.Append(tableFilter, chainRTFWD, rule...); err != nil { + // Insert DROP rules at the beginning, append ACCEPT rules at the end + var err error + if action == firewall.ActionDrop { + // after the established rule + err = r.iptablesClient.Insert(tableFilter, chainRTFWD, 2, rule...) + } else { + err = r.iptablesClient.Append(tableFilter, chainRTFWD, rule...) + } + + if err != nil { return nil, fmt.Errorf("add route rule: %v", err) } diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index f46e5eb5d..de25ff1f1 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -99,6 +99,8 @@ type Manager interface { // Flush the changes to firewall controller Flush() error + + SetLogLevel(log.Level) } func GenKey(format string, pair RouterPair) string { diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index a78626dbc..4fe52bd53 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -318,6 +318,11 @@ func (m *Manager) cleanupNetbirdTables() error { return nil } +// SetLogLevel sets the log level for the firewall manager +func (m *Manager) SetLogLevel(log.Level) { + // not supported +} + // Flush rule/chain/set operations from the buffer // // Method also get all rules after flush and refreshes handle values in the rulesets diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go index 8d693725a..eaa8ef1f5 100644 --- a/client/firewall/nftables/manager_linux_test.go +++ b/client/firewall/nftables/manager_linux_test.go @@ -107,7 +107,7 @@ func TestNftablesManager(t *testing.T) { Kind: expr.VerdictAccept, }, } - require.ElementsMatch(t, rules[0].Exprs, expectedExprs1, "expected the same expressions") + compareExprsIgnoringCounters(t, rules[0].Exprs, expectedExprs1) ipToAdd, _ := netip.AddrFromSlice(ip) add := ipToAdd.Unmap() @@ -307,3 +307,18 @@ func TestNftablesManagerCompatibilityWithIptables(t *testing.T) { stdout, stderr = runIptablesSave(t) verifyIptablesOutput(t, stdout, stderr) } + +func compareExprsIgnoringCounters(t *testing.T, got, want []expr.Any) { + t.Helper() + require.Equal(t, len(got), len(want), "expression count mismatch") + + for i := range got { + if _, isCounter := got[i].(*expr.Counter); isCounter { + _, wantIsCounter := want[i].(*expr.Counter) + require.True(t, wantIsCounter, "expected Counter at index %d", i) + continue + } + + require.Equal(t, got[i], want[i], "expression mismatch at index %d", i) + } +} diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go index 19734673b..92f81f39c 100644 --- a/client/firewall/nftables/router_linux.go +++ b/client/firewall/nftables/router_linux.go @@ -233,7 +233,13 @@ func (r *router) AddRouteFiltering( UserData: []byte(ruleKey), } - rule = r.conn.AddRule(rule) + // Insert DROP rules at the beginning, append ACCEPT rules at the end + if action == firewall.ActionDrop { + // TODO: Insert after the established rule + rule = r.conn.InsertRule(rule) + } else { + rule = r.conn.AddRule(rule) + } log.Tracef("Adding route rule %s", spew.Sdump(rule)) if err := r.conn.Flush(); err != nil { diff --git a/client/firewall/uspfilter/allow_netbird.go b/client/firewall/uspfilter/allow_netbird.go index cc0792255..03f23f5e6 100644 --- a/client/firewall/uspfilter/allow_netbird.go +++ b/client/firewall/uspfilter/allow_netbird.go @@ -3,6 +3,11 @@ package uspfilter import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack" "github.com/netbirdio/netbird/client/internal/statemanager" ) @@ -17,17 +22,29 @@ func (m *Manager) Reset(stateManager *statemanager.Manager) error { if m.udpTracker != nil { m.udpTracker.Close() - m.udpTracker = conntrack.NewUDPTracker(conntrack.DefaultUDPTimeout) + m.udpTracker = conntrack.NewUDPTracker(conntrack.DefaultUDPTimeout, m.logger) } if m.icmpTracker != nil { m.icmpTracker.Close() - m.icmpTracker = conntrack.NewICMPTracker(conntrack.DefaultICMPTimeout) + m.icmpTracker = conntrack.NewICMPTracker(conntrack.DefaultICMPTimeout, m.logger) } if m.tcpTracker != nil { m.tcpTracker.Close() - m.tcpTracker = conntrack.NewTCPTracker(conntrack.DefaultTCPTimeout) + m.tcpTracker = conntrack.NewTCPTracker(conntrack.DefaultTCPTimeout, m.logger) + } + + if m.forwarder != nil { + m.forwarder.Stop() + } + + if m.logger != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := m.logger.Stop(ctx); err != nil { + log.Errorf("failed to shutdown logger: %v", err) + } } if m.nativeFirewall != nil { diff --git a/client/firewall/uspfilter/allow_netbird_windows.go b/client/firewall/uspfilter/allow_netbird_windows.go index 0d55d6268..379585978 100644 --- a/client/firewall/uspfilter/allow_netbird_windows.go +++ b/client/firewall/uspfilter/allow_netbird_windows.go @@ -1,9 +1,11 @@ package uspfilter import ( + "context" "fmt" "os/exec" "syscall" + "time" log "github.com/sirupsen/logrus" @@ -29,17 +31,29 @@ func (m *Manager) Reset(*statemanager.Manager) error { if m.udpTracker != nil { m.udpTracker.Close() - m.udpTracker = conntrack.NewUDPTracker(conntrack.DefaultUDPTimeout) + m.udpTracker = conntrack.NewUDPTracker(conntrack.DefaultUDPTimeout, m.logger) } if m.icmpTracker != nil { m.icmpTracker.Close() - m.icmpTracker = conntrack.NewICMPTracker(conntrack.DefaultICMPTimeout) + m.icmpTracker = conntrack.NewICMPTracker(conntrack.DefaultICMPTimeout, m.logger) } if m.tcpTracker != nil { m.tcpTracker.Close() - m.tcpTracker = conntrack.NewTCPTracker(conntrack.DefaultTCPTimeout) + m.tcpTracker = conntrack.NewTCPTracker(conntrack.DefaultTCPTimeout, m.logger) + } + + if m.forwarder != nil { + m.forwarder.Stop() + } + + if m.logger != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := m.logger.Stop(ctx); err != nil { + log.Errorf("failed to shutdown logger: %v", err) + } } if !isWindowsFirewallReachable() { diff --git a/client/firewall/uspfilter/common/iface.go b/client/firewall/uspfilter/common/iface.go new file mode 100644 index 000000000..d44e79509 --- /dev/null +++ b/client/firewall/uspfilter/common/iface.go @@ -0,0 +1,16 @@ +package common + +import ( + wgdevice "golang.zx2c4.com/wireguard/device" + + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" +) + +// IFaceMapper defines subset methods of interface required for manager +type IFaceMapper interface { + SetFilter(device.PacketFilter) error + Address() iface.WGAddress + GetWGDevice() *wgdevice.Device + GetDevice() *device.FilteredDevice +} diff --git a/client/firewall/uspfilter/conntrack/common.go b/client/firewall/uspfilter/conntrack/common.go index e459bc75a..f5f502540 100644 --- a/client/firewall/uspfilter/conntrack/common.go +++ b/client/firewall/uspfilter/conntrack/common.go @@ -10,12 +10,11 @@ import ( // BaseConnTrack provides common fields and locking for all connection types type BaseConnTrack struct { - SourceIP net.IP - DestIP net.IP - SourcePort uint16 - DestPort uint16 - lastSeen atomic.Int64 // Unix nano for atomic access - established atomic.Bool + SourceIP net.IP + DestIP net.IP + SourcePort uint16 + DestPort uint16 + lastSeen atomic.Int64 // Unix nano for atomic access } // these small methods will be inlined by the compiler @@ -25,16 +24,6 @@ func (b *BaseConnTrack) UpdateLastSeen() { b.lastSeen.Store(time.Now().UnixNano()) } -// IsEstablished safely checks if connection is established -func (b *BaseConnTrack) IsEstablished() bool { - return b.established.Load() -} - -// SetEstablished safely sets the established state -func (b *BaseConnTrack) SetEstablished(state bool) { - b.established.Store(state) -} - // GetLastSeen safely gets the last seen timestamp func (b *BaseConnTrack) GetLastSeen() time.Time { return time.Unix(0, b.lastSeen.Load()) diff --git a/client/firewall/uspfilter/conntrack/common_test.go b/client/firewall/uspfilter/conntrack/common_test.go index 72d006def..81fa64b19 100644 --- a/client/firewall/uspfilter/conntrack/common_test.go +++ b/client/firewall/uspfilter/conntrack/common_test.go @@ -3,8 +3,14 @@ package conntrack import ( "net" "testing" + + "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/firewall/uspfilter/log" ) +var logger = log.NewFromLogrus(logrus.StandardLogger()) + func BenchmarkIPOperations(b *testing.B) { b.Run("MakeIPAddr", func(b *testing.B) { ip := net.ParseIP("192.168.1.1") @@ -34,37 +40,11 @@ func BenchmarkIPOperations(b *testing.B) { }) } -func BenchmarkAtomicOperations(b *testing.B) { - conn := &BaseConnTrack{} - b.Run("UpdateLastSeen", func(b *testing.B) { - for i := 0; i < b.N; i++ { - conn.UpdateLastSeen() - } - }) - - b.Run("IsEstablished", func(b *testing.B) { - for i := 0; i < b.N; i++ { - _ = conn.IsEstablished() - } - }) - - b.Run("SetEstablished", func(b *testing.B) { - for i := 0; i < b.N; i++ { - conn.SetEstablished(i%2 == 0) - } - }) - - b.Run("GetLastSeen", func(b *testing.B) { - for i := 0; i < b.N; i++ { - _ = conn.GetLastSeen() - } - }) -} // Memory pressure tests func BenchmarkMemoryPressure(b *testing.B) { b.Run("TCPHighLoad", func(b *testing.B) { - tracker := NewTCPTracker(DefaultTCPTimeout) + tracker := NewTCPTracker(DefaultTCPTimeout, logger) defer tracker.Close() // Generate different IPs @@ -89,7 +69,7 @@ func BenchmarkMemoryPressure(b *testing.B) { }) b.Run("UDPHighLoad", func(b *testing.B) { - tracker := NewUDPTracker(DefaultUDPTimeout) + tracker := NewUDPTracker(DefaultUDPTimeout, logger) defer tracker.Close() // Generate different IPs diff --git a/client/firewall/uspfilter/conntrack/icmp.go b/client/firewall/uspfilter/conntrack/icmp.go index e0a971678..25cd9e87d 100644 --- a/client/firewall/uspfilter/conntrack/icmp.go +++ b/client/firewall/uspfilter/conntrack/icmp.go @@ -6,6 +6,8 @@ import ( "time" "github.com/google/gopacket/layers" + + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" ) const ( @@ -33,6 +35,7 @@ type ICMPConnTrack struct { // ICMPTracker manages ICMP connection states type ICMPTracker struct { + logger *nblog.Logger connections map[ICMPConnKey]*ICMPConnTrack timeout time.Duration cleanupTicker *time.Ticker @@ -42,12 +45,13 @@ type ICMPTracker struct { } // NewICMPTracker creates a new ICMP connection tracker -func NewICMPTracker(timeout time.Duration) *ICMPTracker { +func NewICMPTracker(timeout time.Duration, logger *nblog.Logger) *ICMPTracker { if timeout == 0 { timeout = DefaultICMPTimeout } tracker := &ICMPTracker{ + logger: logger, connections: make(map[ICMPConnKey]*ICMPConnTrack), timeout: timeout, cleanupTicker: time.NewTicker(ICMPCleanupInterval), @@ -62,7 +66,6 @@ func NewICMPTracker(timeout time.Duration) *ICMPTracker { // TrackOutbound records an outbound ICMP Echo Request func (t *ICMPTracker) TrackOutbound(srcIP net.IP, dstIP net.IP, id uint16, seq uint16) { key := makeICMPKey(srcIP, dstIP, id, seq) - now := time.Now().UnixNano() t.mutex.Lock() conn, exists := t.connections[key] @@ -80,24 +83,19 @@ func (t *ICMPTracker) TrackOutbound(srcIP net.IP, dstIP net.IP, id uint16, seq u ID: id, Sequence: seq, } - conn.lastSeen.Store(now) - conn.established.Store(true) + conn.UpdateLastSeen() t.connections[key] = conn + + t.logger.Trace("New ICMP connection %v", key) } t.mutex.Unlock() - conn.lastSeen.Store(now) + conn.UpdateLastSeen() } // IsValidInbound checks if an inbound ICMP Echo Reply matches a tracked request func (t *ICMPTracker) IsValidInbound(srcIP net.IP, dstIP net.IP, id uint16, seq uint16, icmpType uint8) bool { - switch icmpType { - case uint8(layers.ICMPv4TypeDestinationUnreachable), - uint8(layers.ICMPv4TypeTimeExceeded): - return true - case uint8(layers.ICMPv4TypeEchoReply): - // continue processing - default: + if icmpType != uint8(layers.ICMPv4TypeEchoReply) { return false } @@ -115,8 +113,7 @@ func (t *ICMPTracker) IsValidInbound(srcIP net.IP, dstIP net.IP, id uint16, seq return false } - return conn.IsEstablished() && - ValidateIPs(MakeIPAddr(srcIP), conn.DestIP) && + return ValidateIPs(MakeIPAddr(srcIP), conn.DestIP) && ValidateIPs(MakeIPAddr(dstIP), conn.SourceIP) && conn.ID == id && conn.Sequence == seq @@ -141,6 +138,8 @@ func (t *ICMPTracker) cleanup() { t.ipPool.Put(conn.SourceIP) t.ipPool.Put(conn.DestIP) delete(t.connections, key) + + t.logger.Debug("Removed ICMP connection %v (timeout)", key) } } } diff --git a/client/firewall/uspfilter/conntrack/icmp_test.go b/client/firewall/uspfilter/conntrack/icmp_test.go index 21176e719..32553c836 100644 --- a/client/firewall/uspfilter/conntrack/icmp_test.go +++ b/client/firewall/uspfilter/conntrack/icmp_test.go @@ -7,7 +7,7 @@ import ( func BenchmarkICMPTracker(b *testing.B) { b.Run("TrackOutbound", func(b *testing.B) { - tracker := NewICMPTracker(DefaultICMPTimeout) + tracker := NewICMPTracker(DefaultICMPTimeout, logger) defer tracker.Close() srcIP := net.ParseIP("192.168.1.1") @@ -20,7 +20,7 @@ func BenchmarkICMPTracker(b *testing.B) { }) b.Run("IsValidInbound", func(b *testing.B) { - tracker := NewICMPTracker(DefaultICMPTimeout) + tracker := NewICMPTracker(DefaultICMPTimeout, logger) defer tracker.Close() srcIP := net.ParseIP("192.168.1.1") diff --git a/client/firewall/uspfilter/conntrack/tcp.go b/client/firewall/uspfilter/conntrack/tcp.go index a7968dc73..7c12e8ad0 100644 --- a/client/firewall/uspfilter/conntrack/tcp.go +++ b/client/firewall/uspfilter/conntrack/tcp.go @@ -5,7 +5,10 @@ package conntrack import ( "net" "sync" + "sync/atomic" "time" + + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" ) const ( @@ -61,12 +64,24 @@ type TCPConnKey struct { // TCPConnTrack represents a TCP connection state type TCPConnTrack struct { BaseConnTrack - State TCPState + State TCPState + established atomic.Bool sync.RWMutex } +// IsEstablished safely checks if connection is established +func (t *TCPConnTrack) IsEstablished() bool { + return t.established.Load() +} + +// SetEstablished safely sets the established state +func (t *TCPConnTrack) SetEstablished(state bool) { + t.established.Store(state) +} + // TCPTracker manages TCP connection states type TCPTracker struct { + logger *nblog.Logger connections map[ConnKey]*TCPConnTrack mutex sync.RWMutex cleanupTicker *time.Ticker @@ -76,8 +91,9 @@ type TCPTracker struct { } // NewTCPTracker creates a new TCP connection tracker -func NewTCPTracker(timeout time.Duration) *TCPTracker { +func NewTCPTracker(timeout time.Duration, logger *nblog.Logger) *TCPTracker { tracker := &TCPTracker{ + logger: logger, connections: make(map[ConnKey]*TCPConnTrack), cleanupTicker: time.NewTicker(TCPCleanupInterval), done: make(chan struct{}), @@ -93,7 +109,6 @@ func NewTCPTracker(timeout time.Duration) *TCPTracker { func (t *TCPTracker) TrackOutbound(srcIP net.IP, dstIP net.IP, srcPort uint16, dstPort uint16, flags uint8) { // Create key before lock key := makeConnKey(srcIP, dstIP, srcPort, dstPort) - now := time.Now().UnixNano() t.mutex.Lock() conn, exists := t.connections[key] @@ -113,9 +128,11 @@ func (t *TCPTracker) TrackOutbound(srcIP net.IP, dstIP net.IP, srcPort uint16, d }, State: TCPStateNew, } - conn.lastSeen.Store(now) + conn.UpdateLastSeen() conn.established.Store(false) t.connections[key] = conn + + t.logger.Trace("New TCP connection: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) } t.mutex.Unlock() @@ -123,7 +140,7 @@ func (t *TCPTracker) TrackOutbound(srcIP net.IP, dstIP net.IP, srcPort uint16, d conn.Lock() t.updateState(conn, flags, true) conn.Unlock() - conn.lastSeen.Store(now) + conn.UpdateLastSeen() } // IsValidInbound checks if an inbound TCP packet matches a tracked connection @@ -171,6 +188,9 @@ func (t *TCPTracker) updateState(conn *TCPConnTrack, flags uint8, isOutbound boo if flags&TCPRst != 0 { conn.State = TCPStateClosed conn.SetEstablished(false) + + t.logger.Trace("TCP connection reset: %s:%d -> %s:%d", + conn.SourceIP, conn.SourcePort, conn.DestIP, conn.DestPort) return } @@ -227,6 +247,9 @@ func (t *TCPTracker) updateState(conn *TCPConnTrack, flags uint8, isOutbound boo if flags&TCPAck != 0 { conn.State = TCPStateTimeWait // Keep established = false from previous state + + t.logger.Trace("TCP connection closed (simultaneous) - %s:%d -> %s:%d", + conn.SourceIP, conn.SourcePort, conn.DestIP, conn.DestPort) } case TCPStateCloseWait: @@ -237,11 +260,17 @@ func (t *TCPTracker) updateState(conn *TCPConnTrack, flags uint8, isOutbound boo case TCPStateLastAck: if flags&TCPAck != 0 { conn.State = TCPStateClosed + + t.logger.Trace("TCP connection gracefully closed: %s:%d -> %s:%d", + conn.SourceIP, conn.SourcePort, conn.DestIP, conn.DestPort) } case TCPStateTimeWait: // Stay in TIME-WAIT for 2MSL before transitioning to closed // This is handled by the cleanup routine + + t.logger.Trace("TCP connection completed - %s:%d -> %s:%d", + conn.SourceIP, conn.SourcePort, conn.DestIP, conn.DestPort) } } @@ -318,6 +347,8 @@ func (t *TCPTracker) cleanup() { t.ipPool.Put(conn.SourceIP) t.ipPool.Put(conn.DestIP) delete(t.connections, key) + + t.logger.Trace("Cleaned up TCP connection: %s:%d -> %s:%d", conn.SourceIP, conn.SourcePort, conn.DestIP, conn.DestPort) } } } diff --git a/client/firewall/uspfilter/conntrack/tcp_test.go b/client/firewall/uspfilter/conntrack/tcp_test.go index 6c8f82423..5f4c43915 100644 --- a/client/firewall/uspfilter/conntrack/tcp_test.go +++ b/client/firewall/uspfilter/conntrack/tcp_test.go @@ -9,7 +9,7 @@ import ( ) func TestTCPStateMachine(t *testing.T) { - tracker := NewTCPTracker(DefaultTCPTimeout) + tracker := NewTCPTracker(DefaultTCPTimeout, logger) defer tracker.Close() srcIP := net.ParseIP("100.64.0.1") @@ -154,7 +154,7 @@ func TestTCPStateMachine(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Helper() - tracker = NewTCPTracker(DefaultTCPTimeout) + tracker = NewTCPTracker(DefaultTCPTimeout, logger) tt.test(t) }) } @@ -162,7 +162,7 @@ func TestTCPStateMachine(t *testing.T) { } func TestRSTHandling(t *testing.T) { - tracker := NewTCPTracker(DefaultTCPTimeout) + tracker := NewTCPTracker(DefaultTCPTimeout, logger) defer tracker.Close() srcIP := net.ParseIP("100.64.0.1") @@ -233,7 +233,7 @@ func establishConnection(t *testing.T, tracker *TCPTracker, srcIP, dstIP net.IP, func BenchmarkTCPTracker(b *testing.B) { b.Run("TrackOutbound", func(b *testing.B) { - tracker := NewTCPTracker(DefaultTCPTimeout) + tracker := NewTCPTracker(DefaultTCPTimeout, logger) defer tracker.Close() srcIP := net.ParseIP("192.168.1.1") @@ -246,7 +246,7 @@ func BenchmarkTCPTracker(b *testing.B) { }) b.Run("IsValidInbound", func(b *testing.B) { - tracker := NewTCPTracker(DefaultTCPTimeout) + tracker := NewTCPTracker(DefaultTCPTimeout, logger) defer tracker.Close() srcIP := net.ParseIP("192.168.1.1") @@ -264,7 +264,7 @@ func BenchmarkTCPTracker(b *testing.B) { }) b.Run("ConcurrentAccess", func(b *testing.B) { - tracker := NewTCPTracker(DefaultTCPTimeout) + tracker := NewTCPTracker(DefaultTCPTimeout, logger) defer tracker.Close() srcIP := net.ParseIP("192.168.1.1") @@ -287,7 +287,7 @@ func BenchmarkTCPTracker(b *testing.B) { // Benchmark connection cleanup func BenchmarkCleanup(b *testing.B) { b.Run("TCPCleanup", func(b *testing.B) { - tracker := NewTCPTracker(100 * time.Millisecond) // Short timeout for testing + tracker := NewTCPTracker(100*time.Millisecond, logger) // Short timeout for testing defer tracker.Close() // Pre-populate with expired connections diff --git a/client/firewall/uspfilter/conntrack/udp.go b/client/firewall/uspfilter/conntrack/udp.go index a969a4e84..e73465e31 100644 --- a/client/firewall/uspfilter/conntrack/udp.go +++ b/client/firewall/uspfilter/conntrack/udp.go @@ -4,6 +4,8 @@ import ( "net" "sync" "time" + + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" ) const ( @@ -20,6 +22,7 @@ type UDPConnTrack struct { // UDPTracker manages UDP connection states type UDPTracker struct { + logger *nblog.Logger connections map[ConnKey]*UDPConnTrack timeout time.Duration cleanupTicker *time.Ticker @@ -29,12 +32,13 @@ type UDPTracker struct { } // NewUDPTracker creates a new UDP connection tracker -func NewUDPTracker(timeout time.Duration) *UDPTracker { +func NewUDPTracker(timeout time.Duration, logger *nblog.Logger) *UDPTracker { if timeout == 0 { timeout = DefaultUDPTimeout } tracker := &UDPTracker{ + logger: logger, connections: make(map[ConnKey]*UDPConnTrack), timeout: timeout, cleanupTicker: time.NewTicker(UDPCleanupInterval), @@ -49,7 +53,6 @@ func NewUDPTracker(timeout time.Duration) *UDPTracker { // TrackOutbound records an outbound UDP connection func (t *UDPTracker) TrackOutbound(srcIP net.IP, dstIP net.IP, srcPort uint16, dstPort uint16) { key := makeConnKey(srcIP, dstIP, srcPort, dstPort) - now := time.Now().UnixNano() t.mutex.Lock() conn, exists := t.connections[key] @@ -67,13 +70,14 @@ func (t *UDPTracker) TrackOutbound(srcIP net.IP, dstIP net.IP, srcPort uint16, d DestPort: dstPort, }, } - conn.lastSeen.Store(now) - conn.established.Store(true) + conn.UpdateLastSeen() t.connections[key] = conn + + t.logger.Trace("New UDP connection: %v", conn) } t.mutex.Unlock() - conn.lastSeen.Store(now) + conn.UpdateLastSeen() } // IsValidInbound checks if an inbound packet matches a tracked connection @@ -92,8 +96,7 @@ func (t *UDPTracker) IsValidInbound(srcIP net.IP, dstIP net.IP, srcPort uint16, return false } - return conn.IsEstablished() && - ValidateIPs(MakeIPAddr(srcIP), conn.DestIP) && + return ValidateIPs(MakeIPAddr(srcIP), conn.DestIP) && ValidateIPs(MakeIPAddr(dstIP), conn.SourceIP) && conn.DestPort == srcPort && conn.SourcePort == dstPort @@ -120,6 +123,8 @@ func (t *UDPTracker) cleanup() { t.ipPool.Put(conn.SourceIP) t.ipPool.Put(conn.DestIP) delete(t.connections, key) + + t.logger.Trace("Removed UDP connection %v (timeout)", conn) } } } diff --git a/client/firewall/uspfilter/conntrack/udp_test.go b/client/firewall/uspfilter/conntrack/udp_test.go index 671721890..fa83ee356 100644 --- a/client/firewall/uspfilter/conntrack/udp_test.go +++ b/client/firewall/uspfilter/conntrack/udp_test.go @@ -29,7 +29,7 @@ func TestNewUDPTracker(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tracker := NewUDPTracker(tt.timeout) + tracker := NewUDPTracker(tt.timeout, logger) assert.NotNil(t, tracker) assert.Equal(t, tt.wantTimeout, tracker.timeout) assert.NotNil(t, tracker.connections) @@ -40,7 +40,7 @@ func TestNewUDPTracker(t *testing.T) { } func TestUDPTracker_TrackOutbound(t *testing.T) { - tracker := NewUDPTracker(DefaultUDPTimeout) + tracker := NewUDPTracker(DefaultUDPTimeout, logger) defer tracker.Close() srcIP := net.ParseIP("192.168.1.2") @@ -58,12 +58,11 @@ func TestUDPTracker_TrackOutbound(t *testing.T) { assert.True(t, conn.DestIP.Equal(dstIP)) assert.Equal(t, srcPort, conn.SourcePort) assert.Equal(t, dstPort, conn.DestPort) - assert.True(t, conn.IsEstablished()) assert.WithinDuration(t, time.Now(), conn.GetLastSeen(), 1*time.Second) } func TestUDPTracker_IsValidInbound(t *testing.T) { - tracker := NewUDPTracker(1 * time.Second) + tracker := NewUDPTracker(1*time.Second, logger) defer tracker.Close() srcIP := net.ParseIP("192.168.1.2") @@ -162,6 +161,7 @@ func TestUDPTracker_Cleanup(t *testing.T) { cleanupTicker: time.NewTicker(cleanupInterval), done: make(chan struct{}), ipPool: NewPreallocatedIPs(), + logger: logger, } // Start cleanup routine @@ -211,7 +211,7 @@ func TestUDPTracker_Cleanup(t *testing.T) { func BenchmarkUDPTracker(b *testing.B) { b.Run("TrackOutbound", func(b *testing.B) { - tracker := NewUDPTracker(DefaultUDPTimeout) + tracker := NewUDPTracker(DefaultUDPTimeout, logger) defer tracker.Close() srcIP := net.ParseIP("192.168.1.1") @@ -224,7 +224,7 @@ func BenchmarkUDPTracker(b *testing.B) { }) b.Run("IsValidInbound", func(b *testing.B) { - tracker := NewUDPTracker(DefaultUDPTimeout) + tracker := NewUDPTracker(DefaultUDPTimeout, logger) defer tracker.Close() srcIP := net.ParseIP("192.168.1.1") diff --git a/client/firewall/uspfilter/forwarder/endpoint.go b/client/firewall/uspfilter/forwarder/endpoint.go new file mode 100644 index 000000000..e8a265c94 --- /dev/null +++ b/client/firewall/uspfilter/forwarder/endpoint.go @@ -0,0 +1,81 @@ +package forwarder + +import ( + wgdevice "golang.zx2c4.com/wireguard/device" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/header" + "gvisor.dev/gvisor/pkg/tcpip/stack" + + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" +) + +// endpoint implements stack.LinkEndpoint and handles integration with the wireguard device +type endpoint struct { + logger *nblog.Logger + dispatcher stack.NetworkDispatcher + device *wgdevice.Device + mtu uint32 +} + +func (e *endpoint) Attach(dispatcher stack.NetworkDispatcher) { + e.dispatcher = dispatcher +} + +func (e *endpoint) IsAttached() bool { + return e.dispatcher != nil +} + +func (e *endpoint) MTU() uint32 { + return e.mtu +} + +func (e *endpoint) Capabilities() stack.LinkEndpointCapabilities { + return stack.CapabilityNone +} + +func (e *endpoint) MaxHeaderLength() uint16 { + return 0 +} + +func (e *endpoint) LinkAddress() tcpip.LinkAddress { + return "" +} + +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 + } + + // Send the packet through WireGuard + address := netHeader.DestinationAddress() + err := e.device.CreateOutboundPacket(data.AsSlice(), address.AsSlice()) + if err != nil { + e.logger.Error("CreateOutboundPacket: %v", err) + continue + } + written++ + } + + return written, nil +} + +func (e *endpoint) Wait() { + // not required +} + +func (e *endpoint) ARPHardwareType() header.ARPHardwareType { + return header.ARPHardwareNone +} + +func (e *endpoint) AddHeader(*stack.PacketBuffer) { + // not required +} + +func (e *endpoint) ParseHeader(*stack.PacketBuffer) bool { + return true +} diff --git a/client/firewall/uspfilter/forwarder/forwarder.go b/client/firewall/uspfilter/forwarder/forwarder.go new file mode 100644 index 000000000..4ed152b79 --- /dev/null +++ b/client/firewall/uspfilter/forwarder/forwarder.go @@ -0,0 +1,166 @@ +package forwarder + +import ( + "context" + "fmt" + "net" + "runtime" + + log "github.com/sirupsen/logrus" + "gvisor.dev/gvisor/pkg/buffer" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/header" + "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/icmp" + "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" + "gvisor.dev/gvisor/pkg/tcpip/transport/udp" + + "github.com/netbirdio/netbird/client/firewall/uspfilter/common" + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" +) + +const ( + defaultReceiveWindow = 32768 + defaultMaxInFlight = 1024 + iosReceiveWindow = 16384 + iosMaxInFlight = 256 +) + +type Forwarder struct { + logger *nblog.Logger + stack *stack.Stack + endpoint *endpoint + udpForwarder *udpForwarder + ctx context.Context + cancel context.CancelFunc + ip net.IP + netstack bool +} + +func New(iface common.IFaceMapper, logger *nblog.Logger, netstack bool) (*Forwarder, error) { + s := stack.New(stack.Options{ + NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol}, + TransportProtocols: []stack.TransportProtocolFactory{ + tcp.NewProtocol, + udp.NewProtocol, + icmp.NewProtocol4, + }, + HandleLocal: false, + }) + + mtu, err := iface.GetDevice().MTU() + if err != nil { + return nil, fmt.Errorf("get MTU: %w", err) + } + nicID := tcpip.NICID(1) + endpoint := &endpoint{ + logger: logger, + device: iface.GetWGDevice(), + mtu: uint32(mtu), + } + + if err := s.CreateNIC(nicID, endpoint); err != nil { + return nil, fmt.Errorf("failed to create NIC: %v", err) + } + + ones, _ := iface.Address().Network.Mask.Size() + protoAddr := tcpip.ProtocolAddress{ + Protocol: ipv4.ProtocolNumber, + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: tcpip.AddrFromSlice(iface.Address().IP.To4()), + PrefixLen: ones, + }, + } + + if err := s.AddProtocolAddress(nicID, protoAddr, stack.AddressProperties{}); err != nil { + return nil, fmt.Errorf("failed to add protocol address: %s", err) + } + + defaultSubnet, err := tcpip.NewSubnet( + tcpip.AddrFrom4([4]byte{0, 0, 0, 0}), + tcpip.MaskFromBytes([]byte{0, 0, 0, 0}), + ) + if err != nil { + return nil, fmt.Errorf("creating default subnet: %w", err) + } + + if err := s.SetPromiscuousMode(nicID, true); err != nil { + return nil, fmt.Errorf("set promiscuous mode: %s", err) + } + if err := s.SetSpoofing(nicID, true); err != nil { + return nil, fmt.Errorf("set spoofing: %s", err) + } + + s.SetRouteTable([]tcpip.Route{ + { + Destination: defaultSubnet, + NIC: nicID, + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + f := &Forwarder{ + logger: logger, + stack: s, + endpoint: endpoint, + udpForwarder: newUDPForwarder(mtu, logger), + ctx: ctx, + cancel: cancel, + netstack: netstack, + ip: iface.Address().IP, + } + + receiveWindow := defaultReceiveWindow + maxInFlight := defaultMaxInFlight + if runtime.GOOS == "ios" { + receiveWindow = iosReceiveWindow + maxInFlight = iosMaxInFlight + } + + tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP) + s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket) + + udpForwarder := udp.NewForwarder(s, f.handleUDP) + s.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + + s.SetTransportProtocolHandler(icmp.ProtocolNumber4, f.handleICMP) + + log.Debugf("forwarder: Initialization complete with NIC %d", nicID) + return f, nil +} + +func (f *Forwarder) InjectIncomingPacket(payload []byte) error { + if len(payload) < header.IPv4MinimumSize { + return fmt.Errorf("packet too small: %d bytes", len(payload)) + } + + pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(payload), + }) + defer pkt.DecRef() + + if f.endpoint.dispatcher != nil { + f.endpoint.dispatcher.DeliverNetworkPacket(ipv4.ProtocolNumber, pkt) + } + return nil +} + +// Stop gracefully shuts down the forwarder +func (f *Forwarder) Stop() { + f.cancel() + + if f.udpForwarder != nil { + f.udpForwarder.Stop() + } + + f.stack.Close() + f.stack.Wait() +} + +func (f *Forwarder) determineDialAddr(addr tcpip.Address) net.IP { + if f.netstack && f.ip.Equal(addr.AsSlice()) { + return net.IPv4(127, 0, 0, 1) + } + return addr.AsSlice() +} diff --git a/client/firewall/uspfilter/forwarder/icmp.go b/client/firewall/uspfilter/forwarder/icmp.go new file mode 100644 index 000000000..14cdc37be --- /dev/null +++ b/client/firewall/uspfilter/forwarder/icmp.go @@ -0,0 +1,109 @@ +package forwarder + +import ( + "context" + "net" + "time" + + "gvisor.dev/gvisor/pkg/tcpip/header" + "gvisor.dev/gvisor/pkg/tcpip/stack" +) + +// handleICMP handles ICMP packets from the network stack +func (f *Forwarder) handleICMP(id stack.TransportEndpointID, pkt stack.PacketBufferPtr) bool { + ctx, cancel := context.WithTimeout(f.ctx, 5*time.Second) + defer cancel() + + lc := net.ListenConfig{} + // TODO: support non-root + conn, err := lc.ListenPacket(ctx, "ip4:icmp", "0.0.0.0") + if err != nil { + f.logger.Error("Failed to create ICMP socket for %v: %v", id, err) + + // This will make netstack reply on behalf of the original destination, that's ok for now + return false + } + defer func() { + if err := conn.Close(); err != nil { + f.logger.Debug("Failed to close ICMP socket: %v", err) + } + }() + + dstIP := f.determineDialAddr(id.LocalAddress) + dst := &net.IPAddr{IP: dstIP} + + // Get the complete ICMP message (header + data) + fullPacket := stack.PayloadSince(pkt.TransportHeader()) + payload := fullPacket.AsSlice() + + icmpHdr := header.ICMPv4(pkt.TransportHeader().View().AsSlice()) + + // For Echo Requests, send and handle response + switch icmpHdr.Type() { + case header.ICMPv4Echo: + return f.handleEchoResponse(icmpHdr, payload, dst, conn, id) + case header.ICMPv4EchoReply: + // dont process our own replies + return true + default: + } + + // For other ICMP types (Time Exceeded, Destination Unreachable, etc) + _, err = conn.WriteTo(payload, dst) + if err != nil { + f.logger.Error("Failed to write ICMP packet for %v: %v", id, err) + return true + } + + f.logger.Trace("Forwarded ICMP packet %v type=%v code=%v", + id, icmpHdr.Type(), icmpHdr.Code()) + + return true +} + +func (f *Forwarder) handleEchoResponse(icmpHdr header.ICMPv4, payload []byte, dst *net.IPAddr, conn net.PacketConn, id stack.TransportEndpointID) bool { + if _, err := conn.WriteTo(payload, dst); err != nil { + f.logger.Error("Failed to write ICMP packet for %v: %v", id, err) + return true + } + + f.logger.Trace("Forwarded ICMP packet %v type=%v code=%v", + id, icmpHdr.Type(), icmpHdr.Code()) + + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + f.logger.Error("Failed to set read deadline for ICMP response: %v", err) + return true + } + + response := make([]byte, f.endpoint.mtu) + n, _, err := conn.ReadFrom(response) + if err != nil { + if !isTimeout(err) { + f.logger.Error("Failed to read ICMP response: %v", err) + } + return true + } + + ipHdr := make([]byte, header.IPv4MinimumSize) + ip := header.IPv4(ipHdr) + ip.Encode(&header.IPv4Fields{ + TotalLength: uint16(header.IPv4MinimumSize + n), + TTL: 64, + Protocol: uint8(header.ICMPv4ProtocolNumber), + SrcAddr: id.LocalAddress, + DstAddr: id.RemoteAddress, + }) + ip.SetChecksum(^ip.CalculateChecksum()) + + fullPacket := make([]byte, 0, len(ipHdr)+n) + fullPacket = append(fullPacket, ipHdr...) + fullPacket = append(fullPacket, response[:n]...) + + if err := f.InjectIncomingPacket(fullPacket); err != nil { + f.logger.Error("Failed to inject ICMP response: %v", err) + return true + } + + f.logger.Trace("Forwarded ICMP echo reply for %v", id) + return true +} diff --git a/client/firewall/uspfilter/forwarder/tcp.go b/client/firewall/uspfilter/forwarder/tcp.go new file mode 100644 index 000000000..6d7cf3b6a --- /dev/null +++ b/client/firewall/uspfilter/forwarder/tcp.go @@ -0,0 +1,90 @@ +package forwarder + +import ( + "context" + "fmt" + "io" + "net" + + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" + "gvisor.dev/gvisor/pkg/waiter" +) + +// handleTCP is called by the TCP forwarder for new connections. +func (f *Forwarder) handleTCP(r *tcp.ForwarderRequest) { + id := r.ID() + + dialAddr := fmt.Sprintf("%s:%d", f.determineDialAddr(id.LocalAddress), id.LocalPort) + + outConn, err := (&net.Dialer{}).DialContext(f.ctx, "tcp", dialAddr) + if err != nil { + r.Complete(true) + f.logger.Trace("forwarder: dial error for %v: %v", id, err) + return + } + + // Create wait queue for blocking syscalls + wq := waiter.Queue{} + + ep, epErr := r.CreateEndpoint(&wq) + if epErr != nil { + f.logger.Error("forwarder: failed to create TCP endpoint: %v", epErr) + if err := outConn.Close(); err != nil { + f.logger.Debug("forwarder: outConn close error: %v", err) + } + r.Complete(true) + return + } + + // Complete the handshake + r.Complete(false) + + inConn := gonet.NewTCPConn(&wq, ep) + + f.logger.Trace("forwarder: established TCP connection %v", id) + + go f.proxyTCP(id, inConn, outConn, ep) +} + +func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn, outConn net.Conn, ep tcpip.Endpoint) { + defer func() { + if err := inConn.Close(); err != nil { + f.logger.Debug("forwarder: inConn close error: %v", err) + } + if err := outConn.Close(); err != nil { + f.logger.Debug("forwarder: outConn close error: %v", err) + } + ep.Close() + }() + + // Create context for managing the proxy goroutines + ctx, cancel := context.WithCancel(f.ctx) + defer cancel() + + errChan := make(chan error, 2) + + go func() { + _, err := io.Copy(outConn, inConn) + errChan <- err + }() + + go func() { + _, err := io.Copy(inConn, outConn) + errChan <- err + }() + + select { + case <-ctx.Done(): + f.logger.Trace("forwarder: tearing down TCP connection %v due to context done", id) + return + case err := <-errChan: + if err != nil && !isClosedError(err) { + f.logger.Error("proxyTCP: copy error: %v", err) + } + f.logger.Trace("forwarder: tearing down TCP connection %v", id) + return + } +} diff --git a/client/firewall/uspfilter/forwarder/udp.go b/client/firewall/uspfilter/forwarder/udp.go new file mode 100644 index 000000000..97e4662fd --- /dev/null +++ b/client/firewall/uspfilter/forwarder/udp.go @@ -0,0 +1,288 @@ +package forwarder + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "sync/atomic" + "time" + + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/udp" + "gvisor.dev/gvisor/pkg/waiter" + + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" +) + +const ( + udpTimeout = 30 * time.Second +) + +type udpPacketConn struct { + conn *gonet.UDPConn + outConn net.Conn + lastSeen atomic.Int64 + cancel context.CancelFunc + ep tcpip.Endpoint +} + +type udpForwarder struct { + sync.RWMutex + logger *nblog.Logger + conns map[stack.TransportEndpointID]*udpPacketConn + bufPool sync.Pool + ctx context.Context + cancel context.CancelFunc +} + +type idleConn struct { + id stack.TransportEndpointID + conn *udpPacketConn +} + +func newUDPForwarder(mtu int, logger *nblog.Logger) *udpForwarder { + ctx, cancel := context.WithCancel(context.Background()) + f := &udpForwarder{ + logger: logger, + conns: make(map[stack.TransportEndpointID]*udpPacketConn), + ctx: ctx, + cancel: cancel, + bufPool: sync.Pool{ + New: func() any { + b := make([]byte, mtu) + return &b + }, + }, + } + go f.cleanup() + return f +} + +// Stop stops the UDP forwarder and all active connections +func (f *udpForwarder) Stop() { + f.cancel() + + f.Lock() + defer f.Unlock() + + for id, conn := range f.conns { + conn.cancel() + if err := conn.conn.Close(); err != nil { + f.logger.Debug("forwarder: UDP conn close error for %v: %v", id, err) + } + if err := conn.outConn.Close(); err != nil { + f.logger.Debug("forwarder: UDP outConn close error for %v: %v", id, err) + } + + conn.ep.Close() + delete(f.conns, id) + } +} + +// cleanup periodically removes idle UDP connections +func (f *udpForwarder) cleanup() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + + for { + select { + case <-f.ctx.Done(): + return + case <-ticker.C: + var idleConns []idleConn + + f.RLock() + for id, conn := range f.conns { + if conn.getIdleDuration() > udpTimeout { + idleConns = append(idleConns, idleConn{id, conn}) + } + } + f.RUnlock() + + for _, idle := range idleConns { + idle.conn.cancel() + if err := idle.conn.conn.Close(); err != nil { + f.logger.Debug("forwarder: UDP conn close error for %v: %v", idle.id, err) + } + if err := idle.conn.outConn.Close(); err != nil { + f.logger.Debug("forwarder: UDP outConn close error for %v: %v", idle.id, err) + } + + idle.conn.ep.Close() + + f.Lock() + delete(f.conns, idle.id) + f.Unlock() + + f.logger.Trace("forwarder: cleaned up idle UDP connection %v", idle.id) + } + } + } +} + +// handleUDP is called by the UDP forwarder for new packets +func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) { + if f.ctx.Err() != nil { + f.logger.Trace("forwarder: context done, dropping UDP packet") + return + } + + id := r.ID() + + f.udpForwarder.RLock() + _, exists := f.udpForwarder.conns[id] + f.udpForwarder.RUnlock() + if exists { + f.logger.Trace("forwarder: existing UDP connection for %v", id) + return + } + + dstAddr := fmt.Sprintf("%s:%d", f.determineDialAddr(id.LocalAddress), id.LocalPort) + outConn, err := (&net.Dialer{}).DialContext(f.ctx, "udp", dstAddr) + if err != nil { + f.logger.Debug("forwarder: UDP dial error for %v: %v", id, err) + // TODO: Send ICMP error message + return + } + + // Create wait queue for blocking syscalls + wq := waiter.Queue{} + ep, epErr := r.CreateEndpoint(&wq) + if epErr != nil { + f.logger.Debug("forwarder: failed to create UDP endpoint: %v", epErr) + if err := outConn.Close(); err != nil { + f.logger.Debug("forwarder: UDP outConn close error for %v: %v", id, err) + } + return + } + + inConn := gonet.NewUDPConn(f.stack, &wq, ep) + connCtx, connCancel := context.WithCancel(f.ctx) + + pConn := &udpPacketConn{ + conn: inConn, + outConn: outConn, + cancel: connCancel, + ep: ep, + } + pConn.updateLastSeen() + + f.udpForwarder.Lock() + // Double-check no connection was created while we were setting up + if _, exists := f.udpForwarder.conns[id]; exists { + f.udpForwarder.Unlock() + pConn.cancel() + if err := inConn.Close(); err != nil { + f.logger.Debug("forwarder: UDP inConn close error for %v: %v", id, err) + } + if err := outConn.Close(); err != nil { + f.logger.Debug("forwarder: UDP outConn close error for %v: %v", id, err) + } + return + } + f.udpForwarder.conns[id] = pConn + f.udpForwarder.Unlock() + + f.logger.Trace("forwarder: established UDP connection to %v", id) + go f.proxyUDP(connCtx, pConn, id, ep) +} + +func (f *Forwarder) proxyUDP(ctx context.Context, pConn *udpPacketConn, id stack.TransportEndpointID, ep tcpip.Endpoint) { + defer func() { + pConn.cancel() + if err := pConn.conn.Close(); err != nil { + f.logger.Debug("forwarder: UDP inConn close error for %v: %v", id, err) + } + if err := pConn.outConn.Close(); err != nil { + f.logger.Debug("forwarder: UDP outConn close error for %v: %v", id, err) + } + + ep.Close() + + f.udpForwarder.Lock() + delete(f.udpForwarder.conns, id) + f.udpForwarder.Unlock() + }() + + errChan := make(chan error, 2) + + go func() { + errChan <- pConn.copy(ctx, pConn.conn, pConn.outConn, &f.udpForwarder.bufPool, "outbound->inbound") + }() + + go func() { + errChan <- pConn.copy(ctx, pConn.outConn, pConn.conn, &f.udpForwarder.bufPool, "inbound->outbound") + }() + + select { + case <-ctx.Done(): + f.logger.Trace("forwarder: tearing down UDP connection %v due to context done", id) + return + case err := <-errChan: + if err != nil && !isClosedError(err) { + f.logger.Error("proxyUDP: copy error: %v", err) + } + f.logger.Trace("forwarder: tearing down UDP connection %v", id) + return + } +} + +func (c *udpPacketConn) updateLastSeen() { + c.lastSeen.Store(time.Now().UnixNano()) +} + +func (c *udpPacketConn) getIdleDuration() time.Duration { + lastSeen := time.Unix(0, c.lastSeen.Load()) + return time.Since(lastSeen) +} + +func (c *udpPacketConn) copy(ctx context.Context, dst net.Conn, src net.Conn, bufPool *sync.Pool, direction string) error { + bufp := bufPool.Get().(*[]byte) + defer bufPool.Put(bufp) + buffer := *bufp + + if err := src.SetReadDeadline(time.Now().Add(udpTimeout)); err != nil { + return fmt.Errorf("set read deadline: %w", err) + } + if err := src.SetWriteDeadline(time.Now().Add(udpTimeout)); err != nil { + return fmt.Errorf("set write deadline: %w", err) + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + n, err := src.Read(buffer) + if err != nil { + if isTimeout(err) { + continue + } + return fmt.Errorf("read from %s: %w", direction, err) + } + + _, err = dst.Write(buffer[:n]) + if err != nil { + return fmt.Errorf("write to %s: %w", direction, err) + } + + c.updateLastSeen() + } + } +} + +func isClosedError(err error) bool { + return errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) +} + +func isTimeout(err error) bool { + var netErr net.Error + if errors.As(err, &netErr) { + return netErr.Timeout() + } + return false +} diff --git a/client/firewall/uspfilter/localip.go b/client/firewall/uspfilter/localip.go new file mode 100644 index 000000000..7664b65d5 --- /dev/null +++ b/client/firewall/uspfilter/localip.go @@ -0,0 +1,134 @@ +package uspfilter + +import ( + "fmt" + "net" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/firewall/uspfilter/common" +) + +type localIPManager struct { + mu sync.RWMutex + + // Use bitmap for IPv4 (32 bits * 2^16 = 256KB memory) + ipv4Bitmap [1 << 16]uint32 +} + +func newLocalIPManager() *localIPManager { + return &localIPManager{} +} + +func (m *localIPManager) setBitmapBit(ip net.IP) { + ipv4 := ip.To4() + if ipv4 == nil { + return + } + high := (uint16(ipv4[0]) << 8) | uint16(ipv4[1]) + low := (uint16(ipv4[2]) << 8) | uint16(ipv4[3]) + m.ipv4Bitmap[high] |= 1 << (low % 32) +} + +func (m *localIPManager) checkBitmapBit(ip net.IP) bool { + ipv4 := ip.To4() + if ipv4 == nil { + return false + } + high := (uint16(ipv4[0]) << 8) | uint16(ipv4[1]) + low := (uint16(ipv4[2]) << 8) | uint16(ipv4[3]) + return (m.ipv4Bitmap[high] & (1 << (low % 32))) != 0 +} + +func (m *localIPManager) processIP(ip net.IP, newIPv4Bitmap *[1 << 16]uint32, ipv4Set map[string]struct{}, ipv4Addresses *[]string) error { + if ipv4 := ip.To4(); ipv4 != nil { + high := (uint16(ipv4[0]) << 8) | uint16(ipv4[1]) + low := (uint16(ipv4[2]) << 8) | uint16(ipv4[3]) + if int(high) >= len(*newIPv4Bitmap) { + return fmt.Errorf("invalid IPv4 address: %s", ip) + } + ipStr := ip.String() + if _, exists := ipv4Set[ipStr]; !exists { + ipv4Set[ipStr] = struct{}{} + *ipv4Addresses = append(*ipv4Addresses, ipStr) + newIPv4Bitmap[high] |= 1 << (low % 32) + } + } + return nil +} + +func (m *localIPManager) processInterface(iface net.Interface, newIPv4Bitmap *[1 << 16]uint32, ipv4Set map[string]struct{}, ipv4Addresses *[]string) { + addrs, err := iface.Addrs() + if err != nil { + log.Debugf("get addresses for interface %s failed: %v", iface.Name, err) + return + } + + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + default: + continue + } + + if err := m.processIP(ip, newIPv4Bitmap, ipv4Set, ipv4Addresses); err != nil { + log.Debugf("process IP failed: %v", err) + } + } +} + +func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic: %v", r) + } + }() + + var newIPv4Bitmap [1 << 16]uint32 + ipv4Set := make(map[string]struct{}) + var ipv4Addresses []string + + // 127.0.0.0/8 + high := uint16(127) << 8 + for i := uint16(0); i < 256; i++ { + newIPv4Bitmap[high|i] = 0xffffffff + } + + if iface != nil { + if err := m.processIP(iface.Address().IP, &newIPv4Bitmap, ipv4Set, &ipv4Addresses); err != nil { + return err + } + } + + interfaces, err := net.Interfaces() + if err != nil { + log.Warnf("failed to get interfaces: %v", err) + } else { + for _, intf := range interfaces { + m.processInterface(intf, &newIPv4Bitmap, ipv4Set, &ipv4Addresses) + } + } + + m.mu.Lock() + m.ipv4Bitmap = newIPv4Bitmap + m.mu.Unlock() + + log.Debugf("Local IPv4 addresses: %v", ipv4Addresses) + return nil +} + +func (m *localIPManager) IsLocalIP(ip net.IP) bool { + m.mu.RLock() + defer m.mu.RUnlock() + + if ipv4 := ip.To4(); ipv4 != nil { + return m.checkBitmapBit(ipv4) + } + + return false +} diff --git a/client/firewall/uspfilter/localip_test.go b/client/firewall/uspfilter/localip_test.go new file mode 100644 index 000000000..02f41bf4f --- /dev/null +++ b/client/firewall/uspfilter/localip_test.go @@ -0,0 +1,270 @@ +package uspfilter + +import ( + "net" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/iface" +) + +func TestLocalIPManager(t *testing.T) { + tests := []struct { + name string + setupAddr iface.WGAddress + testIP net.IP + expected bool + }{ + { + name: "Localhost range", + setupAddr: iface.WGAddress{ + IP: net.ParseIP("192.168.1.1"), + Network: &net.IPNet{ + IP: net.ParseIP("192.168.1.0"), + Mask: net.CIDRMask(24, 32), + }, + }, + testIP: net.ParseIP("127.0.0.2"), + expected: true, + }, + { + name: "Localhost standard address", + setupAddr: iface.WGAddress{ + IP: net.ParseIP("192.168.1.1"), + Network: &net.IPNet{ + IP: net.ParseIP("192.168.1.0"), + Mask: net.CIDRMask(24, 32), + }, + }, + testIP: net.ParseIP("127.0.0.1"), + expected: true, + }, + { + name: "Localhost range edge", + setupAddr: iface.WGAddress{ + IP: net.ParseIP("192.168.1.1"), + Network: &net.IPNet{ + IP: net.ParseIP("192.168.1.0"), + Mask: net.CIDRMask(24, 32), + }, + }, + testIP: net.ParseIP("127.255.255.255"), + expected: true, + }, + { + name: "Local IP matches", + setupAddr: iface.WGAddress{ + IP: net.ParseIP("192.168.1.1"), + Network: &net.IPNet{ + IP: net.ParseIP("192.168.1.0"), + Mask: net.CIDRMask(24, 32), + }, + }, + testIP: net.ParseIP("192.168.1.1"), + expected: true, + }, + { + name: "Local IP doesn't match", + setupAddr: iface.WGAddress{ + IP: net.ParseIP("192.168.1.1"), + Network: &net.IPNet{ + IP: net.ParseIP("192.168.1.0"), + Mask: net.CIDRMask(24, 32), + }, + }, + testIP: net.ParseIP("192.168.1.2"), + expected: false, + }, + { + name: "IPv6 address", + setupAddr: iface.WGAddress{ + IP: net.ParseIP("fe80::1"), + Network: &net.IPNet{ + IP: net.ParseIP("fe80::"), + Mask: net.CIDRMask(64, 128), + }, + }, + testIP: net.ParseIP("fe80::1"), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := newLocalIPManager() + + mock := &IFaceMock{ + AddressFunc: func() iface.WGAddress { + return tt.setupAddr + }, + } + + err := manager.UpdateLocalIPs(mock) + require.NoError(t, err) + + result := manager.IsLocalIP(tt.testIP) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestLocalIPManager_AllInterfaces(t *testing.T) { + manager := newLocalIPManager() + mock := &IFaceMock{} + + // Get actual local interfaces + interfaces, err := net.Interfaces() + require.NoError(t, err) + + var tests []struct { + ip string + expected bool + } + + // Add all local interface IPs to test cases + for _, iface := range interfaces { + addrs, err := iface.Addrs() + require.NoError(t, err) + + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + default: + continue + } + + if ip4 := ip.To4(); ip4 != nil { + tests = append(tests, struct { + ip string + expected bool + }{ + ip: ip4.String(), + expected: true, + }) + } + } + } + + // Add some external IPs as negative test cases + externalIPs := []string{ + "8.8.8.8", + "1.1.1.1", + "208.67.222.222", + } + for _, ip := range externalIPs { + tests = append(tests, struct { + ip string + expected bool + }{ + ip: ip, + expected: false, + }) + } + + require.NotEmpty(t, tests, "No test cases generated") + + err = manager.UpdateLocalIPs(mock) + require.NoError(t, err) + + t.Logf("Testing %d IPs", len(tests)) + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + result := manager.IsLocalIP(net.ParseIP(tt.ip)) + require.Equal(t, tt.expected, result, "IP: %s", tt.ip) + }) + } +} + +// 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 version + bitmapManager := &localIPManager{ + ipv4Bitmap: [1 << 16]uint32{}, + } + 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 := &localIPManager{ipv4Bitmap: [1 << 16]uint32{}} + bm.setBitmapBit(wgIP) + b.ResetTimer() + for i := 0; i < b.N; i++ { + bm.checkBitmapBit(wgIP) + } + }) + + b.Run("WG_Last", func(b *testing.B) { + bm := &localIPManager{ipv4Bitmap: [1 << 16]uint32{}} + // 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/log/log.go b/client/firewall/uspfilter/log/log.go new file mode 100644 index 000000000..984b6ad08 --- /dev/null +++ b/client/firewall/uspfilter/log/log.go @@ -0,0 +1,196 @@ +// Package logger provides a high-performance, non-blocking logger for userspace networking +package log + +import ( + "context" + "fmt" + "io" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + maxBatchSize = 1024 * 16 // 16KB max batch size + maxMessageSize = 1024 * 2 // 2KB per message + bufferSize = 1024 * 256 // 256KB ring buffer + defaultFlushInterval = 2 * time.Second +) + +// Level represents log severity +type Level uint32 + +const ( + LevelPanic Level = iota + LevelFatal + LevelError + LevelWarn + LevelInfo + LevelDebug + LevelTrace +) + +var levelStrings = map[Level]string{ + LevelPanic: "PANC", + LevelFatal: "FATL", + LevelError: "ERRO", + LevelWarn: "WARN", + LevelInfo: "INFO", + LevelDebug: "DEBG", + LevelTrace: "TRAC", +} + +// Logger is a high-performance, non-blocking logger +type Logger struct { + output io.Writer + level atomic.Uint32 + buffer *ringBuffer + shutdown chan struct{} + closeOnce sync.Once + wg sync.WaitGroup + + // Reusable buffer pool for formatting messages + bufPool sync.Pool +} + +func NewFromLogrus(logrusLogger *log.Logger) *Logger { + l := &Logger{ + output: logrusLogger.Out, + buffer: newRingBuffer(bufferSize), + shutdown: make(chan struct{}), + bufPool: sync.Pool{ + New: func() interface{} { + // Pre-allocate buffer for message formatting + b := make([]byte, 0, maxMessageSize) + return &b + }, + }, + } + logrusLevel := logrusLogger.GetLevel() + l.level.Store(uint32(logrusLevel)) + level := levelStrings[Level(logrusLevel)] + log.Debugf("New uspfilter logger created with loglevel %v", level) + + l.wg.Add(1) + go l.worker() + + return l +} + +func (l *Logger) SetLevel(level Level) { + l.level.Store(uint32(level)) + + log.Debugf("Set uspfilter logger loglevel to %v", levelStrings[level]) +} + +func (l *Logger) formatMessage(buf *[]byte, level Level, format string, args ...interface{}) { + *buf = (*buf)[:0] + + // Timestamp + *buf = time.Now().AppendFormat(*buf, "2006-01-02T15:04:05-07:00") + *buf = append(*buf, ' ') + + // Level + *buf = append(*buf, levelStrings[level]...) + *buf = append(*buf, ' ') + + // Message + if len(args) > 0 { + *buf = append(*buf, fmt.Sprintf(format, args...)...) + } else { + *buf = append(*buf, format...) + } + + *buf = append(*buf, '\n') +} + +func (l *Logger) log(level Level, format string, args ...interface{}) { + bufp := l.bufPool.Get().(*[]byte) + l.formatMessage(bufp, level, format, args...) + + if len(*bufp) > maxMessageSize { + *bufp = (*bufp)[:maxMessageSize] + } + _, _ = l.buffer.Write(*bufp) + + l.bufPool.Put(bufp) +} + +func (l *Logger) Error(format string, args ...interface{}) { + if l.level.Load() >= uint32(LevelError) { + l.log(LevelError, format, args...) + } +} + +func (l *Logger) Warn(format string, args ...interface{}) { + if l.level.Load() >= uint32(LevelWarn) { + l.log(LevelWarn, format, args...) + } +} + +func (l *Logger) Info(format string, args ...interface{}) { + if l.level.Load() >= uint32(LevelInfo) { + l.log(LevelInfo, format, args...) + } +} + +func (l *Logger) Debug(format string, args ...interface{}) { + if l.level.Load() >= uint32(LevelDebug) { + l.log(LevelDebug, format, args...) + } +} + +func (l *Logger) Trace(format string, args ...interface{}) { + if l.level.Load() >= uint32(LevelTrace) { + l.log(LevelTrace, format, args...) + } +} + +// worker periodically flushes the buffer +func (l *Logger) worker() { + defer l.wg.Done() + + ticker := time.NewTicker(defaultFlushInterval) + defer ticker.Stop() + + buf := make([]byte, 0, maxBatchSize) + + for { + select { + case <-l.shutdown: + return + case <-ticker.C: + // Read accumulated messages + n, _ := l.buffer.Read(buf[:cap(buf)]) + if n == 0 { + continue + } + + // Write batch + _, _ = l.output.Write(buf[:n]) + } + } +} + +// Stop gracefully shuts down the logger +func (l *Logger) Stop(ctx context.Context) error { + done := make(chan struct{}) + + l.closeOnce.Do(func() { + close(l.shutdown) + }) + + go func() { + l.wg.Wait() + close(done) + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-done: + return nil + } +} diff --git a/client/firewall/uspfilter/log/ringbuffer.go b/client/firewall/uspfilter/log/ringbuffer.go new file mode 100644 index 000000000..dbc8f1289 --- /dev/null +++ b/client/firewall/uspfilter/log/ringbuffer.go @@ -0,0 +1,85 @@ +package log + +import "sync" + +// ringBuffer is a simple ring buffer implementation +type ringBuffer struct { + buf []byte + size int + r, w int64 // Read and write positions + mu sync.Mutex +} + +func newRingBuffer(size int) *ringBuffer { + return &ringBuffer{ + buf: make([]byte, size), + size: size, + } +} + +func (r *ringBuffer) Write(p []byte) (n int, err error) { + if len(p) == 0 { + return 0, nil + } + + r.mu.Lock() + defer r.mu.Unlock() + + if len(p) > r.size { + p = p[:r.size] + } + + n = len(p) + + // Write data, handling wrap-around + pos := int(r.w % int64(r.size)) + writeLen := min(len(p), r.size-pos) + copy(r.buf[pos:], p[:writeLen]) + + // If we have more data and need to wrap around + if writeLen < len(p) { + copy(r.buf, p[writeLen:]) + } + + // Update write position + r.w += int64(n) + + return n, nil +} + +func (r *ringBuffer) Read(p []byte) (n int, err error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.w == r.r { + return 0, nil + } + + // Calculate available data accounting for wraparound + available := int(r.w - r.r) + if available < 0 { + available += r.size + } + available = min(available, r.size) + + // Limit read to buffer size + toRead := min(available, len(p)) + if toRead == 0 { + return 0, nil + } + + // Read data, handling wrap-around + pos := int(r.r % int64(r.size)) + readLen := min(toRead, r.size-pos) + n = copy(p, r.buf[pos:pos+readLen]) + + // If we need more data and need to wrap around + if readLen < toRead { + n += copy(p[readLen:toRead], r.buf[:toRead-readLen]) + } + + // Update read position + r.r += int64(n) + + return n, nil +} diff --git a/client/firewall/uspfilter/rule.go b/client/firewall/uspfilter/rule.go index c59d4b264..6a4415f73 100644 --- a/client/firewall/uspfilter/rule.go +++ b/client/firewall/uspfilter/rule.go @@ -2,14 +2,15 @@ package uspfilter import ( "net" + "net/netip" "github.com/google/gopacket" firewall "github.com/netbirdio/netbird/client/firewall/manager" ) -// Rule to handle management of rules -type Rule struct { +// PeerRule to handle management of rules +type PeerRule struct { id string ip net.IP ipLayer gopacket.LayerType @@ -24,6 +25,21 @@ type Rule struct { } // GetRuleID returns the rule id -func (r *Rule) GetRuleID() string { +func (r *PeerRule) GetRuleID() string { + return r.id +} + +type RouteRule struct { + id string + sources []netip.Prefix + destination netip.Prefix + proto firewall.Protocol + srcPort *firewall.Port + dstPort *firewall.Port + action firewall.Action +} + +// GetRuleID returns the rule id +func (r *RouteRule) GetRuleID() string { return r.id } diff --git a/client/firewall/uspfilter/tracer.go b/client/firewall/uspfilter/tracer.go new file mode 100644 index 000000000..a4c653b3b --- /dev/null +++ b/client/firewall/uspfilter/tracer.go @@ -0,0 +1,390 @@ +package uspfilter + +import ( + "fmt" + "net" + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack" +) + +type PacketStage int + +const ( + StageReceived PacketStage = iota + StageConntrack + StagePeerACL + StageRouting + StageRouteACL + StageForwarding + StageCompleted +) + +const msgProcessingCompleted = "Processing completed" + +func (s PacketStage) String() string { + return map[PacketStage]string{ + StageReceived: "Received", + StageConntrack: "Connection Tracking", + StagePeerACL: "Peer ACL", + StageRouting: "Routing", + StageRouteACL: "Route ACL", + StageForwarding: "Forwarding", + StageCompleted: "Completed", + }[s] +} + +type ForwarderAction struct { + Action string + RemoteAddr string + Error error +} + +type TraceResult struct { + Timestamp time.Time + Stage PacketStage + Message string + Allowed bool + ForwarderAction *ForwarderAction +} + +type PacketTrace struct { + SourceIP net.IP + DestinationIP net.IP + Protocol string + SourcePort uint16 + DestinationPort uint16 + Direction fw.RuleDirection + Results []TraceResult +} + +type TCPState struct { + SYN bool + ACK bool + FIN bool + RST bool + PSH bool + URG bool +} + +type PacketBuilder struct { + SrcIP net.IP + DstIP net.IP + Protocol fw.Protocol + SrcPort uint16 + DstPort uint16 + ICMPType uint8 + ICMPCode uint8 + Direction fw.RuleDirection + PayloadSize int + TCPState *TCPState +} + +func (t *PacketTrace) AddResult(stage PacketStage, message string, allowed bool) { + t.Results = append(t.Results, TraceResult{ + Timestamp: time.Now(), + Stage: stage, + Message: message, + Allowed: allowed, + }) +} + +func (t *PacketTrace) AddResultWithForwarder(stage PacketStage, message string, allowed bool, action *ForwarderAction) { + t.Results = append(t.Results, TraceResult{ + Timestamp: time.Now(), + Stage: stage, + Message: message, + Allowed: allowed, + ForwarderAction: action, + }) +} + +func (p *PacketBuilder) Build() ([]byte, error) { + ip := p.buildIPLayer() + pktLayers := []gopacket.SerializableLayer{ip} + + transportLayer, err := p.buildTransportLayer(ip) + if err != nil { + return nil, err + } + pktLayers = append(pktLayers, transportLayer...) + + if p.PayloadSize > 0 { + payload := make([]byte, p.PayloadSize) + pktLayers = append(pktLayers, gopacket.Payload(payload)) + } + + return serializePacket(pktLayers) +} + +func (p *PacketBuilder) buildIPLayer() *layers.IPv4 { + return &layers.IPv4{ + Version: 4, + TTL: 64, + Protocol: layers.IPProtocol(getIPProtocolNumber(p.Protocol)), + SrcIP: p.SrcIP, + DstIP: p.DstIP, + } +} + +func (p *PacketBuilder) buildTransportLayer(ip *layers.IPv4) ([]gopacket.SerializableLayer, error) { + switch p.Protocol { + case "tcp": + return p.buildTCPLayer(ip) + case "udp": + return p.buildUDPLayer(ip) + case "icmp": + return p.buildICMPLayer() + default: + return nil, fmt.Errorf("unsupported protocol: %s", p.Protocol) + } +} + +func (p *PacketBuilder) buildTCPLayer(ip *layers.IPv4) ([]gopacket.SerializableLayer, error) { + tcp := &layers.TCP{ + SrcPort: layers.TCPPort(p.SrcPort), + DstPort: layers.TCPPort(p.DstPort), + Window: 65535, + SYN: p.TCPState != nil && p.TCPState.SYN, + ACK: p.TCPState != nil && p.TCPState.ACK, + FIN: p.TCPState != nil && p.TCPState.FIN, + RST: p.TCPState != nil && p.TCPState.RST, + 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) + } + return []gopacket.SerializableLayer{tcp}, nil +} + +func (p *PacketBuilder) buildUDPLayer(ip *layers.IPv4) ([]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) + } + return []gopacket.SerializableLayer{udp}, nil +} + +func (p *PacketBuilder) buildICMPLayer() ([]gopacket.SerializableLayer, error) { + icmp := &layers.ICMPv4{ + TypeCode: layers.CreateICMPv4TypeCode(p.ICMPType, p.ICMPCode), + } + if p.ICMPType == layers.ICMPv4TypeEchoRequest || p.ICMPType == layers.ICMPv4TypeEchoReply { + icmp.Id = uint16(1) + icmp.Seq = uint16(1) + } + return []gopacket.SerializableLayer{icmp}, nil +} + +func serializePacket(layers []gopacket.SerializableLayer) ([]byte, error) { + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ + ComputeChecksums: true, + FixLengths: true, + } + if err := gopacket.SerializeLayers(buf, opts, layers...); err != nil { + return nil, fmt.Errorf("serialize packet: %w", err) + } + return buf.Bytes(), nil +} + +func getIPProtocolNumber(protocol fw.Protocol) int { + switch protocol { + case fw.ProtocolTCP: + return int(layers.IPProtocolTCP) + case fw.ProtocolUDP: + return int(layers.IPProtocolUDP) + case fw.ProtocolICMP: + return int(layers.IPProtocolICMPv4) + default: + return 0 + } +} + +func (m *Manager) TracePacketFromBuilder(builder *PacketBuilder) (*PacketTrace, error) { + packetData, err := builder.Build() + if err != nil { + return nil, fmt.Errorf("build packet: %w", err) + } + + return m.TracePacket(packetData, builder.Direction), nil +} + +func (m *Manager) TracePacket(packetData []byte, direction fw.RuleDirection) *PacketTrace { + + d := m.decoders.Get().(*decoder) + defer m.decoders.Put(d) + + trace := &PacketTrace{Direction: direction} + + // Initial packet decoding + if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + trace.AddResult(StageReceived, fmt.Sprintf("Failed to decode packet: %v", err), false) + return trace + } + + // Extract base packet info + srcIP, dstIP := m.extractIPs(d) + trace.SourceIP = srcIP + trace.DestinationIP = dstIP + + // Determine protocol and ports + switch d.decoded[1] { + case layers.LayerTypeTCP: + trace.Protocol = "TCP" + trace.SourcePort = uint16(d.tcp.SrcPort) + trace.DestinationPort = uint16(d.tcp.DstPort) + case layers.LayerTypeUDP: + trace.Protocol = "UDP" + trace.SourcePort = uint16(d.udp.SrcPort) + trace.DestinationPort = uint16(d.udp.DstPort) + case layers.LayerTypeICMPv4: + trace.Protocol = "ICMP" + } + + trace.AddResult(StageReceived, fmt.Sprintf("Received %s packet: %s:%d -> %s:%d", + trace.Protocol, srcIP, trace.SourcePort, dstIP, trace.DestinationPort), true) + + if direction == fw.RuleDirectionOUT { + return m.traceOutbound(packetData, trace) + } + + return m.traceInbound(packetData, trace, d, srcIP, dstIP) +} + +func (m *Manager) traceInbound(packetData []byte, trace *PacketTrace, d *decoder, srcIP net.IP, dstIP net.IP) *PacketTrace { + if m.stateful && m.handleConntrackState(trace, d, srcIP, dstIP) { + return trace + } + + if m.handleLocalDelivery(trace, packetData, d, srcIP, dstIP) { + return trace + } + + if !m.handleRouting(trace) { + return trace + } + + if m.nativeRouter { + return m.handleNativeRouter(trace) + } + + return m.handleRouteACLs(trace, d, srcIP, dstIP) +} + +func (m *Manager) handleConntrackState(trace *PacketTrace, d *decoder, srcIP, dstIP net.IP) bool { + allowed := m.isValidTrackedConnection(d, srcIP, dstIP) + msg := "No existing connection found" + if allowed { + msg = m.buildConntrackStateMessage(d) + trace.AddResult(StageConntrack, msg, true) + trace.AddResult(StageCompleted, "Packet allowed by connection tracking", true) + return true + } + trace.AddResult(StageConntrack, msg, false) + return false +} + +func (m *Manager) buildConntrackStateMessage(d *decoder) string { + msg := "Matched existing connection state" + switch d.decoded[1] { + case layers.LayerTypeTCP: + flags := getTCPFlags(&d.tcp) + msg += fmt.Sprintf(" (TCP Flags: SYN=%v ACK=%v RST=%v FIN=%v)", + flags&conntrack.TCPSyn != 0, + flags&conntrack.TCPAck != 0, + flags&conntrack.TCPRst != 0, + flags&conntrack.TCPFin != 0) + case layers.LayerTypeICMPv4: + msg += fmt.Sprintf(" (ICMP ID=%d, Seq=%d)", d.icmp4.Id, d.icmp4.Seq) + } + return msg +} + +func (m *Manager) handleLocalDelivery(trace *PacketTrace, packetData []byte, d *decoder, srcIP, dstIP net.IP) bool { + if !m.localForwarding { + trace.AddResult(StageRouting, "Local forwarding disabled", false) + trace.AddResult(StageCompleted, "Packet dropped - local forwarding disabled", false) + return true + } + + trace.AddResult(StageRouting, "Packet destined for local delivery", true) + blocked := m.peerACLsBlock(srcIP, packetData, m.incomingRules, d) + + msg := "Allowed by peer ACL rules" + if blocked { + msg = "Blocked by peer ACL rules" + } + trace.AddResult(StagePeerACL, msg, !blocked) + + if m.netstack { + m.addForwardingResult(trace, "proxy-local", "127.0.0.1", !blocked) + } + + trace.AddResult(StageCompleted, msgProcessingCompleted, !blocked) + return true +} + +func (m *Manager) handleRouting(trace *PacketTrace) bool { + if !m.routingEnabled { + trace.AddResult(StageRouting, "Routing disabled", false) + trace.AddResult(StageCompleted, "Packet dropped - routing disabled", false) + return false + } + trace.AddResult(StageRouting, "Routing enabled, checking ACLs", true) + return true +} + +func (m *Manager) handleNativeRouter(trace *PacketTrace) *PacketTrace { + trace.AddResult(StageRouteACL, "Using native router, skipping ACL checks", true) + trace.AddResult(StageForwarding, "Forwarding via native router", true) + trace.AddResult(StageCompleted, msgProcessingCompleted, true) + return trace +} + +func (m *Manager) handleRouteACLs(trace *PacketTrace, d *decoder, srcIP, dstIP net.IP) *PacketTrace { + proto := getProtocolFromPacket(d) + srcPort, dstPort := getPortsFromPacket(d) + allowed := m.routeACLsPass(srcIP, dstIP, proto, srcPort, dstPort) + + msg := "Allowed by route ACLs" + if !allowed { + msg = "Blocked by route ACLs" + } + trace.AddResult(StageRouteACL, msg, allowed) + + if allowed && m.forwarder != nil { + m.addForwardingResult(trace, "proxy-remote", fmt.Sprintf("%s:%d", dstIP, dstPort), true) + } + + trace.AddResult(StageCompleted, msgProcessingCompleted, allowed) + return trace +} + +func (m *Manager) addForwardingResult(trace *PacketTrace, action, remoteAddr string, allowed bool) { + fwdAction := &ForwarderAction{ + Action: action, + RemoteAddr: remoteAddr, + } + trace.AddResultWithForwarder(StageForwarding, + fmt.Sprintf("Forwarding to %s", fwdAction.Action), allowed, fwdAction) +} + +func (m *Manager) traceOutbound(packetData []byte, trace *PacketTrace) *PacketTrace { + // will create or update the connection state + dropped := m.processOutgoingHooks(packetData) + if dropped { + trace.AddResult(StageCompleted, "Packet dropped by outgoing hook", false) + } else { + trace.AddResult(StageCompleted, "Packet allowed (outgoing)", true) + } + return trace +} diff --git a/client/firewall/uspfilter/uspfilter.go b/client/firewall/uspfilter/uspfilter.go index 757249b2d..889e4cbb1 100644 --- a/client/firewall/uspfilter/uspfilter.go +++ b/client/firewall/uspfilter/uspfilter.go @@ -1,11 +1,14 @@ package uspfilter import ( + "errors" "fmt" "net" "net/netip" "os" + "slices" "strconv" + "strings" "sync" "github.com/google/gopacket" @@ -14,28 +17,48 @@ import ( log "github.com/sirupsen/logrus" firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/firewall/uspfilter/common" "github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack" - "github.com/netbirdio/netbird/client/iface" - "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/firewall/uspfilter/forwarder" + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" + "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/internal/statemanager" ) const layerTypeAll = 0 -const EnvDisableConntrack = "NB_DISABLE_CONNTRACK" +const ( + // EnvDisableConntrack disables the stateful filter, replies to outbound traffic won't be allowed. + EnvDisableConntrack = "NB_DISABLE_CONNTRACK" -var ( - errRouteNotSupported = fmt.Errorf("route not supported with userspace firewall") + // EnvDisableUserspaceRouting disables userspace routing, to-be-routed packets will be dropped. + EnvDisableUserspaceRouting = "NB_DISABLE_USERSPACE_ROUTING" + + // EnvForceUserspaceRouter forces userspace routing even if native routing is available. + EnvForceUserspaceRouter = "NB_FORCE_USERSPACE_ROUTER" + + // EnvEnableNetstackLocalForwarding enables forwarding of local traffic to the native stack when running netstack + // Leaving this on by default introduces a security risk as sockets on listening on localhost only will be accessible + EnvEnableNetstackLocalForwarding = "NB_ENABLE_NETSTACK_LOCAL_FORWARDING" ) -// IFaceMapper defines subset methods of interface required for manager -type IFaceMapper interface { - SetFilter(device.PacketFilter) error - Address() iface.WGAddress -} - // RuleSet is a set of rules grouped by a string key -type RuleSet map[string]Rule +type RuleSet map[string]PeerRule + +type RouteRules []RouteRule + +func (r RouteRules) Sort() { + slices.SortStableFunc(r, func(a, b RouteRule) int { + // Deny rules come first + if a.action == firewall.ActionDrop && b.action != firewall.ActionDrop { + return -1 + } + if a.action != firewall.ActionDrop && b.action == firewall.ActionDrop { + return 1 + } + return strings.Compare(a.id, b.id) + }) +} // Manager userspace firewall manager type Manager struct { @@ -43,17 +66,32 @@ type Manager struct { outgoingRules map[string]RuleSet // incomingRules is used for filtering and hooks incomingRules map[string]RuleSet + routeRules RouteRules wgNetwork *net.IPNet decoders sync.Pool - wgIface IFaceMapper + wgIface common.IFaceMapper nativeFirewall firewall.Manager mutex sync.RWMutex - stateful bool + // indicates whether we forward packets not destined for ourselves + routingEnabled bool + // indicates whether we leave forwarding and filtering to the native firewall + nativeRouter bool + // indicates whether we track outbound connections + stateful bool + // indicates whether wireguards runs in netstack mode + netstack bool + // indicates whether we forward local traffic to the native stack + localForwarding bool + + localipmanager *localIPManager + udpTracker *conntrack.UDPTracker icmpTracker *conntrack.ICMPTracker tcpTracker *conntrack.TCPTracker + forwarder *forwarder.Forwarder + logger *nblog.Logger } // decoder for packages @@ -70,22 +108,32 @@ type decoder struct { } // Create userspace firewall manager constructor -func Create(iface IFaceMapper) (*Manager, error) { - return create(iface) +func Create(iface common.IFaceMapper, disableServerRoutes bool) (*Manager, error) { + return create(iface, nil, disableServerRoutes) } -func CreateWithNativeFirewall(iface IFaceMapper, nativeFirewall firewall.Manager) (*Manager, error) { - mgr, err := create(iface) +func CreateWithNativeFirewall(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableServerRoutes bool) (*Manager, error) { + if nativeFirewall == nil { + return nil, errors.New("native firewall is nil") + } + + mgr, err := create(iface, nativeFirewall, disableServerRoutes) if err != nil { return nil, err } - mgr.nativeFirewall = nativeFirewall return mgr, nil } -func create(iface IFaceMapper) (*Manager, error) { - disableConntrack, _ := strconv.ParseBool(os.Getenv(EnvDisableConntrack)) +func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableServerRoutes bool) (*Manager, error) { + disableConntrack, err := strconv.ParseBool(os.Getenv(EnvDisableConntrack)) + if err != nil { + log.Warnf("failed to parse %s: %v", EnvDisableConntrack, err) + } + enableLocalForwarding, err := strconv.ParseBool(os.Getenv(EnvEnableNetstackLocalForwarding)) + if err != nil { + log.Warnf("failed to parse %s: %v", EnvEnableNetstackLocalForwarding, err) + } m := &Manager{ decoders: sync.Pool{ @@ -101,52 +149,161 @@ func create(iface IFaceMapper) (*Manager, error) { return d }, }, - outgoingRules: make(map[string]RuleSet), - incomingRules: make(map[string]RuleSet), - wgIface: iface, - stateful: !disableConntrack, + nativeFirewall: nativeFirewall, + outgoingRules: make(map[string]RuleSet), + incomingRules: make(map[string]RuleSet), + wgIface: iface, + localipmanager: newLocalIPManager(), + routingEnabled: false, + stateful: !disableConntrack, + logger: nblog.NewFromLogrus(log.StandardLogger()), + netstack: netstack.IsEnabled(), + // default true for non-netstack, for netstack only if explicitly enabled + localForwarding: !netstack.IsEnabled() || enableLocalForwarding, + } + + if err := m.localipmanager.UpdateLocalIPs(iface); err != nil { + return nil, fmt.Errorf("update local IPs: %w", err) } // Only initialize trackers if stateful mode is enabled if disableConntrack { log.Info("conntrack is disabled") } else { - m.udpTracker = conntrack.NewUDPTracker(conntrack.DefaultUDPTimeout) - m.icmpTracker = conntrack.NewICMPTracker(conntrack.DefaultICMPTimeout) - m.tcpTracker = conntrack.NewTCPTracker(conntrack.DefaultTCPTimeout) + m.udpTracker = conntrack.NewUDPTracker(conntrack.DefaultUDPTimeout, m.logger) + m.icmpTracker = conntrack.NewICMPTracker(conntrack.DefaultICMPTimeout, m.logger) + m.tcpTracker = conntrack.NewTCPTracker(conntrack.DefaultTCPTimeout, m.logger) + } + + m.determineRouting(iface, disableServerRoutes) + + if err := m.blockInvalidRouted(iface); err != nil { + log.Errorf("failed to block invalid routed traffic: %v", err) } if err := iface.SetFilter(m); err != nil { - return nil, err + return nil, fmt.Errorf("set filter: %w", err) } return m, nil } +func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) error { + if m.forwarder == nil { + return nil + } + wgPrefix, err := netip.ParsePrefix(iface.Address().Network.String()) + if err != nil { + return fmt.Errorf("parse wireguard network: %w", err) + } + log.Debugf("blocking invalid routed traffic for %s", wgPrefix) + + if _, err := m.AddRouteFiltering( + []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)}, + wgPrefix, + firewall.ProtocolALL, + nil, + nil, + firewall.ActionDrop, + ); err != nil { + return fmt.Errorf("block wg nte : %w", err) + } + + // TODO: Block networks that we're a client of + + return nil +} + +func (m *Manager) determineRouting(iface common.IFaceMapper, disableServerRoutes bool) { + disableUspRouting, _ := strconv.ParseBool(os.Getenv(EnvDisableUserspaceRouting)) + forceUserspaceRouter, _ := strconv.ParseBool(os.Getenv(EnvForceUserspaceRouter)) + + switch { + case disableUspRouting: + m.routingEnabled = false + m.nativeRouter = false + log.Info("userspace routing is disabled") + + case disableServerRoutes: + // if server routes are disabled we will let packets pass to the native stack + m.routingEnabled = true + m.nativeRouter = true + + log.Info("server routes are disabled") + + case forceUserspaceRouter: + m.routingEnabled = true + m.nativeRouter = false + + log.Info("userspace routing is forced") + + case !m.netstack && m.nativeFirewall != nil && m.nativeFirewall.IsServerRouteSupported(): + // if the OS supports routing natively, then we don't need to filter/route ourselves + // netstack mode won't support native routing as there is no interface + + m.routingEnabled = true + m.nativeRouter = true + + log.Info("native routing is enabled") + + default: + m.routingEnabled = true + m.nativeRouter = false + + log.Info("userspace routing enabled by default") + } + + // netstack needs the forwarder for local traffic + if m.netstack && m.localForwarding || + m.routingEnabled && !m.nativeRouter { + + m.initForwarder(iface) + } +} + +// initForwarder initializes the forwarder, it disables routing on errors +func (m *Manager) initForwarder(iface common.IFaceMapper) { + // Only supported in userspace mode as we need to inject packets back into wireguard directly + intf := iface.GetWGDevice() + if intf == nil { + log.Info("forwarding not supported") + m.routingEnabled = false + return + } + + forwarder, err := forwarder.New(iface, m.logger, m.netstack) + if err != nil { + log.Errorf("failed to create forwarder: %v", err) + m.routingEnabled = false + return + } + + m.forwarder = forwarder +} + func (m *Manager) Init(*statemanager.Manager) error { return nil } func (m *Manager) IsServerRouteSupported() bool { - if m.nativeFirewall == nil { - return false - } else { - return true - } + return m.nativeFirewall != nil || m.routingEnabled && m.forwarder != nil } func (m *Manager) AddNatRule(pair firewall.RouterPair) error { - if m.nativeFirewall == nil { - return errRouteNotSupported + if m.nativeRouter && m.nativeFirewall != nil { + return m.nativeFirewall.AddNatRule(pair) } - return m.nativeFirewall.AddNatRule(pair) + + // userspace routed packets are always SNATed to the inbound direction + // TODO: implement outbound SNAT + return nil } // RemoveNatRule removes a routing firewall rule func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error { - if m.nativeFirewall == nil { - return errRouteNotSupported + if m.nativeRouter && m.nativeFirewall != nil { + return m.nativeFirewall.RemoveNatRule(pair) } - return m.nativeFirewall.RemoveNatRule(pair) + return nil } // AddPeerFiltering rule to the firewall @@ -162,7 +319,7 @@ func (m *Manager) AddPeerFiltering( _ string, comment string, ) ([]firewall.Rule, error) { - r := Rule{ + r := PeerRule{ id: uuid.New().String(), ip: ip, ipLayer: layers.LayerTypeIPv6, @@ -205,18 +362,56 @@ func (m *Manager) AddPeerFiltering( return []firewall.Rule{&r}, nil } -func (m *Manager) AddRouteFiltering(sources []netip.Prefix, destination netip.Prefix, proto firewall.Protocol, sPort *firewall.Port, dPort *firewall.Port, action firewall.Action) (firewall.Rule, error) { - if m.nativeFirewall == nil { - return nil, errRouteNotSupported +func (m *Manager) AddRouteFiltering( + sources []netip.Prefix, + destination netip.Prefix, + proto firewall.Protocol, + sPort *firewall.Port, + dPort *firewall.Port, + action firewall.Action, +) (firewall.Rule, error) { + if m.nativeRouter && m.nativeFirewall != nil { + return m.nativeFirewall.AddRouteFiltering(sources, destination, proto, sPort, dPort, action) } - return m.nativeFirewall.AddRouteFiltering(sources, destination, proto, sPort, dPort, action) + + m.mutex.Lock() + defer m.mutex.Unlock() + + ruleID := uuid.New().String() + rule := RouteRule{ + id: ruleID, + sources: sources, + destination: destination, + proto: proto, + srcPort: sPort, + dstPort: dPort, + action: action, + } + + m.routeRules = append(m.routeRules, rule) + m.routeRules.Sort() + + return &rule, nil } func (m *Manager) DeleteRouteRule(rule firewall.Rule) error { - if m.nativeFirewall == nil { - return errRouteNotSupported + if m.nativeRouter && m.nativeFirewall != nil { + return m.nativeFirewall.DeleteRouteRule(rule) } - return m.nativeFirewall.DeleteRouteRule(rule) + + m.mutex.Lock() + defer m.mutex.Unlock() + + ruleID := rule.GetRuleID() + idx := slices.IndexFunc(m.routeRules, func(r RouteRule) bool { + return r.id == ruleID + }) + if idx < 0 { + return fmt.Errorf("route rule not found: %s", ruleID) + } + + m.routeRules = slices.Delete(m.routeRules, idx, idx+1) + return nil } // DeletePeerRule from the firewall by rule definition @@ -224,7 +419,7 @@ func (m *Manager) DeletePeerRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - r, ok := rule.(*Rule) + r, ok := rule.(*PeerRule) if !ok { return fmt.Errorf("delete rule: invalid rule type: %T", rule) } @@ -255,10 +450,14 @@ func (m *Manager) DropOutgoing(packetData []byte) bool { // DropIncoming filter incoming packets func (m *Manager) DropIncoming(packetData []byte) bool { - return m.dropFilter(packetData, m.incomingRules) + return m.dropFilter(packetData) +} + +// UpdateLocalIPs updates the list of local IPs +func (m *Manager) UpdateLocalIPs() error { + return m.localipmanager.UpdateLocalIPs(m.wgIface) } -// processOutgoingHooks processes UDP hooks for outgoing packets and tracks TCP/UDP/ICMP func (m *Manager) processOutgoingHooks(packetData []byte) bool { m.mutex.RLock() defer m.mutex.RUnlock() @@ -279,18 +478,11 @@ func (m *Manager) processOutgoingHooks(packetData []byte) bool { return false } - // Always process UDP hooks - if d.decoded[1] == layers.LayerTypeUDP { - // Track UDP state only if enabled - if m.stateful { - m.trackUDPOutbound(d, srcIP, dstIP) - } - return m.checkUDPHooks(d, dstIP, packetData) - } - - // Track other protocols only if stateful mode is enabled + // Track all protocols if stateful mode is enabled if m.stateful { switch d.decoded[1] { + case layers.LayerTypeUDP: + m.trackUDPOutbound(d, srcIP, dstIP) case layers.LayerTypeTCP: m.trackTCPOutbound(d, srcIP, dstIP) case layers.LayerTypeICMPv4: @@ -298,6 +490,11 @@ func (m *Manager) processOutgoingHooks(packetData []byte) bool { } } + // Process UDP hooks even if stateful mode is disabled + if d.decoded[1] == layers.LayerTypeUDP { + return m.checkUDPHooks(d, dstIP, packetData) + } + return false } @@ -379,10 +576,9 @@ func (m *Manager) trackICMPOutbound(d *decoder, srcIP, dstIP net.IP) { } } -// dropFilter implements filtering logic for incoming packets -func (m *Manager) dropFilter(packetData []byte, rules map[string]RuleSet) bool { - // TODO: Disable router if --disable-server-router is set - +// dropFilter implements filtering logic for incoming packets. +// If it returns true, the packet should be dropped. +func (m *Manager) dropFilter(packetData []byte) bool { m.mutex.RLock() defer m.mutex.RUnlock() @@ -390,25 +586,120 @@ func (m *Manager) dropFilter(packetData []byte, rules map[string]RuleSet) bool { defer m.decoders.Put(d) if !m.isValidPacket(d, packetData) { + m.logger.Trace("Invalid packet structure") return true } srcIP, dstIP := m.extractIPs(d) if srcIP == nil { - log.Errorf("unknown layer: %v", d.decoded[0]) + m.logger.Error("Unknown network layer: %v", d.decoded[0]) return true } - if !m.isWireguardTraffic(srcIP, dstIP) { - return false - } - - // Check connection state only if enabled + // For all inbound traffic, first check if it matches a tracked connection. + // This must happen before any other filtering because the packets are statefully tracked. if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP) { return false } - return m.applyRules(srcIP, packetData, rules, d) + if m.localipmanager.IsLocalIP(dstIP) { + return m.handleLocalTraffic(d, srcIP, dstIP, packetData) + } + + return m.handleRoutedTraffic(d, srcIP, dstIP, packetData) +} + +// handleLocalTraffic handles local traffic. +// If it returns true, the packet should be dropped. +func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP net.IP, packetData []byte) bool { + if !m.localForwarding { + m.logger.Trace("Dropping local packet (local forwarding disabled): src=%s dst=%s", srcIP, dstIP) + return true + } + + if m.peerACLsBlock(srcIP, packetData, m.incomingRules, d) { + m.logger.Trace("Dropping local packet (ACL denied): src=%s dst=%s", + srcIP, dstIP) + return true + } + + // if running in netstack mode we need to pass this to the forwarder + if m.netstack { + m.handleNetstackLocalTraffic(packetData) + + // don't process this packet further + return true + } + + return false +} +func (m *Manager) handleNetstackLocalTraffic(packetData []byte) { + if m.forwarder == nil { + return + } + + if err := m.forwarder.InjectIncomingPacket(packetData); err != nil { + m.logger.Error("Failed to inject local packet: %v", err) + } +} + +// handleRoutedTraffic handles routed traffic. +// If it returns true, the packet should be dropped. +func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP net.IP, packetData []byte) bool { + // Drop if routing is disabled + if !m.routingEnabled { + m.logger.Trace("Dropping routed packet (routing disabled): src=%s dst=%s", + srcIP, dstIP) + return true + } + + // Pass to native stack if native router is enabled or forced + if m.nativeRouter { + return false + } + + // Get protocol and ports for route ACL check + proto := getProtocolFromPacket(d) + srcPort, dstPort := getPortsFromPacket(d) + + // Check route ACLs + if !m.routeACLsPass(srcIP, dstIP, proto, srcPort, dstPort) { + m.logger.Trace("Dropping routed packet (ACL denied): src=%s:%d dst=%s:%d proto=%v", + srcIP, srcPort, dstIP, dstPort, proto) + return true + } + + // Let forwarder handle the packet if it passed route ACLs + if err := m.forwarder.InjectIncomingPacket(packetData); err != nil { + m.logger.Error("Failed to inject incoming packet: %v", err) + } + + // Forwarded packets shouldn't reach the native stack, hence they won't be visible in a packet capture + return true +} + +func getProtocolFromPacket(d *decoder) firewall.Protocol { + switch d.decoded[1] { + case layers.LayerTypeTCP: + return firewall.ProtocolTCP + case layers.LayerTypeUDP: + return firewall.ProtocolUDP + case layers.LayerTypeICMPv4, layers.LayerTypeICMPv6: + return firewall.ProtocolICMP + default: + return firewall.ProtocolALL + } +} + +func getPortsFromPacket(d *decoder) (srcPort, dstPort uint16) { + switch d.decoded[1] { + case layers.LayerTypeTCP: + return uint16(d.tcp.SrcPort), uint16(d.tcp.DstPort) + case layers.LayerTypeUDP: + return uint16(d.udp.SrcPort), uint16(d.udp.DstPort) + default: + return 0, 0 + } } func (m *Manager) isValidPacket(d *decoder, packetData []byte) bool { @@ -424,10 +715,6 @@ func (m *Manager) isValidPacket(d *decoder, packetData []byte) bool { return true } -func (m *Manager) isWireguardTraffic(srcIP, dstIP net.IP) bool { - return m.wgNetwork.Contains(srcIP) && m.wgNetwork.Contains(dstIP) -} - func (m *Manager) isValidTrackedConnection(d *decoder, srcIP, dstIP net.IP) bool { switch d.decoded[1] { case layers.LayerTypeTCP: @@ -462,7 +749,22 @@ func (m *Manager) isValidTrackedConnection(d *decoder, srcIP, dstIP net.IP) bool return false } -func (m *Manager) applyRules(srcIP net.IP, packetData []byte, rules map[string]RuleSet, d *decoder) bool { +// isSpecialICMP returns true if the packet is a special ICMP packet that should be allowed +func (m *Manager) isSpecialICMP(d *decoder) bool { + if d.decoded[1] != layers.LayerTypeICMPv4 { + return false + } + + icmpType := d.icmp4.TypeCode.Type() + return icmpType == layers.ICMPv4TypeDestinationUnreachable || + icmpType == layers.ICMPv4TypeTimeExceeded +} + +func (m *Manager) peerACLsBlock(srcIP net.IP, packetData []byte, rules map[string]RuleSet, d *decoder) bool { + if m.isSpecialICMP(d) { + return false + } + if filter, ok := validateRule(srcIP, packetData, rules[srcIP.String()], d); ok { return filter } @@ -496,7 +798,7 @@ func portsMatch(rulePort *firewall.Port, packetPort uint16) bool { return false } -func validateRule(ip net.IP, packetData []byte, rules map[string]Rule, d *decoder) (bool, bool) { +func validateRule(ip net.IP, packetData []byte, rules map[string]PeerRule, d *decoder) (bool, bool) { payloadLayer := d.decoded[1] for _, rule := range rules { if rule.matchByIP && !ip.Equal(rule.ip) { @@ -533,6 +835,51 @@ func validateRule(ip net.IP, packetData []byte, rules map[string]Rule, d *decode return false, false } +// routeACLsPass returns treu if the packet is allowed by the route ACLs +func (m *Manager) routeACLsPass(srcIP, dstIP net.IP, proto firewall.Protocol, srcPort, dstPort uint16) bool { + m.mutex.RLock() + defer m.mutex.RUnlock() + + srcAddr := netip.AddrFrom4([4]byte(srcIP.To4())) + dstAddr := netip.AddrFrom4([4]byte(dstIP.To4())) + + for _, rule := range m.routeRules { + if m.ruleMatches(rule, srcAddr, dstAddr, proto, srcPort, dstPort) { + return rule.action == firewall.ActionAccept + } + } + return false +} + +func (m *Manager) ruleMatches(rule RouteRule, srcAddr, dstAddr netip.Addr, proto firewall.Protocol, srcPort, dstPort uint16) bool { + if !rule.destination.Contains(dstAddr) { + return false + } + + sourceMatched := false + for _, src := range rule.sources { + if src.Contains(srcAddr) { + sourceMatched = true + break + } + } + if !sourceMatched { + return false + } + + if rule.proto != firewall.ProtocolALL && rule.proto != proto { + return false + } + + if proto == firewall.ProtocolTCP || proto == firewall.ProtocolUDP { + if !portsMatch(rule.srcPort, srcPort) || !portsMatch(rule.dstPort, dstPort) { + return false + } + } + + return true +} + // SetNetwork of the wireguard interface to which filtering applied func (m *Manager) SetNetwork(network *net.IPNet) { m.wgNetwork = network @@ -544,7 +891,7 @@ func (m *Manager) SetNetwork(network *net.IPNet) { func (m *Manager) AddUDPPacketHook( in bool, ip net.IP, dPort uint16, hook func([]byte) bool, ) string { - r := Rule{ + r := PeerRule{ id: uuid.New().String(), ip: ip, protoLayer: layers.LayerTypeUDP, @@ -561,12 +908,12 @@ func (m *Manager) AddUDPPacketHook( m.mutex.Lock() if in { if _, ok := m.incomingRules[r.ip.String()]; !ok { - m.incomingRules[r.ip.String()] = make(map[string]Rule) + m.incomingRules[r.ip.String()] = make(map[string]PeerRule) } m.incomingRules[r.ip.String()][r.id] = r } else { if _, ok := m.outgoingRules[r.ip.String()]; !ok { - m.outgoingRules[r.ip.String()] = make(map[string]Rule) + m.outgoingRules[r.ip.String()] = make(map[string]PeerRule) } m.outgoingRules[r.ip.String()][r.id] = r } @@ -599,3 +946,10 @@ func (m *Manager) RemovePacketHook(hookID string) error { } return fmt.Errorf("hook with given id not found") } + +// SetLogLevel sets the log level for the firewall manager +func (m *Manager) SetLogLevel(level log.Level) { + if m.logger != nil { + m.logger.SetLevel(nblog.Level(level)) + } +} diff --git a/client/firewall/uspfilter/uspfilter_bench_test.go b/client/firewall/uspfilter/uspfilter_bench_test.go index 46bc4439d..875bb2425 100644 --- a/client/firewall/uspfilter/uspfilter_bench_test.go +++ b/client/firewall/uspfilter/uspfilter_bench_test.go @@ -1,9 +1,12 @@ +//go:build uspbench + package uspfilter import ( "fmt" "math/rand" "net" + "net/netip" "os" "strings" "testing" @@ -155,7 +158,7 @@ func BenchmarkCoreFiltering(b *testing.B) { // Create manager and basic setup manager, _ := Create(&IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, - }) + }, false) defer b.Cleanup(func() { require.NoError(b, manager.Reset(nil)) }) @@ -185,7 +188,7 @@ func BenchmarkCoreFiltering(b *testing.B) { // Measure inbound packet processing b.ResetTimer() for i := 0; i < b.N; i++ { - manager.dropFilter(inbound, manager.incomingRules) + manager.dropFilter(inbound) } }) } @@ -200,7 +203,7 @@ func BenchmarkStateScaling(b *testing.B) { b.Run(fmt.Sprintf("conns_%d", count), func(b *testing.B) { manager, _ := Create(&IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, - }) + }, false) b.Cleanup(func() { require.NoError(b, manager.Reset(nil)) }) @@ -228,7 +231,7 @@ func BenchmarkStateScaling(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - manager.dropFilter(testIn, manager.incomingRules) + manager.dropFilter(testIn) } }) } @@ -248,7 +251,7 @@ func BenchmarkEstablishmentOverhead(b *testing.B) { b.Run(sc.name, func(b *testing.B) { manager, _ := Create(&IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, - }) + }, false) b.Cleanup(func() { require.NoError(b, manager.Reset(nil)) }) @@ -269,7 +272,7 @@ func BenchmarkEstablishmentOverhead(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - manager.dropFilter(inbound, manager.incomingRules) + manager.dropFilter(inbound) } }) } @@ -447,7 +450,7 @@ func BenchmarkRoutedNetworkReturn(b *testing.B) { b.Run(sc.name, func(b *testing.B) { manager, _ := Create(&IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, - }) + }, false) b.Cleanup(func() { require.NoError(b, manager.Reset(nil)) }) @@ -472,7 +475,7 @@ func BenchmarkRoutedNetworkReturn(b *testing.B) { manager.processOutgoingHooks(syn) // SYN-ACK synack := generateTCPPacketWithFlags(b, dstIP, srcIP, 80, 1024, uint16(conntrack.TCPSyn|conntrack.TCPAck)) - manager.dropFilter(synack, manager.incomingRules) + manager.dropFilter(synack) // ACK ack := generateTCPPacketWithFlags(b, srcIP, dstIP, 1024, 80, uint16(conntrack.TCPAck)) manager.processOutgoingHooks(ack) @@ -481,7 +484,7 @@ func BenchmarkRoutedNetworkReturn(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - manager.dropFilter(inbound, manager.incomingRules) + manager.dropFilter(inbound) } }) } @@ -574,7 +577,7 @@ func BenchmarkLongLivedConnections(b *testing.B) { manager, _ := Create(&IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, - }) + }, false) defer b.Cleanup(func() { require.NoError(b, manager.Reset(nil)) }) @@ -618,7 +621,7 @@ func BenchmarkLongLivedConnections(b *testing.B) { // SYN-ACK synack := generateTCPPacketWithFlags(b, dstIPs[i], srcIPs[i], 80, uint16(1024+i), uint16(conntrack.TCPSyn|conntrack.TCPAck)) - manager.dropFilter(synack, manager.incomingRules) + manager.dropFilter(synack) // ACK ack := generateTCPPacketWithFlags(b, srcIPs[i], dstIPs[i], @@ -646,7 +649,7 @@ func BenchmarkLongLivedConnections(b *testing.B) { // First outbound data manager.processOutgoingHooks(outPackets[connIdx]) // Then inbound response - this is what we're actually measuring - manager.dropFilter(inPackets[connIdx], manager.incomingRules) + manager.dropFilter(inPackets[connIdx]) } }) } @@ -665,7 +668,7 @@ func BenchmarkShortLivedConnections(b *testing.B) { manager, _ := Create(&IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, - }) + }, false) defer b.Cleanup(func() { require.NoError(b, manager.Reset(nil)) }) @@ -754,17 +757,17 @@ func BenchmarkShortLivedConnections(b *testing.B) { // Connection establishment manager.processOutgoingHooks(p.syn) - manager.dropFilter(p.synAck, manager.incomingRules) + manager.dropFilter(p.synAck) manager.processOutgoingHooks(p.ack) // Data transfer manager.processOutgoingHooks(p.request) - manager.dropFilter(p.response, manager.incomingRules) + manager.dropFilter(p.response) // Connection teardown manager.processOutgoingHooks(p.finClient) - manager.dropFilter(p.ackServer, manager.incomingRules) - manager.dropFilter(p.finServer, manager.incomingRules) + manager.dropFilter(p.ackServer) + manager.dropFilter(p.finServer) manager.processOutgoingHooks(p.ackClient) } }) @@ -784,7 +787,7 @@ func BenchmarkParallelLongLivedConnections(b *testing.B) { manager, _ := Create(&IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, - }) + }, false) defer b.Cleanup(func() { require.NoError(b, manager.Reset(nil)) }) @@ -825,7 +828,7 @@ func BenchmarkParallelLongLivedConnections(b *testing.B) { synack := generateTCPPacketWithFlags(b, dstIPs[i], srcIPs[i], 80, uint16(1024+i), uint16(conntrack.TCPSyn|conntrack.TCPAck)) - manager.dropFilter(synack, manager.incomingRules) + manager.dropFilter(synack) ack := generateTCPPacketWithFlags(b, srcIPs[i], dstIPs[i], uint16(1024+i), 80, uint16(conntrack.TCPAck)) @@ -852,7 +855,7 @@ func BenchmarkParallelLongLivedConnections(b *testing.B) { // Simulate bidirectional traffic manager.processOutgoingHooks(outPackets[connIdx]) - manager.dropFilter(inPackets[connIdx], manager.incomingRules) + manager.dropFilter(inPackets[connIdx]) } }) }) @@ -872,7 +875,7 @@ func BenchmarkParallelShortLivedConnections(b *testing.B) { manager, _ := Create(&IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, - }) + }, false) defer b.Cleanup(func() { require.NoError(b, manager.Reset(nil)) }) @@ -949,15 +952,15 @@ func BenchmarkParallelShortLivedConnections(b *testing.B) { // Full connection lifecycle manager.processOutgoingHooks(p.syn) - manager.dropFilter(p.synAck, manager.incomingRules) + manager.dropFilter(p.synAck) manager.processOutgoingHooks(p.ack) manager.processOutgoingHooks(p.request) - manager.dropFilter(p.response, manager.incomingRules) + manager.dropFilter(p.response) manager.processOutgoingHooks(p.finClient) - manager.dropFilter(p.ackServer, manager.incomingRules) - manager.dropFilter(p.finServer, manager.incomingRules) + manager.dropFilter(p.ackServer) + manager.dropFilter(p.finServer) manager.processOutgoingHooks(p.ackClient) } }) @@ -996,3 +999,72 @@ func generateTCPPacketWithFlags(b *testing.B, srcIP, dstIP net.IP, srcPort, dstP require.NoError(b, gopacket.SerializeLayers(buf, opts, ipv4, tcp, gopacket.Payload("test"))) return buf.Bytes() } + +func BenchmarkRouteACLs(b *testing.B) { + manager := setupRoutedManager(b, "10.10.0.100/16") + + // Add several route rules to simulate real-world scenario + rules := []struct { + sources []netip.Prefix + dest netip.Prefix + proto fw.Protocol + port *fw.Port + }{ + { + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + port: &fw.Port{Values: []uint16{80, 443}}, + }, + { + sources: []netip.Prefix{ + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("10.0.0.0/8"), + }, + dest: netip.MustParsePrefix("0.0.0.0/0"), + proto: fw.ProtocolICMP, + }, + { + sources: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, + dest: netip.MustParsePrefix("192.168.0.0/16"), + proto: fw.ProtocolUDP, + port: &fw.Port{Values: []uint16{53}}, + }, + } + + for _, r := range rules { + _, err := manager.AddRouteFiltering( + r.sources, + r.dest, + r.proto, + nil, + r.port, + fw.ActionAccept, + ) + if err != nil { + b.Fatal(err) + } + } + + // Test cases that exercise different matching scenarios + cases := []struct { + srcIP string + dstIP string + proto fw.Protocol + dstPort uint16 + }{ + {"100.10.0.1", "192.168.1.100", fw.ProtocolTCP, 443}, // Match first rule + {"172.16.0.1", "8.8.8.8", fw.ProtocolICMP, 0}, // Match second rule + {"1.1.1.1", "192.168.1.53", fw.ProtocolUDP, 53}, // Match third rule + {"192.168.1.1", "10.0.0.1", fw.ProtocolTCP, 8080}, // No match + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, tc := range cases { + srcIP := net.ParseIP(tc.srcIP) + dstIP := net.ParseIP(tc.dstIP) + manager.routeACLsPass(srcIP, dstIP, tc.proto, 0, tc.dstPort) + } + } +} diff --git a/client/firewall/uspfilter/uspfilter_filter_test.go b/client/firewall/uspfilter/uspfilter_filter_test.go new file mode 100644 index 000000000..d7aebb1aa --- /dev/null +++ b/client/firewall/uspfilter/uspfilter_filter_test.go @@ -0,0 +1,1014 @@ +package uspfilter + +import ( + "net" + "net/netip" + "testing" + + "github.com/golang/mock/gomock" + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/require" + wgdevice "golang.zx2c4.com/wireguard/device" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/mocks" +) + +func TestPeerACLFiltering(t *testing.T) { + localIP := net.ParseIP("100.10.0.100") + wgNet := &net.IPNet{ + IP: net.ParseIP("100.10.0.0"), + Mask: net.CIDRMask(16, 32), + } + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() iface.WGAddress { + return iface.WGAddress{ + IP: localIP, + Network: wgNet, + } + }, + } + + manager, err := Create(ifaceMock, false) + require.NoError(t, err) + require.NotNil(t, manager) + + t.Cleanup(func() { + require.NoError(t, manager.Reset(nil)) + }) + + manager.wgNetwork = wgNet + + 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 + ruleSrcPort *fw.Port + ruleDstPort *fw.Port + ruleAction fw.Action + shouldBeBlocked bool + }{ + { + name: "Allow TCP traffic from WG peer", + srcIP: "100.10.0.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + ruleIP: "100.10.0.1", + ruleProto: fw.ProtocolTCP, + ruleDstPort: &fw.Port{Values: []uint16{443}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "Allow UDP traffic from WG peer", + srcIP: "100.10.0.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolUDP, + srcPort: 12345, + dstPort: 53, + ruleIP: "100.10.0.1", + ruleProto: fw.ProtocolUDP, + ruleDstPort: &fw.Port{Values: []uint16{53}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "Allow ICMP traffic from WG peer", + srcIP: "100.10.0.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolICMP, + ruleIP: "100.10.0.1", + ruleProto: fw.ProtocolICMP, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "Allow all traffic from WG peer", + srcIP: "100.10.0.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + ruleIP: "100.10.0.1", + ruleProto: fw.ProtocolALL, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "Allow traffic from non-WG source", + srcIP: "192.168.1.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + ruleIP: "192.168.1.1", + ruleProto: fw.ProtocolTCP, + ruleDstPort: &fw.Port{Values: []uint16{443}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "Allow all traffic with 0.0.0.0 rule", + srcIP: "100.10.0.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + ruleIP: "0.0.0.0", + ruleProto: fw.ProtocolALL, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "Allow TCP traffic within port range", + srcIP: "100.10.0.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 8080, + ruleIP: "100.10.0.1", + ruleProto: fw.ProtocolTCP, + ruleDstPort: &fw.Port{IsRange: true, Values: []uint16{8000, 8100}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "Block TCP traffic outside port range", + srcIP: "100.10.0.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 7999, + ruleIP: "100.10.0.1", + ruleProto: fw.ProtocolTCP, + ruleDstPort: &fw.Port{IsRange: true, Values: []uint16{8000, 8100}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: true, + }, + { + name: "Allow TCP traffic with source port range", + srcIP: "100.10.0.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolTCP, + srcPort: 32100, + dstPort: 443, + ruleIP: "100.10.0.1", + ruleProto: fw.ProtocolTCP, + ruleSrcPort: &fw.Port{IsRange: true, Values: []uint16{32000, 33000}}, + ruleDstPort: &fw.Port{Values: []uint16{443}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "Block TCP traffic outside source port range", + srcIP: "100.10.0.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolTCP, + srcPort: 31999, + dstPort: 443, + ruleIP: "100.10.0.1", + ruleProto: fw.ProtocolTCP, + ruleSrcPort: &fw.Port{IsRange: true, Values: []uint16{32000, 33000}}, + ruleDstPort: &fw.Port{Values: []uint16{443}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: true, + }, + } + + t.Run("Implicit DROP (no rules)", func(t *testing.T) { + packet := createTestPacket(t, "100.10.0.1", "100.10.0.100", fw.ProtocolTCP, 12345, 443) + isDropped := manager.DropIncoming(packet) + require.True(t, isDropped, "Packet should be dropped when no rules exist") + }) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rules, err := manager.AddPeerFiltering( + net.ParseIP(tc.ruleIP), + tc.ruleProto, + tc.ruleSrcPort, + tc.ruleDstPort, + tc.ruleAction, + "", + tc.name, + ) + 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.DropIncoming(packet) + require.Equal(t, tc.shouldBeBlocked, isDropped) + }) + } +} + +func createTestPacket(t *testing.T, srcIP, dstIP string, proto fw.Protocol, srcPort, dstPort uint16) []byte { + t.Helper() + + 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), + } + + 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), + } + 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), + } + err = gopacket.SerializeLayers(buf, opts, ipLayer, icmp) + + default: + err = gopacket.SerializeLayers(buf, opts, ipLayer) + } + + require.NoError(t, err) + return buf.Bytes() +} + +func setupRoutedManager(tb testing.TB, network string) *Manager { + tb.Helper() + + ctrl := gomock.NewController(tb) + dev := mocks.NewMockDevice(ctrl) + dev.EXPECT().MTU().Return(1500, nil).AnyTimes() + + localIP, wgNet, err := net.ParseCIDR(network) + require.NoError(tb, err) + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() iface.WGAddress { + return iface.WGAddress{ + IP: localIP, + Network: wgNet, + } + }, + GetDeviceFunc: func() *device.FilteredDevice { + return &device.FilteredDevice{Device: dev} + }, + GetWGDeviceFunc: func() *wgdevice.Device { + return &wgdevice.Device{} + }, + } + + manager, err := Create(ifaceMock, false) + require.NoError(tb, err) + require.NotNil(tb, manager) + require.True(tb, manager.routingEnabled) + require.False(tb, manager.nativeRouter) + + tb.Cleanup(func() { + require.NoError(tb, manager.Reset(nil)) + }) + + return manager +} + +func TestRouteACLFiltering(t *testing.T) { + manager := setupRoutedManager(t, "10.10.0.100/16") + + type rule struct { + sources []netip.Prefix + dest netip.Prefix + proto fw.Protocol + srcPort *fw.Port + dstPort *fw.Port + action fw.Action + } + + testCases := []struct { + name string + srcIP string + dstIP string + proto fw.Protocol + srcPort uint16 + dstPort uint16 + rule rule + shouldPass bool + }{ + { + name: "Allow TCP with specific source and destination", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{443}}, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Allow any source to specific destination", + srcIP: "172.16.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{443}}, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Allow any source to any destination", + srcIP: "172.16.0.1", + dstIP: "203.0.113.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, + dest: netip.MustParsePrefix("0.0.0.0/0"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{443}}, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Allow UDP DNS traffic", + srcIP: "100.10.0.1", + dstIP: "192.168.1.53", + proto: fw.ProtocolUDP, + srcPort: 54321, + dstPort: 53, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolUDP, + dstPort: &fw.Port{Values: []uint16{53}}, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Allow ICMP to any destination", + srcIP: "100.10.0.1", + dstIP: "8.8.8.8", + proto: fw.ProtocolICMP, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("0.0.0.0/0"), + proto: fw.ProtocolICMP, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Allow all protocols but specific port", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolALL, + dstPort: &fw.Port{Values: []uint16{80}}, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Implicit deny - wrong destination port", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 8080, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{80}}, + action: fw.ActionAccept, + }, + shouldPass: false, + }, + { + name: "Implicit deny - wrong protocol", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolUDP, + srcPort: 12345, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{80}}, + action: fw.ActionAccept, + }, + shouldPass: false, + }, + { + name: "Implicit deny - wrong source network", + srcIP: "172.16.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{80}}, + action: fw.ActionAccept, + }, + shouldPass: false, + }, + { + name: "Source port match", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + srcPort: &fw.Port{Values: []uint16{12345}}, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Multiple source networks", + srcIP: "172.16.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{ + netip.MustParsePrefix("100.10.0.0/16"), + netip.MustParsePrefix("172.16.0.0/16"), + }, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{80}}, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Allow ALL protocol without ports", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolICMP, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolALL, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Allow ALL protocol with specific ports", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolALL, + dstPort: &fw.Port{Values: []uint16{80}}, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Multiple source networks with mismatched protocol", + srcIP: "172.16.0.1", + dstIP: "192.168.1.100", + // Should not match TCP rule + proto: fw.ProtocolUDP, + srcPort: 12345, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{ + netip.MustParsePrefix("100.10.0.0/16"), + netip.MustParsePrefix("172.16.0.0/16"), + }, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{80}}, + action: fw.ActionAccept, + }, + shouldPass: false, + }, + { + name: "Allow multiple destination ports", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 8080, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{80, 8080, 443}}, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Allow multiple source ports", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + srcPort: &fw.Port{Values: []uint16{12345, 12346, 12347}}, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Allow ALL protocol with both src and dst ports", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolALL, + srcPort: &fw.Port{Values: []uint16{12345}}, + dstPort: &fw.Port{Values: []uint16{80}}, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Port Range - Within Range", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 8080, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{ + IsRange: true, + Values: []uint16{8000, 8100}, + }, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Port Range - Outside Range", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 7999, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{ + IsRange: true, + Values: []uint16{8000, 8100}, + }, + action: fw.ActionAccept, + }, + shouldPass: false, + }, + { + name: "Source Port Range - Within Range", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 32100, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + srcPort: &fw.Port{ + IsRange: true, + Values: []uint16{32000, 33000}, + }, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Mixed Port Specification - Range and Single", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 32100, + dstPort: 443, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + srcPort: &fw.Port{ + IsRange: true, + Values: []uint16{32000, 33000}, + }, + dstPort: &fw.Port{ + Values: []uint16{443}, + }, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Edge Case - Port at Range Boundary", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 8100, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{ + IsRange: true, + Values: []uint16{8000, 8100}, + }, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "UDP Port Range", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolUDP, + srcPort: 12345, + dstPort: 5060, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolUDP, + dstPort: &fw.Port{ + IsRange: true, + Values: []uint16{5060, 5070}, + }, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "ALL Protocol with Port Range", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 8080, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolALL, + dstPort: &fw.Port{ + IsRange: true, + Values: []uint16{8000, 8100}, + }, + action: fw.ActionAccept, + }, + shouldPass: true, + }, + { + name: "Drop TCP traffic to specific destination", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{443}}, + action: fw.ActionDrop, + }, + shouldPass: false, + }, + { + name: "Drop all traffic to specific destination", + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolALL, + action: fw.ActionDrop, + }, + shouldPass: false, + }, + { + name: "Drop traffic from multiple source networks", + srcIP: "172.16.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 80, + rule: rule{ + sources: []netip.Prefix{ + netip.MustParsePrefix("100.10.0.0/16"), + netip.MustParsePrefix("172.16.0.0/16"), + }, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{80}}, + action: fw.ActionDrop, + }, + shouldPass: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rule, err := manager.AddRouteFiltering( + tc.rule.sources, + tc.rule.dest, + tc.rule.proto, + tc.rule.srcPort, + tc.rule.dstPort, + tc.rule.action, + ) + require.NoError(t, err) + require.NotNil(t, rule) + + t.Cleanup(func() { + require.NoError(t, manager.DeleteRouteRule(rule)) + }) + + srcIP := net.ParseIP(tc.srcIP) + dstIP := net.ParseIP(tc.dstIP) + + // testing routeACLsPass only and not DropIncoming, as routed packets are dropped after being passed + // to the forwarder + isAllowed := manager.routeACLsPass(srcIP, dstIP, tc.proto, tc.srcPort, tc.dstPort) + require.Equal(t, tc.shouldPass, isAllowed) + }) + } +} + +func TestRouteACLOrder(t *testing.T) { + manager := setupRoutedManager(t, "10.10.0.100/16") + + type testCase struct { + name string + rules []struct { + sources []netip.Prefix + dest netip.Prefix + proto fw.Protocol + srcPort *fw.Port + dstPort *fw.Port + action fw.Action + } + packets []struct { + srcIP string + dstIP string + proto fw.Protocol + srcPort uint16 + dstPort uint16 + shouldPass bool + } + } + + testCases := []testCase{ + { + name: "Drop rules take precedence over accept", + rules: []struct { + sources []netip.Prefix + dest netip.Prefix + proto fw.Protocol + srcPort *fw.Port + dstPort *fw.Port + action fw.Action + }{ + { + // Accept rule added first + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{80, 443}}, + action: fw.ActionAccept, + }, + { + // Drop rule added second but should be evaluated first + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{443}}, + action: fw.ActionDrop, + }, + }, + packets: []struct { + srcIP string + dstIP string + proto fw.Protocol + srcPort uint16 + dstPort uint16 + shouldPass bool + }{ + { + // Should be dropped by the drop rule + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + shouldPass: false, + }, + { + // Should be allowed by the accept rule (port 80 not in drop rule) + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 80, + shouldPass: true, + }, + }, + }, + { + name: "Multiple drop rules take precedence", + rules: []struct { + sources []netip.Prefix + dest netip.Prefix + proto fw.Protocol + srcPort *fw.Port + dstPort *fw.Port + action fw.Action + }{ + { + // Accept all + sources: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, + dest: netip.MustParsePrefix("0.0.0.0/0"), + proto: fw.ProtocolALL, + action: fw.ActionAccept, + }, + { + // Drop specific port + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{443}}, + action: fw.ActionDrop, + }, + { + // Drop different port + sources: []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + dest: netip.MustParsePrefix("192.168.1.0/24"), + proto: fw.ProtocolTCP, + dstPort: &fw.Port{Values: []uint16{80}}, + action: fw.ActionDrop, + }, + }, + packets: []struct { + srcIP string + dstIP string + proto fw.Protocol + srcPort uint16 + dstPort uint16 + shouldPass bool + }{ + { + // Should be dropped by first drop rule + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + shouldPass: false, + }, + { + // Should be dropped by second drop rule + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 80, + shouldPass: false, + }, + { + // Should be allowed by the accept rule (different port) + srcIP: "100.10.0.1", + dstIP: "192.168.1.100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 8080, + shouldPass: true, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var rules []fw.Rule + for _, r := range tc.rules { + rule, err := manager.AddRouteFiltering( + r.sources, + r.dest, + r.proto, + r.srcPort, + r.dstPort, + r.action, + ) + require.NoError(t, err) + require.NotNil(t, rule) + rules = append(rules, rule) + } + + t.Cleanup(func() { + for _, rule := range rules { + require.NoError(t, manager.DeleteRouteRule(rule)) + } + }) + + for i, p := range tc.packets { + srcIP := net.ParseIP(p.srcIP) + dstIP := net.ParseIP(p.dstIP) + + isAllowed := manager.routeACLsPass(srcIP, dstIP, p.proto, p.srcPort, p.dstPort) + require.Equal(t, p.shouldPass, isAllowed, "packet %d failed", i) + } + }) + } +} diff --git a/client/firewall/uspfilter/uspfilter_test.go b/client/firewall/uspfilter/uspfilter_test.go index 9d795de69..089bf8f55 100644 --- a/client/firewall/uspfilter/uspfilter_test.go +++ b/client/firewall/uspfilter/uspfilter_test.go @@ -9,17 +9,38 @@ import ( "github.com/google/gopacket" "github.com/google/gopacket/layers" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" + wgdevice "golang.zx2c4.com/wireguard/device" fw "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack" + "github.com/netbirdio/netbird/client/firewall/uspfilter/log" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/device" ) +var logger = log.NewFromLogrus(logrus.StandardLogger()) + type IFaceMock struct { - SetFilterFunc func(device.PacketFilter) error - AddressFunc func() iface.WGAddress + SetFilterFunc func(device.PacketFilter) error + AddressFunc func() iface.WGAddress + GetWGDeviceFunc func() *wgdevice.Device + GetDeviceFunc func() *device.FilteredDevice +} + +func (i *IFaceMock) GetWGDevice() *wgdevice.Device { + if i.GetWGDeviceFunc == nil { + return nil + } + return i.GetWGDeviceFunc() +} + +func (i *IFaceMock) GetDevice() *device.FilteredDevice { + if i.GetDeviceFunc == nil { + return nil + } + return i.GetDeviceFunc() } func (i *IFaceMock) SetFilter(iface device.PacketFilter) error { @@ -41,7 +62,7 @@ func TestManagerCreate(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock) + m, err := Create(ifaceMock, false) if err != nil { t.Errorf("failed to create Manager: %v", err) return @@ -61,7 +82,7 @@ func TestManagerAddPeerFiltering(t *testing.T) { }, } - m, err := Create(ifaceMock) + m, err := Create(ifaceMock, false) if err != nil { t.Errorf("failed to create Manager: %v", err) return @@ -95,7 +116,7 @@ func TestManagerDeleteRule(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock) + m, err := Create(ifaceMock, false) if err != nil { t.Errorf("failed to create Manager: %v", err) return @@ -166,12 +187,12 @@ func TestAddUDPPacketHook(t *testing.T) { t.Run(tt.name, func(t *testing.T) { manager, err := Create(&IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, - }) + }, false) require.NoError(t, err) manager.AddUDPPacketHook(tt.in, tt.ip, tt.dPort, tt.hook) - var addedRule Rule + var addedRule PeerRule if tt.in { if len(manager.incomingRules[tt.ip.String()]) != 1 { t.Errorf("expected 1 incoming rule, got %d", len(manager.incomingRules)) @@ -215,7 +236,7 @@ func TestManagerReset(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock) + m, err := Create(ifaceMock, false) if err != nil { t.Errorf("failed to create Manager: %v", err) return @@ -247,9 +268,18 @@ func TestManagerReset(t *testing.T) { func TestNotMatchByIP(t *testing.T) { ifaceMock := &IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() iface.WGAddress { + return iface.WGAddress{ + IP: net.ParseIP("100.10.0.100"), + Network: &net.IPNet{ + IP: net.ParseIP("100.10.0.0"), + Mask: net.CIDRMask(16, 32), + }, + } + }, } - m, err := Create(ifaceMock) + m, err := Create(ifaceMock, false) if err != nil { t.Errorf("failed to create Manager: %v", err) return @@ -298,7 +328,7 @@ func TestNotMatchByIP(t *testing.T) { return } - if m.dropFilter(buf.Bytes(), m.incomingRules) { + if m.dropFilter(buf.Bytes()) { t.Errorf("expected packet to be accepted") return } @@ -317,7 +347,7 @@ func TestRemovePacketHook(t *testing.T) { } // creating manager instance - manager, err := Create(iface) + manager, err := Create(iface, false) if err != nil { t.Fatalf("Failed to create Manager: %s", err) } @@ -363,7 +393,7 @@ func TestRemovePacketHook(t *testing.T) { func TestProcessOutgoingHooks(t *testing.T) { manager, err := Create(&IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, - }) + }, false) require.NoError(t, err) manager.wgNetwork = &net.IPNet{ @@ -371,7 +401,7 @@ func TestProcessOutgoingHooks(t *testing.T) { Mask: net.CIDRMask(16, 32), } manager.udpTracker.Close() - manager.udpTracker = conntrack.NewUDPTracker(100 * time.Millisecond) + manager.udpTracker = conntrack.NewUDPTracker(100*time.Millisecond, logger) defer func() { require.NoError(t, manager.Reset(nil)) }() @@ -449,7 +479,7 @@ func TestUSPFilterCreatePerformance(t *testing.T) { ifaceMock := &IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, } - manager, err := Create(ifaceMock) + manager, err := Create(ifaceMock, false) require.NoError(t, err) time.Sleep(time.Second) @@ -476,7 +506,7 @@ func TestUSPFilterCreatePerformance(t *testing.T) { func TestStatefulFirewall_UDPTracking(t *testing.T) { manager, err := Create(&IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, - }) + }, false) require.NoError(t, err) manager.wgNetwork = &net.IPNet{ @@ -485,7 +515,7 @@ func TestStatefulFirewall_UDPTracking(t *testing.T) { } manager.udpTracker.Close() // Close the existing tracker - manager.udpTracker = conntrack.NewUDPTracker(200 * time.Millisecond) + manager.udpTracker = conntrack.NewUDPTracker(200*time.Millisecond, logger) manager.decoders = sync.Pool{ New: func() any { d := &decoder{ @@ -606,7 +636,7 @@ func TestStatefulFirewall_UDPTracking(t *testing.T) { for _, cp := range checkPoints { time.Sleep(cp.sleep) - drop = manager.dropFilter(inboundBuf.Bytes(), manager.incomingRules) + drop = manager.dropFilter(inboundBuf.Bytes()) require.Equal(t, cp.shouldAllow, !drop, cp.description) // If the connection should still be valid, verify it exists @@ -677,7 +707,7 @@ func TestStatefulFirewall_UDPTracking(t *testing.T) { require.NoError(t, err) // Verify the invalid packet is dropped - drop = manager.dropFilter(testBuf.Bytes(), manager.incomingRules) + drop = manager.dropFilter(testBuf.Bytes()) require.True(t, drop, tc.description) }) } diff --git a/client/iface/device.go b/client/iface/device.go index 0d4e69145..2a170adfb 100644 --- a/client/iface/device.go +++ b/client/iface/device.go @@ -3,6 +3,8 @@ package iface import ( + wgdevice "golang.zx2c4.com/wireguard/device" + "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" ) @@ -15,4 +17,5 @@ type WGTunDevice interface { DeviceName() string Close() error FilteredDevice() *device.FilteredDevice + Device() *wgdevice.Device } diff --git a/client/iface/device/device_darwin.go b/client/iface/device/device_darwin.go index b5a128bc1..fe7ed1752 100644 --- a/client/iface/device/device_darwin.go +++ b/client/iface/device/device_darwin.go @@ -117,6 +117,11 @@ func (t *TunDevice) FilteredDevice() *FilteredDevice { return t.filteredDevice } +// Device returns the wireguard device +func (t *TunDevice) Device() *device.Device { + return t.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()) diff --git a/client/iface/device/device_kernel_unix.go b/client/iface/device/device_kernel_unix.go index 0dfed4d90..3314b576b 100644 --- a/client/iface/device/device_kernel_unix.go +++ b/client/iface/device/device_kernel_unix.go @@ -9,6 +9,7 @@ import ( "github.com/pion/transport/v3" log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/device" "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/configurer" @@ -151,6 +152,11 @@ func (t *TunKernelDevice) DeviceName() string { return t.name } +// Device returns the wireguard device, not applicable for kernel devices +func (t *TunKernelDevice) Device() *device.Device { + return nil +} + func (t *TunKernelDevice) FilteredDevice() *FilteredDevice { return nil } diff --git a/client/iface/device/device_netstack.go b/client/iface/device/device_netstack.go index f5d39e9e0..c7d297187 100644 --- a/client/iface/device/device_netstack.go +++ b/client/iface/device/device_netstack.go @@ -117,3 +117,8 @@ func (t *TunNetstackDevice) DeviceName() string { func (t *TunNetstackDevice) FilteredDevice() *FilteredDevice { return t.filteredDevice } + +// Device returns the wireguard device +func (t *TunNetstackDevice) Device() *device.Device { + return t.device +} diff --git a/client/iface/device/device_usp_unix.go b/client/iface/device/device_usp_unix.go index 3562f312d..4ac87aecb 100644 --- a/client/iface/device/device_usp_unix.go +++ b/client/iface/device/device_usp_unix.go @@ -124,6 +124,11 @@ func (t *USPDevice) FilteredDevice() *FilteredDevice { return t.filteredDevice } +// Device returns the wireguard device +func (t *USPDevice) Device() *device.Device { + return t.device +} + // assignAddr Adds IP address to the tunnel interface func (t *USPDevice) assignAddr() error { link := newWGLink(t.name) diff --git a/client/iface/device/device_windows.go b/client/iface/device/device_windows.go index 86968d06d..e603d7696 100644 --- a/client/iface/device/device_windows.go +++ b/client/iface/device/device_windows.go @@ -150,6 +150,11 @@ func (t *TunDevice) FilteredDevice() *FilteredDevice { return t.filteredDevice } +// Device returns the wireguard device +func (t *TunDevice) Device() *device.Device { + return t.device +} + func (t *TunDevice) GetInterfaceGUIDString() (string, error) { if t.nativeTunDevice == nil { return "", fmt.Errorf("interface has not been initialized yet") diff --git a/client/iface/device_android.go b/client/iface/device_android.go index 3d15080ff..028f6fa7d 100644 --- a/client/iface/device_android.go +++ b/client/iface/device_android.go @@ -1,6 +1,8 @@ package iface import ( + wgdevice "golang.zx2c4.com/wireguard/device" + "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" ) @@ -13,4 +15,5 @@ type WGTunDevice interface { DeviceName() string Close() error FilteredDevice() *device.FilteredDevice + Device() *wgdevice.Device } diff --git a/client/iface/iface.go b/client/iface/iface.go index 1fb9c2691..64219975f 100644 --- a/client/iface/iface.go +++ b/client/iface/iface.go @@ -11,6 +11,8 @@ import ( log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + wgdevice "golang.zx2c4.com/wireguard/device" + "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/configurer" @@ -203,6 +205,11 @@ func (w *WGIface) GetDevice() *device.FilteredDevice { return w.tun.FilteredDevice() } +// GetWGDevice returns the WireGuard device +func (w *WGIface) GetWGDevice() *wgdevice.Device { + return w.tun.Device() +} + // GetStats returns the last handshake time, rx and tx bytes for the given peer func (w *WGIface) GetStats(peerKey string) (configurer.WGStats, error) { return w.configurer.GetStats(peerKey) diff --git a/client/iface/iface_moc.go b/client/iface/iface_moc.go index d91a7224f..5f57bc821 100644 --- a/client/iface/iface_moc.go +++ b/client/iface/iface_moc.go @@ -4,6 +4,7 @@ import ( "net" "time" + wgdevice "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface/bind" @@ -29,6 +30,7 @@ type MockWGIface struct { SetFilterFunc func(filter device.PacketFilter) error GetFilterFunc func() device.PacketFilter GetDeviceFunc func() *device.FilteredDevice + GetWGDeviceFunc func() *wgdevice.Device GetStatsFunc func(peerKey string) (configurer.WGStats, error) GetInterfaceGUIDStringFunc func() (string, error) GetProxyFunc func() wgproxy.Proxy @@ -102,11 +104,14 @@ func (m *MockWGIface) GetDevice() *device.FilteredDevice { return m.GetDeviceFunc() } +func (m *MockWGIface) GetWGDevice() *wgdevice.Device { + return m.GetWGDeviceFunc() +} + func (m *MockWGIface) GetStats(peerKey string) (configurer.WGStats, error) { return m.GetStatsFunc(peerKey) } func (m *MockWGIface) GetProxy() wgproxy.Proxy { - //TODO implement me - panic("implement me") + return m.GetProxyFunc() } diff --git a/client/iface/iwginterface.go b/client/iface/iwginterface.go index f5ab29539..472ab45f9 100644 --- a/client/iface/iwginterface.go +++ b/client/iface/iwginterface.go @@ -6,6 +6,7 @@ import ( "net" "time" + wgdevice "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface/bind" @@ -32,5 +33,6 @@ type IWGIface interface { SetFilter(filter device.PacketFilter) error GetFilter() device.PacketFilter GetDevice() *device.FilteredDevice + GetWGDevice() *wgdevice.Device GetStats(peerKey string) (configurer.WGStats, error) } diff --git a/client/iface/iwginterface_windows.go b/client/iface/iwginterface_windows.go index 96eec52a5..c9183cafd 100644 --- a/client/iface/iwginterface_windows.go +++ b/client/iface/iwginterface_windows.go @@ -4,6 +4,7 @@ import ( "net" "time" + wgdevice "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface/bind" @@ -30,6 +31,7 @@ type IWGIface interface { SetFilter(filter device.PacketFilter) error GetFilter() device.PacketFilter GetDevice() *device.FilteredDevice + GetWGDevice() *wgdevice.Device GetStats(peerKey string) (configurer.WGStats, error) GetInterfaceGUIDString() (string, error) } diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go index 6049b4f48..217dbce9f 100644 --- a/client/internal/acl/manager_test.go +++ b/client/internal/acl/manager_test.go @@ -49,9 +49,10 @@ func TestDefaultManager(t *testing.T) { IP: ip, Network: network, }).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() // we receive one rule from the management so for testing purposes ignore it - fw, err := firewall.NewFirewall(ifaceMock, nil) + fw, err := firewall.NewFirewall(ifaceMock, nil, false) if err != nil { t.Errorf("create firewall: %v", err) return @@ -342,9 +343,10 @@ func TestDefaultManagerEnableSSHRules(t *testing.T) { IP: ip, Network: network, }).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() // we receive one rule from the management so for testing purposes ignore it - fw, err := firewall.NewFirewall(ifaceMock, nil) + fw, err := firewall.NewFirewall(ifaceMock, nil, false) if err != nil { t.Errorf("create firewall: %v", err) return diff --git a/client/internal/acl/mocks/iface_mapper.go b/client/internal/acl/mocks/iface_mapper.go index 3ed12b6dd..08aa4fd5a 100644 --- a/client/internal/acl/mocks/iface_mapper.go +++ b/client/internal/acl/mocks/iface_mapper.go @@ -8,6 +8,8 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" + wgdevice "golang.zx2c4.com/wireguard/device" + iface "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/device" ) @@ -90,3 +92,31 @@ func (mr *MockIFaceMapperMockRecorder) SetFilter(arg0 interface{}) *gomock.Call mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetFilter", reflect.TypeOf((*MockIFaceMapper)(nil).SetFilter), arg0) } + +// GetDevice mocks base method. +func (m *MockIFaceMapper) GetDevice() *device.FilteredDevice { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDevice") + ret0, _ := ret[0].(*device.FilteredDevice) + return ret0 +} + +// GetDevice indicates an expected call of GetDevice. +func (mr *MockIFaceMapperMockRecorder) GetDevice() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDevice", reflect.TypeOf((*MockIFaceMapper)(nil).GetDevice)) +} + +// GetWGDevice mocks base method. +func (m *MockIFaceMapper) GetWGDevice() *wgdevice.Device { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWGDevice") + ret0, _ := ret[0].(*wgdevice.Device) + return ret0 +} + +// GetWGDevice indicates an expected call of GetWGDevice. +func (mr *MockIFaceMapperMockRecorder) GetWGDevice() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWGDevice", reflect.TypeOf((*MockIFaceMapper)(nil).GetWGDevice)) +} diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index c166820c4..14ff1bb71 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -849,7 +849,7 @@ func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) { return nil, err } - pf, err := uspfilter.Create(wgIface) + pf, err := uspfilter.Create(wgIface, false) if err != nil { t.Fatalf("failed to create uspfilter: %v", err) return nil, err diff --git a/client/internal/engine.go b/client/internal/engine.go index 335729d92..14e0d348f 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -42,13 +42,13 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager" "github.com/netbirdio/netbird/client/internal/routemanager/systemops" "github.com/netbirdio/netbird/client/internal/statemanager" + "github.com/netbirdio/netbird/management/domain" semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group" nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" mgm "github.com/netbirdio/netbird/management/client" - "github.com/netbirdio/netbird/management/domain" mgmProto "github.com/netbirdio/netbird/management/proto" auth "github.com/netbirdio/netbird/relay/auth/hmac" relayClient "github.com/netbirdio/netbird/relay/client" @@ -193,6 +193,10 @@ type Peer struct { WgAllowedIps string } +type localIpUpdater interface { + UpdateLocalIPs() error +} + // NewEngine creates a new Connection Engine with probes attached func NewEngine( clientCtx context.Context, @@ -433,7 +437,7 @@ func (e *Engine) createFirewall() error { } var err error - e.firewall, err = firewall.NewFirewall(e.wgInterface, e.stateManager) + e.firewall, err = firewall.NewFirewall(e.wgInterface, e.stateManager, e.config.DisableServerRoutes) if err != nil || e.firewall == nil { log.Errorf("failed creating firewall manager: %s", err) return nil @@ -883,6 +887,14 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { e.acl.ApplyFiltering(networkMap) } + if e.firewall != nil { + if localipfw, ok := e.firewall.(localIpUpdater); ok { + if err := localipfw.UpdateLocalIPs(); err != nil { + log.Errorf("failed to update local IPs: %v", err) + } + } + } + // DNS forwarder dnsRouteFeatureFlag := toDNSFeatureFlag(networkMap) dnsRouteDomains := toRouteDomains(e.config.WgPrivateKey.PublicKey().String(), networkMap.GetRoutes()) @@ -1446,6 +1458,11 @@ func (e *Engine) GetRouteManager() routemanager.Manager { return e.routeManager } +// GetFirewallManager returns the firewall manager +func (e *Engine) GetFirewallManager() manager.Manager { + return e.firewall +} + func findIPFromInterfaceName(ifaceName string) (net.IP, error) { iface, err := net.InterfaceByName(ifaceName) if err != nil { @@ -1657,6 +1674,14 @@ func (e *Engine) GetLatestNetworkMap() (*mgmProto.NetworkMap, error) { return nm, nil } +// GetWgAddr returns the wireguard address +func (e *Engine) GetWgAddr() net.IP { + if e.wgInterface == nil { + return nil + } + return e.wgInterface.Address().IP +} + // updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag func (e *Engine) updateDNSForwarder(enabled bool, domains []string) { if !enabled { diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 6f73fb166..34bd67893 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -422,11 +422,6 @@ func (m *DefaultManager) classifyRoutes(newRoutes []*route.Route) (map[route.ID] haID := newRoute.GetHAUniqueID() if newRoute.Peer == m.pubKey { ownNetworkIDs[haID] = true - // only linux is supported for now - if runtime.GOOS != "linux" { - log.Warnf("received a route to manage, but agent doesn't support router mode on %s OS", runtime.GOOS) - continue - } newServerRoutesMap[newRoute.ID] = newRoute } } diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 30f7473cd..c9651efed 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -2571,6 +2571,330 @@ func (*SetNetworkMapPersistenceResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{39} } +type TCPFlags struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Syn bool `protobuf:"varint,1,opt,name=syn,proto3" json:"syn,omitempty"` + Ack bool `protobuf:"varint,2,opt,name=ack,proto3" json:"ack,omitempty"` + Fin bool `protobuf:"varint,3,opt,name=fin,proto3" json:"fin,omitempty"` + Rst bool `protobuf:"varint,4,opt,name=rst,proto3" json:"rst,omitempty"` + Psh bool `protobuf:"varint,5,opt,name=psh,proto3" json:"psh,omitempty"` + Urg bool `protobuf:"varint,6,opt,name=urg,proto3" json:"urg,omitempty"` +} + +func (x *TCPFlags) Reset() { + *x = TCPFlags{} + if protoimpl.UnsafeEnabled { + mi := &file_daemon_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TCPFlags) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TCPFlags) ProtoMessage() {} + +func (x *TCPFlags) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[40] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TCPFlags.ProtoReflect.Descriptor instead. +func (*TCPFlags) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{40} +} + +func (x *TCPFlags) GetSyn() bool { + if x != nil { + return x.Syn + } + return false +} + +func (x *TCPFlags) GetAck() bool { + if x != nil { + return x.Ack + } + return false +} + +func (x *TCPFlags) GetFin() bool { + if x != nil { + return x.Fin + } + return false +} + +func (x *TCPFlags) GetRst() bool { + if x != nil { + return x.Rst + } + return false +} + +func (x *TCPFlags) GetPsh() bool { + if x != nil { + return x.Psh + } + return false +} + +func (x *TCPFlags) GetUrg() bool { + if x != nil { + return x.Urg + } + return false +} + +type TracePacketRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SourceIp string `protobuf:"bytes,1,opt,name=source_ip,json=sourceIp,proto3" json:"source_ip,omitempty"` + DestinationIp string `protobuf:"bytes,2,opt,name=destination_ip,json=destinationIp,proto3" json:"destination_ip,omitempty"` + Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` + SourcePort uint32 `protobuf:"varint,4,opt,name=source_port,json=sourcePort,proto3" json:"source_port,omitempty"` + DestinationPort uint32 `protobuf:"varint,5,opt,name=destination_port,json=destinationPort,proto3" json:"destination_port,omitempty"` + Direction string `protobuf:"bytes,6,opt,name=direction,proto3" json:"direction,omitempty"` + TcpFlags *TCPFlags `protobuf:"bytes,7,opt,name=tcp_flags,json=tcpFlags,proto3,oneof" json:"tcp_flags,omitempty"` + IcmpType *uint32 `protobuf:"varint,8,opt,name=icmp_type,json=icmpType,proto3,oneof" json:"icmp_type,omitempty"` + IcmpCode *uint32 `protobuf:"varint,9,opt,name=icmp_code,json=icmpCode,proto3,oneof" json:"icmp_code,omitempty"` +} + +func (x *TracePacketRequest) Reset() { + *x = TracePacketRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_daemon_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TracePacketRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TracePacketRequest) ProtoMessage() {} + +func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[41] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TracePacketRequest.ProtoReflect.Descriptor instead. +func (*TracePacketRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{41} +} + +func (x *TracePacketRequest) GetSourceIp() string { + if x != nil { + return x.SourceIp + } + return "" +} + +func (x *TracePacketRequest) GetDestinationIp() string { + if x != nil { + return x.DestinationIp + } + return "" +} + +func (x *TracePacketRequest) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *TracePacketRequest) GetSourcePort() uint32 { + if x != nil { + return x.SourcePort + } + return 0 +} + +func (x *TracePacketRequest) GetDestinationPort() uint32 { + if x != nil { + return x.DestinationPort + } + return 0 +} + +func (x *TracePacketRequest) GetDirection() string { + if x != nil { + return x.Direction + } + return "" +} + +func (x *TracePacketRequest) GetTcpFlags() *TCPFlags { + if x != nil { + return x.TcpFlags + } + return nil +} + +func (x *TracePacketRequest) GetIcmpType() uint32 { + if x != nil && x.IcmpType != nil { + return *x.IcmpType + } + return 0 +} + +func (x *TracePacketRequest) GetIcmpCode() uint32 { + if x != nil && x.IcmpCode != nil { + return *x.IcmpCode + } + return 0 +} + +type TraceStage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + Allowed bool `protobuf:"varint,3,opt,name=allowed,proto3" json:"allowed,omitempty"` + ForwardingDetails *string `protobuf:"bytes,4,opt,name=forwarding_details,json=forwardingDetails,proto3,oneof" json:"forwarding_details,omitempty"` +} + +func (x *TraceStage) Reset() { + *x = TraceStage{} + if protoimpl.UnsafeEnabled { + mi := &file_daemon_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TraceStage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TraceStage) ProtoMessage() {} + +func (x *TraceStage) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[42] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TraceStage.ProtoReflect.Descriptor instead. +func (*TraceStage) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{42} +} + +func (x *TraceStage) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TraceStage) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *TraceStage) GetAllowed() bool { + if x != nil { + return x.Allowed + } + return false +} + +func (x *TraceStage) GetForwardingDetails() string { + if x != nil && x.ForwardingDetails != nil { + return *x.ForwardingDetails + } + return "" +} + +type TracePacketResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Stages []*TraceStage `protobuf:"bytes,1,rep,name=stages,proto3" json:"stages,omitempty"` + FinalDisposition bool `protobuf:"varint,2,opt,name=final_disposition,json=finalDisposition,proto3" json:"final_disposition,omitempty"` +} + +func (x *TracePacketResponse) Reset() { + *x = TracePacketResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_daemon_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TracePacketResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TracePacketResponse) ProtoMessage() {} + +func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[43] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TracePacketResponse.ProtoReflect.Descriptor instead. +func (*TracePacketResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{43} +} + +func (x *TracePacketResponse) GetStages() []*TraceStage { + if x != nil { + return x.Stages + } + return nil +} + +func (x *TracePacketResponse) GetFinalDisposition() bool { + if x != nil { + return x.FinalDisposition + } + return false +} + var File_daemon_proto protoreflect.FileDescriptor var file_daemon_proto_rawDesc = []byte{ @@ -2920,87 +3244,141 @@ var file_daemon_proto_rawDesc = []byte{ 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x22, 0x0a, 0x20, 0x53, 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x2a, 0x62, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, - 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, - 0x50, 0x41, 0x4e, 0x49, 0x43, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x46, 0x41, 0x54, 0x41, 0x4c, - 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x03, 0x12, 0x08, 0x0a, - 0x04, 0x57, 0x41, 0x52, 0x4e, 0x10, 0x04, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, - 0x05, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x06, 0x12, 0x09, 0x0a, 0x05, - 0x54, 0x52, 0x41, 0x43, 0x45, 0x10, 0x07, 0x32, 0x93, 0x09, 0x0a, 0x0d, 0x44, 0x61, 0x65, 0x6d, - 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x36, 0x0a, 0x05, 0x4c, 0x6f, 0x67, - 0x69, 0x6e, 0x12, 0x14, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x69, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, - 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x00, 0x12, 0x4b, 0x0a, 0x0c, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, - 0x6e, 0x12, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x57, 0x61, 0x69, 0x74, 0x53, - 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, + 0x6e, 0x73, 0x65, 0x22, 0x76, 0x0a, 0x08, 0x54, 0x43, 0x50, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, + 0x10, 0x0a, 0x03, 0x73, 0x79, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x73, 0x79, + 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, + 0x61, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x66, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x03, 0x66, 0x69, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x72, 0x73, 0x74, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x03, 0x72, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x73, 0x68, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x70, 0x73, 0x68, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x67, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x75, 0x72, 0x67, 0x22, 0x80, 0x03, 0x0a, 0x12, + 0x54, 0x72, 0x61, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, + 0x25, 0x0a, 0x0e, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, + 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x72, + 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, + 0x6f, 0x72, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0f, 0x64, + 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1c, + 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x32, 0x0a, 0x09, + 0x74, 0x63, 0x70, 0x5f, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x10, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x54, 0x43, 0x50, 0x46, 0x6c, 0x61, 0x67, + 0x73, 0x48, 0x00, 0x52, 0x08, 0x74, 0x63, 0x70, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x88, 0x01, 0x01, + 0x12, 0x20, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x0d, 0x48, 0x01, 0x52, 0x08, 0x69, 0x63, 0x6d, 0x70, 0x54, 0x79, 0x70, 0x65, 0x88, + 0x01, 0x01, 0x12, 0x20, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x02, 0x52, 0x08, 0x69, 0x63, 0x6d, 0x70, 0x43, 0x6f, 0x64, + 0x65, 0x88, 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x74, 0x63, 0x70, 0x5f, 0x66, 0x6c, 0x61, + 0x67, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x74, 0x79, 0x70, 0x65, + 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x22, 0x9f, + 0x01, 0x0a, 0x0a, 0x54, 0x72, 0x61, 0x63, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x61, + 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x61, 0x6c, + 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12, 0x32, 0x0a, 0x12, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x48, 0x00, 0x52, 0x11, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x44, + 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x88, 0x01, 0x01, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x66, 0x6f, + 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, + 0x22, 0x6e, 0x0a, 0x13, 0x54, 0x72, 0x61, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, + 0x2e, 0x54, 0x72, 0x61, 0x63, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, + 0x67, 0x65, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x66, 0x69, 0x6e, 0x61, 0x6c, 0x5f, 0x64, 0x69, 0x73, + 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, + 0x66, 0x69, 0x6e, 0x61, 0x6c, 0x44, 0x69, 0x73, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, + 0x2a, 0x62, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x0b, 0x0a, 0x07, + 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x50, 0x41, 0x4e, + 0x49, 0x43, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x46, 0x41, 0x54, 0x41, 0x4c, 0x10, 0x02, 0x12, + 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x57, 0x41, + 0x52, 0x4e, 0x10, 0x04, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x05, 0x12, 0x09, + 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x06, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, + 0x43, 0x45, 0x10, 0x07, 0x32, 0xdd, 0x09, 0x0a, 0x0d, 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x36, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, + 0x14, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x4b, + 0x0a, 0x0c, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, - 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x2d, - 0x0a, 0x02, 0x55, 0x70, 0x12, 0x11, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x55, 0x70, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, - 0x2e, 0x55, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x39, 0x0a, - 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x15, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, - 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, - 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x04, 0x44, 0x6f, 0x77, 0x6e, - 0x12, 0x13, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, - 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x42, 0x0a, - 0x09, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x2e, 0x64, 0x61, 0x65, - 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, - 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x00, 0x12, 0x4b, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x73, 0x12, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, + 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x64, 0x61, + 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, + 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x2d, 0x0a, 0x02, 0x55, + 0x70, 0x12, 0x11, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x55, 0x70, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x55, 0x70, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x39, 0x0a, 0x06, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x12, 0x15, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x64, 0x61, + 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x04, 0x44, 0x6f, 0x77, 0x6e, 0x12, 0x13, 0x2e, + 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x6f, 0x77, 0x6e, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x42, 0x0a, 0x09, 0x47, 0x65, + 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, + 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x4b, + 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x12, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x51, - 0x0a, 0x0e, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, - 0x12, 0x1d, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, - 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x1e, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x00, 0x12, 0x53, 0x0a, 0x10, 0x44, 0x65, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, - 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x12, 0x1d, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, - 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, - 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x42, - 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, - 0x65, 0x62, 0x75, 0x67, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x62, 0x75, 0x67, - 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, - 0x12, 0x48, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, - 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, - 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, - 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x53, 0x65, - 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, - 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, - 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x22, 0x00, 0x12, 0x45, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, - 0x65, 0x73, 0x12, 0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, - 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, - 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x45, 0x0a, 0x0a, 0x43, - 0x6c, 0x65, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d, - 0x6f, 0x6e, 0x2e, 0x43, 0x6c, 0x65, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x43, 0x6c, - 0x65, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, - 0x65, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, - 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x6f, 0x0a, 0x18, - 0x53, 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, - 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x27, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, - 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, - 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x28, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x4e, 0x65, - 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, - 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, - 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x64, 0x61, + 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x51, 0x0a, 0x0e, 0x53, + 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x12, 0x1d, 0x2e, + 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x64, + 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, + 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x53, + 0x0a, 0x10, 0x44, 0x65, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x73, 0x12, 0x1d, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65, + 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1e, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65, 0x63, + 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x42, 0x75, 0x6e, 0x64, + 0x6c, 0x65, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x62, 0x75, + 0x67, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, + 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x62, 0x75, 0x67, 0x42, 0x75, 0x6e, + 0x64, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, + 0x0b, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1a, 0x2e, 0x64, + 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, + 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, + 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x4c, 0x6f, + 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, + 0x53, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x4c, + 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x00, 0x12, 0x45, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x12, + 0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, + 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x64, 0x61, 0x65, + 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x45, 0x0a, 0x0a, 0x43, 0x6c, 0x65, 0x61, + 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, + 0x43, 0x6c, 0x65, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x43, 0x6c, 0x65, 0x61, 0x6e, + 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, + 0x48, 0x0a, 0x0b, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1a, + 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, + 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, + 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x6f, 0x0a, 0x18, 0x53, 0x65, 0x74, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73, + 0x74, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x27, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, + 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, + 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, + 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x54, 0x72, + 0x61, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, + 0x6f, 0x6e, 0x2e, 0x54, 0x72, 0x61, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x54, + 0x72, 0x61, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -3016,7 +3394,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 41) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 45) var file_daemon_proto_goTypes = []interface{}{ (LogLevel)(0), // 0: daemon.LogLevel (*LoginRequest)(nil), // 1: daemon.LoginRequest @@ -3059,16 +3437,20 @@ var file_daemon_proto_goTypes = []interface{}{ (*DeleteStateResponse)(nil), // 38: daemon.DeleteStateResponse (*SetNetworkMapPersistenceRequest)(nil), // 39: daemon.SetNetworkMapPersistenceRequest (*SetNetworkMapPersistenceResponse)(nil), // 40: daemon.SetNetworkMapPersistenceResponse - nil, // 41: daemon.Network.ResolvedIPsEntry - (*durationpb.Duration)(nil), // 42: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 43: google.protobuf.Timestamp + (*TCPFlags)(nil), // 41: daemon.TCPFlags + (*TracePacketRequest)(nil), // 42: daemon.TracePacketRequest + (*TraceStage)(nil), // 43: daemon.TraceStage + (*TracePacketResponse)(nil), // 44: daemon.TracePacketResponse + nil, // 45: daemon.Network.ResolvedIPsEntry + (*durationpb.Duration)(nil), // 46: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 47: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 42, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 46, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 19, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 43, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 43, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 42, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 47, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 47, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 46, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration 16, // 5: daemon.FullStatus.managementState:type_name -> daemon.ManagementState 15, // 6: daemon.FullStatus.signalState:type_name -> daemon.SignalState 14, // 7: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState @@ -3076,48 +3458,52 @@ var file_daemon_proto_depIdxs = []int32{ 17, // 9: daemon.FullStatus.relays:type_name -> daemon.RelayState 18, // 10: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState 25, // 11: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 41, // 12: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 45, // 12: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry 0, // 13: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel 0, // 14: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel 32, // 15: daemon.ListStatesResponse.states:type_name -> daemon.State - 24, // 16: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList - 1, // 17: daemon.DaemonService.Login:input_type -> daemon.LoginRequest - 3, // 18: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest - 5, // 19: daemon.DaemonService.Up:input_type -> daemon.UpRequest - 7, // 20: daemon.DaemonService.Status:input_type -> daemon.StatusRequest - 9, // 21: daemon.DaemonService.Down:input_type -> daemon.DownRequest - 11, // 22: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest - 20, // 23: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest - 22, // 24: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest - 22, // 25: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest - 26, // 26: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest - 28, // 27: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest - 30, // 28: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest - 33, // 29: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest - 35, // 30: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest - 37, // 31: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest - 39, // 32: daemon.DaemonService.SetNetworkMapPersistence:input_type -> daemon.SetNetworkMapPersistenceRequest - 2, // 33: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 4, // 34: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 6, // 35: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 8, // 36: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 10, // 37: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 12, // 38: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 21, // 39: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 23, // 40: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 23, // 41: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 27, // 42: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 29, // 43: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 31, // 44: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 34, // 45: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 36, // 46: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 38, // 47: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 40, // 48: daemon.DaemonService.SetNetworkMapPersistence:output_type -> daemon.SetNetworkMapPersistenceResponse - 33, // [33:49] is the sub-list for method output_type - 17, // [17:33] is the sub-list for method input_type - 17, // [17:17] is the sub-list for extension type_name - 17, // [17:17] is the sub-list for extension extendee - 0, // [0:17] is the sub-list for field type_name + 41, // 16: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags + 43, // 17: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage + 24, // 18: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList + 1, // 19: daemon.DaemonService.Login:input_type -> daemon.LoginRequest + 3, // 20: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest + 5, // 21: daemon.DaemonService.Up:input_type -> daemon.UpRequest + 7, // 22: daemon.DaemonService.Status:input_type -> daemon.StatusRequest + 9, // 23: daemon.DaemonService.Down:input_type -> daemon.DownRequest + 11, // 24: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest + 20, // 25: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest + 22, // 26: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest + 22, // 27: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest + 26, // 28: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest + 28, // 29: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest + 30, // 30: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest + 33, // 31: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest + 35, // 32: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest + 37, // 33: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest + 39, // 34: daemon.DaemonService.SetNetworkMapPersistence:input_type -> daemon.SetNetworkMapPersistenceRequest + 42, // 35: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest + 2, // 36: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 4, // 37: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 6, // 38: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 8, // 39: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 10, // 40: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 12, // 41: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 21, // 42: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 23, // 43: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 23, // 44: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 27, // 45: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 29, // 46: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 31, // 47: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 34, // 48: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 36, // 49: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 38, // 50: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 40, // 51: daemon.DaemonService.SetNetworkMapPersistence:output_type -> daemon.SetNetworkMapPersistenceResponse + 44, // 52: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 36, // [36:53] is the sub-list for method output_type + 19, // [19:36] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -3606,15 +3992,65 @@ func file_daemon_proto_init() { return nil } } + file_daemon_proto_msgTypes[40].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TCPFlags); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_daemon_proto_msgTypes[41].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TracePacketRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_daemon_proto_msgTypes[42].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TraceStage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_daemon_proto_msgTypes[43].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TracePacketResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } file_daemon_proto_msgTypes[0].OneofWrappers = []interface{}{} + file_daemon_proto_msgTypes[41].OneofWrappers = []interface{}{} + file_daemon_proto_msgTypes[42].OneofWrappers = []interface{}{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_daemon_proto_rawDesc, NumEnums: 1, - NumMessages: 41, + NumMessages: 45, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 8db3add08..412449076 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -57,6 +57,8 @@ service DaemonService { // SetNetworkMapPersistence enables or disables network map persistence rpc SetNetworkMapPersistence(SetNetworkMapPersistenceRequest) returns (SetNetworkMapPersistenceResponse) {} + + rpc TracePacket(TracePacketRequest) returns (TracePacketResponse) {} } @@ -356,3 +358,36 @@ message SetNetworkMapPersistenceRequest { } message SetNetworkMapPersistenceResponse {} + +message TCPFlags { + bool syn = 1; + bool ack = 2; + bool fin = 3; + bool rst = 4; + bool psh = 5; + bool urg = 6; +} + +message TracePacketRequest { + string source_ip = 1; + string destination_ip = 2; + string protocol = 3; + uint32 source_port = 4; + uint32 destination_port = 5; + string direction = 6; + optional TCPFlags tcp_flags = 7; + optional uint32 icmp_type = 8; + optional uint32 icmp_code = 9; +} + +message TraceStage { + string name = 1; + string message = 2; + bool allowed = 3; + optional string forwarding_details = 4; +} + +message TracePacketResponse { + repeated TraceStage stages = 1; + bool final_disposition = 2; +} diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 39424aee9..9dcb543a8 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -51,6 +51,7 @@ type DaemonServiceClient interface { DeleteState(ctx context.Context, in *DeleteStateRequest, opts ...grpc.CallOption) (*DeleteStateResponse, error) // SetNetworkMapPersistence enables or disables network map persistence SetNetworkMapPersistence(ctx context.Context, in *SetNetworkMapPersistenceRequest, opts ...grpc.CallOption) (*SetNetworkMapPersistenceResponse, error) + TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error) } type daemonServiceClient struct { @@ -205,6 +206,15 @@ func (c *daemonServiceClient) SetNetworkMapPersistence(ctx context.Context, in * return out, nil } +func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error) { + out := new(TracePacketResponse) + err := c.cc.Invoke(ctx, "/daemon.DaemonService/TracePacket", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // DaemonServiceServer is the server API for DaemonService service. // All implementations must embed UnimplementedDaemonServiceServer // for forward compatibility @@ -242,6 +252,7 @@ type DaemonServiceServer interface { DeleteState(context.Context, *DeleteStateRequest) (*DeleteStateResponse, error) // SetNetworkMapPersistence enables or disables network map persistence SetNetworkMapPersistence(context.Context, *SetNetworkMapPersistenceRequest) (*SetNetworkMapPersistenceResponse, error) + TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) mustEmbedUnimplementedDaemonServiceServer() } @@ -297,6 +308,9 @@ func (UnimplementedDaemonServiceServer) DeleteState(context.Context, *DeleteStat func (UnimplementedDaemonServiceServer) SetNetworkMapPersistence(context.Context, *SetNetworkMapPersistenceRequest) (*SetNetworkMapPersistenceResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method SetNetworkMapPersistence not implemented") } +func (UnimplementedDaemonServiceServer) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method TracePacket not implemented") +} func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {} // UnsafeDaemonServiceServer may be embedded to opt out of forward compatibility for this service. @@ -598,6 +612,24 @@ func _DaemonService_SetNetworkMapPersistence_Handler(srv interface{}, ctx contex return interceptor(ctx, in, info, handler) } +func _DaemonService_TracePacket_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TracePacketRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).TracePacket(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/daemon.DaemonService/TracePacket", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).TracePacket(ctx, req.(*TracePacketRequest)) + } + return interceptor(ctx, in, info, handler) +} + // DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -669,6 +701,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SetNetworkMapPersistence", Handler: _DaemonService_SetNetworkMapPersistence_Handler, }, + { + MethodName: "TracePacket", + Handler: _DaemonService_TracePacket_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "daemon.proto", diff --git a/client/server/debug.go b/client/server/debug.go index a37195b29..749220d62 100644 --- a/client/server/debug.go +++ b/client/server/debug.go @@ -538,7 +538,24 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) ( } log.SetLevel(level) + + if s.connectClient == nil { + return nil, fmt.Errorf("connect client not initialized") + } + engine := s.connectClient.Engine() + if engine == nil { + return nil, fmt.Errorf("engine not initialized") + } + + fwManager := engine.GetFirewallManager() + if fwManager == nil { + return nil, fmt.Errorf("firewall manager not initialized") + } + + fwManager.SetLogLevel(level) + log.Infof("Log level set to %s", level.String()) + return &proto.SetLogLevelResponse{}, nil } diff --git a/client/server/trace.go b/client/server/trace.go new file mode 100644 index 000000000..66b83d8cf --- /dev/null +++ b/client/server/trace.go @@ -0,0 +1,123 @@ +package server + +import ( + "context" + "fmt" + "net" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/firewall/uspfilter" + "github.com/netbirdio/netbird/client/proto" +) + +type packetTracer interface { + TracePacketFromBuilder(builder *uspfilter.PacketBuilder) (*uspfilter.PacketTrace, error) +} + +func (s *Server) TracePacket(_ context.Context, req *proto.TracePacketRequest) (*proto.TracePacketResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.connectClient == nil { + return nil, fmt.Errorf("connect client not initialized") + } + engine := s.connectClient.Engine() + if engine == nil { + return nil, fmt.Errorf("engine not initialized") + } + + fwManager := engine.GetFirewallManager() + if fwManager == nil { + return nil, fmt.Errorf("firewall manager not initialized") + } + + tracer, ok := fwManager.(packetTracer) + if !ok { + return nil, fmt.Errorf("firewall manager does not support packet tracing") + } + + srcIP := net.ParseIP(req.GetSourceIp()) + if req.GetSourceIp() == "self" { + srcIP = engine.GetWgAddr() + } + + dstIP := net.ParseIP(req.GetDestinationIp()) + if req.GetDestinationIp() == "self" { + dstIP = engine.GetWgAddr() + } + + if srcIP == nil || dstIP == nil { + return nil, fmt.Errorf("invalid IP address") + } + + var tcpState *uspfilter.TCPState + if flags := req.GetTcpFlags(); flags != nil { + tcpState = &uspfilter.TCPState{ + SYN: flags.GetSyn(), + ACK: flags.GetAck(), + FIN: flags.GetFin(), + RST: flags.GetRst(), + PSH: flags.GetPsh(), + URG: flags.GetUrg(), + } + } + + var dir fw.RuleDirection + switch req.GetDirection() { + case "in": + dir = fw.RuleDirectionIN + case "out": + dir = fw.RuleDirectionOUT + default: + return nil, fmt.Errorf("invalid direction") + } + + var protocol fw.Protocol + switch req.GetProtocol() { + case "tcp": + protocol = fw.ProtocolTCP + case "udp": + protocol = fw.ProtocolUDP + case "icmp": + protocol = fw.ProtocolICMP + default: + return nil, fmt.Errorf("invalid protocolcol") + } + + builder := &uspfilter.PacketBuilder{ + SrcIP: srcIP, + DstIP: dstIP, + Protocol: protocol, + SrcPort: uint16(req.GetSourcePort()), + DstPort: uint16(req.GetDestinationPort()), + Direction: dir, + TCPState: tcpState, + ICMPType: uint8(req.GetIcmpType()), + ICMPCode: uint8(req.GetIcmpCode()), + } + trace, err := tracer.TracePacketFromBuilder(builder) + if err != nil { + return nil, fmt.Errorf("trace packet: %w", err) + } + + resp := &proto.TracePacketResponse{} + + for _, result := range trace.Results { + stage := &proto.TraceStage{ + Name: result.Stage.String(), + Message: result.Message, + Allowed: result.Allowed, + } + if result.ForwarderAction != nil { + details := fmt.Sprintf("%s to %s", result.ForwarderAction.Action, result.ForwarderAction.RemoteAddr) + stage.ForwardingDetails = &details + } + resp.Stages = append(resp.Stages, stage) + } + + if len(trace.Results) > 0 { + resp.FinalDisposition = trace.Results[len(trace.Results)-1].Allowed + } + + return resp, nil +} diff --git a/go.mod b/go.mod index 77d570662..3e1208e5a 100644 --- a/go.mod +++ b/go.mod @@ -102,6 +102,7 @@ require ( gorm.io/driver/postgres v1.5.7 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 + gvisor.dev/gvisor v0.0.0-20231020174304-b8a429915ff1 ) require ( @@ -237,7 +238,6 @@ require ( gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect - gvisor.dev/gvisor v0.0.0-20231020174304-db3d49b921f9 // indirect k8s.io/apimachinery v0.26.2 // indirect ) @@ -245,7 +245,7 @@ replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-2024 replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949 -replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20241125150134-f9cdce5e32e9 +replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20241230120307-6a676aebaaf6 replace github.com/cloudflare/circl => github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 diff --git a/go.sum b/go.sum index 4b9e90eba..54b77dbee 100644 --- a/go.sum +++ b/go.sum @@ -535,8 +535,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20241010133937-e0df50df217d h1:bRq5TKgC7Iq20pDiuC54yXaWnAVeS5PdGpSokFTlR28= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20241010133937-e0df50df217d/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= -github.com/netbirdio/wireguard-go v0.0.0-20241125150134-f9cdce5e32e9 h1:Pu/7EukijT09ynHUOzQYW7cC3M/BKU8O4qyN/TvTGoY= -github.com/netbirdio/wireguard-go v0.0.0-20241125150134-f9cdce5e32e9/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= +github.com/netbirdio/wireguard-go v0.0.0-20241230120307-6a676aebaaf6 h1:X5h5QgP7uHAv78FWgHV8+WYLjHxK9v3ilkVXT1cpCrQ= +github.com/netbirdio/wireguard-go v0.0.0-20241230120307-6a676aebaaf6/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM= github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -1250,8 +1250,8 @@ gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -gvisor.dev/gvisor v0.0.0-20231020174304-db3d49b921f9 h1:sCEaoA7ZmkuFwa2IR61pl4+RYZPwCJOiaSYT0k+BRf8= -gvisor.dev/gvisor v0.0.0-20231020174304-db3d49b921f9/go.mod h1:8hmigyCdYtw5xJGfQDJzSH5Ju8XEIDBnpyi8+O6GRt8= +gvisor.dev/gvisor v0.0.0-20231020174304-b8a429915ff1 h1:qDCwdCWECGnwQSQC01Dpnp09fRHxJs9PbktotUqG+hs= +gvisor.dev/gvisor v0.0.0-20231020174304-b8a429915ff1/go.mod h1:8hmigyCdYtw5xJGfQDJzSH5Ju8XEIDBnpyi8+O6GRt8= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= From 58b2eb4b92254ccd4bb921d95f1662d6bd474e2e Mon Sep 17 00:00:00 2001 From: ransomware <4thel00z@gmail.com> Date: Fri, 7 Feb 2025 15:05:41 +0100 Subject: [PATCH 07/29] [signal] Fix context propagation in signal server (#3251) --- signal/server/signal.go | 97 ++++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/signal/server/signal.go b/signal/server/signal.go index 305fd052b..abc1c367b 100644 --- a/signal/server/signal.go +++ b/signal/server/signal.go @@ -52,13 +52,13 @@ func NewServer(ctx context.Context, meter metric.Meter) (*Server, error) { return nil, fmt.Errorf("creating app metrics: %v", err) } - dispatcher, err := dispatcher.NewDispatcher(ctx, meter) + d, err := dispatcher.NewDispatcher(ctx, meter) if err != nil { return nil, fmt.Errorf("creating dispatcher: %v", err) } s := &Server{ - dispatcher: dispatcher, + dispatcher: d, registry: peer.NewRegistry(appMetrics), metrics: appMetrics, } @@ -75,7 +75,7 @@ func (s *Server) Send(ctx context.Context, msg *proto.EncryptedMessage) (*proto. return &proto.EncryptedMessage{}, nil } - return s.dispatcher.SendMessage(context.Background(), msg) + return s.dispatcher.SendMessage(ctx, msg) } // ConnectStream connects to the exchange stream @@ -98,76 +98,81 @@ func (s *Server) ConnectStream(stream proto.SignalExchange_ConnectStreamServer) log.Debugf("peer connected [%s] [streamID %d] ", p.Id, p.StreamID) for { - // read incoming messages - msg, err := stream.Recv() - if err == io.EOF { - break - } else if err != nil { - return err - } + select { + case <-stream.Context().Done(): + log.Debugf("stream closed for peer [%s] [streamID %d] due to context cancellation", p.Id, p.StreamID) + return stream.Context().Err() + default: + // read incoming messages + msg, err := stream.Recv() + if err == io.EOF { + break + } else if err != nil { + return err + } - log.Debugf("Received a response from peer [%s] to peer [%s]", msg.Key, msg.RemoteKey) + log.Debugf("Received a response from peer [%s] to peer [%s]", msg.Key, msg.RemoteKey) - _, err = s.dispatcher.SendMessage(stream.Context(), msg) - if err != nil { - log.Debugf("error while sending message from peer [%s] to peer [%s] %v", msg.Key, msg.RemoteKey, err) + _, err = s.dispatcher.SendMessage(stream.Context(), msg) + if err != nil { + log.Debugf("error while sending message from peer [%s] to peer [%s] %v", msg.Key, msg.RemoteKey, err) + } } } - - <-stream.Context().Done() - return stream.Context().Err() } func (s *Server) RegisterPeer(stream proto.SignalExchange_ConnectStreamServer) (*peer.Peer, error) { log.Debugf("registering new peer") - if meta, hasMeta := metadata.FromIncomingContext(stream.Context()); hasMeta { - if id, found := meta[proto.HeaderId]; found { - p := peer.NewPeer(id[0], stream) - - s.registry.Register(p) - s.dispatcher.ListenForMessages(stream.Context(), p.Id, s.forwardMessageToPeer) - - return p, nil - } else { - s.metrics.RegistrationFailures.Add(stream.Context(), 1, metric.WithAttributes(attribute.String(labelError, labelErrorMissingId))) - return nil, status.Errorf(codes.FailedPrecondition, "missing connection header: "+proto.HeaderId) - } - } else { + meta, hasMeta := metadata.FromIncomingContext(stream.Context()) + if !hasMeta { s.metrics.RegistrationFailures.Add(stream.Context(), 1, metric.WithAttributes(attribute.String(labelError, labelErrorMissingMeta))) return nil, status.Errorf(codes.FailedPrecondition, "missing connection stream meta") } + + id, found := meta[proto.HeaderId] + if !found { + s.metrics.RegistrationFailures.Add(stream.Context(), 1, metric.WithAttributes(attribute.String(labelError, labelErrorMissingId))) + return nil, status.Errorf(codes.FailedPrecondition, "missing connection header: %s", proto.HeaderId) + } + + p := peer.NewPeer(id[0], stream) + s.registry.Register(p) + s.dispatcher.ListenForMessages(stream.Context(), p.Id, s.forwardMessageToPeer) + return p, nil } func (s *Server) DeregisterPeer(p *peer.Peer) { log.Debugf("peer disconnected [%s] [streamID %d] ", p.Id, p.StreamID) s.registry.Deregister(p) - s.metrics.PeerConnectionDuration.Record(p.Stream.Context(), int64(time.Since(p.RegisteredAt).Seconds())) } func (s *Server) forwardMessageToPeer(ctx context.Context, msg *proto.EncryptedMessage) { log.Debugf("forwarding a new message from peer [%s] to peer [%s]", msg.Key, msg.RemoteKey) - getRegistrationStart := time.Now() // lookup the target peer where the message is going to - if dstPeer, found := s.registry.Get(msg.RemoteKey); found { - s.metrics.GetRegistrationDelay.Record(ctx, float64(time.Since(getRegistrationStart).Nanoseconds())/1e6, metric.WithAttributes(attribute.String(labelType, labelTypeStream), attribute.String(labelRegistrationStatus, labelRegistrationFound))) - start := time.Now() - // forward the message to the target peer - if err := dstPeer.Stream.Send(msg); err != nil { - log.Warnf("error while forwarding message from peer [%s] to peer [%s] %v", msg.Key, msg.RemoteKey, err) - // todo respond to the sender? - s.metrics.MessageForwardFailures.Add(ctx, 1, metric.WithAttributes(attribute.String(labelType, labelTypeError))) - } else { - // in milliseconds - s.metrics.MessageForwardLatency.Record(ctx, float64(time.Since(start).Nanoseconds())/1e6, metric.WithAttributes(attribute.String(labelType, labelTypeStream))) - s.metrics.MessagesForwarded.Add(ctx, 1) - } - } else { + dstPeer, found := s.registry.Get(msg.RemoteKey) + + if !found { s.metrics.GetRegistrationDelay.Record(ctx, float64(time.Since(getRegistrationStart).Nanoseconds())/1e6, metric.WithAttributes(attribute.String(labelType, labelTypeStream), attribute.String(labelRegistrationStatus, labelRegistrationNotFound))) s.metrics.MessageForwardFailures.Add(ctx, 1, metric.WithAttributes(attribute.String(labelType, labelTypeNotConnected))) log.Debugf("message from peer [%s] can't be forwarded to peer [%s] because destination peer is not connected", msg.Key, msg.RemoteKey) // todo respond to the sender? } + + s.metrics.GetRegistrationDelay.Record(ctx, float64(time.Since(getRegistrationStart).Nanoseconds())/1e6, metric.WithAttributes(attribute.String(labelType, labelTypeStream), attribute.String(labelRegistrationStatus, labelRegistrationFound))) + start := time.Now() + + // forward the message to the target peer + if err := dstPeer.Stream.Send(msg); err != nil { + log.Warnf("error while forwarding message from peer [%s] to peer [%s] %v", msg.Key, msg.RemoteKey, err) + // todo respond to the sender? + s.metrics.MessageForwardFailures.Add(ctx, 1, metric.WithAttributes(attribute.String(labelType, labelTypeError))) + return + } + + // in milliseconds + s.metrics.MessageForwardLatency.Record(ctx, float64(time.Since(start).Nanoseconds())/1e6, metric.WithAttributes(attribute.String(labelType, labelTypeStream))) + s.metrics.MessagesForwarded.Add(ctx, 1) } From 5953b43ead2e0059807c3d295a2f9b324f0773b3 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 10 Feb 2025 10:32:50 +0100 Subject: [PATCH 08/29] [client, relay] Fix/wg watch (#3261) Fix WireGuard watcher related issues - Fix race handling between TURN and Relayed reconnection - Move the WgWatcher logic to separate struct - Handle timeouts in a more defensive way - Fix initial Relay client reconnection to the home server --- client/internal/peer/conn.go | 49 +++----- client/internal/peer/guard/guard.go | 36 ++---- client/internal/peer/wg_watcher.go | 154 ++++++++++++++++++++++++ client/internal/peer/wg_watcher_test.go | 98 +++++++++++++++ client/internal/peer/worker_ice.go | 17 +-- client/internal/peer/worker_relay.go | 127 ++++--------------- relay/client/client.go | 18 --- relay/client/guard.go | 71 +++++++---- relay/client/manager.go | 10 +- 9 files changed, 365 insertions(+), 215 deletions(-) create mode 100644 client/internal/peer/wg_watcher.go create mode 100644 client/internal/peer/wg_watcher_test.go diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index b8cb2582f..7caafa53d 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -32,8 +32,8 @@ const ( defaultWgKeepAlive = 25 * time.Second connPriorityRelay ConnPriority = 1 - connPriorityICETurn ConnPriority = 1 - connPriorityICEP2P ConnPriority = 2 + connPriorityICETurn ConnPriority = 2 + connPriorityICEP2P ConnPriority = 3 ) type WgConfig struct { @@ -66,14 +66,6 @@ type ConnConfig struct { ICEConfig icemaker.Config } -type WorkerCallbacks struct { - OnRelayReadyCallback func(info RelayConnInfo) - OnRelayStatusChanged func(ConnStatus) - - OnICEConnReadyCallback func(ConnPriority, ICEConnInfo) - OnICEStatusChanged func(ConnStatus) -} - type Conn struct { log *log.Entry mu sync.Mutex @@ -135,21 +127,11 @@ func NewConn(engineCtx context.Context, config ConnConfig, statusRecorder *Statu semaphore: semaphore, } - rFns := WorkerRelayCallbacks{ - OnConnReady: conn.relayConnectionIsReady, - OnDisconnected: conn.onWorkerRelayStateDisconnected, - } - - wFns := WorkerICECallbacks{ - OnConnReady: conn.iCEConnectionIsReady, - OnStatusChanged: conn.onWorkerICEStateDisconnected, - } - ctrl := isController(config) - conn.workerRelay = NewWorkerRelay(connLog, ctrl, config, relayManager, rFns) + conn.workerRelay = NewWorkerRelay(connLog, ctrl, config, conn, relayManager) relayIsSupportedLocally := conn.workerRelay.RelayIsSupportedLocally() - conn.workerICE, err = NewWorkerICE(ctx, connLog, config, signaler, iFaceDiscover, statusRecorder, relayIsSupportedLocally, wFns) + conn.workerICE, err = NewWorkerICE(ctx, connLog, config, conn, signaler, iFaceDiscover, statusRecorder, relayIsSupportedLocally) if err != nil { return nil, err } @@ -304,7 +286,7 @@ func (conn *Conn) GetKey() string { } // configureConnection starts proxying traffic from/to local Wireguard and sets connection status to StatusConnected -func (conn *Conn) iCEConnectionIsReady(priority ConnPriority, iceConnInfo ICEConnInfo) { +func (conn *Conn) onICEConnectionIsReady(priority ConnPriority, iceConnInfo ICEConnInfo) { conn.mu.Lock() defer conn.mu.Unlock() @@ -376,7 +358,7 @@ func (conn *Conn) iCEConnectionIsReady(priority ConnPriority, iceConnInfo ICECon } // todo review to make sense to handle connecting and disconnected status also? -func (conn *Conn) onWorkerICEStateDisconnected(newState ConnStatus) { +func (conn *Conn) onICEStateDisconnected() { conn.mu.Lock() defer conn.mu.Unlock() @@ -384,7 +366,7 @@ func (conn *Conn) onWorkerICEStateDisconnected(newState ConnStatus) { return } - conn.log.Tracef("ICE connection state changed to %s", newState) + conn.log.Tracef("ICE connection state changed to disconnected") if conn.wgProxyICE != nil { if err := conn.wgProxyICE.CloseConn(); err != nil { @@ -404,10 +386,11 @@ func (conn *Conn) onWorkerICEStateDisconnected(newState ConnStatus) { conn.currentConnPriority = connPriorityRelay } - changed := conn.statusICE.Get() != newState && newState != StatusConnecting - conn.statusICE.Set(newState) - - conn.guard.SetICEConnDisconnected(changed) + changed := conn.statusICE.Get() != StatusDisconnected + if changed { + conn.guard.SetICEConnDisconnected() + } + conn.statusICE.Set(StatusDisconnected) peerState := State{ PubKey: conn.config.Key, @@ -422,7 +405,7 @@ func (conn *Conn) onWorkerICEStateDisconnected(newState ConnStatus) { } } -func (conn *Conn) relayConnectionIsReady(rci RelayConnInfo) { +func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { conn.mu.Lock() defer conn.mu.Unlock() @@ -474,7 +457,7 @@ func (conn *Conn) relayConnectionIsReady(rci RelayConnInfo) { conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr) } -func (conn *Conn) onWorkerRelayStateDisconnected() { +func (conn *Conn) onRelayDisconnected() { conn.mu.Lock() defer conn.mu.Unlock() @@ -497,8 +480,10 @@ func (conn *Conn) onWorkerRelayStateDisconnected() { } changed := conn.statusRelay.Get() != StatusDisconnected + if changed { + conn.guard.SetRelayedConnDisconnected() + } conn.statusRelay.Set(StatusDisconnected) - conn.guard.SetRelayedConnDisconnected(changed) peerState := State{ PubKey: conn.config.Key, diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go index bf3527a62..1fc2b4a4a 100644 --- a/client/internal/peer/guard/guard.go +++ b/client/internal/peer/guard/guard.go @@ -29,8 +29,8 @@ type Guard struct { isConnectedOnAllWay isConnectedFunc timeout time.Duration srWatcher *SRWatcher - relayedConnDisconnected chan bool - iCEConnDisconnected chan bool + relayedConnDisconnected chan struct{} + iCEConnDisconnected chan struct{} } func NewGuard(log *log.Entry, isController bool, isConnectedFn isConnectedFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard { @@ -41,8 +41,8 @@ func NewGuard(log *log.Entry, isController bool, isConnectedFn isConnectedFunc, isConnectedOnAllWay: isConnectedFn, timeout: timeout, srWatcher: srWatcher, - relayedConnDisconnected: make(chan bool, 1), - iCEConnDisconnected: make(chan bool, 1), + relayedConnDisconnected: make(chan struct{}, 1), + iCEConnDisconnected: make(chan struct{}, 1), } } @@ -54,16 +54,16 @@ func (g *Guard) Start(ctx context.Context) { } } -func (g *Guard) SetRelayedConnDisconnected(changed bool) { +func (g *Guard) SetRelayedConnDisconnected() { select { - case g.relayedConnDisconnected <- changed: + case g.relayedConnDisconnected <- struct{}{}: default: } } -func (g *Guard) SetICEConnDisconnected(changed bool) { +func (g *Guard) SetICEConnDisconnected() { select { - case g.iCEConnDisconnected <- changed: + case g.iCEConnDisconnected <- struct{}{}: default: } } @@ -96,19 +96,13 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context) { g.triggerOfferSending() } - case changed := <-g.relayedConnDisconnected: - if !changed { - continue - } + case <-g.relayedConnDisconnected: g.log.Debugf("Relay connection changed, reset reconnection ticker") ticker.Stop() ticker = g.prepareExponentTicker(ctx) tickerChannel = ticker.C - case changed := <-g.iCEConnDisconnected: - if !changed { - continue - } + case <-g.iCEConnDisconnected: g.log.Debugf("ICE connection changed, reset reconnection ticker") ticker.Stop() ticker = g.prepareExponentTicker(ctx) @@ -138,16 +132,10 @@ func (g *Guard) listenForDisconnectEvents(ctx context.Context) { g.log.Infof("start listen for reconnect events...") for { select { - case changed := <-g.relayedConnDisconnected: - if !changed { - continue - } + case <-g.relayedConnDisconnected: g.log.Debugf("Relay connection changed, triggering reconnect") g.triggerOfferSending() - case changed := <-g.iCEConnDisconnected: - if !changed { - continue - } + case <-g.iCEConnDisconnected: g.log.Debugf("ICE state changed, try to send new offer") g.triggerOfferSending() case <-srReconnectedChan: diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go new file mode 100644 index 000000000..6670c6517 --- /dev/null +++ b/client/internal/peer/wg_watcher.go @@ -0,0 +1,154 @@ +package peer + +import ( + "context" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/iface/configurer" +) + +const ( + wgHandshakePeriod = 3 * time.Minute +) + +var ( + wgHandshakeOvertime = 30 * time.Second // allowed delay in network + checkPeriod = wgHandshakePeriod + wgHandshakeOvertime +) + +type WGInterfaceStater interface { + GetStats(key string) (configurer.WGStats, error) +} + +type WGWatcher struct { + log *log.Entry + wgIfaceStater WGInterfaceStater + peerKey string + + ctx context.Context + ctxCancel context.CancelFunc + ctxLock sync.Mutex + waitGroup sync.WaitGroup +} + +func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey string) *WGWatcher { + return &WGWatcher{ + log: log, + wgIfaceStater: wgIfaceStater, + peerKey: peerKey, + } +} + +// EnableWgWatcher starts the WireGuard watcher. If it is already enabled, it will return immediately and do nothing. +func (w *WGWatcher) EnableWgWatcher(parentCtx context.Context, onDisconnectedFn func()) { + w.log.Debugf("enable WireGuard watcher") + w.ctxLock.Lock() + defer w.ctxLock.Unlock() + + if w.ctx != nil && w.ctx.Err() == nil { + w.log.Errorf("WireGuard watcher already enabled") + return + } + + ctx, ctxCancel := context.WithCancel(parentCtx) + w.ctx = ctx + w.ctxCancel = ctxCancel + + initialHandshake, err := w.wgState() + if err != nil { + w.log.Warnf("failed to read initial wg stats: %v", err) + } + + w.waitGroup.Add(1) + go w.periodicHandshakeCheck(ctx, ctxCancel, onDisconnectedFn, initialHandshake) +} + +// DisableWgWatcher stops the WireGuard watcher and wait for the watcher to exit +func (w *WGWatcher) DisableWgWatcher() { + w.ctxLock.Lock() + defer w.ctxLock.Unlock() + + if w.ctxCancel == nil { + return + } + + w.log.Debugf("disable WireGuard watcher") + + w.ctxCancel() + w.ctxCancel = nil + w.waitGroup.Wait() +} + +// wgStateCheck help to check the state of the WireGuard handshake and relay connection +func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, ctxCancel context.CancelFunc, onDisconnectedFn func(), initialHandshake time.Time) { + w.log.Infof("WireGuard watcher started") + defer w.waitGroup.Done() + + timer := time.NewTimer(wgHandshakeOvertime) + defer timer.Stop() + defer ctxCancel() + + lastHandshake := initialHandshake + + for { + select { + case <-timer.C: + handshake, ok := w.handshakeCheck(lastHandshake) + if !ok { + onDisconnectedFn() + return + } + lastHandshake = *handshake + + resetTime := time.Until(handshake.Add(checkPeriod)) + timer.Reset(resetTime) + + w.log.Debugf("WireGuard watcher reset timer: %v", resetTime) + case <-ctx.Done(): + w.log.Infof("WireGuard watcher stopped") + return + } + } +} + +// handshakeCheck checks the WireGuard handshake and return the new handshake time if it is different from the previous one +func (w *WGWatcher) handshakeCheck(lastHandshake time.Time) (*time.Time, bool) { + handshake, err := w.wgState() + if err != nil { + w.log.Errorf("failed to read wg stats: %v", err) + return nil, false + } + + w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake) + + // the current know handshake did not change + if handshake.Equal(lastHandshake) { + w.log.Warnf("WireGuard handshake timed out, closing relay connection: %v", handshake) + return nil, false + } + + // in case if the machine is suspended, the handshake time will be in the past + if handshake.Add(checkPeriod).Before(time.Now()) { + w.log.Warnf("WireGuard handshake timed out, closing relay connection: %v", handshake) + return nil, false + } + + // error handling for handshake time in the future + if handshake.After(time.Now()) { + w.log.Warnf("WireGuard handshake is in the future, closing relay connection: %v", handshake) + return nil, false + } + + return &handshake, true +} + +func (w *WGWatcher) wgState() (time.Time, error) { + wgState, err := w.wgIfaceStater.GetStats(w.peerKey) + if err != nil { + return time.Time{}, err + } + return wgState.LastHandshake, nil +} diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go new file mode 100644 index 000000000..a5b9026ad --- /dev/null +++ b/client/internal/peer/wg_watcher_test.go @@ -0,0 +1,98 @@ +package peer + +import ( + "context" + "testing" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/iface/configurer" +) + +type MocWgIface struct { + initial bool + lastHandshake time.Time + stop bool +} + +func (m *MocWgIface) GetStats(key string) (configurer.WGStats, error) { + if !m.initial { + m.initial = true + return configurer.WGStats{}, nil + } + + if !m.stop { + m.lastHandshake = time.Now() + } + + stats := configurer.WGStats{ + LastHandshake: m.lastHandshake, + } + + return stats, nil +} + +func (m *MocWgIface) disconnect() { + m.stop = true +} + +func TestWGWatcher_EnableWgWatcher(t *testing.T) { + checkPeriod = 5 * time.Second + wgHandshakeOvertime = 1 * time.Second + + mlog := log.WithField("peer", "tet") + mocWgIface := &MocWgIface{} + watcher := NewWGWatcher(mlog, mocWgIface, "") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + onDisconnected := make(chan struct{}, 1) + watcher.EnableWgWatcher(ctx, func() { + mlog.Infof("onDisconnectedFn") + onDisconnected <- struct{}{} + }) + + // wait for initial reading + time.Sleep(2 * time.Second) + mocWgIface.disconnect() + + select { + case <-onDisconnected: + case <-time.After(10 * time.Second): + t.Errorf("timeout") + } + watcher.DisableWgWatcher() +} + +func TestWGWatcher_ReEnable(t *testing.T) { + checkPeriod = 5 * time.Second + wgHandshakeOvertime = 1 * time.Second + + mlog := log.WithField("peer", "tet") + mocWgIface := &MocWgIface{} + watcher := NewWGWatcher(mlog, mocWgIface, "") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + onDisconnected := make(chan struct{}, 1) + + watcher.EnableWgWatcher(ctx, func() {}) + watcher.DisableWgWatcher() + + watcher.EnableWgWatcher(ctx, func() { + onDisconnected <- struct{}{} + }) + + time.Sleep(2 * time.Second) + mocWgIface.disconnect() + + select { + case <-onDisconnected: + case <-time.After(10 * time.Second): + t.Errorf("timeout") + } + watcher.DisableWgWatcher() +} diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index 008318492..7dd84a98e 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -31,20 +31,15 @@ type ICEConnInfo struct { RelayedOnLocal bool } -type WorkerICECallbacks struct { - OnConnReady func(ConnPriority, ICEConnInfo) - OnStatusChanged func(ConnStatus) -} - type WorkerICE struct { ctx context.Context log *log.Entry config ConnConfig + conn *Conn signaler *Signaler iFaceDiscover stdnet.ExternalIFaceDiscover statusRecorder *Status hasRelayOnLocally bool - conn WorkerICECallbacks agent *ice.Agent muxAgent sync.Mutex @@ -60,16 +55,16 @@ type WorkerICE struct { lastKnownState ice.ConnectionState } -func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool, callBacks WorkerICECallbacks) (*WorkerICE, error) { +func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *Conn, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool) (*WorkerICE, error) { w := &WorkerICE{ ctx: ctx, log: log, config: config, + conn: conn, signaler: signaler, iFaceDiscover: ifaceDiscover, statusRecorder: statusRecorder, hasRelayOnLocally: hasRelayOnLocally, - conn: callBacks, } localUfrag, localPwd, err := icemaker.GenerateICECredentials() @@ -154,8 +149,8 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { Relayed: isRelayed(pair), RelayedOnLocal: isRelayCandidate(pair.Local), } - w.log.Debugf("on ICE conn read to use ready") - go w.conn.OnConnReady(selectedPriority(pair), ci) + w.log.Debugf("on ICE conn is ready to use") + go w.conn.onICEConnectionIsReady(selectedPriority(pair), ci) } // OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer. @@ -220,7 +215,7 @@ func (w *WorkerICE) reCreateAgent(agentCancel context.CancelFunc, candidates []i case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected: if w.lastKnownState != ice.ConnectionStateDisconnected { w.lastKnownState = ice.ConnectionStateDisconnected - w.conn.OnStatusChanged(StatusDisconnected) + w.conn.onICEStateDisconnected() } w.closeAgent(agentCancel) default: diff --git a/client/internal/peer/worker_relay.go b/client/internal/peer/worker_relay.go index c22dcdeda..56c19cd1e 100644 --- a/client/internal/peer/worker_relay.go +++ b/client/internal/peer/worker_relay.go @@ -6,52 +6,41 @@ import ( "net" "sync" "sync/atomic" - "time" log "github.com/sirupsen/logrus" relayClient "github.com/netbirdio/netbird/relay/client" ) -var ( - wgHandshakePeriod = 3 * time.Minute - wgHandshakeOvertime = 30 * time.Second -) - type RelayConnInfo struct { relayedConn net.Conn rosenpassPubKey []byte rosenpassAddr string } -type WorkerRelayCallbacks struct { - OnConnReady func(RelayConnInfo) - OnDisconnected func() -} - type WorkerRelay struct { log *log.Entry isController bool config ConnConfig + conn *Conn relayManager relayClient.ManagerService - callBacks WorkerRelayCallbacks - relayedConn net.Conn - relayLock sync.Mutex - ctxWgWatch context.Context - ctxCancelWgWatch context.CancelFunc - ctxLock sync.Mutex + relayedConn net.Conn + relayLock sync.Mutex relaySupportedOnRemotePeer atomic.Bool + + wgWatcher *WGWatcher } -func NewWorkerRelay(log *log.Entry, ctrl bool, config ConnConfig, relayManager relayClient.ManagerService, callbacks WorkerRelayCallbacks) *WorkerRelay { +func NewWorkerRelay(log *log.Entry, ctrl bool, config ConnConfig, conn *Conn, relayManager relayClient.ManagerService) *WorkerRelay { r := &WorkerRelay{ log: log, isController: ctrl, config: config, + conn: conn, relayManager: relayManager, - callBacks: callbacks, + wgWatcher: NewWGWatcher(log, config.WgConfig.WgInterface, config.Key), } return r } @@ -87,7 +76,7 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.relayedConn = relayedConn w.relayLock.Unlock() - err = w.relayManager.AddCloseListener(srv, w.onRelayMGDisconnected) + err = w.relayManager.AddCloseListener(srv, w.onRelayClientDisconnected) if err != nil { log.Errorf("failed to add close listener: %s", err) _ = relayedConn.Close() @@ -95,7 +84,7 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) { } w.log.Debugf("peer conn opened via Relay: %s", srv) - go w.callBacks.OnConnReady(RelayConnInfo{ + go w.conn.onRelayConnectionIsReady(RelayConnInfo{ relayedConn: relayedConn, rosenpassPubKey: remoteOfferAnswer.RosenpassPubKey, rosenpassAddr: remoteOfferAnswer.RosenpassAddr, @@ -103,32 +92,11 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) { } func (w *WorkerRelay) EnableWgWatcher(ctx context.Context) { - w.log.Debugf("enable WireGuard watcher") - w.ctxLock.Lock() - defer w.ctxLock.Unlock() - - if w.ctxWgWatch != nil && w.ctxWgWatch.Err() == nil { - return - } - - ctx, ctxCancel := context.WithCancel(ctx) - w.ctxWgWatch = ctx - w.ctxCancelWgWatch = ctxCancel - - w.wgStateCheck(ctx, ctxCancel) + w.wgWatcher.EnableWgWatcher(ctx, w.onWGDisconnected) } func (w *WorkerRelay) DisableWgWatcher() { - w.ctxLock.Lock() - defer w.ctxLock.Unlock() - - if w.ctxCancelWgWatch == nil { - return - } - - w.log.Debugf("disable WireGuard watcher") - - w.ctxCancelWgWatch() + w.wgWatcher.DisableWgWatcher() } func (w *WorkerRelay) RelayInstanceAddress() (string, error) { @@ -150,57 +118,17 @@ func (w *WorkerRelay) CloseConn() { return } - err := w.relayedConn.Close() - if err != nil { + if err := w.relayedConn.Close(); err != nil { w.log.Warnf("failed to close relay connection: %v", err) } } -// wgStateCheck help to check the state of the WireGuard handshake and relay connection -func (w *WorkerRelay) wgStateCheck(ctx context.Context, ctxCancel context.CancelFunc) { - w.log.Debugf("WireGuard watcher started") - lastHandshake, err := w.wgState() - if err != nil { - w.log.Warnf("failed to read wg stats: %v", err) - lastHandshake = time.Time{} - } - - go func(lastHandshake time.Time) { - timer := time.NewTimer(wgHandshakeOvertime) - defer timer.Stop() - defer ctxCancel() - - for { - select { - case <-timer.C: - handshake, err := w.wgState() - if err != nil { - w.log.Errorf("failed to read wg stats: %v", err) - timer.Reset(wgHandshakeOvertime) - continue - } - - w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake) - - if handshake.Equal(lastHandshake) { - w.log.Infof("WireGuard handshake timed out, closing relay connection: %v", handshake) - w.relayLock.Lock() - _ = w.relayedConn.Close() - w.relayLock.Unlock() - w.callBacks.OnDisconnected() - return - } - - resetTime := time.Until(handshake.Add(wgHandshakePeriod + wgHandshakeOvertime)) - lastHandshake = handshake - timer.Reset(resetTime) - case <-ctx.Done(): - w.log.Debugf("WireGuard watcher stopped") - return - } - } - }(lastHandshake) +func (w *WorkerRelay) onWGDisconnected() { + w.relayLock.Lock() + _ = w.relayedConn.Close() + w.relayLock.Unlock() + w.conn.onRelayDisconnected() } func (w *WorkerRelay) isRelaySupported(answer *OfferAnswer) bool { @@ -217,20 +145,7 @@ func (w *WorkerRelay) preferredRelayServer(myRelayAddress, remoteRelayAddress st return remoteRelayAddress } -func (w *WorkerRelay) wgState() (time.Time, error) { - wgState, err := w.config.WgConfig.WgInterface.GetStats(w.config.Key) - if err != nil { - return time.Time{}, err - } - return wgState.LastHandshake, nil -} - -func (w *WorkerRelay) onRelayMGDisconnected() { - w.ctxLock.Lock() - defer w.ctxLock.Unlock() - - if w.ctxCancelWgWatch != nil { - w.ctxCancelWgWatch() - } - go w.callBacks.OnDisconnected() +func (w *WorkerRelay) onRelayClientDisconnected() { + w.wgWatcher.DisableWgWatcher() + go w.conn.onRelayDisconnected() } diff --git a/relay/client/client.go b/relay/client/client.go index 3c23b70d2..9e7e54393 100644 --- a/relay/client/client.go +++ b/relay/client/client.go @@ -141,7 +141,6 @@ type Client struct { muInstanceURL sync.Mutex onDisconnectListener func(string) - onConnectedListener func() listenerMutex sync.Mutex } @@ -190,7 +189,6 @@ func (c *Client) Connect() error { c.wgReadLoop.Add(1) go c.readLoop(c.relayConn) - go c.notifyConnected() return nil } @@ -238,12 +236,6 @@ func (c *Client) SetOnDisconnectListener(fn func(string)) { c.onDisconnectListener = fn } -func (c *Client) SetOnConnectedListener(fn func()) { - c.listenerMutex.Lock() - defer c.listenerMutex.Unlock() - c.onConnectedListener = fn -} - // HasConns returns true if there are connections. func (c *Client) HasConns() bool { c.mu.Lock() @@ -559,16 +551,6 @@ func (c *Client) notifyDisconnected() { go c.onDisconnectListener(c.connectionURL) } -func (c *Client) notifyConnected() { - c.listenerMutex.Lock() - defer c.listenerMutex.Unlock() - - if c.onConnectedListener == nil { - return - } - go c.onConnectedListener() -} - func (c *Client) writeCloseMsg() { msg := messages.MarshalCloseMsg() _, err := c.relayConn.Write(msg) diff --git a/relay/client/guard.go b/relay/client/guard.go index b971363a8..554330ea3 100644 --- a/relay/client/guard.go +++ b/relay/client/guard.go @@ -14,8 +14,9 @@ var ( // Guard manage the reconnection tries to the Relay server in case of disconnection event. type Guard struct { - // OnNewRelayClient is a channel that is used to notify the relay client about a new relay client instance. + // OnNewRelayClient is a channel that is used to notify the relay manager about a new relay client instance. OnNewRelayClient chan *Client + OnReconnected chan struct{} serverPicker *ServerPicker } @@ -23,6 +24,7 @@ type Guard struct { func NewGuard(sp *ServerPicker) *Guard { g := &Guard{ OnNewRelayClient: make(chan *Client, 1), + OnReconnected: make(chan struct{}, 1), serverPicker: sp, } return g @@ -39,14 +41,13 @@ func NewGuard(sp *ServerPicker) *Guard { // - relayClient: The relay client instance that was disconnected. // todo prevent multiple reconnection instances. In the current usage it should not happen, but it is better to prevent func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) { - if relayClient == nil { - goto RETRY - } - if g.isServerURLStillValid(relayClient) && g.quickReconnect(ctx, relayClient) { + // try to reconnect to the same server + if ok := g.tryToQuickReconnect(ctx, relayClient); ok { + g.notifyReconnected() return } -RETRY: + // start a ticker to pick a new server ticker := exponentTicker(ctx) defer ticker.Stop() @@ -64,6 +65,28 @@ RETRY: } } +func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool { + if rc == nil { + return false + } + + if !g.isServerURLStillValid(rc) { + return false + } + + if cancelled := waiteBeforeRetry(parentCtx); !cancelled { + return false + } + + log.Infof("try to reconnect to Relay server: %s", rc.connectionURL) + + if err := rc.Connect(); err != nil { + log.Errorf("failed to reconnect to relay server: %s", err) + return false + } + return true +} + func (g *Guard) retry(ctx context.Context) error { log.Infof("try to pick up a new Relay server") relayClient, err := g.serverPicker.PickServer(ctx) @@ -78,23 +101,6 @@ func (g *Guard) retry(ctx context.Context) error { return nil } -func (g *Guard) quickReconnect(parentCtx context.Context, rc *Client) bool { - ctx, cancel := context.WithTimeout(parentCtx, 1500*time.Millisecond) - defer cancel() - <-ctx.Done() - - if parentCtx.Err() != nil { - return false - } - log.Infof("try to reconnect to Relay server: %s", rc.connectionURL) - - if err := rc.Connect(); err != nil { - log.Errorf("failed to reconnect to relay server: %s", err) - return false - } - return true -} - func (g *Guard) drainRelayClientChan() { select { case <-g.OnNewRelayClient: @@ -111,6 +117,13 @@ func (g *Guard) isServerURLStillValid(rc *Client) bool { return false } +func (g *Guard) notifyReconnected() { + select { + case g.OnReconnected <- struct{}{}: + default: + } +} + func exponentTicker(ctx context.Context) *backoff.Ticker { bo := backoff.WithContext(&backoff.ExponentialBackOff{ InitialInterval: 2 * time.Second, @@ -121,3 +134,15 @@ func exponentTicker(ctx context.Context) *backoff.Ticker { return backoff.NewTicker(bo) } + +func waiteBeforeRetry(ctx context.Context) bool { + timer := time.NewTimer(1500 * time.Millisecond) + defer timer.Stop() + + select { + case <-timer.C: + return true + case <-ctx.Done(): + return false + } +} diff --git a/relay/client/manager.go b/relay/client/manager.go index d847bb879..26b113050 100644 --- a/relay/client/manager.go +++ b/relay/client/manager.go @@ -165,6 +165,9 @@ func (m *Manager) Ready() bool { } func (m *Manager) SetOnReconnectedListener(f func()) { + m.listenerLock.Lock() + defer m.listenerLock.Unlock() + m.onReconnectedListenerFn = f } @@ -284,6 +287,9 @@ func (m *Manager) openConnVia(serverAddress, peerKey string) (net.Conn, error) { } func (m *Manager) onServerConnected() { + m.listenerLock.Lock() + defer m.listenerLock.Unlock() + if m.onReconnectedListenerFn == nil { return } @@ -304,8 +310,11 @@ func (m *Manager) onServerDisconnected(serverAddress string) { func (m *Manager) listenGuardEvent(ctx context.Context) { for { select { + case <-m.reconnectGuard.OnReconnected: + m.onServerConnected() case rc := <-m.reconnectGuard.OnNewRelayClient: m.storeClient(rc) + m.onServerConnected() case <-ctx.Done(): return } @@ -317,7 +326,6 @@ func (m *Manager) storeClient(client *Client) { defer m.relayClientMu.Unlock() m.relayClient = client - m.relayClient.SetOnConnectedListener(m.onServerConnected) m.relayClient.SetOnDisconnectListener(m.onServerDisconnected) } From 488b697479e95db88bca281022e965af192527ed Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 10 Feb 2025 18:13:34 +0100 Subject: [PATCH 09/29] [client] Support dns upstream failover for nameserver groups with same match domain (#3178) --- client/internal/dns/handler_chain.go | 24 +- client/internal/dns/handler_chain_test.go | 26 +- client/internal/dns/local.go | 7 +- client/internal/dns/server.go | 241 ++++++---- client/internal/dns/server_test.go | 525 +++++++++++++++++++++- client/internal/dns/upstream.go | 59 ++- client/internal/dns/upstream_android.go | 3 +- client/internal/dns/upstream_general.go | 3 +- client/internal/dns/upstream_ios.go | 3 +- client/internal/dns/upstream_test.go | 38 +- client/internal/peer/status.go | 4 +- 11 files changed, 747 insertions(+), 186 deletions(-) diff --git a/client/internal/dns/handler_chain.go b/client/internal/dns/handler_chain.go index 673f410e2..3286daabf 100644 --- a/client/internal/dns/handler_chain.go +++ b/client/internal/dns/handler_chain.go @@ -12,7 +12,7 @@ import ( const ( PriorityDNSRoute = 100 PriorityMatchDomain = 50 - PriorityDefault = 0 + PriorityDefault = 1 ) type SubdomainMatcher interface { @@ -26,7 +26,6 @@ type HandlerEntry struct { Pattern string OrigPattern string IsWildcard bool - StopHandler handlerWithStop MatchSubdomains bool } @@ -64,7 +63,7 @@ func (w *ResponseWriterChain) GetOrigPattern() string { } // AddHandler adds a new handler to the chain, replacing any existing handler with the same pattern and priority -func (c *HandlerChain) AddHandler(pattern string, handler dns.Handler, priority int, stopHandler handlerWithStop) { +func (c *HandlerChain) AddHandler(pattern string, handler dns.Handler, priority int) { c.mu.Lock() defer c.mu.Unlock() @@ -78,9 +77,6 @@ func (c *HandlerChain) AddHandler(pattern string, handler dns.Handler, priority // First remove any existing handler with same pattern (case-insensitive) and priority for i := len(c.handlers) - 1; i >= 0; i-- { if strings.EqualFold(c.handlers[i].OrigPattern, origPattern) && c.handlers[i].Priority == priority { - if c.handlers[i].StopHandler != nil { - c.handlers[i].StopHandler.stop() - } c.handlers = append(c.handlers[:i], c.handlers[i+1:]...) break } @@ -101,7 +97,6 @@ func (c *HandlerChain) AddHandler(pattern string, handler dns.Handler, priority Pattern: pattern, OrigPattern: origPattern, IsWildcard: isWildcard, - StopHandler: stopHandler, MatchSubdomains: matchSubdomains, } @@ -142,9 +137,6 @@ func (c *HandlerChain) RemoveHandler(pattern string, priority int) { for i := len(c.handlers) - 1; i >= 0; i-- { entry := c.handlers[i] if strings.EqualFold(entry.OrigPattern, pattern) && entry.Priority == priority { - if entry.StopHandler != nil { - entry.StopHandler.stop() - } c.handlers = append(c.handlers[:i], c.handlers[i+1:]...) return } @@ -180,8 +172,8 @@ func (c *HandlerChain) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { if log.IsLevelEnabled(log.TraceLevel) { log.Tracef("current handlers (%d):", len(handlers)) for _, h := range handlers { - log.Tracef(" - pattern: domain=%s original: domain=%s wildcard=%v priority=%d", - h.Pattern, h.OrigPattern, h.IsWildcard, h.Priority) + log.Tracef(" - pattern: domain=%s original: domain=%s wildcard=%v match_subdomain=%v priority=%d", + h.Pattern, h.OrigPattern, h.IsWildcard, h.MatchSubdomains, h.Priority) } } @@ -206,13 +198,13 @@ func (c *HandlerChain) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { } if !matched { - log.Tracef("trying domain match: request: domain=%s pattern: domain=%s wildcard=%v match_subdomain=%v matched=false", - qname, entry.OrigPattern, entry.MatchSubdomains, entry.IsWildcard) + log.Tracef("trying domain match: request: domain=%s pattern: domain=%s wildcard=%v match_subdomain=%v priority=%d matched=false", + qname, entry.OrigPattern, entry.MatchSubdomains, entry.IsWildcard, entry.Priority) continue } - log.Tracef("handler matched: request: domain=%s pattern: domain=%s wildcard=%v match_subdomain=%v", - qname, entry.OrigPattern, entry.IsWildcard, entry.MatchSubdomains) + log.Tracef("handler matched: request: domain=%s pattern: domain=%s wildcard=%v match_subdomain=%v priority=%d", + qname, entry.OrigPattern, entry.IsWildcard, entry.MatchSubdomains, entry.Priority) chainWriter := &ResponseWriterChain{ ResponseWriter: w, diff --git a/client/internal/dns/handler_chain_test.go b/client/internal/dns/handler_chain_test.go index d04bfbbb3..8c66446ee 100644 --- a/client/internal/dns/handler_chain_test.go +++ b/client/internal/dns/handler_chain_test.go @@ -21,9 +21,9 @@ func TestHandlerChain_ServeDNS_Priorities(t *testing.T) { dnsRouteHandler := &nbdns.MockHandler{} // Setup handlers with different priorities - chain.AddHandler("example.com.", defaultHandler, nbdns.PriorityDefault, nil) - chain.AddHandler("example.com.", matchDomainHandler, nbdns.PriorityMatchDomain, nil) - chain.AddHandler("example.com.", dnsRouteHandler, nbdns.PriorityDNSRoute, nil) + chain.AddHandler("example.com.", defaultHandler, nbdns.PriorityDefault) + chain.AddHandler("example.com.", matchDomainHandler, nbdns.PriorityMatchDomain) + chain.AddHandler("example.com.", dnsRouteHandler, nbdns.PriorityDNSRoute) // Create test request r := new(dns.Msg) @@ -138,7 +138,7 @@ func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) { pattern = "*." + tt.handlerDomain[2:] } - chain.AddHandler(pattern, handler, nbdns.PriorityDefault, nil) + chain.AddHandler(pattern, handler, nbdns.PriorityDefault) r := new(dns.Msg) r.SetQuestion(tt.queryDomain, dns.TypeA) @@ -253,7 +253,7 @@ func TestHandlerChain_ServeDNS_OverlappingDomains(t *testing.T) { handler.On("ServeDNS", mock.Anything, mock.Anything).Maybe() } - chain.AddHandler(tt.handlers[i].pattern, handler, tt.handlers[i].priority, nil) + chain.AddHandler(tt.handlers[i].pattern, handler, tt.handlers[i].priority) } // Create and execute request @@ -280,9 +280,9 @@ func TestHandlerChain_ServeDNS_ChainContinuation(t *testing.T) { handler3 := &nbdns.MockHandler{} // Add handlers in priority order - chain.AddHandler("example.com.", handler1, nbdns.PriorityDNSRoute, nil) - chain.AddHandler("example.com.", handler2, nbdns.PriorityMatchDomain, nil) - chain.AddHandler("example.com.", handler3, nbdns.PriorityDefault, nil) + chain.AddHandler("example.com.", handler1, nbdns.PriorityDNSRoute) + chain.AddHandler("example.com.", handler2, nbdns.PriorityMatchDomain) + chain.AddHandler("example.com.", handler3, nbdns.PriorityDefault) // Create test request r := new(dns.Msg) @@ -416,7 +416,7 @@ func TestHandlerChain_PriorityDeregistration(t *testing.T) { if op.action == "add" { handler := &nbdns.MockHandler{} handlers[op.priority] = handler - chain.AddHandler(op.pattern, handler, op.priority, nil) + chain.AddHandler(op.pattern, handler, op.priority) } else { chain.RemoveHandler(op.pattern, op.priority) } @@ -471,9 +471,9 @@ func TestHandlerChain_MultiPriorityHandling(t *testing.T) { r.SetQuestion(testQuery, dns.TypeA) // Add handlers in mixed order - chain.AddHandler(testDomain, defaultHandler, nbdns.PriorityDefault, nil) - chain.AddHandler(testDomain, routeHandler, nbdns.PriorityDNSRoute, nil) - chain.AddHandler(testDomain, matchHandler, nbdns.PriorityMatchDomain, nil) + chain.AddHandler(testDomain, defaultHandler, nbdns.PriorityDefault) + chain.AddHandler(testDomain, routeHandler, nbdns.PriorityDNSRoute) + chain.AddHandler(testDomain, matchHandler, nbdns.PriorityMatchDomain) // Test 1: Initial state with all three handlers w := &nbdns.ResponseWriterChain{ResponseWriter: &mockResponseWriter{}} @@ -653,7 +653,7 @@ func TestHandlerChain_CaseSensitivity(t *testing.T) { handler = mockHandler } - chain.AddHandler(pattern, handler, h.priority, nil) + chain.AddHandler(pattern, handler, h.priority) } // Execute request diff --git a/client/internal/dns/local.go b/client/internal/dns/local.go index 9a78d4d50..1fe88f750 100644 --- a/client/internal/dns/local.go +++ b/client/internal/dns/local.go @@ -29,10 +29,15 @@ func (d *localResolver) String() string { return fmt.Sprintf("local resolver [%d records]", len(d.registeredMap)) } +// ID returns the unique handler ID +func (d *localResolver) id() handlerID { + return "local-resolver" +} + // ServeDNS handles a DNS request func (d *localResolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { if len(r.Question) > 0 { - log.Tracef("received question: domain=%s type=%v class=%v", r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass) + log.Tracef("received local question: domain=%s type=%v class=%v", r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass) } replyMessage := &dns.Msg{} diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index 1fe913fd9..f714f9857 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -5,7 +5,6 @@ import ( "fmt" "net/netip" "runtime" - "strings" "sync" "github.com/miekg/dns" @@ -42,7 +41,12 @@ type Server interface { ProbeAvailability() } -type registeredHandlerMap map[string]handlerWithStop +type handlerID string + +type nsGroupsByDomain struct { + domain string + groups []*nbdns.NameServerGroup +} // DefaultServer dns server object type DefaultServer struct { @@ -52,7 +56,6 @@ type DefaultServer struct { mux sync.Mutex service service dnsMuxMap registeredHandlerMap - handlerPriorities map[string]int localResolver *localResolver wgInterface WGIface hostManager hostManager @@ -77,14 +80,17 @@ type handlerWithStop interface { dns.Handler stop() probeAvailability() + id() handlerID } -type muxUpdate struct { +type handlerWrapper struct { domain string handler handlerWithStop priority int } +type registeredHandlerMap map[handlerID]handlerWrapper + // NewDefaultServer returns a new dns server func NewDefaultServer( ctx context.Context, @@ -158,13 +164,12 @@ func newDefaultServer( ) *DefaultServer { ctx, stop := context.WithCancel(ctx) defaultServer := &DefaultServer{ - ctx: ctx, - ctxCancel: stop, - disableSys: disableSys, - service: dnsService, - handlerChain: NewHandlerChain(), - dnsMuxMap: make(registeredHandlerMap), - handlerPriorities: make(map[string]int), + ctx: ctx, + ctxCancel: stop, + disableSys: disableSys, + service: dnsService, + handlerChain: NewHandlerChain(), + dnsMuxMap: make(registeredHandlerMap), localResolver: &localResolver{ registeredMap: make(registrationMap), }, @@ -192,8 +197,7 @@ func (s *DefaultServer) registerHandler(domains []string, handler dns.Handler, p log.Warn("skipping empty domain") continue } - s.handlerChain.AddHandler(domain, handler, priority, nil) - s.handlerPriorities[domain] = priority + s.handlerChain.AddHandler(domain, handler, priority) s.service.RegisterMux(nbdns.NormalizeZone(domain), s.handlerChain) } } @@ -209,14 +213,15 @@ func (s *DefaultServer) deregisterHandler(domains []string, priority int) { log.Debugf("deregistering handler %v with priority %d", domains, priority) for _, domain := range domains { + if domain == "" { + log.Warn("skipping empty domain") + continue + } + s.handlerChain.RemoveHandler(domain, priority) // Only deregister from service if no handlers remain if !s.handlerChain.HasHandlers(domain) { - if domain == "" { - log.Warn("skipping empty domain") - continue - } s.service.DeregisterMux(nbdns.NormalizeZone(domain)) } } @@ -283,14 +288,24 @@ func (s *DefaultServer) Stop() { // OnUpdatedHostDNSServer update the DNS servers addresses for root zones // It will be applied if the mgm server do not enforce DNS settings for root zone + func (s *DefaultServer) OnUpdatedHostDNSServer(hostsDnsList []string) { s.hostsDNSHolder.set(hostsDnsList) - _, ok := s.dnsMuxMap[nbdns.RootZone] - if ok { + // Check if there's any root handler + var hasRootHandler bool + for _, handler := range s.dnsMuxMap { + if handler.domain == nbdns.RootZone { + hasRootHandler = true + break + } + } + + if hasRootHandler { log.Debugf("on new host DNS config but skip to apply it") return } + log.Debugf("update host DNS settings: %+v", hostsDnsList) s.addHostRootZone() } @@ -364,7 +379,7 @@ func (s *DefaultServer) ProbeAvailability() { go func(mux handlerWithStop) { defer wg.Done() mux.probeAvailability() - }(mux) + }(mux.handler) } wg.Wait() } @@ -419,8 +434,8 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error { return nil } -func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) ([]muxUpdate, map[string]nbdns.SimpleRecord, error) { - var muxUpdates []muxUpdate +func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) ([]handlerWrapper, map[string]nbdns.SimpleRecord, error) { + var muxUpdates []handlerWrapper localRecords := make(map[string]nbdns.SimpleRecord, 0) for _, customZone := range customZones { @@ -428,7 +443,7 @@ func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) return nil, nil, fmt.Errorf("received an empty list of records") } - muxUpdates = append(muxUpdates, muxUpdate{ + muxUpdates = append(muxUpdates, handlerWrapper{ domain: customZone.Domain, handler: s.localResolver, priority: PriorityMatchDomain, @@ -446,15 +461,59 @@ func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) return muxUpdates, localRecords, nil } -func (s *DefaultServer) buildUpstreamHandlerUpdate(nameServerGroups []*nbdns.NameServerGroup) ([]muxUpdate, error) { +func (s *DefaultServer) buildUpstreamHandlerUpdate(nameServerGroups []*nbdns.NameServerGroup) ([]handlerWrapper, error) { + var muxUpdates []handlerWrapper - var muxUpdates []muxUpdate for _, nsGroup := range nameServerGroups { if len(nsGroup.NameServers) == 0 { log.Warn("received a nameserver group with empty nameserver list") continue } + if !nsGroup.Primary && len(nsGroup.Domains) == 0 { + return nil, fmt.Errorf("received a non primary nameserver group with an empty domain list") + } + + for _, domain := range nsGroup.Domains { + if domain == "" { + return nil, fmt.Errorf("received a nameserver group with an empty domain element") + } + } + } + + groupedNS := groupNSGroupsByDomain(nameServerGroups) + + for _, domainGroup := range groupedNS { + basePriority := PriorityMatchDomain + if domainGroup.domain == nbdns.RootZone { + basePriority = PriorityDefault + } + + updates, err := s.createHandlersForDomainGroup(domainGroup, basePriority) + if err != nil { + return nil, err + } + muxUpdates = append(muxUpdates, updates...) + } + + return muxUpdates, nil +} + +func (s *DefaultServer) createHandlersForDomainGroup(domainGroup nsGroupsByDomain, basePriority int) ([]handlerWrapper, error) { + var muxUpdates []handlerWrapper + + for i, nsGroup := range domainGroup.groups { + // Decrement priority by handler index (0, 1, 2, ...) to avoid conflicts + priority := basePriority - i + + // Check if we're about to overlap with the next priority tier + if basePriority == PriorityMatchDomain && priority <= PriorityDefault { + log.Warnf("too many handlers for domain=%s, would overlap with default priority tier (diff=%d). Skipping remaining handlers", + domainGroup.domain, PriorityMatchDomain-PriorityDefault) + break + } + + log.Debugf("creating handler for domain=%s with priority=%d", domainGroup.domain, priority) handler, err := newUpstreamResolver( s.ctx, s.wgInterface.Name(), @@ -462,10 +521,12 @@ func (s *DefaultServer) buildUpstreamHandlerUpdate(nameServerGroups []*nbdns.Nam s.wgInterface.Address().Network, s.statusRecorder, s.hostsDNSHolder, + domainGroup.domain, ) if err != nil { - return nil, fmt.Errorf("unable to create a new upstream resolver, error: %v", err) + return nil, fmt.Errorf("create upstream resolver: %v", err) } + for _, ns := range nsGroup.NameServers { if ns.NSType != nbdns.UDPNameServerType { log.Warnf("skipping nameserver %s with type %s, this peer supports only %s", @@ -489,78 +550,47 @@ func (s *DefaultServer) buildUpstreamHandlerUpdate(nameServerGroups []*nbdns.Nam // after some period defined by upstream it tries to reactivate self by calling this hook // everything we need here is just to re-apply current configuration because it already // contains this upstream settings (temporal deactivation not removed it) - handler.deactivate, handler.reactivate = s.upstreamCallbacks(nsGroup, handler) + handler.deactivate, handler.reactivate = s.upstreamCallbacks(nsGroup, handler, priority) - if nsGroup.Primary { - muxUpdates = append(muxUpdates, muxUpdate{ - domain: nbdns.RootZone, - handler: handler, - priority: PriorityDefault, - }) - continue - } - - if len(nsGroup.Domains) == 0 { - handler.stop() - return nil, fmt.Errorf("received a non primary nameserver group with an empty domain list") - } - - for _, domain := range nsGroup.Domains { - if domain == "" { - handler.stop() - return nil, fmt.Errorf("received a nameserver group with an empty domain element") - } - muxUpdates = append(muxUpdates, muxUpdate{ - domain: domain, - handler: handler, - priority: PriorityMatchDomain, - }) - } + muxUpdates = append(muxUpdates, handlerWrapper{ + domain: domainGroup.domain, + handler: handler, + priority: priority, + }) } return muxUpdates, nil } -func (s *DefaultServer) updateMux(muxUpdates []muxUpdate) { - muxUpdateMap := make(registeredHandlerMap) - handlersByPriority := make(map[string]int) - - var isContainRootUpdate bool - - // First register new handlers - for _, update := range muxUpdates { - s.registerHandler([]string{update.domain}, update.handler, update.priority) - muxUpdateMap[update.domain] = update.handler - handlersByPriority[update.domain] = update.priority - - if existingHandler, ok := s.dnsMuxMap[update.domain]; ok { - existingHandler.stop() - } - - if update.domain == nbdns.RootZone { - isContainRootUpdate = true - } +func (s *DefaultServer) updateMux(muxUpdates []handlerWrapper) { + // this will introduce a short period of time when the server is not able to handle DNS requests + for _, existing := range s.dnsMuxMap { + s.deregisterHandler([]string{existing.domain}, existing.priority) + existing.handler.stop() } - // Then deregister old handlers not in the update - for key, existingHandler := range s.dnsMuxMap { - _, found := muxUpdateMap[key] - if !found { - if !isContainRootUpdate && key == nbdns.RootZone { + muxUpdateMap := make(registeredHandlerMap) + var containsRootUpdate bool + + for _, update := range muxUpdates { + if update.domain == nbdns.RootZone { + containsRootUpdate = true + } + s.registerHandler([]string{update.domain}, update.handler, update.priority) + muxUpdateMap[update.handler.id()] = update + } + + // If there's no root update and we had a root handler, restore it + if !containsRootUpdate { + for _, existing := range s.dnsMuxMap { + if existing.domain == nbdns.RootZone { s.addHostRootZone() - existingHandler.stop() - } else { - existingHandler.stop() - // Deregister with the priority that was used to register - if oldPriority, ok := s.handlerPriorities[key]; ok { - s.deregisterHandler([]string{key}, oldPriority) - } + break } } } s.dnsMuxMap = muxUpdateMap - s.handlerPriorities = handlersByPriority } func (s *DefaultServer) updateLocalResolver(update map[string]nbdns.SimpleRecord) { @@ -593,6 +623,7 @@ func getNSHostPort(ns nbdns.NameServer) string { func (s *DefaultServer) upstreamCallbacks( nsGroup *nbdns.NameServerGroup, handler dns.Handler, + priority int, ) (deactivate func(error), reactivate func()) { var removeIndex map[string]int deactivate = func(err error) { @@ -609,13 +640,13 @@ func (s *DefaultServer) upstreamCallbacks( if nsGroup.Primary { removeIndex[nbdns.RootZone] = -1 s.currentConfig.RouteAll = false - s.deregisterHandler([]string{nbdns.RootZone}, PriorityDefault) + s.deregisterHandler([]string{nbdns.RootZone}, priority) } for i, item := range s.currentConfig.Domains { if _, found := removeIndex[item.Domain]; found { s.currentConfig.Domains[i].Disabled = true - s.deregisterHandler([]string{item.Domain}, PriorityMatchDomain) + s.deregisterHandler([]string{item.Domain}, priority) removeIndex[item.Domain] = i } } @@ -635,8 +666,8 @@ func (s *DefaultServer) upstreamCallbacks( } s.updateNSState(nsGroup, err, false) - } + reactivate = func() { s.mux.Lock() defer s.mux.Unlock() @@ -646,7 +677,7 @@ func (s *DefaultServer) upstreamCallbacks( continue } s.currentConfig.Domains[i].Disabled = false - s.registerHandler([]string{domain}, handler, PriorityMatchDomain) + s.registerHandler([]string{domain}, handler, priority) } l := log.WithField("nameservers", nsGroup.NameServers) @@ -654,7 +685,7 @@ func (s *DefaultServer) upstreamCallbacks( if nsGroup.Primary { s.currentConfig.RouteAll = true - s.registerHandler([]string{nbdns.RootZone}, handler, PriorityDefault) + s.registerHandler([]string{nbdns.RootZone}, handler, priority) } if s.hostManager != nil { @@ -676,6 +707,7 @@ func (s *DefaultServer) addHostRootZone() { s.wgInterface.Address().Network, s.statusRecorder, s.hostsDNSHolder, + nbdns.RootZone, ) if err != nil { log.Errorf("unable to create a new upstream resolver, error: %v", err) @@ -732,5 +764,34 @@ func generateGroupKey(nsGroup *nbdns.NameServerGroup) string { for _, ns := range nsGroup.NameServers { servers = append(servers, fmt.Sprintf("%s:%d", ns.IP, ns.Port)) } - return fmt.Sprintf("%s_%s_%s", nsGroup.ID, nsGroup.Name, strings.Join(servers, ",")) + return fmt.Sprintf("%v_%v", servers, nsGroup.Domains) +} + +// groupNSGroupsByDomain groups nameserver groups by their match domains +func groupNSGroupsByDomain(nsGroups []*nbdns.NameServerGroup) []nsGroupsByDomain { + domainMap := make(map[string][]*nbdns.NameServerGroup) + + for _, group := range nsGroups { + if group.Primary { + domainMap[nbdns.RootZone] = append(domainMap[nbdns.RootZone], group) + continue + } + + for _, domain := range group.Domains { + if domain == "" { + continue + } + domainMap[domain] = append(domainMap[domain], group) + } + } + + var result []nsGroupsByDomain + for domain, groups := range domainMap { + result = append(result, nsGroupsByDomain{ + domain: domain, + groups: groups, + }) + } + + return result } diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index 14ff1bb71..db49f96a2 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -13,6 +13,7 @@ import ( "github.com/golang/mock/gomock" "github.com/miekg/dns" log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" @@ -88,6 +89,18 @@ func init() { formatter.SetTextFormatter(log.StandardLogger()) } +func generateDummyHandler(domain string, servers []nbdns.NameServer) *upstreamResolverBase { + var srvs []string + for _, srv := range servers { + srvs = append(srvs, getNSHostPort(srv)) + } + return &upstreamResolverBase{ + domain: domain, + upstreamServers: srvs, + cancel: func() {}, + } +} + func TestUpdateDNSServer(t *testing.T) { nameServers := []nbdns.NameServer{ { @@ -140,15 +153,37 @@ func TestUpdateDNSServer(t *testing.T) { }, }, }, - expectedUpstreamMap: registeredHandlerMap{"netbird.io": dummyHandler, "netbird.cloud": dummyHandler, nbdns.RootZone: dummyHandler}, - expectedLocalMap: registrationMap{buildRecordKey(zoneRecords[0].Name, 1, 1): struct{}{}}, + expectedUpstreamMap: registeredHandlerMap{ + generateDummyHandler("netbird.io", nameServers).id(): handlerWrapper{ + domain: "netbird.io", + handler: dummyHandler, + priority: PriorityMatchDomain, + }, + dummyHandler.id(): handlerWrapper{ + domain: "netbird.cloud", + handler: dummyHandler, + priority: PriorityMatchDomain, + }, + generateDummyHandler(".", nameServers).id(): handlerWrapper{ + domain: nbdns.RootZone, + handler: dummyHandler, + priority: PriorityDefault, + }, + }, + expectedLocalMap: registrationMap{buildRecordKey(zoneRecords[0].Name, 1, 1): struct{}{}}, }, { - name: "New Config Should Succeed", - initLocalMap: registrationMap{"netbird.cloud": struct{}{}}, - initUpstreamMap: registeredHandlerMap{buildRecordKey(zoneRecords[0].Name, 1, 1): dummyHandler}, - initSerial: 0, - inputSerial: 1, + name: "New Config Should Succeed", + initLocalMap: registrationMap{"netbird.cloud": struct{}{}}, + initUpstreamMap: registeredHandlerMap{ + generateDummyHandler(zoneRecords[0].Name, nameServers).id(): handlerWrapper{ + domain: buildRecordKey(zoneRecords[0].Name, 1, 1), + handler: dummyHandler, + priority: PriorityMatchDomain, + }, + }, + initSerial: 0, + inputSerial: 1, inputUpdate: nbdns.Config{ ServiceEnable: true, CustomZones: []nbdns.CustomZone{ @@ -164,8 +199,19 @@ func TestUpdateDNSServer(t *testing.T) { }, }, }, - expectedUpstreamMap: registeredHandlerMap{"netbird.io": dummyHandler, "netbird.cloud": dummyHandler}, - expectedLocalMap: registrationMap{buildRecordKey(zoneRecords[0].Name, 1, 1): struct{}{}}, + expectedUpstreamMap: registeredHandlerMap{ + generateDummyHandler("netbird.io", nameServers).id(): handlerWrapper{ + domain: "netbird.io", + handler: dummyHandler, + priority: PriorityMatchDomain, + }, + "local-resolver": handlerWrapper{ + domain: "netbird.cloud", + handler: dummyHandler, + priority: PriorityMatchDomain, + }, + }, + expectedLocalMap: registrationMap{buildRecordKey(zoneRecords[0].Name, 1, 1): struct{}{}}, }, { name: "Smaller Config Serial Should Be Skipped", @@ -242,9 +288,15 @@ func TestUpdateDNSServer(t *testing.T) { shouldFail: true, }, { - name: "Empty Config Should Succeed and Clean Maps", - initLocalMap: registrationMap{"netbird.cloud": struct{}{}}, - initUpstreamMap: registeredHandlerMap{zoneRecords[0].Name: dummyHandler}, + name: "Empty Config Should Succeed and Clean Maps", + initLocalMap: registrationMap{"netbird.cloud": struct{}{}}, + initUpstreamMap: registeredHandlerMap{ + generateDummyHandler(zoneRecords[0].Name, nameServers).id(): handlerWrapper{ + domain: zoneRecords[0].Name, + handler: dummyHandler, + priority: PriorityMatchDomain, + }, + }, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ServiceEnable: true}, @@ -252,9 +304,15 @@ func TestUpdateDNSServer(t *testing.T) { expectedLocalMap: make(registrationMap), }, { - name: "Disabled Service Should clean map", - initLocalMap: registrationMap{"netbird.cloud": struct{}{}}, - initUpstreamMap: registeredHandlerMap{zoneRecords[0].Name: dummyHandler}, + name: "Disabled Service Should clean map", + initLocalMap: registrationMap{"netbird.cloud": struct{}{}}, + initUpstreamMap: registeredHandlerMap{ + generateDummyHandler(zoneRecords[0].Name, nameServers).id(): handlerWrapper{ + domain: zoneRecords[0].Name, + handler: dummyHandler, + priority: PriorityMatchDomain, + }, + }, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ServiceEnable: false}, @@ -421,7 +479,13 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) { } }() - dnsServer.dnsMuxMap = registeredHandlerMap{zoneRecords[0].Name: &localResolver{}} + dnsServer.dnsMuxMap = registeredHandlerMap{ + "id1": handlerWrapper{ + domain: zoneRecords[0].Name, + handler: &localResolver{}, + priority: PriorityMatchDomain, + }, + } dnsServer.localResolver.registeredMap = registrationMap{"netbird.cloud": struct{}{}} dnsServer.updateSerial = 0 @@ -562,9 +626,8 @@ func TestDNSServerUpstreamDeactivateCallback(t *testing.T) { localResolver: &localResolver{ registeredMap: make(registrationMap), }, - handlerChain: NewHandlerChain(), - handlerPriorities: make(map[string]int), - hostManager: hostManager, + handlerChain: NewHandlerChain(), + hostManager: hostManager, currentConfig: HostDNSConfig{ Domains: []DomainConfig{ {false, "domain0", false}, @@ -593,7 +656,7 @@ func TestDNSServerUpstreamDeactivateCallback(t *testing.T) { NameServers: []nbdns.NameServer{ {IP: netip.MustParseAddr("8.8.0.0"), NSType: nbdns.UDPNameServerType, Port: 53}, }, - }, nil) + }, nil, 0) deactivate(nil) expected := "domain0,domain2" @@ -903,8 +966,8 @@ func TestHandlerChain_DomainPriorities(t *testing.T) { Subdomains: true, } - chain.AddHandler("example.com.", dnsRouteHandler, PriorityDNSRoute, nil) - chain.AddHandler("example.com.", upstreamHandler, PriorityMatchDomain, nil) + chain.AddHandler("example.com.", dnsRouteHandler, PriorityDNSRoute) + chain.AddHandler("example.com.", upstreamHandler, PriorityMatchDomain) testCases := []struct { name string @@ -959,3 +1022,421 @@ func TestHandlerChain_DomainPriorities(t *testing.T) { }) } } + +type mockHandler struct { + Id string +} + +func (m *mockHandler) ServeDNS(dns.ResponseWriter, *dns.Msg) {} +func (m *mockHandler) stop() {} +func (m *mockHandler) probeAvailability() {} +func (m *mockHandler) id() handlerID { return handlerID(m.Id) } + +type mockService struct{} + +func (m *mockService) Listen() error { return nil } +func (m *mockService) Stop() {} +func (m *mockService) RuntimeIP() string { return "127.0.0.1" } +func (m *mockService) RuntimePort() int { return 53 } +func (m *mockService) RegisterMux(string, dns.Handler) {} +func (m *mockService) DeregisterMux(string) {} + +func TestDefaultServer_UpdateMux(t *testing.T) { + baseMatchHandlers := registeredHandlerMap{ + "upstream-group1": { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group1", + }, + priority: PriorityMatchDomain, + }, + "upstream-group2": { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group2", + }, + priority: PriorityMatchDomain - 1, + }, + } + + baseRootHandlers := registeredHandlerMap{ + "upstream-root1": { + domain: ".", + handler: &mockHandler{ + Id: "upstream-root1", + }, + priority: PriorityDefault, + }, + "upstream-root2": { + domain: ".", + handler: &mockHandler{ + Id: "upstream-root2", + }, + priority: PriorityDefault - 1, + }, + } + + baseMixedHandlers := registeredHandlerMap{ + "upstream-group1": { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group1", + }, + priority: PriorityMatchDomain, + }, + "upstream-group2": { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group2", + }, + priority: PriorityMatchDomain - 1, + }, + "upstream-other": { + domain: "other.com", + handler: &mockHandler{ + Id: "upstream-other", + }, + priority: PriorityMatchDomain, + }, + } + + tests := []struct { + name string + initialHandlers registeredHandlerMap + updates []handlerWrapper + expectedHandlers map[string]string // map[handlerID]domain + description string + }{ + { + name: "Remove group1 from update", + initialHandlers: baseMatchHandlers, + updates: []handlerWrapper{ + // Only group2 remains + { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group2", + }, + priority: PriorityMatchDomain - 1, + }, + }, + expectedHandlers: map[string]string{ + "upstream-group2": "example.com", + }, + description: "When group1 is not included in the update, it should be removed while group2 remains", + }, + { + name: "Remove group2 from update", + initialHandlers: baseMatchHandlers, + updates: []handlerWrapper{ + // Only group1 remains + { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group1", + }, + priority: PriorityMatchDomain, + }, + }, + expectedHandlers: map[string]string{ + "upstream-group1": "example.com", + }, + description: "When group2 is not included in the update, it should be removed while group1 remains", + }, + { + name: "Add group3 in first position", + initialHandlers: baseMatchHandlers, + updates: []handlerWrapper{ + // Add group3 with highest priority + { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group3", + }, + priority: PriorityMatchDomain + 1, + }, + // Keep existing groups with their original priorities + { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group1", + }, + priority: PriorityMatchDomain, + }, + { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group2", + }, + priority: PriorityMatchDomain - 1, + }, + }, + expectedHandlers: map[string]string{ + "upstream-group1": "example.com", + "upstream-group2": "example.com", + "upstream-group3": "example.com", + }, + description: "When adding group3 with highest priority, it should be first in chain while maintaining existing groups", + }, + { + name: "Add group3 in last position", + initialHandlers: baseMatchHandlers, + updates: []handlerWrapper{ + // Keep existing groups with their original priorities + { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group1", + }, + priority: PriorityMatchDomain, + }, + { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group2", + }, + priority: PriorityMatchDomain - 1, + }, + // Add group3 with lowest priority + { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group3", + }, + priority: PriorityMatchDomain - 2, + }, + }, + expectedHandlers: map[string]string{ + "upstream-group1": "example.com", + "upstream-group2": "example.com", + "upstream-group3": "example.com", + }, + description: "When adding group3 with lowest priority, it should be last in chain while maintaining existing groups", + }, + // Root zone tests + { + name: "Remove root1 from update", + initialHandlers: baseRootHandlers, + updates: []handlerWrapper{ + { + domain: ".", + handler: &mockHandler{ + Id: "upstream-root2", + }, + priority: PriorityDefault - 1, + }, + }, + expectedHandlers: map[string]string{ + "upstream-root2": ".", + }, + description: "When root1 is not included in the update, it should be removed while root2 remains", + }, + { + name: "Remove root2 from update", + initialHandlers: baseRootHandlers, + updates: []handlerWrapper{ + { + domain: ".", + handler: &mockHandler{ + Id: "upstream-root1", + }, + priority: PriorityDefault, + }, + }, + expectedHandlers: map[string]string{ + "upstream-root1": ".", + }, + description: "When root2 is not included in the update, it should be removed while root1 remains", + }, + { + name: "Add root3 in first position", + initialHandlers: baseRootHandlers, + updates: []handlerWrapper{ + { + domain: ".", + handler: &mockHandler{ + Id: "upstream-root3", + }, + priority: PriorityDefault + 1, + }, + { + domain: ".", + handler: &mockHandler{ + Id: "upstream-root1", + }, + priority: PriorityDefault, + }, + { + domain: ".", + handler: &mockHandler{ + Id: "upstream-root2", + }, + priority: PriorityDefault - 1, + }, + }, + expectedHandlers: map[string]string{ + "upstream-root1": ".", + "upstream-root2": ".", + "upstream-root3": ".", + }, + description: "When adding root3 with highest priority, it should be first in chain while maintaining existing root handlers", + }, + { + name: "Add root3 in last position", + initialHandlers: baseRootHandlers, + updates: []handlerWrapper{ + { + domain: ".", + handler: &mockHandler{ + Id: "upstream-root1", + }, + priority: PriorityDefault, + }, + { + domain: ".", + handler: &mockHandler{ + Id: "upstream-root2", + }, + priority: PriorityDefault - 1, + }, + { + domain: ".", + handler: &mockHandler{ + Id: "upstream-root3", + }, + priority: PriorityDefault - 2, + }, + }, + expectedHandlers: map[string]string{ + "upstream-root1": ".", + "upstream-root2": ".", + "upstream-root3": ".", + }, + description: "When adding root3 with lowest priority, it should be last in chain while maintaining existing root handlers", + }, + // Mixed domain tests + { + name: "Update with mixed domains - remove one of duplicate domain", + initialHandlers: baseMixedHandlers, + updates: []handlerWrapper{ + { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group1", + }, + priority: PriorityMatchDomain, + }, + { + domain: "other.com", + handler: &mockHandler{ + Id: "upstream-other", + }, + priority: PriorityMatchDomain, + }, + }, + expectedHandlers: map[string]string{ + "upstream-group1": "example.com", + "upstream-other": "other.com", + }, + description: "When updating mixed domains, should correctly handle removal of one duplicate while maintaining other domains", + }, + { + name: "Update with mixed domains - add new domain", + initialHandlers: baseMixedHandlers, + updates: []handlerWrapper{ + { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group1", + }, + priority: PriorityMatchDomain, + }, + { + domain: "example.com", + handler: &mockHandler{ + Id: "upstream-group2", + }, + priority: PriorityMatchDomain - 1, + }, + { + domain: "other.com", + handler: &mockHandler{ + Id: "upstream-other", + }, + priority: PriorityMatchDomain, + }, + { + domain: "new.com", + handler: &mockHandler{ + Id: "upstream-new", + }, + priority: PriorityMatchDomain, + }, + }, + expectedHandlers: map[string]string{ + "upstream-group1": "example.com", + "upstream-group2": "example.com", + "upstream-other": "other.com", + "upstream-new": "new.com", + }, + description: "When updating mixed domains, should maintain existing duplicates and add new domain", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := &DefaultServer{ + dnsMuxMap: tt.initialHandlers, + handlerChain: NewHandlerChain(), + service: &mockService{}, + } + + // Perform the update + server.updateMux(tt.updates) + + // Verify the results + assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxMap), + "Number of handlers after update doesn't match expected") + + // Check each expected handler + for id, expectedDomain := range tt.expectedHandlers { + handler, exists := server.dnsMuxMap[handlerID(id)] + assert.True(t, exists, "Expected handler %s not found", id) + if exists { + assert.Equal(t, expectedDomain, handler.domain, + "Domain mismatch for handler %s", id) + } + } + + // Verify no unexpected handlers exist + for handlerID := range server.dnsMuxMap { + _, expected := tt.expectedHandlers[string(handlerID)] + assert.True(t, expected, "Unexpected handler found: %s", handlerID) + } + + // Verify the handlerChain state and order + previousPriority := 0 + for _, chainEntry := range server.handlerChain.handlers { + // Verify priority order + if previousPriority > 0 { + assert.True(t, chainEntry.Priority <= previousPriority, + "Handlers in chain not properly ordered by priority") + } + previousPriority = chainEntry.Priority + + // Verify handler exists in mux + foundInMux := false + for _, muxEntry := range server.dnsMuxMap { + if chainEntry.Handler == muxEntry.handler && + chainEntry.Priority == muxEntry.priority && + chainEntry.Pattern == dns.Fqdn(muxEntry.domain) { + foundInMux = true + break + } + } + assert.True(t, foundInMux, + "Handler in chain not found in dnsMuxMap") + } + }) + } +} diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go index f0aa12b65..4c69a173d 100644 --- a/client/internal/dns/upstream.go +++ b/client/internal/dns/upstream.go @@ -2,9 +2,13 @@ package dns import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "net" + "slices" + "strings" "sync" "sync/atomic" "time" @@ -40,6 +44,7 @@ type upstreamResolverBase struct { cancel context.CancelFunc upstreamClient upstreamClient upstreamServers []string + domain string disabled bool failsCount atomic.Int32 successCount atomic.Int32 @@ -53,12 +58,13 @@ type upstreamResolverBase struct { statusRecorder *peer.Status } -func newUpstreamResolverBase(ctx context.Context, statusRecorder *peer.Status) *upstreamResolverBase { +func newUpstreamResolverBase(ctx context.Context, statusRecorder *peer.Status, domain string) *upstreamResolverBase { ctx, cancel := context.WithCancel(ctx) return &upstreamResolverBase{ ctx: ctx, cancel: cancel, + domain: domain, upstreamTimeout: upstreamTimeout, reactivatePeriod: reactivatePeriod, failsTillDeact: failsTillDeact, @@ -71,6 +77,17 @@ func (u *upstreamResolverBase) String() string { return fmt.Sprintf("upstream %v", u.upstreamServers) } +// ID returns the unique handler ID +func (u *upstreamResolverBase) id() handlerID { + servers := slices.Clone(u.upstreamServers) + slices.Sort(servers) + + hash := sha256.New() + hash.Write([]byte(u.domain + ":")) + hash.Write([]byte(strings.Join(servers, ","))) + return handlerID("upstream-" + hex.EncodeToString(hash.Sum(nil)[:8])) +} + func (u *upstreamResolverBase) MatchSubdomains() bool { return true } @@ -87,7 +104,7 @@ func (u *upstreamResolverBase) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { u.checkUpstreamFails(err) }() - log.WithField("question", r.Question[0]).Trace("received an upstream question") + log.Tracef("received upstream question: domain=%s type=%v class=%v", r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass) // set the AuthenticatedData flag and the EDNS0 buffer size to 4096 bytes to support larger dns records if r.Extra == nil { r.SetEdns0(4096, false) @@ -96,6 +113,7 @@ func (u *upstreamResolverBase) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { select { case <-u.ctx.Done(): + log.Tracef("%s has been stopped", u) return default: } @@ -112,41 +130,36 @@ func (u *upstreamResolverBase) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { if err != nil { if errors.Is(err, context.DeadlineExceeded) || isTimeout(err) { - log.WithError(err).WithField("upstream", upstream). - Warn("got an error while connecting to upstream") + log.Warnf("upstream %s timed out for question domain=%s", upstream, r.Question[0].Name) continue } - u.failsCount.Add(1) - log.WithError(err).WithField("upstream", upstream). - Error("got other error while querying the upstream") - return + log.Warnf("failed to query upstream %s for question domain=%s: %s", upstream, r.Question[0].Name, err) + continue } - if rm == nil { - log.WithError(err).WithField("upstream", upstream). - Warn("no response from upstream") - return - } - // those checks need to be independent of each other due to memory address issues - if !rm.Response { - log.WithError(err).WithField("upstream", upstream). - Warn("no response from upstream") - return + if rm == nil || !rm.Response { + log.Warnf("no response from upstream %s for question domain=%s", upstream, r.Question[0].Name) + continue } u.successCount.Add(1) - log.Tracef("took %s to query the upstream %s", t, upstream) + log.Tracef("took %s to query the upstream %s for question domain=%s", t, upstream, r.Question[0].Name) - err = w.WriteMsg(rm) - if err != nil { - log.WithError(err).Error("got an error while writing the upstream resolver response") + if err = w.WriteMsg(rm); err != nil { + log.Errorf("failed to write DNS response for question domain=%s: %s", r.Question[0].Name, err) } // count the fails only if they happen sequentially u.failsCount.Store(0) return } u.failsCount.Add(1) - log.Error("all queries to the upstream nameservers failed with timeout") + log.Errorf("all queries to the %s failed for question domain=%s", u, r.Question[0].Name) + + m := new(dns.Msg) + m.SetRcode(r, dns.RcodeServerFailure) + if err := w.WriteMsg(m); err != nil { + log.Errorf("failed to write error response for %s for question domain=%s: %s", u, r.Question[0].Name, err) + } } // checkUpstreamFails counts fails and disables or enables upstream resolving diff --git a/client/internal/dns/upstream_android.go b/client/internal/dns/upstream_android.go index 36ea05e44..a9e46ca02 100644 --- a/client/internal/dns/upstream_android.go +++ b/client/internal/dns/upstream_android.go @@ -27,8 +27,9 @@ func newUpstreamResolver( _ *net.IPNet, statusRecorder *peer.Status, hostsDNSHolder *hostsDNSHolder, + domain string, ) (*upstreamResolver, error) { - upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder) + upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, domain) c := &upstreamResolver{ upstreamResolverBase: upstreamResolverBase, hostsDNSHolder: hostsDNSHolder, diff --git a/client/internal/dns/upstream_general.go b/client/internal/dns/upstream_general.go index a29350f8c..51acbf7a6 100644 --- a/client/internal/dns/upstream_general.go +++ b/client/internal/dns/upstream_general.go @@ -23,8 +23,9 @@ func newUpstreamResolver( _ *net.IPNet, statusRecorder *peer.Status, _ *hostsDNSHolder, + domain string, ) (*upstreamResolver, error) { - upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder) + upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, domain) nonIOS := &upstreamResolver{ upstreamResolverBase: upstreamResolverBase, } diff --git a/client/internal/dns/upstream_ios.go b/client/internal/dns/upstream_ios.go index 60ed79d87..7d3301e14 100644 --- a/client/internal/dns/upstream_ios.go +++ b/client/internal/dns/upstream_ios.go @@ -30,8 +30,9 @@ func newUpstreamResolver( net *net.IPNet, statusRecorder *peer.Status, _ *hostsDNSHolder, + domain string, ) (*upstreamResolverIOS, error) { - upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder) + upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, domain) ios := &upstreamResolverIOS{ upstreamResolverBase: upstreamResolverBase, diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go index c1251dcc1..c5adc0858 100644 --- a/client/internal/dns/upstream_test.go +++ b/client/internal/dns/upstream_test.go @@ -20,6 +20,7 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) { timeout time.Duration cancelCTX bool expectedAnswer string + acceptNXDomain bool }{ { name: "Should Resolve A Record", @@ -36,11 +37,11 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) { expectedAnswer: "1.1.1.1", }, { - name: "Should Not Resolve If Can't Connect To Both Servers", - inputMSG: new(dns.Msg).SetQuestion("one.one.one.one.", dns.TypeA), - InputServers: []string{"8.0.0.0:53", "8.0.0.1:53"}, - timeout: 200 * time.Millisecond, - responseShouldBeNil: true, + name: "Should Not Resolve If Can't Connect To Both Servers", + inputMSG: new(dns.Msg).SetQuestion("one.one.one.one.", dns.TypeA), + InputServers: []string{"8.0.0.0:53", "8.0.0.1:53"}, + timeout: 200 * time.Millisecond, + acceptNXDomain: true, }, { name: "Should Not Resolve If Parent Context Is Canceled", @@ -51,14 +52,11 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) { responseShouldBeNil: true, }, } - // should resolve if first upstream times out - // should not write when both fails - // should not resolve if parent context is canceled for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { ctx, cancel := context.WithCancel(context.TODO()) - resolver, _ := newUpstreamResolver(ctx, "", net.IP{}, &net.IPNet{}, nil, nil) + resolver, _ := newUpstreamResolver(ctx, "", net.IP{}, &net.IPNet{}, nil, nil, ".") resolver.upstreamServers = testCase.InputServers resolver.upstreamTimeout = testCase.timeout if testCase.cancelCTX { @@ -84,16 +82,22 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) { t.Fatalf("should write a response message") } - foundAnswer := false - for _, answer := range responseMSG.Answer { - if strings.Contains(answer.String(), testCase.expectedAnswer) { - foundAnswer = true - break - } + if testCase.acceptNXDomain && responseMSG.Rcode == dns.RcodeNameError { + return } - if !foundAnswer { - t.Errorf("couldn't find the required answer, %s, in the dns response", testCase.expectedAnswer) + if testCase.expectedAnswer != "" { + foundAnswer := false + for _, answer := range responseMSG.Answer { + if strings.Contains(answer.String(), testCase.expectedAnswer) { + foundAnswer = true + break + } + } + + if !foundAnswer { + t.Errorf("couldn't find the required answer, %s, in the dns response", testCase.expectedAnswer) + } } }) } diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 0df2a2e81..311ddbd7f 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -721,7 +721,9 @@ func (d *Status) GetRelayStates() []relay.ProbeResult { func (d *Status) GetDNSStates() []NSGroupState { d.mux.Lock() defer d.mux.Unlock() - return d.nsGroupStates + + // shallow copy is good enough, as slices fields are currently not updated + return slices.Clone(d.nsGroupStates) } func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo { From 44407a158a9416e076e5c9ca615134c42c64a036 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 11 Feb 2025 12:42:04 +0100 Subject: [PATCH 10/29] [client] Fix dns handler chain test (#3307) --- client/internal/dns/handler_chain_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/internal/dns/handler_chain_test.go b/client/internal/dns/handler_chain_test.go index 8c66446ee..94aa987af 100644 --- a/client/internal/dns/handler_chain_test.go +++ b/client/internal/dns/handler_chain_test.go @@ -795,7 +795,7 @@ func TestHandlerChain_DomainSpecificityOrdering(t *testing.T) { if op.action == "add" { handler := &nbdns.MockSubdomainHandler{Subdomains: op.subdomain} handlers[op.pattern] = handler - chain.AddHandler(op.pattern, handler, op.priority, nil) + chain.AddHandler(op.pattern, handler, op.priority) } else { chain.RemoveHandler(op.pattern, op.priority) } From 18f84f0df5e978f0bfb4435ca3d9282b8ee9ac31 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 11 Feb 2025 13:09:17 +0100 Subject: [PATCH 11/29] [client] Check for fwmark support and use fallback routing if not supported (#3220) --- client/iface/configurer/usp.go | 2 +- client/internal/connect.go | 3 + client/internal/routemanager/manager.go | 11 +- .../routemanager/systemops/systemops_linux.go | 29 +---- .../systemops/systemops_unix_test.go | 1 + util/grpc/dialer.go | 1 - util/net/env.go | 21 ++-- util/net/env_generic.go | 12 ++ util/net/env_linux.go | 119 ++++++++++++++++++ util/net/net_linux.go | 20 +-- 10 files changed, 163 insertions(+), 56 deletions(-) create mode 100644 util/net/env_generic.go create mode 100644 util/net/env_linux.go diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index 21d65ab2a..391269dd0 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -362,7 +362,7 @@ func toWgUserspaceString(wgCfg wgtypes.Config) string { } func getFwmark() int { - if runtime.GOOS == "linux" && !nbnet.CustomRoutingDisabled() { + if nbnet.AdvancedRouting() { return nbnet.NetbirdFwmark } return 0 diff --git a/client/internal/connect.go b/client/internal/connect.go index ddd10e5cd..a0d585ffe 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -31,6 +31,7 @@ import ( relayClient "github.com/netbirdio/netbird/relay/client" signal "github.com/netbirdio/netbird/signal/client" "github.com/netbirdio/netbird/util" + nbnet "github.com/netbirdio/netbird/util/net" "github.com/netbirdio/netbird/version" ) @@ -109,6 +110,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan log.Infof("starting NetBird client version %s on %s/%s", version.NetbirdVersion(), runtime.GOOS, runtime.GOARCH) + nbnet.Init() + backOff := &backoff.ExponentialBackOff{ InitialInterval: time.Second, RandomizationFactor: 1, diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 34bd67893..9c7f1f6fa 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -113,13 +113,14 @@ func NewManager(config ManagerConfig) *DefaultManager { disableServerRoutes: config.DisableServerRoutes, } + useNoop := netstack.IsEnabled() || config.DisableClientRoutes + dm.setupRefCounters(useNoop) + // don't proceed with client routes if it is disabled if config.DisableClientRoutes { return dm } - dm.setupRefCounters() - if runtime.GOOS == "android" { cr := dm.initialClientRoutes(config.InitialRoutes) dm.notifier.SetInitialClientRoutes(cr) @@ -127,7 +128,7 @@ func NewManager(config ManagerConfig) *DefaultManager { return dm } -func (m *DefaultManager) setupRefCounters() { +func (m *DefaultManager) setupRefCounters(useNoop bool) { m.routeRefCounter = refcounter.New( func(prefix netip.Prefix, _ struct{}) (struct{}, error) { return struct{}{}, m.sysOps.AddVPNRoute(prefix, m.wgInterface.ToInterface()) @@ -137,7 +138,7 @@ func (m *DefaultManager) setupRefCounters() { }, ) - if netstack.IsEnabled() { + if useNoop { m.routeRefCounter = refcounter.New( func(netip.Prefix, struct{}) (struct{}, error) { return struct{}{}, refcounter.ErrIgnore @@ -449,7 +450,7 @@ func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*ro } func isRouteSupported(route *route.Route) bool { - if !nbnet.CustomRoutingDisabled() || route.IsDynamic() { + if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() { return true } diff --git a/client/internal/routemanager/systemops/systemops_linux.go b/client/internal/routemanager/systemops/systemops_linux.go index 1da92cc80..d724cb1a7 100644 --- a/client/internal/routemanager/systemops/systemops_linux.go +++ b/client/internal/routemanager/systemops/systemops_linux.go @@ -53,20 +53,6 @@ type ruleParams struct { description string } -// isLegacy determines whether to use the legacy routing setup -func isLegacy() bool { - return os.Getenv("NB_USE_LEGACY_ROUTING") == "true" || nbnet.CustomRoutingDisabled() || nbnet.SkipSocketMark() -} - -// setIsLegacy sets the legacy routing setup -func setIsLegacy(b bool) { - if b { - os.Setenv("NB_USE_LEGACY_ROUTING", "true") - } else { - os.Unsetenv("NB_USE_LEGACY_ROUTING") - } -} - func getSetupRules() []ruleParams { return []ruleParams{ {100, -1, syscall.RT_TABLE_MAIN, netlink.FAMILY_V4, false, 0, "rule with suppress prefixlen v4"}, @@ -87,7 +73,7 @@ func getSetupRules() []ruleParams { // This table is where a default route or other specific routes received from the management server are configured, // enabling VPN connectivity. func (r *SysOps) SetupRouting(initAddresses []net.IP, stateManager *statemanager.Manager) (_ nbnet.AddHookFunc, _ nbnet.RemoveHookFunc, err error) { - if isLegacy() { + if !nbnet.AdvancedRouting() { log.Infof("Using legacy routing setup") return r.setupRefCounter(initAddresses, stateManager) } @@ -103,11 +89,6 @@ func (r *SysOps) SetupRouting(initAddresses []net.IP, stateManager *statemanager rules := getSetupRules() for _, rule := range rules { if err := addRule(rule); err != nil { - if errors.Is(err, syscall.EOPNOTSUPP) { - log.Warnf("Rule operations are not supported, falling back to the legacy routing setup") - setIsLegacy(true) - return r.setupRefCounter(initAddresses, stateManager) - } return nil, nil, fmt.Errorf("%s: %w", rule.description, err) } } @@ -130,7 +111,7 @@ func (r *SysOps) SetupRouting(initAddresses []net.IP, stateManager *statemanager // It systematically removes the three rules and any associated routing table entries to ensure a clean state. // The function uses error aggregation to report any errors encountered during the cleanup process. func (r *SysOps) CleanupRouting(stateManager *statemanager.Manager) error { - if isLegacy() { + if !nbnet.AdvancedRouting() { return r.cleanupRefCounter(stateManager) } @@ -168,7 +149,7 @@ func (r *SysOps) removeFromRouteTable(prefix netip.Prefix, nexthop Nexthop) erro } func (r *SysOps) AddVPNRoute(prefix netip.Prefix, intf *net.Interface) error { - if isLegacy() { + if !nbnet.AdvancedRouting() { return r.genericAddVPNRoute(prefix, intf) } @@ -191,7 +172,7 @@ func (r *SysOps) AddVPNRoute(prefix netip.Prefix, intf *net.Interface) error { } func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) error { - if isLegacy() { + if !nbnet.AdvancedRouting() { return r.genericRemoveVPNRoute(prefix, intf) } @@ -504,7 +485,7 @@ func getAddressFamily(prefix netip.Prefix) int { } func hasSeparateRouting() ([]netip.Prefix, error) { - if isLegacy() { + if !nbnet.AdvancedRouting() { return GetRoutesFromTable() } return nil, ErrRoutingIsSeparate diff --git a/client/internal/routemanager/systemops/systemops_unix_test.go b/client/internal/routemanager/systemops/systemops_unix_test.go index a6000d963..d88c1ab6b 100644 --- a/client/internal/routemanager/systemops/systemops_unix_test.go +++ b/client/internal/routemanager/systemops/systemops_unix_test.go @@ -85,6 +85,7 @@ var testCases = []testCase{ } func TestRouting(t *testing.T) { + nbnet.Init() for _, tc := range testCases { // todo resolve test execution on freebsd if runtime.GOOS == "freebsd" { diff --git a/util/grpc/dialer.go b/util/grpc/dialer.go index 83a11c65d..f6d6d2f04 100644 --- a/util/grpc/dialer.go +++ b/util/grpc/dialer.go @@ -40,7 +40,6 @@ func WithCustomDialer() grpc.DialOption { } } - log.Debug("Using nbnet.NewDialer()") conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr) if err != nil { log.Errorf("Failed to dial: %s", err) diff --git a/util/net/env.go b/util/net/env.go index 099da39b7..32425665d 100644 --- a/util/net/env.go +++ b/util/net/env.go @@ -2,6 +2,7 @@ package net import ( "os" + "strconv" log "github.com/sirupsen/logrus" @@ -10,20 +11,24 @@ import ( const ( envDisableCustomRouting = "NB_DISABLE_CUSTOM_ROUTING" - envSkipSocketMark = "NB_SKIP_SOCKET_MARK" ) +// CustomRoutingDisabled returns true if custom routing is disabled. +// This will fall back to the operation mode before the exit node functionality was implemented. +// In particular exclusion routes won't be set up and all dialers and listeners will use net.Dial and net.Listen, respectively. func CustomRoutingDisabled() bool { if netstack.IsEnabled() { return true } - return os.Getenv(envDisableCustomRouting) == "true" -} -func SkipSocketMark() bool { - if skipSocketMark := os.Getenv(envSkipSocketMark); skipSocketMark == "true" { - log.Infof("%s is set to true, skipping SO_MARK", envSkipSocketMark) - return true + var customRoutingDisabled bool + if val := os.Getenv(envDisableCustomRouting); val != "" { + var err error + customRoutingDisabled, err = strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", envDisableCustomRouting, err) + } } - return false + + return customRoutingDisabled } diff --git a/util/net/env_generic.go b/util/net/env_generic.go new file mode 100644 index 000000000..6d142a838 --- /dev/null +++ b/util/net/env_generic.go @@ -0,0 +1,12 @@ +//go:build !linux || android + +package net + +func Init() { + // nothing to do on non-linux +} + +func AdvancedRouting() bool { + // non-linux currently doesn't support advanced routing + return false +} diff --git a/util/net/env_linux.go b/util/net/env_linux.go new file mode 100644 index 000000000..124bf64de --- /dev/null +++ b/util/net/env_linux.go @@ -0,0 +1,119 @@ +//go:build linux && !android + +package net + +import ( + "errors" + "os" + "strconv" + "syscall" + "time" + + log "github.com/sirupsen/logrus" + "github.com/vishvananda/netlink" + + "github.com/netbirdio/netbird/client/iface/netstack" +) + +const ( + // these have the same effect, skip socket env supported for backward compatibility + envSkipSocketMark = "NB_SKIP_SOCKET_MARK" + envUseLegacyRouting = "NB_USE_LEGACY_ROUTING" +) + +var advancedRoutingSupported bool + +func Init() { + advancedRoutingSupported = checkAdvancedRoutingSupport() +} + +func AdvancedRouting() bool { + return advancedRoutingSupported +} + +func checkAdvancedRoutingSupport() bool { + var err error + + var legacyRouting bool + if val := os.Getenv(envUseLegacyRouting); val != "" { + legacyRouting, err = strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", envUseLegacyRouting, err) + } + } + + var skipSocketMark bool + if val := os.Getenv(envSkipSocketMark); val != "" { + skipSocketMark, err = strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", envSkipSocketMark, err) + } + } + + // requested to disable advanced routing + if legacyRouting || skipSocketMark || + // envCustomRoutingDisabled disables the custom dialers. + // There is no point in using advanced routing without those, as they set up fwmarks on the sockets. + CustomRoutingDisabled() || + // netstack mode doesn't need routing at all + netstack.IsEnabled() { + + log.Info("advanced routing has been requested to be disabled") + return false + } + + if !CheckFwmarkSupport() || !CheckRuleOperationsSupport() { + log.Warn("system doesn't support required routing features, falling back to legacy routing") + return false + } + + log.Info("system supports advanced routing") + + return true +} + +func CheckFwmarkSupport() bool { + // temporarily enable advanced routing to check fwmarks are supported + old := advancedRoutingSupported + advancedRoutingSupported = true + defer func() { + advancedRoutingSupported = old + }() + + dialer := NewDialer() + dialer.Timeout = 100 * time.Millisecond + + conn, err := dialer.Dial("udp", "127.0.0.1:9") + if err != nil { + log.Warnf("failed to dial with fwmark: %v", err) + return false + } + if err := conn.Close(); err != nil { + log.Warnf("failed to close connection: %v", err) + + } + + return true +} + +func CheckRuleOperationsSupport() bool { + rule := netlink.NewRule() + // low precedence, semi-random + rule.Priority = 32321 + rule.Table = syscall.RT_TABLE_MAIN + rule.Family = netlink.FAMILY_V4 + + if err := netlink.RuleAdd(rule); err != nil { + if errors.Is(err, syscall.EOPNOTSUPP) { + log.Warn("IP rule operations are not supported") + return false + } + log.Warnf("failed to test rule support: %v", err) + return false + } + + if err := netlink.RuleDel(rule); err != nil { + log.Warnf("failed to delete test rule: %v", err) + } + return true +} diff --git a/util/net/net_linux.go b/util/net/net_linux.go index fc486ebd4..eae483a26 100644 --- a/util/net/net_linux.go +++ b/util/net/net_linux.go @@ -5,13 +5,11 @@ package net import ( "fmt" "syscall" - - log "github.com/sirupsen/logrus" ) // SetSocketMark sets the SO_MARK option on the given socket connection func SetSocketMark(conn syscall.Conn) error { - if isSocketMarkDisabled() { + if !AdvancedRouting() { return nil } @@ -25,7 +23,7 @@ func SetSocketMark(conn syscall.Conn) error { // SetSocketOpt sets the SO_MARK option on the given file descriptor func SetSocketOpt(fd int) error { - if isSocketMarkDisabled() { + if !AdvancedRouting() { return nil } @@ -36,7 +34,7 @@ func setRawSocketMark(conn syscall.RawConn) error { var setErr error err := conn.Control(func(fd uintptr) { - if isSocketMarkDisabled() { + if !AdvancedRouting() { return } setErr = setSocketOptInt(int(fd)) @@ -55,15 +53,3 @@ func setRawSocketMark(conn syscall.RawConn) error { func setSocketOptInt(fd int) error { return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, NetbirdFwmark) } - -func isSocketMarkDisabled() bool { - if CustomRoutingDisabled() { - log.Infof("Custom routing is disabled, skipping SO_MARK") - return true - } - - if SkipSocketMark() { - return true - } - return false -} From b41de7fcd1ed6a38022e9e7d9acdb642e57f50a6 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:10:49 +0100 Subject: [PATCH 12/29] [client] Enable userspace forwarder conditionally (#3309) * Enable userspace forwarder conditionally * Move disable/enable logic --- client/firewall/iptables/manager_linux.go | 8 + client/firewall/manager/firewall.go | 4 + client/firewall/nftables/manager_linux.go | 8 + client/firewall/uspfilter/uspfilter.go | 154 +++++++++++++----- .../uspfilter/uspfilter_filter_test.go | 1 + client/internal/routemanager/manager.go | 12 +- .../routemanager/server_nonandroid.go | 12 +- 7 files changed, 145 insertions(+), 54 deletions(-) diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 679f288e3..929e8a656 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -218,6 +218,14 @@ func (m *Manager) SetLogLevel(log.Level) { // not supported } +func (m *Manager) EnableRouting() error { + return nil +} + +func (m *Manager) DisableRouting() error { + return nil +} + func getConntrackEstablished() []string { return []string{"-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"} } diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index de25ff1f1..d007e20a5 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -101,6 +101,10 @@ type Manager interface { Flush() error SetLogLevel(log.Level) + + EnableRouting() error + + DisableRouting() error } func GenKey(format string, pair RouterPair) string { diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index 4fe52bd53..de68f3291 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -323,6 +323,14 @@ func (m *Manager) SetLogLevel(log.Level) { // not supported } +func (m *Manager) EnableRouting() error { + return nil +} + +func (m *Manager) DisableRouting() error { + return nil +} + // Flush rule/chain/set operations from the buffer // // Method also get all rules after flush and refreshes handle values in the rulesets diff --git a/client/firewall/uspfilter/uspfilter.go b/client/firewall/uspfilter/uspfilter.go index 889e4cbb1..5bb225ccd 100644 --- a/client/firewall/uspfilter/uspfilter.go +++ b/client/firewall/uspfilter/uspfilter.go @@ -74,6 +74,8 @@ type Manager struct { mutex sync.RWMutex + // indicates whether server routes are disabled + disableServerRoutes bool // indicates whether we forward packets not destined for ourselves routingEnabled bool // indicates whether we leave forwarding and filtering to the native firewall @@ -125,15 +127,27 @@ func CreateWithNativeFirewall(iface common.IFaceMapper, nativeFirewall firewall. return mgr, nil } +func parseCreateEnv() (bool, bool) { + var disableConntrack, enableLocalForwarding bool + var err error + if val := os.Getenv(EnvDisableConntrack); val != "" { + disableConntrack, err = strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", EnvDisableConntrack, err) + } + } + if val := os.Getenv(EnvEnableNetstackLocalForwarding); val != "" { + enableLocalForwarding, err = strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", EnvEnableNetstackLocalForwarding, err) + } + } + + return disableConntrack, enableLocalForwarding +} + func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableServerRoutes bool) (*Manager, error) { - disableConntrack, err := strconv.ParseBool(os.Getenv(EnvDisableConntrack)) - if err != nil { - log.Warnf("failed to parse %s: %v", EnvDisableConntrack, err) - } - enableLocalForwarding, err := strconv.ParseBool(os.Getenv(EnvEnableNetstackLocalForwarding)) - if err != nil { - log.Warnf("failed to parse %s: %v", EnvEnableNetstackLocalForwarding, err) - } + disableConntrack, enableLocalForwarding := parseCreateEnv() m := &Manager{ decoders: sync.Pool{ @@ -149,15 +163,16 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe return d }, }, - nativeFirewall: nativeFirewall, - outgoingRules: make(map[string]RuleSet), - incomingRules: make(map[string]RuleSet), - wgIface: iface, - localipmanager: newLocalIPManager(), - routingEnabled: false, - stateful: !disableConntrack, - logger: nblog.NewFromLogrus(log.StandardLogger()), - netstack: netstack.IsEnabled(), + nativeFirewall: nativeFirewall, + outgoingRules: make(map[string]RuleSet), + incomingRules: make(map[string]RuleSet), + wgIface: iface, + localipmanager: newLocalIPManager(), + disableServerRoutes: disableServerRoutes, + routingEnabled: false, + stateful: !disableConntrack, + logger: nblog.NewFromLogrus(log.StandardLogger()), + netstack: netstack.IsEnabled(), // default true for non-netstack, for netstack only if explicitly enabled localForwarding: !netstack.IsEnabled() || enableLocalForwarding, } @@ -166,7 +181,6 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe return nil, fmt.Errorf("update local IPs: %w", err) } - // Only initialize trackers if stateful mode is enabled if disableConntrack { log.Info("conntrack is disabled") } else { @@ -175,7 +189,12 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe m.tcpTracker = conntrack.NewTCPTracker(conntrack.DefaultTCPTimeout, m.logger) } - m.determineRouting(iface, disableServerRoutes) + // netstack needs the forwarder for local traffic + if m.netstack && m.localForwarding { + if err := m.initForwarder(); err != nil { + log.Errorf("failed to initialize forwarder: %v", err) + } + } if err := m.blockInvalidRouted(iface); err != nil { log.Errorf("failed to block invalid routed traffic: %v", err) @@ -213,9 +232,21 @@ func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) error { return nil } -func (m *Manager) determineRouting(iface common.IFaceMapper, disableServerRoutes bool) { - disableUspRouting, _ := strconv.ParseBool(os.Getenv(EnvDisableUserspaceRouting)) - forceUserspaceRouter, _ := strconv.ParseBool(os.Getenv(EnvForceUserspaceRouter)) +func (m *Manager) determineRouting() error { + var disableUspRouting, forceUserspaceRouter bool + var err error + if val := os.Getenv(EnvDisableUserspaceRouting); val != "" { + disableUspRouting, err = strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", EnvDisableUserspaceRouting, err) + } + } + if val := os.Getenv(EnvForceUserspaceRouter); val != "" { + forceUserspaceRouter, err = strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", EnvForceUserspaceRouter, err) + } + } switch { case disableUspRouting: @@ -223,7 +254,7 @@ func (m *Manager) determineRouting(iface common.IFaceMapper, disableServerRoutes m.nativeRouter = false log.Info("userspace routing is disabled") - case disableServerRoutes: + case m.disableServerRoutes: // if server routes are disabled we will let packets pass to the native stack m.routingEnabled = true m.nativeRouter = true @@ -252,32 +283,37 @@ func (m *Manager) determineRouting(iface common.IFaceMapper, disableServerRoutes log.Info("userspace routing enabled by default") } - // netstack needs the forwarder for local traffic - if m.netstack && m.localForwarding || - m.routingEnabled && !m.nativeRouter { - - m.initForwarder(iface) + if m.routingEnabled && !m.nativeRouter { + return m.initForwarder() } + + return nil } // initForwarder initializes the forwarder, it disables routing on errors -func (m *Manager) initForwarder(iface common.IFaceMapper) { - // Only supported in userspace mode as we need to inject packets back into wireguard directly - intf := iface.GetWGDevice() - if intf == nil { - log.Info("forwarding not supported") - m.routingEnabled = false - return +func (m *Manager) initForwarder() error { + if m.forwarder != nil { + return nil } - forwarder, err := forwarder.New(iface, m.logger, m.netstack) - if err != nil { - log.Errorf("failed to create forwarder: %v", err) + // Only supported in userspace mode as we need to inject packets back into wireguard directly + intf := m.wgIface.GetWGDevice() + if intf == nil { m.routingEnabled = false - return + return errors.New("forwarding not supported") + } + + forwarder, err := forwarder.New(m.wgIface, m.logger, m.netstack) + if err != nil { + m.routingEnabled = false + return fmt.Errorf("create forwarder: %w", err) } m.forwarder = forwarder + + log.Debug("forwarder initialized") + + return nil } func (m *Manager) Init(*statemanager.Manager) error { @@ -285,7 +321,7 @@ func (m *Manager) Init(*statemanager.Manager) error { } func (m *Manager) IsServerRouteSupported() bool { - return m.nativeFirewall != nil || m.routingEnabled && m.forwarder != nil + return true } func (m *Manager) AddNatRule(pair firewall.RouterPair) error { @@ -586,7 +622,6 @@ func (m *Manager) dropFilter(packetData []byte) bool { defer m.decoders.Put(d) if !m.isValidPacket(d, packetData) { - m.logger.Trace("Invalid packet structure") return true } @@ -658,11 +693,9 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP net.IP, packetDat return false } - // Get protocol and ports for route ACL check proto := getProtocolFromPacket(d) srcPort, dstPort := getPortsFromPacket(d) - // Check route ACLs if !m.routeACLsPass(srcIP, dstIP, proto, srcPort, dstPort) { m.logger.Trace("Dropping routed packet (ACL denied): src=%s:%d dst=%s:%d proto=%v", srcIP, srcPort, dstIP, dstPort, proto) @@ -704,12 +737,12 @@ func getPortsFromPacket(d *decoder) (srcPort, dstPort uint16) { func (m *Manager) isValidPacket(d *decoder, packetData []byte) bool { if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { - log.Tracef("couldn't decode layer, err: %s", err) + m.logger.Trace("couldn't decode packet, err: %s", err) return false } if len(d.decoded) < 2 { - log.Tracef("not enough levels in network packet") + m.logger.Trace("packet doesn't have network and transport layers") return false } return true @@ -953,3 +986,34 @@ func (m *Manager) SetLogLevel(level log.Level) { m.logger.SetLevel(nblog.Level(level)) } } + +func (m *Manager) EnableRouting() error { + m.mutex.Lock() + defer m.mutex.Unlock() + + return m.determineRouting() +} + +func (m *Manager) DisableRouting() error { + m.mutex.Lock() + defer m.mutex.Unlock() + + if m.forwarder == nil { + return nil + } + + m.routingEnabled = false + m.nativeRouter = false + + // don't stop forwarder if in use by netstack + if m.netstack && m.localForwarding { + return nil + } + + m.forwarder.Stop() + m.forwarder = nil + + log.Debug("forwarder stopped") + + return nil +} diff --git a/client/firewall/uspfilter/uspfilter_filter_test.go b/client/firewall/uspfilter/uspfilter_filter_test.go index d7aebb1aa..9a1456d00 100644 --- a/client/firewall/uspfilter/uspfilter_filter_test.go +++ b/client/firewall/uspfilter/uspfilter_filter_test.go @@ -303,6 +303,7 @@ func setupRoutedManager(tb testing.TB, network string) *Manager { } manager, err := Create(ifaceMock, false) + require.NoError(tb, manager.EnableRouting()) require.NoError(tb, err) require.NotNil(tb, manager) require.True(tb, manager.routingEnabled) diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 9c7f1f6fa..52de0948b 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -286,15 +286,15 @@ func (m *DefaultManager) UpdateRoutes(updateSerial uint64, newRoutes []*route.Ro m.updateClientNetworks(updateSerial, filteredClientRoutes) m.notifier.OnNewRoutes(filteredClientRoutes) } + m.clientRoutes = newClientRoutesIDMap - if m.serverRouter != nil { - err := m.serverRouter.updateRoutes(newServerRoutesMap) - if err != nil { - return err - } + if m.serverRouter == nil { + return nil } - m.clientRoutes = newClientRoutesIDMap + if err := m.serverRouter.updateRoutes(newServerRoutesMap); err != nil { + return fmt.Errorf("update routes: %w", err) + } return nil } diff --git a/client/internal/routemanager/server_nonandroid.go b/client/internal/routemanager/server_nonandroid.go index b60cb318e..4690e3f0e 100644 --- a/client/internal/routemanager/server_nonandroid.go +++ b/client/internal/routemanager/server_nonandroid.go @@ -71,9 +71,15 @@ func (m *serverRouter) updateRoutes(routesMap map[route.ID]*route.Route) error { } if len(m.routes) > 0 { - err := systemops.EnableIPForwarding() - if err != nil { - return err + if err := systemops.EnableIPForwarding(); err != nil { + return fmt.Errorf("enable ip forwarding: %w", err) + } + if err := m.firewall.EnableRouting(); err != nil { + return fmt.Errorf("enable routing: %w", err) + } + } else { + if err := m.firewall.DisableRouting(); err != nil { + return fmt.Errorf("disable routing: %w", err) } } From d48edb983787dd1221d83f17aea9972daba4437c Mon Sep 17 00:00:00 2001 From: Pedro Maia Costa <550684+pnmcosta@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:16:51 +0000 Subject: [PATCH 13/29] fix integration tests (#3311) --- management/client/rest/accounts_test.go | 29 +++++--- management/client/rest/client_test.go | 14 ++-- management/client/rest/dns_test.go | 41 ++++++----- management/client/rest/events_test.go | 17 +++-- management/client/rest/geo_test.go | 21 ++++-- management/client/rest/groups_test.go | 33 +++++---- management/client/rest/networks_test.go | 77 +++++++++++--------- management/client/rest/peers_test.go | 33 +++++---- management/client/rest/policies_test.go | 33 +++++---- management/client/rest/posturechecks_test.go | 33 +++++---- management/client/rest/routes_test.go | 33 +++++---- management/client/rest/setupkeys_test.go | 33 +++++---- management/client/rest/tokens_test.go | 29 +++++--- management/client/rest/users_test.go | 33 +++++---- 14 files changed, 264 insertions(+), 195 deletions(-) diff --git a/management/client/rest/accounts_test.go b/management/client/rest/accounts_test.go index 3c1925fbc..621228261 100644 --- a/management/client/rest/accounts_test.go +++ b/management/client/rest/accounts_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -7,10 +10,12 @@ import ( "net/http" "testing" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -33,7 +38,7 @@ var ( ) func TestAccounts_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/accounts", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.Account{testAccount}) _, err := w.Write(retBytes) @@ -47,7 +52,7 @@ func TestAccounts_List_200(t *testing.T) { } func TestAccounts_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/accounts", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -62,7 +67,7 @@ func TestAccounts_List_Err(t *testing.T) { } func TestAccounts_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/accounts/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -87,7 +92,7 @@ func TestAccounts_Update_200(t *testing.T) { } func TestAccounts_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/accounts/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -106,7 +111,7 @@ func TestAccounts_Update_Err(t *testing.T) { } func TestAccounts_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/accounts/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -117,7 +122,7 @@ func TestAccounts_Delete_200(t *testing.T) { } func TestAccounts_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/accounts/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -131,7 +136,7 @@ func TestAccounts_Delete_Err(t *testing.T) { } func TestAccounts_Integration_List(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { accounts, err := c.Accounts.List(context.Background()) require.NoError(t, err) assert.Len(t, accounts, 1) @@ -141,7 +146,7 @@ func TestAccounts_Integration_List(t *testing.T) { } func TestAccounts_Integration_Update(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { accounts, err := c.Accounts.List(context.Background()) require.NoError(t, err) assert.Len(t, accounts, 1) @@ -157,7 +162,7 @@ func TestAccounts_Integration_Update(t *testing.T) { // Account deletion on MySQL and PostgreSQL databases causes unknown errors // func TestAccounts_Integration_Delete(t *testing.T) { -// withBlackBoxServer(t, func(c *Client) { +// withBlackBoxServer(t, func(c *rest.Client) { // accounts, err := c.Accounts.List(context.Background()) // require.NoError(t, err) // assert.Len(t, accounts, 1) diff --git a/management/client/rest/client_test.go b/management/client/rest/client_test.go index a42b12fa3..70e6c73e1 100644 --- a/management/client/rest/client_test.go +++ b/management/client/rest/client_test.go @@ -1,18 +1,22 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "net/http" "net/http/httptest" "testing" + "github.com/netbirdio/netbird/management/client/rest" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" ) -func withMockClient(callback func(*Client, *http.ServeMux)) { +func withMockClient(callback func(*rest.Client, *http.ServeMux)) { mux := &http.ServeMux{} server := httptest.NewServer(mux) defer server.Close() - c := New(server.URL, "ABC") + c := rest.New(server.URL, "ABC") callback(c, mux) } @@ -20,11 +24,11 @@ func ptr[T any, PT *T](x T) PT { return &x } -func withBlackBoxServer(t *testing.T, callback func(*Client)) { +func withBlackBoxServer(t *testing.T, callback func(*rest.Client)) { t.Helper() handler, _, _ := testing_tools.BuildApiBlackBoxWithDBState(t, "../../server/testdata/store.sql", nil, false) server := httptest.NewServer(handler) defer server.Close() - c := New(server.URL, "nbp_apTmlmUXHSC4PKmHwtIZNaGr8eqcVI2gMURp") + c := rest.New(server.URL, "nbp_apTmlmUXHSC4PKmHwtIZNaGr8eqcVI2gMURp") callback(c) } diff --git a/management/client/rest/dns_test.go b/management/client/rest/dns_test.go index d2c00549c..0d57d63d7 100644 --- a/management/client/rest/dns_test.go +++ b/management/client/rest/dns_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -7,10 +10,12 @@ import ( "net/http" "testing" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -25,7 +30,7 @@ var ( ) func TestDNSNameserverGroup_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/nameservers", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.NameserverGroup{testNameserverGroup}) _, err := w.Write(retBytes) @@ -39,7 +44,7 @@ func TestDNSNameserverGroup_List_200(t *testing.T) { } func TestDNSNameserverGroup_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/nameservers", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -54,7 +59,7 @@ func TestDNSNameserverGroup_List_Err(t *testing.T) { } func TestDNSNameserverGroup_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/nameservers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testNameserverGroup) _, err := w.Write(retBytes) @@ -67,7 +72,7 @@ func TestDNSNameserverGroup_Get_200(t *testing.T) { } func TestDNSNameserverGroup_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/nameservers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -82,7 +87,7 @@ func TestDNSNameserverGroup_Get_Err(t *testing.T) { } func TestDNSNameserverGroup_Create_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/nameservers", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -104,7 +109,7 @@ func TestDNSNameserverGroup_Create_200(t *testing.T) { } func TestDNSNameserverGroup_Create_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/nameservers", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -121,7 +126,7 @@ func TestDNSNameserverGroup_Create_Err(t *testing.T) { } func TestDNSNameserverGroup_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/nameservers/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -143,7 +148,7 @@ func TestDNSNameserverGroup_Update_200(t *testing.T) { } func TestDNSNameserverGroup_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/nameservers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -160,7 +165,7 @@ func TestDNSNameserverGroup_Update_Err(t *testing.T) { } func TestDNSNameserverGroup_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/nameservers/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -171,7 +176,7 @@ func TestDNSNameserverGroup_Delete_200(t *testing.T) { } func TestDNSNameserverGroup_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/nameservers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -185,7 +190,7 @@ func TestDNSNameserverGroup_Delete_Err(t *testing.T) { } func TestDNSSettings_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/settings", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testSettings) _, err := w.Write(retBytes) @@ -198,7 +203,7 @@ func TestDNSSettings_Get_200(t *testing.T) { } func TestDNSSettings_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/settings", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -213,7 +218,7 @@ func TestDNSSettings_Get_Err(t *testing.T) { } func TestDNSSettings_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/settings", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -235,7 +240,7 @@ func TestDNSSettings_Update_200(t *testing.T) { } func TestDNSSettings_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/dns/settings", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -267,7 +272,7 @@ func TestDNS_Integration(t *testing.T) { Primary: true, SearchDomainsEnabled: false, } - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { // Create nsGroup, err := c.DNS.CreateNameserverGroup(context.Background(), nsGroupReq) require.NoError(t, err) diff --git a/management/client/rest/events_test.go b/management/client/rest/events_test.go index 515c227e6..2589193a2 100644 --- a/management/client/rest/events_test.go +++ b/management/client/rest/events_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -6,10 +9,12 @@ import ( "net/http" "testing" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -20,7 +25,7 @@ var ( ) func TestEvents_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.Event{testEvent}) _, err := w.Write(retBytes) @@ -34,7 +39,7 @@ func TestEvents_List_200(t *testing.T) { } func TestEvents_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -49,7 +54,7 @@ func TestEvents_List_Err(t *testing.T) { } func TestEvents_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { // Do something that would trigger any event _, err := c.SetupKeys.Create(context.Background(), api.CreateSetupKeyRequest{ Ephemeral: ptr(true), diff --git a/management/client/rest/geo_test.go b/management/client/rest/geo_test.go index dd42ecba8..d24405094 100644 --- a/management/client/rest/geo_test.go +++ b/management/client/rest/geo_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -6,10 +9,12 @@ import ( "net/http" "testing" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -25,7 +30,7 @@ var ( ) func TestGeo_ListCountries_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/locations/countries", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.Country{testCountry}) _, err := w.Write(retBytes) @@ -39,7 +44,7 @@ func TestGeo_ListCountries_200(t *testing.T) { } func TestGeo_ListCountries_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/locations/countries", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -54,7 +59,7 @@ func TestGeo_ListCountries_Err(t *testing.T) { } func TestGeo_ListCountryCities_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/locations/countries/Test/cities", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.City{testCity}) _, err := w.Write(retBytes) @@ -68,7 +73,7 @@ func TestGeo_ListCountryCities_200(t *testing.T) { } func TestGeo_ListCountryCities_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/locations/countries/Test/cities", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -84,7 +89,7 @@ func TestGeo_ListCountryCities_Err(t *testing.T) { func TestGeo_Integration(t *testing.T) { // Blackbox is initialized with empty GeoLocations - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { countries, err := c.GeoLocation.ListCountries(context.Background()) require.NoError(t, err) assert.Empty(t, countries) diff --git a/management/client/rest/groups_test.go b/management/client/rest/groups_test.go index ac534437d..d6a5410e0 100644 --- a/management/client/rest/groups_test.go +++ b/management/client/rest/groups_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -7,10 +10,12 @@ import ( "net/http" "testing" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -22,7 +27,7 @@ var ( ) func TestGroups_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.Group{testGroup}) _, err := w.Write(retBytes) @@ -36,7 +41,7 @@ func TestGroups_List_200(t *testing.T) { } func TestGroups_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -51,7 +56,7 @@ func TestGroups_List_Err(t *testing.T) { } func TestGroups_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/groups/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testGroup) _, err := w.Write(retBytes) @@ -64,7 +69,7 @@ func TestGroups_Get_200(t *testing.T) { } func TestGroups_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/groups/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -79,7 +84,7 @@ func TestGroups_Get_Err(t *testing.T) { } func TestGroups_Create_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -101,7 +106,7 @@ func TestGroups_Create_200(t *testing.T) { } func TestGroups_Create_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -118,7 +123,7 @@ func TestGroups_Create_Err(t *testing.T) { } func TestGroups_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/groups/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -140,7 +145,7 @@ func TestGroups_Update_200(t *testing.T) { } func TestGroups_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/groups/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -157,7 +162,7 @@ func TestGroups_Update_Err(t *testing.T) { } func TestGroups_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/groups/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -168,7 +173,7 @@ func TestGroups_Delete_200(t *testing.T) { } func TestGroups_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/groups/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -182,7 +187,7 @@ func TestGroups_Delete_Err(t *testing.T) { } func TestGroups_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { groups, err := c.Groups.List(context.Background()) require.NoError(t, err) assert.Len(t, groups, 1) diff --git a/management/client/rest/networks_test.go b/management/client/rest/networks_test.go index 934c55380..0772d7540 100644 --- a/management/client/rest/networks_test.go +++ b/management/client/rest/networks_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -7,10 +10,12 @@ import ( "net/http" "testing" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -30,7 +35,7 @@ var ( ) func TestNetworks_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.Network{testNetwork}) _, err := w.Write(retBytes) @@ -44,7 +49,7 @@ func TestNetworks_List_200(t *testing.T) { } func TestNetworks_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -59,7 +64,7 @@ func TestNetworks_List_Err(t *testing.T) { } func TestNetworks_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testNetwork) _, err := w.Write(retBytes) @@ -72,7 +77,7 @@ func TestNetworks_Get_200(t *testing.T) { } func TestNetworks_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -87,7 +92,7 @@ func TestNetworks_Get_Err(t *testing.T) { } func TestNetworks_Create_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -109,7 +114,7 @@ func TestNetworks_Create_200(t *testing.T) { } func TestNetworks_Create_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -126,7 +131,7 @@ func TestNetworks_Create_Err(t *testing.T) { } func TestNetworks_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -148,7 +153,7 @@ func TestNetworks_Update_200(t *testing.T) { } func TestNetworks_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -165,7 +170,7 @@ func TestNetworks_Update_Err(t *testing.T) { } func TestNetworks_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -176,7 +181,7 @@ func TestNetworks_Delete_200(t *testing.T) { } func TestNetworks_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -190,7 +195,7 @@ func TestNetworks_Delete_Err(t *testing.T) { } func TestNetworks_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { network, err := c.Networks.Create(context.Background(), api.NetworkRequest{ Description: ptr("TestNetwork"), Name: "Test", @@ -216,7 +221,7 @@ func TestNetworks_Integration(t *testing.T) { } func TestNetworkResources_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/resources", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.NetworkResource{testNetworkResource}) _, err := w.Write(retBytes) @@ -230,7 +235,7 @@ func TestNetworkResources_List_200(t *testing.T) { } func TestNetworkResources_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/resources", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -245,7 +250,7 @@ func TestNetworkResources_List_Err(t *testing.T) { } func TestNetworkResources_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/resources/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testNetworkResource) _, err := w.Write(retBytes) @@ -258,7 +263,7 @@ func TestNetworkResources_Get_200(t *testing.T) { } func TestNetworkResources_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/resources/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -273,7 +278,7 @@ func TestNetworkResources_Get_Err(t *testing.T) { } func TestNetworkResources_Create_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/resources", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -295,7 +300,7 @@ func TestNetworkResources_Create_200(t *testing.T) { } func TestNetworkResources_Create_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/resources", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -312,7 +317,7 @@ func TestNetworkResources_Create_Err(t *testing.T) { } func TestNetworkResources_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/resources/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -334,7 +339,7 @@ func TestNetworkResources_Update_200(t *testing.T) { } func TestNetworkResources_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/resources/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -351,7 +356,7 @@ func TestNetworkResources_Update_Err(t *testing.T) { } func TestNetworkResources_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/resources/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -362,7 +367,7 @@ func TestNetworkResources_Delete_200(t *testing.T) { } func TestNetworkResources_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/resources/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -376,7 +381,7 @@ func TestNetworkResources_Delete_Err(t *testing.T) { } func TestNetworkResources_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { _, err := c.Networks.Resources("TestNetwork").Create(context.Background(), api.NetworkResourceRequest{ Address: "test.com", Description: ptr("Description"), @@ -403,7 +408,7 @@ func TestNetworkResources_Integration(t *testing.T) { } func TestNetworkRouters_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/routers", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.NetworkRouter{testNetworkRouter}) _, err := w.Write(retBytes) @@ -417,7 +422,7 @@ func TestNetworkRouters_List_200(t *testing.T) { } func TestNetworkRouters_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/routers", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -432,7 +437,7 @@ func TestNetworkRouters_List_Err(t *testing.T) { } func TestNetworkRouters_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/routers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testNetworkRouter) _, err := w.Write(retBytes) @@ -445,7 +450,7 @@ func TestNetworkRouters_Get_200(t *testing.T) { } func TestNetworkRouters_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/routers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -460,7 +465,7 @@ func TestNetworkRouters_Get_Err(t *testing.T) { } func TestNetworkRouters_Create_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/routers", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -482,7 +487,7 @@ func TestNetworkRouters_Create_200(t *testing.T) { } func TestNetworkRouters_Create_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/routers", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -499,7 +504,7 @@ func TestNetworkRouters_Create_Err(t *testing.T) { } func TestNetworkRouters_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/routers/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -521,7 +526,7 @@ func TestNetworkRouters_Update_200(t *testing.T) { } func TestNetworkRouters_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/routers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -538,7 +543,7 @@ func TestNetworkRouters_Update_Err(t *testing.T) { } func TestNetworkRouters_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/routers/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -549,7 +554,7 @@ func TestNetworkRouters_Delete_200(t *testing.T) { } func TestNetworkRouters_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/networks/Meow/routers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -563,7 +568,7 @@ func TestNetworkRouters_Delete_Err(t *testing.T) { } func TestNetworkRouters_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { _, err := c.Networks.Routers("TestNetwork").Create(context.Background(), api.NetworkRouterRequest{ Enabled: false, Masquerade: false, diff --git a/management/client/rest/peers_test.go b/management/client/rest/peers_test.go index 216ee990c..4c5cd1e60 100644 --- a/management/client/rest/peers_test.go +++ b/management/client/rest/peers_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -7,10 +10,12 @@ import ( "net/http" "testing" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -24,7 +29,7 @@ var ( ) func TestPeers_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.Peer{testPeer}) _, err := w.Write(retBytes) @@ -38,7 +43,7 @@ func TestPeers_List_200(t *testing.T) { } func TestPeers_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -53,7 +58,7 @@ func TestPeers_List_Err(t *testing.T) { } func TestPeers_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testPeer) _, err := w.Write(retBytes) @@ -66,7 +71,7 @@ func TestPeers_Get_200(t *testing.T) { } func TestPeers_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -81,7 +86,7 @@ func TestPeers_Get_Err(t *testing.T) { } func TestPeers_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -103,7 +108,7 @@ func TestPeers_Update_200(t *testing.T) { } func TestPeers_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -120,7 +125,7 @@ func TestPeers_Update_Err(t *testing.T) { } func TestPeers_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -131,7 +136,7 @@ func TestPeers_Delete_200(t *testing.T) { } func TestPeers_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -145,7 +150,7 @@ func TestPeers_Delete_Err(t *testing.T) { } func TestPeers_ListAccessiblePeers_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers/Test/accessible-peers", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.Peer{testPeer}) _, err := w.Write(retBytes) @@ -159,7 +164,7 @@ func TestPeers_ListAccessiblePeers_200(t *testing.T) { } func TestPeers_ListAccessiblePeers_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers/Test/accessible-peers", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -174,7 +179,7 @@ func TestPeers_ListAccessiblePeers_Err(t *testing.T) { } func TestPeers_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { peers, err := c.Peers.List(context.Background()) require.NoError(t, err) require.NotEmpty(t, peers) diff --git a/management/client/rest/policies_test.go b/management/client/rest/policies_test.go index f7fc6ff10..5792048df 100644 --- a/management/client/rest/policies_test.go +++ b/management/client/rest/policies_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -7,10 +10,12 @@ import ( "net/http" "testing" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -22,7 +27,7 @@ var ( ) func TestPolicies_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/policies", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.Policy{testPolicy}) _, err := w.Write(retBytes) @@ -36,7 +41,7 @@ func TestPolicies_List_200(t *testing.T) { } func TestPolicies_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/policies", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -51,7 +56,7 @@ func TestPolicies_List_Err(t *testing.T) { } func TestPolicies_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/policies/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testPolicy) _, err := w.Write(retBytes) @@ -64,7 +69,7 @@ func TestPolicies_Get_200(t *testing.T) { } func TestPolicies_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/policies/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -79,7 +84,7 @@ func TestPolicies_Get_Err(t *testing.T) { } func TestPolicies_Create_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/policies", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -101,7 +106,7 @@ func TestPolicies_Create_200(t *testing.T) { } func TestPolicies_Create_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/policies", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -118,7 +123,7 @@ func TestPolicies_Create_Err(t *testing.T) { } func TestPolicies_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/policies/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -140,7 +145,7 @@ func TestPolicies_Update_200(t *testing.T) { } func TestPolicies_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/policies/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -157,7 +162,7 @@ func TestPolicies_Update_Err(t *testing.T) { } func TestPolicies_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/policies/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -168,7 +173,7 @@ func TestPolicies_Delete_200(t *testing.T) { } func TestPolicies_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/policies/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -182,7 +187,7 @@ func TestPolicies_Delete_Err(t *testing.T) { } func TestPolicies_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { policies, err := c.Policies.List(context.Background()) require.NoError(t, err) require.NotEmpty(t, policies) diff --git a/management/client/rest/posturechecks_test.go b/management/client/rest/posturechecks_test.go index 6fefc0140..a891d6ac9 100644 --- a/management/client/rest/posturechecks_test.go +++ b/management/client/rest/posturechecks_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -7,10 +10,12 @@ import ( "net/http" "testing" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -21,7 +26,7 @@ var ( ) func TestPostureChecks_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/posture-checks", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.PostureCheck{testPostureCheck}) _, err := w.Write(retBytes) @@ -35,7 +40,7 @@ func TestPostureChecks_List_200(t *testing.T) { } func TestPostureChecks_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/posture-checks", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -50,7 +55,7 @@ func TestPostureChecks_List_Err(t *testing.T) { } func TestPostureChecks_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/posture-checks/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testPostureCheck) _, err := w.Write(retBytes) @@ -63,7 +68,7 @@ func TestPostureChecks_Get_200(t *testing.T) { } func TestPostureChecks_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/posture-checks/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -78,7 +83,7 @@ func TestPostureChecks_Get_Err(t *testing.T) { } func TestPostureChecks_Create_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/posture-checks", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -100,7 +105,7 @@ func TestPostureChecks_Create_200(t *testing.T) { } func TestPostureChecks_Create_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/posture-checks", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -117,7 +122,7 @@ func TestPostureChecks_Create_Err(t *testing.T) { } func TestPostureChecks_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/posture-checks/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -139,7 +144,7 @@ func TestPostureChecks_Update_200(t *testing.T) { } func TestPostureChecks_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/posture-checks/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -156,7 +161,7 @@ func TestPostureChecks_Update_Err(t *testing.T) { } func TestPostureChecks_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/posture-checks/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -167,7 +172,7 @@ func TestPostureChecks_Delete_200(t *testing.T) { } func TestPostureChecks_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/posture-checks/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -181,7 +186,7 @@ func TestPostureChecks_Delete_Err(t *testing.T) { } func TestPostureChecks_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { check, err := c.PostureChecks.Create(context.Background(), api.PostureCheckUpdate{ Name: "Test", Description: "Testing", diff --git a/management/client/rest/routes_test.go b/management/client/rest/routes_test.go index 123bd41d4..1c698a7fb 100644 --- a/management/client/rest/routes_test.go +++ b/management/client/rest/routes_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -7,10 +10,12 @@ import ( "net/http" "testing" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -21,7 +26,7 @@ var ( ) func TestRoutes_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/routes", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.Route{testRoute}) _, err := w.Write(retBytes) @@ -35,7 +40,7 @@ func TestRoutes_List_200(t *testing.T) { } func TestRoutes_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/routes", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -50,7 +55,7 @@ func TestRoutes_List_Err(t *testing.T) { } func TestRoutes_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/routes/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testRoute) _, err := w.Write(retBytes) @@ -63,7 +68,7 @@ func TestRoutes_Get_200(t *testing.T) { } func TestRoutes_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/routes/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -78,7 +83,7 @@ func TestRoutes_Get_Err(t *testing.T) { } func TestRoutes_Create_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/routes", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -100,7 +105,7 @@ func TestRoutes_Create_200(t *testing.T) { } func TestRoutes_Create_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/routes", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -117,7 +122,7 @@ func TestRoutes_Create_Err(t *testing.T) { } func TestRoutes_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/routes/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -139,7 +144,7 @@ func TestRoutes_Update_200(t *testing.T) { } func TestRoutes_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/routes/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -156,7 +161,7 @@ func TestRoutes_Update_Err(t *testing.T) { } func TestRoutes_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/routes/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -167,7 +172,7 @@ func TestRoutes_Delete_200(t *testing.T) { } func TestRoutes_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/routes/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -181,7 +186,7 @@ func TestRoutes_Delete_Err(t *testing.T) { } func TestRoutes_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { route, err := c.Routes.Create(context.Background(), api.RouteRequest{ Description: "Meow", Enabled: false, diff --git a/management/client/rest/setupkeys_test.go b/management/client/rest/setupkeys_test.go index 82c3d1fc8..8edce8428 100644 --- a/management/client/rest/setupkeys_test.go +++ b/management/client/rest/setupkeys_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -7,10 +10,12 @@ import ( "net/http" "testing" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -31,7 +36,7 @@ var ( ) func TestSetupKeys_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/setup-keys", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.SetupKey{testSetupKey}) _, err := w.Write(retBytes) @@ -45,7 +50,7 @@ func TestSetupKeys_List_200(t *testing.T) { } func TestSetupKeys_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/setup-keys", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -60,7 +65,7 @@ func TestSetupKeys_List_Err(t *testing.T) { } func TestSetupKeys_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/setup-keys/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testSetupKey) _, err := w.Write(retBytes) @@ -73,7 +78,7 @@ func TestSetupKeys_Get_200(t *testing.T) { } func TestSetupKeys_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/setup-keys/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -88,7 +93,7 @@ func TestSetupKeys_Get_Err(t *testing.T) { } func TestSetupKeys_Create_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/setup-keys", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -110,7 +115,7 @@ func TestSetupKeys_Create_200(t *testing.T) { } func TestSetupKeys_Create_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/setup-keys", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -127,7 +132,7 @@ func TestSetupKeys_Create_Err(t *testing.T) { } func TestSetupKeys_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/setup-keys/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -149,7 +154,7 @@ func TestSetupKeys_Update_200(t *testing.T) { } func TestSetupKeys_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/setup-keys/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -166,7 +171,7 @@ func TestSetupKeys_Update_Err(t *testing.T) { } func TestSetupKeys_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/setup-keys/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -177,7 +182,7 @@ func TestSetupKeys_Delete_200(t *testing.T) { } func TestSetupKeys_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/setup-keys/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -191,7 +196,7 @@ func TestSetupKeys_Delete_Err(t *testing.T) { } func TestSetupKeys_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { group, err := c.Groups.Create(context.Background(), api.GroupRequest{ Name: "Test", }) diff --git a/management/client/rest/tokens_test.go b/management/client/rest/tokens_test.go index 478fae93e..eea55d22f 100644 --- a/management/client/rest/tokens_test.go +++ b/management/client/rest/tokens_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -8,10 +11,12 @@ import ( "testing" "time" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -31,7 +36,7 @@ var ( ) func TestTokens_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/meow/tokens", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.PersonalAccessToken{testToken}) _, err := w.Write(retBytes) @@ -45,7 +50,7 @@ func TestTokens_List_200(t *testing.T) { } func TestTokens_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/meow/tokens", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -60,7 +65,7 @@ func TestTokens_List_Err(t *testing.T) { } func TestTokens_Get_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/meow/tokens/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(testToken) _, err := w.Write(retBytes) @@ -73,7 +78,7 @@ func TestTokens_Get_200(t *testing.T) { } func TestTokens_Get_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/meow/tokens/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -88,7 +93,7 @@ func TestTokens_Get_Err(t *testing.T) { } func TestTokens_Create_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/meow/tokens", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -110,7 +115,7 @@ func TestTokens_Create_200(t *testing.T) { } func TestTokens_Create_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/meow/tokens", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -127,7 +132,7 @@ func TestTokens_Create_Err(t *testing.T) { } func TestTokens_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/meow/tokens/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -138,7 +143,7 @@ func TestTokens_Delete_200(t *testing.T) { } func TestTokens_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/meow/tokens/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -152,7 +157,7 @@ func TestTokens_Delete_Err(t *testing.T) { } func TestTokens_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { tokenClear, err := c.Tokens.Create(context.Background(), "a23efe53-63fb-11ec-90d6-0242ac120003", api.PersonalAccessTokenRequest{ Name: "Test", ExpiresIn: 365, diff --git a/management/client/rest/users_test.go b/management/client/rest/users_test.go index aaec3bf42..2ff8a0327 100644 --- a/management/client/rest/users_test.go +++ b/management/client/rest/users_test.go @@ -1,4 +1,7 @@ -package rest +//go:build integration +// +build integration + +package rest_test import ( "context" @@ -8,10 +11,12 @@ import ( "testing" "time" - "github.com/netbirdio/netbird/management/server/http/api" - "github.com/netbirdio/netbird/management/server/http/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" ) var ( @@ -34,7 +39,7 @@ var ( ) func TestUsers_List_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal([]api.User{testUser}) _, err := w.Write(retBytes) @@ -48,7 +53,7 @@ func TestUsers_List_200(t *testing.T) { } func TestUsers_List_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -63,7 +68,7 @@ func TestUsers_List_Err(t *testing.T) { } func TestUsers_Create_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -85,7 +90,7 @@ func TestUsers_Create_200(t *testing.T) { } func TestUsers_Create_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -102,7 +107,7 @@ func TestUsers_Create_Err(t *testing.T) { } func TestUsers_Update_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) reqBytes, err := io.ReadAll(r.Body) @@ -125,7 +130,7 @@ func TestUsers_Update_200(t *testing.T) { } func TestUsers_Update_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) w.WriteHeader(400) @@ -142,7 +147,7 @@ func TestUsers_Update_Err(t *testing.T) { } func TestUsers_Delete_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/Test", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) w.WriteHeader(200) @@ -153,7 +158,7 @@ func TestUsers_Delete_200(t *testing.T) { } func TestUsers_Delete_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/Test", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -167,7 +172,7 @@ func TestUsers_Delete_Err(t *testing.T) { } func TestUsers_ResendInvitation_200(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/Test/invite", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) w.WriteHeader(200) @@ -178,7 +183,7 @@ func TestUsers_ResendInvitation_200(t *testing.T) { } func TestUsers_ResendInvitation_Err(t *testing.T) { - withMockClient(func(c *Client, mux *http.ServeMux) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/users/Test/invite", func(w http.ResponseWriter, r *http.Request) { retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) w.WriteHeader(404) @@ -192,7 +197,7 @@ func TestUsers_ResendInvitation_Err(t *testing.T) { } func TestUsers_Integration(t *testing.T) { - withBlackBoxServer(t, func(c *Client) { + withBlackBoxServer(t, func(c *rest.Client) { user, err := c.Users.Create(context.Background(), api.UserCreateRequest{ AutoGroups: []string{}, Email: ptr("test@example.com"), From a930c2aecf415bba1233e7b7aaa77f0d39ee6709 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 13 Feb 2025 15:48:10 +0100 Subject: [PATCH 14/29] Fix priority handling (#3313) --- client/internal/peer/conn.go | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 7caafa53d..8bbea6a2b 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -2,6 +2,7 @@ package peer import ( "context" + "fmt" "math/rand" "net" "os" @@ -28,9 +29,25 @@ import ( type ConnPriority int +func (cp ConnPriority) String() string { + switch cp { + case connPriorityNone: + return "None" + case connPriorityRelay: + return "PriorityRelay" + case connPriorityICETurn: + return "PriorityICETurn" + case connPriorityICEP2P: + return "PriorityICEP2P" + default: + return fmt.Sprintf("ConnPriority(%d)", cp) + } +} + const ( defaultWgKeepAlive = 25 * time.Second + connPriorityNone ConnPriority = 0 connPriorityRelay ConnPriority = 1 connPriorityICETurn ConnPriority = 2 connPriorityICEP2P ConnPriority = 3 @@ -299,9 +316,10 @@ func (conn *Conn) onICEConnectionIsReady(priority ConnPriority, iceConnInfo ICEC return } - conn.log.Debugf("ICE connection is ready") - + // this never should happen, because Relay is the lower priority and ICE always close the deprecated connection before upgrade + // todo consider to remove this check if conn.currentConnPriority > priority { + conn.log.Infof("current connection priority (%s) is higher than the new one (%s), do not upgrade connection", conn.currentConnPriority, priority) conn.statusICE.Set(StatusConnected) conn.updateIceState(iceConnInfo) return @@ -357,7 +375,6 @@ func (conn *Conn) onICEConnectionIsReady(priority ConnPriority, iceConnInfo ICEC conn.doOnConnected(iceConnInfo.RosenpassPubKey, iceConnInfo.RosenpassAddr) } -// todo review to make sense to handle connecting and disconnected status also? func (conn *Conn) onICEStateDisconnected() { conn.mu.Lock() defer conn.mu.Unlock() @@ -376,7 +393,7 @@ func (conn *Conn) onICEStateDisconnected() { // switch back to relay connection if conn.isReadyToUpgrade() { - conn.log.Debugf("ICE disconnected, set Relay to active connection") + conn.log.Infof("ICE disconnected, set Relay to active connection") conn.wgProxyRelay.Work() if err := conn.configureWGEndpoint(conn.wgProxyRelay.EndpointAddr()); err != nil { @@ -384,6 +401,9 @@ func (conn *Conn) onICEStateDisconnected() { } conn.workerRelay.EnableWgWatcher(conn.ctx) conn.currentConnPriority = connPriorityRelay + } else { + conn.log.Infof("ICE disconnected, do not switch to Relay. Reset priority to: %s", connPriorityNone.String()) + conn.currentConnPriority = connPriorityNone } changed := conn.statusICE.Get() != StatusDisconnected @@ -427,7 +447,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { conn.log.Infof("created new wgProxy for relay connection: %s", wgProxy.EndpointAddr().String()) if conn.iceP2PIsActive() { - conn.log.Debugf("do not switch to relay because current priority is: %v", conn.currentConnPriority) + conn.log.Debugf("do not switch to relay because current priority is: %s", conn.currentConnPriority.String()) conn.setRelayedProxy(wgProxy) conn.statusRelay.Set(StatusConnected) conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey) From c4a6dafd272ca29364d5da342e333cead23c7888 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 13 Feb 2025 18:17:18 +0100 Subject: [PATCH 15/29] [client] Use GPO DNS Policy Config to configure DNS if present (#3319) --- client/internal/dns/host_windows.go | 250 +++++++++++------- .../internal/dns/unclean_shutdown_windows.go | 7 +- 2 files changed, 155 insertions(+), 102 deletions(-) diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index 7ecca8a41..0cd078472 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -1,35 +1,51 @@ package dns import ( + "errors" "fmt" "io" "strings" + "syscall" + "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" "golang.org/x/sys/windows/registry" + nberrors "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/internal/statemanager" ) +var ( + userenv = syscall.NewLazyDLL("userenv.dll") + + // https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-refreshpolicyex + refreshPolicyExFn = userenv.NewProc("RefreshPolicyEx") +) + const ( - dnsPolicyConfigMatchPath = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig\NetBird-Match` + dnsPolicyConfigMatchPath = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig\NetBird-Match` + gpoDnsPolicyRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient` + gpoDnsPolicyConfigMatchPath = gpoDnsPolicyRoot + `\DnsPolicyConfig\NetBird-Match` + dnsPolicyConfigVersionKey = "Version" dnsPolicyConfigVersionValue = 2 dnsPolicyConfigNameKey = "Name" dnsPolicyConfigGenericDNSServersKey = "GenericDNSServers" dnsPolicyConfigConfigOptionsKey = "ConfigOptions" dnsPolicyConfigConfigOptionsValue = 0x8 -) -const ( interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` interfaceConfigNameServerKey = "NameServer" interfaceConfigSearchListKey = "SearchList" + + // RP_FORCE: Reapply all policies even if no policy change was detected + rpForce = 0x1 ) type registryConfigurator struct { guid string routingAll bool + gpo bool } func newHostManager(wgInterface WGIface) (*registryConfigurator, error) { @@ -37,12 +53,20 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) { if err != nil { return nil, err } - return newHostManagerWithGuid(guid) -} -func newHostManagerWithGuid(guid string) (*registryConfigurator, error) { + var useGPO bool + k, err := registry.OpenKey(registry.LOCAL_MACHINE, gpoDnsPolicyRoot, registry.QUERY_VALUE) + if err != nil { + log.Debugf("failed to open GPO DNS policy root: %v", err) + } else { + closer(k) + useGPO = true + log.Infof("detected GPO DNS policy configuration, using policy store") + } + return ®istryConfigurator{ guid: guid, + gpo: useGPO, }, nil } @@ -51,30 +75,23 @@ func (r *registryConfigurator) supportCustomPort() bool { } func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error { - var err error if config.RouteAll { - err = r.addDNSSetupForAll(config.ServerIP) - if err != nil { + if err := r.addDNSSetupForAll(config.ServerIP); err != nil { return fmt.Errorf("add dns setup: %w", err) } } else if r.routingAll { - err = r.deleteInterfaceRegistryKeyProperty(interfaceConfigNameServerKey) - if err != nil { + if err := r.deleteInterfaceRegistryKeyProperty(interfaceConfigNameServerKey); err != nil { return fmt.Errorf("delete interface registry key property: %w", err) } r.routingAll = false log.Infof("removed %s as main DNS forwarder for this peer", config.ServerIP) } - if err := stateManager.UpdateState(&ShutdownState{Guid: r.guid}); err != nil { + if err := stateManager.UpdateState(&ShutdownState{Guid: r.guid, GPO: r.gpo}); err != nil { log.Errorf("failed to update shutdown state: %s", err) } - var ( - searchDomains []string - matchDomains []string - ) - + var searchDomains, matchDomains []string for _, dConf := range config.Domains { if dConf.Disabled { continue @@ -86,16 +103,16 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager } if len(matchDomains) != 0 { - err = r.addDNSMatchPolicy(matchDomains, config.ServerIP) + if err := r.addDNSMatchPolicy(matchDomains, config.ServerIP); err != nil { + return fmt.Errorf("add dns match policy: %w", err) + } } else { - err = removeRegistryKeyFromDNSPolicyConfig(dnsPolicyConfigMatchPath) - } - if err != nil { - return fmt.Errorf("add dns match policy: %w", err) + if err := r.removeDNSMatchPolicies(); err != nil { + return fmt.Errorf("remove dns match policies: %w", err) + } } - err = r.updateSearchDomains(searchDomains) - if err != nil { + if err := r.updateSearchDomains(searchDomains); err != nil { return fmt.Errorf("update search domains: %w", err) } @@ -103,9 +120,8 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager } func (r *registryConfigurator) addDNSSetupForAll(ip string) error { - err := r.setInterfaceRegistryKeyStringValue(interfaceConfigNameServerKey, ip) - if err != nil { - return fmt.Errorf("adding dns setup for all failed with error: %w", err) + if err := r.setInterfaceRegistryKeyStringValue(interfaceConfigNameServerKey, ip); err != nil { + return fmt.Errorf("adding dns setup for all failed: %w", err) } r.routingAll = true log.Infof("configured %s:53 as main DNS forwarder for this peer", ip) @@ -113,64 +129,54 @@ func (r *registryConfigurator) addDNSSetupForAll(ip string) error { } func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip string) error { - _, err := registry.OpenKey(registry.LOCAL_MACHINE, dnsPolicyConfigMatchPath, registry.QUERY_VALUE) - if err == nil { - err = registry.DeleteKey(registry.LOCAL_MACHINE, dnsPolicyConfigMatchPath) - if err != nil { - return fmt.Errorf("unable to remove existing key from registry, key: HKEY_LOCAL_MACHINE\\%s, error: %w", dnsPolicyConfigMatchPath, err) + // if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored + // see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745 + policyPath := dnsPolicyConfigMatchPath + if r.gpo { + policyPath = gpoDnsPolicyConfigMatchPath + } + + if err := removeRegistryKeyFromDNSPolicyConfig(policyPath); err != nil { + return fmt.Errorf("remove existing dns policy: %w", err) + } + + regKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, policyPath, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("create registry key HKEY_LOCAL_MACHINE\\%s: %w", policyPath, err) + } + defer closer(regKey) + + if err := regKey.SetDWordValue(dnsPolicyConfigVersionKey, dnsPolicyConfigVersionValue); err != nil { + return fmt.Errorf("set %s: %w", dnsPolicyConfigVersionKey, err) + } + + if err := regKey.SetStringsValue(dnsPolicyConfigNameKey, domains); err != nil { + return fmt.Errorf("set %s: %w", dnsPolicyConfigNameKey, err) + } + + if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, ip); err != nil { + return fmt.Errorf("set %s: %w", dnsPolicyConfigGenericDNSServersKey, err) + } + + if err := regKey.SetDWordValue(dnsPolicyConfigConfigOptionsKey, dnsPolicyConfigConfigOptionsValue); err != nil { + return fmt.Errorf("set %s: %w", dnsPolicyConfigConfigOptionsKey, err) + } + + if r.gpo { + if err := refreshGroupPolicy(); err != nil { + log.Warnf("failed to refresh group policy: %v", err) } } - regKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, dnsPolicyConfigMatchPath, registry.SET_VALUE) - if err != nil { - return fmt.Errorf("unable to create registry key, key: HKEY_LOCAL_MACHINE\\%s, error: %w", dnsPolicyConfigMatchPath, err) - } - - err = regKey.SetDWordValue(dnsPolicyConfigVersionKey, dnsPolicyConfigVersionValue) - if err != nil { - return fmt.Errorf("unable to set registry value for %s, error: %w", dnsPolicyConfigVersionKey, err) - } - - err = regKey.SetStringsValue(dnsPolicyConfigNameKey, domains) - if err != nil { - return fmt.Errorf("unable to set registry value for %s, error: %w", dnsPolicyConfigNameKey, err) - } - - err = regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, ip) - if err != nil { - return fmt.Errorf("unable to set registry value for %s, error: %w", dnsPolicyConfigGenericDNSServersKey, err) - } - - err = regKey.SetDWordValue(dnsPolicyConfigConfigOptionsKey, dnsPolicyConfigConfigOptionsValue) - if err != nil { - return fmt.Errorf("unable to set registry value for %s, error: %w", dnsPolicyConfigConfigOptionsKey, err) - } - - log.Infof("added %d match domains to the state. Domain list: %s", len(domains), domains) - - return nil -} - -func (r *registryConfigurator) restoreHostDNS() error { - if err := removeRegistryKeyFromDNSPolicyConfig(dnsPolicyConfigMatchPath); err != nil { - log.Errorf("remove registry key from dns policy config: %s", err) - } - - if err := r.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey); err != nil { - return fmt.Errorf("remove interface registry key: %w", err) - } - + log.Infof("added %d match domains. Domain list: %s", len(domains), domains) return nil } func (r *registryConfigurator) updateSearchDomains(domains []string) error { - err := r.setInterfaceRegistryKeyStringValue(interfaceConfigSearchListKey, strings.Join(domains, ",")) - if err != nil { - return fmt.Errorf("adding search domain failed with error: %w", err) + if err := r.setInterfaceRegistryKeyStringValue(interfaceConfigSearchListKey, strings.Join(domains, ",")); err != nil { + return fmt.Errorf("update search domains: %w", err) } - - log.Infof("updated the search domains in the registry with %d domains. Domain list: %s", len(domains), domains) - + log.Infof("updated search domains: %s", domains) return nil } @@ -181,11 +187,9 @@ func (r *registryConfigurator) setInterfaceRegistryKeyStringValue(key, value str } defer closer(regKey) - err = regKey.SetStringValue(key, value) - if err != nil { - return fmt.Errorf("applying key %s with value \"%s\" for interface failed with error: %w", key, value, err) + if err := regKey.SetStringValue(key, value); err != nil { + return fmt.Errorf("set key %s=%s: %w", key, value, err) } - return nil } @@ -196,43 +200,91 @@ func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey st } defer closer(regKey) - err = regKey.DeleteValue(propertyKey) - if err != nil { - return fmt.Errorf("deleting registry key %s for interface failed with error: %w", propertyKey, err) + if err := regKey.DeleteValue(propertyKey); err != nil { + return fmt.Errorf("delete registry key %s: %w", propertyKey, err) } - return nil } func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) { - var regKey registry.Key - regKeyPath := interfaceConfigPath + "\\" + r.guid - regKey, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.SET_VALUE) if err != nil { - return regKey, fmt.Errorf("unable to open the interface registry key, key: HKEY_LOCAL_MACHINE\\%s, error: %w", regKeyPath, err) + return regKey, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err) } - return regKey, nil } -func (r *registryConfigurator) restoreUncleanShutdownDNS() error { - if err := r.restoreHostDNS(); err != nil { - return fmt.Errorf("restoring dns via registry: %w", err) +func (r *registryConfigurator) restoreHostDNS() error { + if err := r.removeDNSMatchPolicies(); err != nil { + log.Errorf("remove dns match policies: %s", err) } + + if err := r.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey); err != nil { + return fmt.Errorf("remove interface registry key: %w", err) + } + return nil } +func (r *registryConfigurator) removeDNSMatchPolicies() error { + var merr *multierror.Error + if err := removeRegistryKeyFromDNSPolicyConfig(dnsPolicyConfigMatchPath); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove local registry key: %w", err)) + } + + if err := removeRegistryKeyFromDNSPolicyConfig(gpoDnsPolicyConfigMatchPath); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove GPO registry key: %w", err)) + } + + if err := refreshGroupPolicy(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("refresh group policy: %w", err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *registryConfigurator) restoreUncleanShutdownDNS() error { + return r.restoreHostDNS() +} + func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error { k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE) - if err == nil { - defer closer(k) - err = registry.DeleteKey(registry.LOCAL_MACHINE, regKeyPath) - if err != nil { - return fmt.Errorf("unable to remove existing key from registry, key: HKEY_LOCAL_MACHINE\\%s, error: %w", regKeyPath, err) - } + if err != nil { + log.Debugf("failed to open HKEY_LOCAL_MACHINE\\%s: %v", regKeyPath, err) + return nil } + + closer(k) + if err := registry.DeleteKey(registry.LOCAL_MACHINE, regKeyPath); err != nil { + return fmt.Errorf("delete HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err) + } + + return nil +} + +func refreshGroupPolicy() error { + // refreshPolicyExFn.Call() panics if the func is not found + defer func() { + if r := recover(); r != nil { + log.Errorf("Recovered from panic: %v", r) + } + }() + + ret, _, err := refreshPolicyExFn.Call( + // bMachine = TRUE (computer policy) + uintptr(1), + // dwOptions = RP_FORCE + uintptr(rpForce), + ) + + if ret == 0 { + if err != nil && !errors.Is(err, syscall.Errno(0)) { + return fmt.Errorf("RefreshPolicyEx failed: %w", err) + } + return fmt.Errorf("RefreshPolicyEx failed") + } + return nil } diff --git a/client/internal/dns/unclean_shutdown_windows.go b/client/internal/dns/unclean_shutdown_windows.go index 74e40cc11..ab0b2cc63 100644 --- a/client/internal/dns/unclean_shutdown_windows.go +++ b/client/internal/dns/unclean_shutdown_windows.go @@ -6,6 +6,7 @@ import ( type ShutdownState struct { Guid string + GPO bool } func (s *ShutdownState) Name() string { @@ -13,9 +14,9 @@ func (s *ShutdownState) Name() string { } func (s *ShutdownState) Cleanup() error { - manager, err := newHostManagerWithGuid(s.Guid) - if err != nil { - return fmt.Errorf("create host manager: %w", err) + manager := ®istryConfigurator{ + guid: s.Guid, + gpo: s.GPO, } if err := manager.restoreUncleanShutdownDNS(); err != nil { From 039a985f41f80eaadf4719651f5aa4da2786f2fe Mon Sep 17 00:00:00 2001 From: hakansa <43675540+hakansa@users.noreply.github.com> Date: Fri, 14 Feb 2025 13:13:40 +0300 Subject: [PATCH 16/29] [client] Normalize DNS record names to lowercase in local handler update (#3323) * [client] Normalize DNS record names to lowercase in lookup --- client/internal/dns/local.go | 2 ++ client/internal/dns/server.go | 1 + 2 files changed, 3 insertions(+) diff --git a/client/internal/dns/local.go b/client/internal/dns/local.go index 1fe88f750..80113885a 100644 --- a/client/internal/dns/local.go +++ b/client/internal/dns/local.go @@ -2,6 +2,7 @@ package dns import ( "fmt" + "strings" "sync" "github.com/miekg/dns" @@ -60,6 +61,7 @@ func (d *localResolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { func (d *localResolver) lookupRecord(r *dns.Msg) dns.RR { question := r.Question[0] + question.Name = strings.ToLower(question.Name) record, found := d.records.Load(buildRecordKey(question.Name, question.Qclass, question.Qtype)) if !found { return nil diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index f714f9857..fb94e07ac 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -454,6 +454,7 @@ func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) if record.Class != nbdns.DefaultClass { return nil, nil, fmt.Errorf("received an invalid class type: %s", record.Class) } + key := buildRecordKey(record.Name, class, uint16(record.Type)) localRecords[key] = record } From abe8da697c7737bfe7aca45fba10f621506fad1d Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:07:30 +0100 Subject: [PATCH 17/29] [signal] add pprof and message size metrics (#3337) --- signal/cmd/run.go | 13 +++++++++++++ signal/metrics/app.go | 29 +++++++++++++++++++++++++++++ signal/server/signal.go | 2 ++ 3 files changed, 44 insertions(+) diff --git a/signal/cmd/run.go b/signal/cmd/run.go index 1bb2f1d0c..3a671a848 100644 --- a/signal/cmd/run.go +++ b/signal/cmd/run.go @@ -8,6 +8,8 @@ import ( "fmt" "net" "net/http" + // nolint:gosec + _ "net/http/pprof" "strings" "time" @@ -82,6 +84,8 @@ var ( RunE: func(cmd *cobra.Command, args []string) error { flag.Parse() + startPprof() + opts, certManager, err := getTLSConfigurations() if err != nil { return err @@ -170,6 +174,15 @@ var ( } ) +func startPprof() { + go func() { + log.Debugf("Starting pprof server on 127.0.0.1:6060") + if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil { + log.Fatalf("pprof server failed: %v", err) + } + }() +} + func getTLSConfigurations() ([]grpc.ServerOption, *autocert.Manager, error) { var ( err error diff --git a/signal/metrics/app.go b/signal/metrics/app.go index b3457cf96..e3b1c67cd 100644 --- a/signal/metrics/app.go +++ b/signal/metrics/app.go @@ -20,6 +20,8 @@ type AppMetrics struct { MessagesForwarded metric.Int64Counter MessageForwardFailures metric.Int64Counter MessageForwardLatency metric.Float64Histogram + + MessageSize metric.Int64Histogram } func NewAppMetrics(meter metric.Meter) (*AppMetrics, error) { @@ -97,6 +99,16 @@ func NewAppMetrics(meter metric.Meter) (*AppMetrics, error) { return nil, err } + messageSize, err := meter.Int64Histogram( + "message.size.bytes", + metric.WithUnit("bytes"), + metric.WithExplicitBucketBoundaries(getMessageSizeBucketBoundaries()...), + metric.WithDescription("Records the size of each message sent"), + ) + if err != nil { + return nil, err + } + return &AppMetrics{ Meter: meter, @@ -112,9 +124,26 @@ func NewAppMetrics(meter metric.Meter) (*AppMetrics, error) { MessagesForwarded: messagesForwarded, MessageForwardFailures: messageForwardFailures, MessageForwardLatency: messageForwardLatency, + + MessageSize: messageSize, }, nil } +func getMessageSizeBucketBoundaries() []float64 { + return []float64{ + 100, + 250, + 500, + 1000, + 5000, + 10000, + 50000, + 100000, + 500000, + 1000000, + } +} + func getStandardBucketBoundaries() []float64 { return []float64{ 0.1, diff --git a/signal/server/signal.go b/signal/server/signal.go index abc1c367b..05cc43276 100644 --- a/signal/server/signal.go +++ b/signal/server/signal.go @@ -13,6 +13,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + gproto "google.golang.org/protobuf/proto" "github.com/netbirdio/netbird/signal/metrics" "github.com/netbirdio/netbird/signal/peer" @@ -175,4 +176,5 @@ func (s *Server) forwardMessageToPeer(ctx context.Context, msg *proto.EncryptedM // in milliseconds s.metrics.MessageForwardLatency.Record(ctx, float64(time.Since(start).Nanoseconds())/1e6, metric.WithAttributes(attribute.String(labelType, labelTypeStream))) s.metrics.MessagesForwarded.Add(ctx, 1) + s.metrics.MessageSize.Record(ctx, int64(gproto.Size(msg)), metric.WithAttributes(attribute.String(labelType, labelTypeMessage))) } From 4cdb2e533a2ef3d6dbefff390502d9cb45c07334 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Mon, 17 Feb 2025 21:43:12 +0300 Subject: [PATCH 18/29] [management] Refactor users to use store methods (#2917) * Refactor setup key handling to use store methods Signed-off-by: bcmmbaga * add lock to get account groups Signed-off-by: bcmmbaga * add check for regular user Signed-off-by: bcmmbaga * get only required groups for auto-group validation Signed-off-by: bcmmbaga * add account lock and return auto groups map on validation Signed-off-by: bcmmbaga * refactor account peers update Signed-off-by: bcmmbaga * Refactor groups to use store methods Signed-off-by: bcmmbaga * refactor GetGroupByID and add NewGroupNotFoundError Signed-off-by: bcmmbaga * fix tests Signed-off-by: bcmmbaga * Add AddPeer and RemovePeer methods to Group struct Signed-off-by: bcmmbaga * Preserve store engine in SqlStore transactions Signed-off-by: bcmmbaga * Run groups ops in transaction Signed-off-by: bcmmbaga * fix missing group removed from setup key activity Signed-off-by: bcmmbaga * fix merge Signed-off-by: bcmmbaga * Refactor posture checks to remove get and save account Signed-off-by: bcmmbaga * fix refactor Signed-off-by: bcmmbaga * fix tests Signed-off-by: bcmmbaga * fix merge Signed-off-by: bcmmbaga * fix sonar Signed-off-by: bcmmbaga * Change setup key log level to debug for missing group Signed-off-by: bcmmbaga * Retrieve modified peers once for group events Signed-off-by: bcmmbaga * Refactor policy get and save account to use store methods Signed-off-by: bcmmbaga * Fix tests Signed-off-by: bcmmbaga * Add tests Signed-off-by: bcmmbaga * Add tests Signed-off-by: bcmmbaga * Retrieve policy groups and posture checks once for validation Signed-off-by: bcmmbaga * Fix typo Signed-off-by: bcmmbaga * Add policy tests Signed-off-by: bcmmbaga * Refactor anyGroupHasPeers to retrieve all groups once Signed-off-by: bcmmbaga * Refactor dns settings to use store methods Signed-off-by: bcmmbaga * Add tests Signed-off-by: bcmmbaga * Add account locking and merge group deletion methods Signed-off-by: bcmmbaga * Fix tests Signed-off-by: bcmmbaga * Refactor name server groups to use store methods Signed-off-by: bcmmbaga * Add tests Signed-off-by: bcmmbaga * Add peer store methods Signed-off-by: bcmmbaga * Refactor ephemeral peers Signed-off-by: bcmmbaga * Add lock for peer store methods Signed-off-by: bcmmbaga * Refactor peer handlers Signed-off-by: bcmmbaga * Refactor peer to use store methods Signed-off-by: bcmmbaga * Fix tests Signed-off-by: bcmmbaga * Fix typo Signed-off-by: bcmmbaga * Add locks and remove log Signed-off-by: bcmmbaga * run peer ops in transaction Signed-off-by: bcmmbaga * remove duplicate store method Signed-off-by: bcmmbaga * fix peer fields updated after save Signed-off-by: bcmmbaga * add tests Signed-off-by: bcmmbaga * Use update strength and simplify check Signed-off-by: bcmmbaga * prevent changing ruleID when not empty Signed-off-by: bcmmbaga * prevent duplicate rules during updates Signed-off-by: bcmmbaga * fix tests Signed-off-by: bcmmbaga * fix lint Signed-off-by: bcmmbaga * Refactor auth middleware Signed-off-by: bcmmbaga * Refactor account methods and mock Signed-off-by: bcmmbaga * Refactor user and PAT handling Signed-off-by: bcmmbaga * Remove db query context and fix get user by id Signed-off-by: bcmmbaga * Fix database transaction locking issue Signed-off-by: bcmmbaga * Fix tests Signed-off-by: bcmmbaga * Use UTC time in test Signed-off-by: bcmmbaga * Add account locks Signed-off-by: bcmmbaga * Fix prevent users from creating PATs for other users Signed-off-by: bcmmbaga * Add tests Signed-off-by: bcmmbaga * Add store locks and prevent fetching setup keys peers when retrieving user peers with empty userID Signed-off-by: bcmmbaga * Add missing tests Signed-off-by: bcmmbaga * Refactor test names and remove duplicate TestPostgresql_SavePeerStatus Signed-off-by: bcmmbaga * Add account locks and remove redundant ephemeral check Signed-off-by: bcmmbaga * Retrieve all groups for peers and restrict groups for regular users Signed-off-by: bcmmbaga * Fix merge Signed-off-by: bcmmbaga * Fix merge Signed-off-by: bcmmbaga * fix merge Signed-off-by: bcmmbaga * fix store tests Signed-off-by: bcmmbaga * use account object to get validated peers Signed-off-by: bcmmbaga * Fix merge Signed-off-by: bcmmbaga * Improve peer performance Signed-off-by: bcmmbaga * Get account direct from store without buffer Signed-off-by: bcmmbaga * Add get peer groups tests Signed-off-by: bcmmbaga * Adjust benchmarks Signed-off-by: bcmmbaga * Adjust benchmarks Signed-off-by: bcmmbaga * [management] Update benchmark workflow (#3181) * update local benchmark expectations * update cloud expectations * Add status error for generic result error Signed-off-by: bcmmbaga * Use integrated validator direct Signed-off-by: bcmmbaga * update expectations * update expectations * update expectations * Refactor peer scheduler to retry every 3 seconds on errors Signed-off-by: bcmmbaga * update expectations * fix validator * fix validator * fix validator * update timeouts * Refactor ToGroupsInfo to process slices of groups Signed-off-by: bcmmbaga * update expectations * update expectations * update expectations * Bump integrations version Signed-off-by: bcmmbaga * Refactor GetValidatedPeers Signed-off-by: bcmmbaga * Fix tests Signed-off-by: bcmmbaga * go mod tidy Signed-off-by: bcmmbaga * Use peers and groups map for peers validation Signed-off-by: bcmmbaga * remove mysql from api benchmark tests * Fix merge Signed-off-by: bcmmbaga * Fix blocked db calls on user auto groups update Signed-off-by: bcmmbaga * Fix tests Signed-off-by: bcmmbaga * update expectations Signed-off-by: bcmmbaga * update expectations Signed-off-by: bcmmbaga * Skip user check for system initiated peer deletion Signed-off-by: bcmmbaga * Remove context in db calls Signed-off-by: bcmmbaga * update expectations Signed-off-by: bcmmbaga * [management] Improve group peer/resource counting (#3192) * Fix sonar Signed-off-by: bcmmbaga * Adjust bench expectations Signed-off-by: bcmmbaga * Rename GetAccountInfoFromPAT to GetTokenInfo Signed-off-by: bcmmbaga * Fix tests Signed-off-by: bcmmbaga * Remove global account lock for ListUsers Signed-off-by: bcmmbaga * build userinfo after updating users in db Signed-off-by: bcmmbaga * [management] Optimize user bulk deletion (#3315) * refactor building user infos Signed-off-by: bcmmbaga * fix tests Signed-off-by: bcmmbaga * remove unused code Signed-off-by: bcmmbaga * Refactor GetUsersFromAccount to return a map of UserInfo instead of a slice Signed-off-by: bcmmbaga * Export BuildUserInfosForAccount to account manager Signed-off-by: bcmmbaga * Fetch account user info once for bulk users save Signed-off-by: bcmmbaga * Update user deletion expectations Signed-off-by: bcmmbaga * Set max open conns for activity store Signed-off-by: bcmmbaga * Update bench expectations Signed-off-by: bcmmbaga --------- Signed-off-by: bcmmbaga --------- Signed-off-by: bcmmbaga Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Co-authored-by: Pascal Fischer Co-authored-by: Pedro Costa <550684+pnmcosta@users.noreply.github.com> --- management/client/rest/dns_test.go | 1 + management/server/account.go | 175 ++-- management/server/account_test.go | 7 +- management/server/activity/sqlite/sqlite.go | 2 + management/server/http/handler.go | 2 +- .../handlers/events/events_handler_test.go | 4 +- .../http/handlers/users/users_handler_test.go | 12 +- .../server/http/middleware/auth_middleware.go | 22 +- .../http/middleware/auth_middleware_test.go | 11 +- .../users_handler_benchmark_test.go | 50 +- management/server/mock_server/account_mock.go | 31 +- management/server/peer_test.go | 5 +- management/server/status/error.go | 17 +- management/server/store/sql_store.go | 184 +++- management/server/store/sql_store_test.go | 365 +++++++- management/server/store/store.go | 13 +- management/server/testdata/store.sql | 2 +- .../server/types/personal_access_token.go | 3 +- management/server/user.go | 877 ++++++++---------- management/server/user_test.go | 16 +- 20 files changed, 1080 insertions(+), 719 deletions(-) diff --git a/management/client/rest/dns_test.go b/management/client/rest/dns_test.go index 0d57d63d7..b2e0a0bee 100644 --- a/management/client/rest/dns_test.go +++ b/management/client/rest/dns_test.go @@ -260,6 +260,7 @@ func TestDNS_Integration(t *testing.T) { nsGroupReq := api.NameserverGroupRequest{ Description: "Test", Enabled: true, + Domains: []string{}, Groups: []string{"cs1tnh0hhcjnqoiuebeg"}, Name: "test", Nameservers: []api.Nameserver{ diff --git a/management/server/account.go b/management/server/account.go index 2c62a2453..a0c6fd0b0 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -67,7 +67,7 @@ type AccountManager interface { SaveSetupKey(ctx context.Context, accountID string, key *types.SetupKey, userID string) (*types.SetupKey, error) CreateUser(ctx context.Context, accountID, initiatorUserID string, key *types.UserInfo) (*types.UserInfo, error) DeleteUser(ctx context.Context, accountID, initiatorUserID string, targetUserID string) error - DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string) error + DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error ListSetupKeys(ctx context.Context, accountID, userID string) ([]*types.SetupKey, error) SaveUser(ctx context.Context, accountID, initiatorUserID string, update *types.User) (*types.UserInfo, error) @@ -79,7 +79,7 @@ type AccountManager interface { GetAccountIDByUserID(ctx context.Context, userID, domain string) (string, error) GetAccountIDFromToken(ctx context.Context, claims jwtclaims.AuthorizationClaims) (string, string, error) CheckUserAccessByJWTGroups(ctx context.Context, claims jwtclaims.AuthorizationClaims) error - GetAccountFromPAT(ctx context.Context, pat string) (*types.Account, *types.User, *types.PersonalAccessToken, error) + GetPATInfo(ctx context.Context, token string) (*types.User, *types.PersonalAccessToken, string, string, error) DeleteAccount(ctx context.Context, accountID, userID string) error MarkPATUsed(ctx context.Context, tokenID string) error GetUserByID(ctx context.Context, id string) (*types.User, error) @@ -96,7 +96,7 @@ type AccountManager interface { DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error) GetAllPATs(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) ([]*types.PersonalAccessToken, error) - GetUsersFromAccount(ctx context.Context, accountID, userID string) ([]*types.UserInfo, error) + GetUsersFromAccount(ctx context.Context, accountID, userID string) (map[string]*types.UserInfo, error) GetGroup(ctx context.Context, accountId, groupID, userID string) (*types.Group, error) GetAllGroups(ctx context.Context, accountID, userID string) ([]*types.Group, error) GetGroupByName(ctx context.Context, groupName, accountID string) (*types.Group, error) @@ -149,6 +149,7 @@ type AccountManager interface { 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) + BuildUserInfosForAccount(ctx context.Context, accountID, initiatorUserID string, accountUsers []*types.User) (map[string]*types.UserInfo, error) } type DefaultAccountManager struct { @@ -617,6 +618,12 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u if user.Role != types.UserRoleOwner { return status.Errorf(status.PermissionDenied, "user is not allowed to delete account. Only account owner can delete account") } + + userInfosMap, err := am.BuildUserInfosForAccount(ctx, accountID, userID, maps.Values(account.Users)) + if err != nil { + return status.Errorf(status.Internal, "failed to build user infos for account %s: %v", accountID, err) + } + for _, otherUser := range account.Users { if otherUser.IsServiceUser { continue @@ -626,13 +633,23 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u continue } - deleteUserErr := am.deleteRegularUser(ctx, account, userID, otherUser.Id) + 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 } } - err = am.deleteRegularUser(ctx, account, userID, userID) + userInfo, ok := userInfosMap[userID] + if !ok { + return status.Errorf(status.NotFound, "user info not found for user %s", userID) + } + + _, err = am.deleteRegularUser(ctx, accountID, userID, userInfo) if err != nil { log.WithContext(ctx).Errorf("failed deleting user %s. error: %s", userID, err) return err @@ -689,20 +706,8 @@ func isNil(i idp.Manager) bool { // addAccountIDToIDPAppMeta update user's app metadata in idp manager func (am *DefaultAccountManager) addAccountIDToIDPAppMeta(ctx context.Context, userID string, accountID string) error { if !isNil(am.idpManager) { - accountUsers, err := am.Store.GetAccountUsers(ctx, store.LockingStrengthShare, accountID) - if err != nil { - return err - } - cachedAccount := &types.Account{ - Id: accountID, - Users: make(map[string]*types.User), - } - for _, user := range accountUsers { - cachedAccount.Users[user.Id] = user - } - // user can be nil if it wasn't found (e.g., just created) - user, err := am.lookupUserInCache(ctx, userID, cachedAccount) + user, err := am.lookupUserInCache(ctx, userID, accountID) if err != nil { return err } @@ -778,10 +783,15 @@ func (am *DefaultAccountManager) lookupUserInCacheByEmail(ctx context.Context, e } // lookupUserInCache looks up user in the IdP cache and returns it. If the user wasn't found, the function returns nil -func (am *DefaultAccountManager) lookupUserInCache(ctx context.Context, userID string, account *types.Account) (*idp.UserData, error) { - users := make(map[string]userLoggedInOnce, len(account.Users)) +func (am *DefaultAccountManager) lookupUserInCache(ctx context.Context, userID string, accountID string) (*idp.UserData, error) { + accountUsers, err := am.Store.GetAccountUsers(ctx, store.LockingStrengthShare, accountID) + if err != nil { + return nil, err + } + + users := make(map[string]userLoggedInOnce, len(accountUsers)) // ignore service users and users provisioned by integrations than are never logged in - for _, user := range account.Users { + for _, user := range accountUsers { if user.IsServiceUser { continue } @@ -790,8 +800,8 @@ func (am *DefaultAccountManager) lookupUserInCache(ctx context.Context, userID s } users[user.Id] = userLoggedInOnce(!user.GetLastLogin().IsZero()) } - log.WithContext(ctx).Debugf("looking up user %s of account %s in cache", userID, account.Id) - userData, err := am.lookupCache(ctx, users, account.Id) + log.WithContext(ctx).Debugf("looking up user %s of account %s in cache", userID, accountID) + userData, err := am.lookupCache(ctx, users, accountID) if err != nil { return nil, err } @@ -804,13 +814,13 @@ func (am *DefaultAccountManager) lookupUserInCache(ctx context.Context, userID s // add extra check on external cache manager. We may get to this point when the user is not yet findable in IDP, // or it didn't have its metadata updated with am.addAccountIDToIDPAppMeta - user, err := account.FindUser(userID) + user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, userID) if err != nil { - log.WithContext(ctx).Errorf("failed finding user %s in account %s", userID, account.Id) + log.WithContext(ctx).Errorf("failed finding user %s in account %s", userID, accountID) return nil, err } - key := user.IntegrationReference.CacheKey(account.Id, userID) + key := user.IntegrationReference.CacheKey(accountID, userID) ud, err := am.externalCacheManager.Get(am.ctx, key) if err != nil { log.WithContext(ctx).Debugf("failed to get externalCache for key: %s, error: %s", key, err) @@ -1050,9 +1060,9 @@ func (am *DefaultAccountManager) addNewUserToDomainAccount(ctx context.Context, unlockAccount := am.Store.AcquireWriteLockByUID(ctx, domainAccountID) defer unlockAccount() - usersMap := make(map[string]*types.User) - usersMap[claims.UserId] = types.NewRegularUser(claims.UserId) - err := am.Store.SaveUsers(domainAccountID, usersMap) + newUser := types.NewRegularUser(claims.UserId) + newUser.AccountID = domainAccountID + err := am.Store.SaveUser(ctx, store.LockingStrengthUpdate, newUser) if err != nil { return "", err } @@ -1075,12 +1085,7 @@ func (am *DefaultAccountManager) redeemInvite(ctx context.Context, accountID str return nil } - account, err := am.Store.GetAccount(ctx, accountID) - if err != nil { - return err - } - - user, err := am.lookupUserInCache(ctx, userID, account) + user, err := am.lookupUserInCache(ctx, userID, accountID) if err != nil { return err } @@ -1090,17 +1095,17 @@ func (am *DefaultAccountManager) redeemInvite(ctx context.Context, accountID str } if user.AppMetadata.WTPendingInvite != nil && *user.AppMetadata.WTPendingInvite { - log.WithContext(ctx).Infof("redeeming invite for user %s account %s", userID, account.Id) + log.WithContext(ctx).Infof("redeeming invite for user %s account %s", userID, accountID) // User has already logged in, meaning that IdP should have set wt_pending_invite to false. // Our job is to just reload cache. go func() { - _, err = am.refreshCache(ctx, account.Id) + _, err = am.refreshCache(ctx, accountID) if err != nil { - log.WithContext(ctx).Warnf("failed reloading cache when redeeming user %s under account %s", userID, account.Id) + log.WithContext(ctx).Warnf("failed reloading cache when redeeming user %s under account %s", userID, accountID) return } - log.WithContext(ctx).Debugf("user %s of account %s redeemed invite", user.ID, account.Id) - am.StoreEvent(ctx, userID, userID, account.Id, activity.UserJoined, nil) + log.WithContext(ctx).Debugf("user %s of account %s redeemed invite", user.ID, accountID) + am.StoreEvent(ctx, userID, userID, accountID, activity.UserJoined, nil) }() } @@ -1109,33 +1114,7 @@ func (am *DefaultAccountManager) redeemInvite(ctx context.Context, accountID str // MarkPATUsed marks a personal access token as used func (am *DefaultAccountManager) MarkPATUsed(ctx context.Context, tokenID string) error { - - user, err := am.Store.GetUserByTokenID(ctx, tokenID) - if err != nil { - return err - } - - account, err := am.Store.GetAccountByUser(ctx, user.Id) - if err != nil { - return err - } - - unlock := am.Store.AcquireWriteLockByUID(ctx, account.Id) - defer unlock() - - account, err = am.Store.GetAccountByUser(ctx, user.Id) - if err != nil { - return err - } - - pat, ok := account.Users[user.Id].PATs[tokenID] - if !ok { - return fmt.Errorf("token not found") - } - - pat.LastUsed = util.ToPtr(time.Now().UTC()) - - return am.Store.SaveAccount(ctx, account) + return am.Store.MarkPATUsed(ctx, store.LockingStrengthUpdate, tokenID) } // GetAccount returns an account associated with this account ID. @@ -1143,52 +1122,64 @@ func (am *DefaultAccountManager) GetAccount(ctx context.Context, accountID strin return am.Store.GetAccount(ctx, accountID) } -// GetAccountFromPAT returns Account and User associated with a personal access token -func (am *DefaultAccountManager) GetAccountFromPAT(ctx context.Context, token string) (*types.Account, *types.User, *types.PersonalAccessToken, error) { +// GetPATInfo retrieves user, personal access token, domain, and category details from a personal access token. +func (am *DefaultAccountManager) GetPATInfo(ctx context.Context, token string) (user *types.User, pat *types.PersonalAccessToken, domain string, category string, err error) { + user, pat, err = am.extractPATFromToken(ctx, token) + if err != nil { + return nil, nil, "", "", err + } + + domain, category, err = am.Store.GetAccountDomainAndCategory(ctx, store.LockingStrengthShare, user.AccountID) + if err != nil { + return nil, nil, "", "", err + } + + return user, pat, domain, category, nil +} + +// extractPATFromToken validates the token structure and retrieves associated User and PAT. +func (am *DefaultAccountManager) extractPATFromToken(ctx context.Context, token string) (*types.User, *types.PersonalAccessToken, error) { if len(token) != types.PATLength { - return nil, nil, nil, fmt.Errorf("token has wrong length") + return nil, nil, fmt.Errorf("token has incorrect length") } prefix := token[:len(types.PATPrefix)] if prefix != types.PATPrefix { - return nil, nil, nil, fmt.Errorf("token has wrong prefix") + return nil, nil, fmt.Errorf("token has wrong prefix") } secret := token[len(types.PATPrefix) : len(types.PATPrefix)+types.PATSecretLength] encodedChecksum := token[len(types.PATPrefix)+types.PATSecretLength : len(types.PATPrefix)+types.PATSecretLength+types.PATChecksumLength] verificationChecksum, err := base62.Decode(encodedChecksum) if err != nil { - return nil, nil, nil, fmt.Errorf("token checksum decoding failed: %w", err) + return nil, nil, fmt.Errorf("token checksum decoding failed: %w", err) } secretChecksum := crc32.ChecksumIEEE([]byte(secret)) if secretChecksum != verificationChecksum { - return nil, nil, nil, fmt.Errorf("token checksum does not match") + return nil, nil, fmt.Errorf("token checksum does not match") } hashedToken := sha256.Sum256([]byte(token)) encodedHashedToken := b64.StdEncoding.EncodeToString(hashedToken[:]) - tokenID, err := am.Store.GetTokenIDByHashedToken(ctx, encodedHashedToken) + + var user *types.User + var pat *types.PersonalAccessToken + + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + pat, err = transaction.GetPATByHashedToken(ctx, store.LockingStrengthShare, encodedHashedToken) + if err != nil { + return err + } + + user, err = transaction.GetUserByPATID(ctx, store.LockingStrengthShare, pat.ID) + return err + }) if err != nil { - return nil, nil, nil, err + return nil, nil, err } - user, err := am.Store.GetUserByTokenID(ctx, tokenID) - if err != nil { - return nil, nil, nil, err - } - - account, err := am.Store.GetAccountByUser(ctx, user.Id) - if err != nil { - return nil, nil, nil, err - } - - pat := user.PATs[tokenID] - if pat == nil { - return nil, nil, nil, fmt.Errorf("personal access token not found") - } - - return account, user, pat, nil + return user, pat, nil } // GetAccountByID returns an account associated with this account ID. @@ -1334,7 +1325,7 @@ func (am *DefaultAccountManager) syncJWTGroups(ctx context.Context, accountID st return fmt.Errorf("error getting user peers: %w", err) } - updatedGroups, err := am.updateUserPeersInGroups(groupsMap, peers, addNewGroups, removeOldGroups) + updatedGroups, err := updateUserPeersInGroups(groupsMap, peers, addNewGroups, removeOldGroups) if err != nil { return fmt.Errorf("error modifying user peers in groups: %w", err) } diff --git a/management/server/account_test.go b/management/server/account_test.go index 1fc1ceb92..0a7f9119b 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -732,6 +732,7 @@ func TestAccountManager_GetAccountFromPAT(t *testing.T) { PATs: map[string]*types.PersonalAccessToken{ "tokenId": { ID: "tokenId", + UserID: "someUser", HashedToken: encodedHashedToken, }, }, @@ -745,14 +746,14 @@ func TestAccountManager_GetAccountFromPAT(t *testing.T) { Store: store, } - account, user, pat, err := am.GetAccountFromPAT(context.Background(), token) + user, pat, _, _, err := am.GetPATInfo(context.Background(), token) if err != nil { t.Fatalf("Error when getting Account from PAT: %s", err) } - assert.Equal(t, "account_id", account.Id) + assert.Equal(t, "account_id", user.AccountID) assert.Equal(t, "someUser", user.Id) - assert.Equal(t, account.Users["someUser"].PATs["tokenId"], pat) + assert.Equal(t, account.Users["someUser"].PATs["tokenId"].ID, pat.ID) } func TestDefaultAccountManager_MarkPATUsed(t *testing.T) { diff --git a/management/server/activity/sqlite/sqlite.go b/management/server/activity/sqlite/sqlite.go index 823e0b4ac..ffb863de9 100644 --- a/management/server/activity/sqlite/sqlite.go +++ b/management/server/activity/sqlite/sqlite.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "path/filepath" + "runtime" "time" _ "github.com/mattn/go-sqlite3" @@ -95,6 +96,7 @@ func NewSQLiteStore(ctx context.Context, dataDir string, encryptionKey string) ( if err != nil { return nil, err } + db.SetMaxOpenConns(runtime.NumCPU()) crypt, err := NewFieldEncrypt(encryptionKey) if err != nil { diff --git a/management/server/http/handler.go b/management/server/http/handler.go index cc2ad00b7..7ce09fffa 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -43,7 +43,7 @@ func NewAPIHandler(ctx context.Context, accountManager s.AccountManager, network ) authMiddleware := middleware.NewAuthMiddleware( - accountManager.GetAccountFromPAT, + accountManager.GetPATInfo, jwtValidator.ValidateAndParse, accountManager.MarkPATUsed, accountManager.CheckUserAccessByJWTGroups, diff --git a/management/server/http/handlers/events/events_handler_test.go b/management/server/http/handlers/events/events_handler_test.go index 17478aba3..fd603f289 100644 --- a/management/server/http/handlers/events/events_handler_test.go +++ b/management/server/http/handlers/events/events_handler_test.go @@ -32,8 +32,8 @@ func initEventsTestData(account string, events ...*activity.Event) *handler { GetAccountIDFromTokenFunc: func(_ context.Context, claims jwtclaims.AuthorizationClaims) (string, string, error) { return claims.AccountId, claims.UserId, nil }, - GetUsersFromAccountFunc: func(_ context.Context, accountID, userID string) ([]*types.UserInfo, error) { - return make([]*types.UserInfo, 0), nil + GetUsersFromAccountFunc: func(_ context.Context, accountID, userID string) (map[string]*types.UserInfo, error) { + return make(map[string]*types.UserInfo), nil }, }, claimsExtractor: jwtclaims.NewClaimsExtractor( diff --git a/management/server/http/handlers/users/users_handler_test.go b/management/server/http/handlers/users/users_handler_test.go index 90081830a..ff77cedff 100644 --- a/management/server/http/handlers/users/users_handler_test.go +++ b/management/server/http/handlers/users/users_handler_test.go @@ -52,7 +52,7 @@ var usersTestAccount = &types.Account{ Issued: types.UserIssuedAPI, }, nonDeletableServiceUserID: { - Id: serviceUserID, + Id: nonDeletableServiceUserID, Role: "admin", IsServiceUser: true, NonDeletable: true, @@ -70,10 +70,10 @@ func initUsersTestData() *handler { GetUserByIDFunc: func(ctx context.Context, id string) (*types.User, error) { return usersTestAccount.Users[id], nil }, - GetUsersFromAccountFunc: func(_ context.Context, accountID, userID string) ([]*types.UserInfo, error) { - users := make([]*types.UserInfo, 0) + GetUsersFromAccountFunc: func(_ context.Context, accountID, userID string) (map[string]*types.UserInfo, error) { + usersInfos := make(map[string]*types.UserInfo) for _, v := range usersTestAccount.Users { - users = append(users, &types.UserInfo{ + usersInfos[v.Id] = &types.UserInfo{ ID: v.Id, Role: string(v.Role), Name: "", @@ -81,9 +81,9 @@ func initUsersTestData() *handler { IsServiceUser: v.IsServiceUser, NonDeletable: v.NonDeletable, Issued: v.Issued, - }) + } } - return users, nil + return usersInfos, nil }, CreateUserFunc: func(_ context.Context, accountID, userID string, key *types.UserInfo) (*types.UserInfo, error) { if userID != existingUserID { diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go index 182c30cf6..dcf73259a 100644 --- a/management/server/http/middleware/auth_middleware.go +++ b/management/server/http/middleware/auth_middleware.go @@ -19,8 +19,8 @@ import ( "github.com/netbirdio/netbird/management/server/types" ) -// GetAccountFromPATFunc function -type GetAccountFromPATFunc func(ctx context.Context, token string) (*types.Account, *types.User, *types.PersonalAccessToken, error) +// GetAccountInfoFromPATFunc function +type GetAccountInfoFromPATFunc func(ctx context.Context, token string) (user *types.User, pat *types.PersonalAccessToken, domain string, category string, err error) // ValidateAndParseTokenFunc function type ValidateAndParseTokenFunc func(ctx context.Context, token string) (*jwt.Token, error) @@ -33,7 +33,7 @@ type CheckUserAccessByJWTGroupsFunc func(ctx context.Context, claims jwtclaims.A // AuthMiddleware middleware to verify personal access tokens (PAT) and JWT tokens type AuthMiddleware struct { - getAccountFromPAT GetAccountFromPATFunc + getAccountInfoFromPAT GetAccountInfoFromPATFunc validateAndParseToken ValidateAndParseTokenFunc markPATUsed MarkPATUsedFunc checkUserAccessByJWTGroups CheckUserAccessByJWTGroupsFunc @@ -47,7 +47,7 @@ const ( ) // NewAuthMiddleware instance constructor -func NewAuthMiddleware(getAccountFromPAT GetAccountFromPATFunc, validateAndParseToken ValidateAndParseTokenFunc, +func NewAuthMiddleware(getAccountInfoFromPAT GetAccountInfoFromPATFunc, validateAndParseToken ValidateAndParseTokenFunc, markPATUsed MarkPATUsedFunc, checkUserAccessByJWTGroups CheckUserAccessByJWTGroupsFunc, claimsExtractor *jwtclaims.ClaimsExtractor, audience string, userIdClaim string) *AuthMiddleware { if userIdClaim == "" { @@ -55,7 +55,7 @@ func NewAuthMiddleware(getAccountFromPAT GetAccountFromPATFunc, validateAndParse } return &AuthMiddleware{ - getAccountFromPAT: getAccountFromPAT, + getAccountInfoFromPAT: getAccountInfoFromPAT, validateAndParseToken: validateAndParseToken, markPATUsed: markPATUsed, checkUserAccessByJWTGroups: checkUserAccessByJWTGroups, @@ -151,13 +151,11 @@ func (m *AuthMiddleware) verifyUserAccess(ctx context.Context, validatedToken *j // CheckPATFromRequest checks if the PAT is valid func (m *AuthMiddleware) checkPATFromRequest(w http.ResponseWriter, r *http.Request, auth []string) error { token, err := getTokenFromPATRequest(auth) - - // If an error occurs, call the error handler and return an error if err != nil { - return fmt.Errorf("Error extracting token: %w", err) + return fmt.Errorf("error extracting token: %w", err) } - account, user, pat, err := m.getAccountFromPAT(r.Context(), token) + user, pat, accDomain, accCategory, err := m.getAccountInfoFromPAT(r.Context(), token) if err != nil { return fmt.Errorf("invalid Token: %w", err) } @@ -172,9 +170,9 @@ func (m *AuthMiddleware) checkPATFromRequest(w http.ResponseWriter, r *http.Requ claimMaps := jwt.MapClaims{} claimMaps[m.userIDClaim] = user.Id - claimMaps[m.audience+jwtclaims.AccountIDSuffix] = account.Id - claimMaps[m.audience+jwtclaims.DomainIDSuffix] = account.Domain - claimMaps[m.audience+jwtclaims.DomainCategorySuffix] = account.DomainCategory + claimMaps[m.audience+jwtclaims.AccountIDSuffix] = user.AccountID + claimMaps[m.audience+jwtclaims.DomainIDSuffix] = accDomain + claimMaps[m.audience+jwtclaims.DomainCategorySuffix] = accCategory claimMaps[jwtclaims.IsToken] = true jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claimMaps) newRequest := r.WithContext(context.WithValue(r.Context(), jwtclaims.TokenUserProperty, jwtToken)) //nolint diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go index 41bdb7fc5..c1686ed44 100644 --- a/management/server/http/middleware/auth_middleware_test.go +++ b/management/server/http/middleware/auth_middleware_test.go @@ -34,7 +34,8 @@ var testAccount = &types.Account{ Domain: domain, Users: map[string]*types.User{ userID: { - Id: userID, + Id: userID, + AccountID: accountID, PATs: map[string]*types.PersonalAccessToken{ tokenID: { ID: tokenID, @@ -50,11 +51,11 @@ var testAccount = &types.Account{ }, } -func mockGetAccountFromPAT(_ context.Context, token string) (*types.Account, *types.User, *types.PersonalAccessToken, error) { +func mockGetAccountInfoFromPAT(_ context.Context, token string) (user *types.User, pat *types.PersonalAccessToken, domain string, category string, err error) { if token == PAT { - return testAccount, testAccount.Users[userID], testAccount.Users[userID].PATs[tokenID], nil + return testAccount.Users[userID], testAccount.Users[userID].PATs[tokenID], testAccount.Domain, testAccount.DomainCategory, nil } - return nil, nil, nil, fmt.Errorf("PAT invalid") + return nil, nil, "", "", fmt.Errorf("PAT invalid") } func mockValidateAndParseToken(_ context.Context, token string) (*jwt.Token, error) { @@ -166,7 +167,7 @@ func TestAuthMiddleware_Handler(t *testing.T) { ) authMiddleware := NewAuthMiddleware( - mockGetAccountFromPAT, + mockGetAccountInfoFromPAT, mockValidateAndParseToken, mockMarkPATUsed, mockCheckUserAccessByJWTGroups, diff --git a/management/server/http/testing/benchmarks/users_handler_benchmark_test.go b/management/server/http/testing/benchmarks/users_handler_benchmark_test.go index 549a51c0e..0baf76328 100644 --- a/management/server/http/testing/benchmarks/users_handler_benchmark_test.go +++ b/management/server/http/testing/benchmarks/users_handler_benchmark_test.go @@ -35,14 +35,14 @@ var benchCasesUsers = map[string]testing_tools.BenchmarkCase{ func BenchmarkUpdateUser(b *testing.B) { var expectedMetrics = map[string]testing_tools.PerformanceMetrics{ - "Users - XS": {MinMsPerOpLocal: 700, MaxMsPerOpLocal: 1000, MinMsPerOpCICD: 1300, MaxMsPerOpCICD: 8000}, - "Users - S": {MinMsPerOpLocal: 1, MaxMsPerOpLocal: 5, MinMsPerOpCICD: 4, MaxMsPerOpCICD: 50}, - "Users - M": {MinMsPerOpLocal: 20, MaxMsPerOpLocal: 40, MinMsPerOpCICD: 30, MaxMsPerOpCICD: 250}, - "Users - L": {MinMsPerOpLocal: 60, MaxMsPerOpLocal: 100, MinMsPerOpCICD: 90, MaxMsPerOpCICD: 700}, - "Peers - L": {MinMsPerOpLocal: 300, MaxMsPerOpLocal: 500, MinMsPerOpCICD: 550, MaxMsPerOpCICD: 2400}, - "Groups - L": {MinMsPerOpLocal: 400, MaxMsPerOpLocal: 600, MinMsPerOpCICD: 750, MaxMsPerOpCICD: 5000}, - "Setup Keys - L": {MinMsPerOpLocal: 50, MaxMsPerOpLocal: 200, MinMsPerOpCICD: 130, MaxMsPerOpCICD: 1000}, - "Users - XL": {MinMsPerOpLocal: 350, MaxMsPerOpLocal: 550, MinMsPerOpCICD: 650, MaxMsPerOpCICD: 3500}, + "Users - XS": {MinMsPerOpLocal: 100, MaxMsPerOpLocal: 160, MinMsPerOpCICD: 100, MaxMsPerOpCICD: 310}, + "Users - S": {MinMsPerOpLocal: 0.3, MaxMsPerOpLocal: 3, MinMsPerOpCICD: 1, MaxMsPerOpCICD: 15}, + "Users - M": {MinMsPerOpLocal: 1, MaxMsPerOpLocal: 10, MinMsPerOpCICD: 3, MaxMsPerOpCICD: 20}, + "Users - L": {MinMsPerOpLocal: 5, MaxMsPerOpLocal: 20, MinMsPerOpCICD: 2, MaxMsPerOpCICD: 50}, + "Peers - L": {MinMsPerOpLocal: 80, MaxMsPerOpLocal: 150, MinMsPerOpCICD: 80, MaxMsPerOpCICD: 310}, + "Groups - L": {MinMsPerOpLocal: 10, MaxMsPerOpLocal: 50, MinMsPerOpCICD: 20, MaxMsPerOpCICD: 120}, + "Setup Keys - L": {MinMsPerOpLocal: 5, MaxMsPerOpLocal: 20, MinMsPerOpCICD: 2, MaxMsPerOpCICD: 50}, + "Users - XL": {MinMsPerOpLocal: 30, MaxMsPerOpLocal: 100, MinMsPerOpCICD: 60, MaxMsPerOpCICD: 280}, } log.SetOutput(io.Discard) @@ -118,14 +118,14 @@ func BenchmarkGetOneUser(b *testing.B) { func BenchmarkGetAllUsers(b *testing.B) { var expectedMetrics = map[string]testing_tools.PerformanceMetrics{ - "Users - XS": {MinMsPerOpLocal: 50, MaxMsPerOpLocal: 90, MinMsPerOpCICD: 60, MaxMsPerOpCICD: 180}, - "Users - S": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 2, MinMsPerOpCICD: 0, MaxMsPerOpCICD: 30}, - "Users - M": {MinMsPerOpLocal: 5, MaxMsPerOpLocal: 12, MinMsPerOpCICD: 0, MaxMsPerOpCICD: 30}, - "Users - L": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 2, MinMsPerOpCICD: 0, MaxMsPerOpCICD: 30}, - "Peers - L": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 2, MinMsPerOpCICD: 0, MaxMsPerOpCICD: 30}, - "Groups - L": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 2, MinMsPerOpCICD: 0, MaxMsPerOpCICD: 30}, - "Setup Keys - L": {MinMsPerOpLocal: 40, MaxMsPerOpLocal: 140, MinMsPerOpCICD: 60, MaxMsPerOpCICD: 200}, - "Users - XL": {MinMsPerOpLocal: 15, MaxMsPerOpLocal: 40, MinMsPerOpCICD: 20, MaxMsPerOpCICD: 90}, + "Users - XS": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 2, MinMsPerOpCICD: 0, MaxMsPerOpCICD: 10}, + "Users - S": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 2, MinMsPerOpCICD: 0, MaxMsPerOpCICD: 10}, + "Users - M": {MinMsPerOpLocal: 3, MaxMsPerOpLocal: 10, MinMsPerOpCICD: 5, MaxMsPerOpCICD: 15}, + "Users - L": {MinMsPerOpLocal: 10, MaxMsPerOpLocal: 20, MinMsPerOpCICD: 20, MaxMsPerOpCICD: 50}, + "Peers - L": {MinMsPerOpLocal: 15, MaxMsPerOpLocal: 25, MinMsPerOpCICD: 20, MaxMsPerOpCICD: 55}, + "Groups - L": {MinMsPerOpLocal: 15, MaxMsPerOpLocal: 25, MinMsPerOpCICD: 25, MaxMsPerOpCICD: 55}, + "Setup Keys - L": {MinMsPerOpLocal: 15, MaxMsPerOpLocal: 25, MinMsPerOpCICD: 25, MaxMsPerOpCICD: 55}, + "Users - XL": {MinMsPerOpLocal: 80, MaxMsPerOpLocal: 120, MinMsPerOpCICD: 100, MaxMsPerOpCICD: 300}, } log.SetOutput(io.Discard) @@ -141,7 +141,7 @@ func BenchmarkGetAllUsers(b *testing.B) { b.ResetTimer() start := time.Now() for i := 0; i < b.N; i++ { - req := testing_tools.BuildRequest(b, nil, http.MethodGet, "/api/setup-keys", testing_tools.TestAdminId) + req := testing_tools.BuildRequest(b, nil, http.MethodGet, "/api/users", testing_tools.TestAdminId) apiHandler.ServeHTTP(recorder, req) } @@ -152,14 +152,14 @@ func BenchmarkGetAllUsers(b *testing.B) { func BenchmarkDeleteUsers(b *testing.B) { var expectedMetrics = map[string]testing_tools.PerformanceMetrics{ - "Users - XS": {MinMsPerOpLocal: 1000, MaxMsPerOpLocal: 1600, MinMsPerOpCICD: 1900, MaxMsPerOpCICD: 11000}, - "Users - S": {MinMsPerOpLocal: 15, MaxMsPerOpLocal: 40, MinMsPerOpCICD: 30, MaxMsPerOpCICD: 200}, - "Users - M": {MinMsPerOpLocal: 15, MaxMsPerOpLocal: 70, MinMsPerOpCICD: 15, MaxMsPerOpCICD: 230}, - "Users - L": {MinMsPerOpLocal: 15, MaxMsPerOpLocal: 45, MinMsPerOpCICD: 30, MaxMsPerOpCICD: 190}, - "Peers - L": {MinMsPerOpLocal: 400, MaxMsPerOpLocal: 600, MinMsPerOpCICD: 650, MaxMsPerOpCICD: 1800}, - "Groups - L": {MinMsPerOpLocal: 600, MaxMsPerOpLocal: 800, MinMsPerOpCICD: 1200, MaxMsPerOpCICD: 7500}, - "Setup Keys - L": {MinMsPerOpLocal: 20, MaxMsPerOpLocal: 200, MinMsPerOpCICD: 40, MaxMsPerOpCICD: 600}, - "Users - XL": {MinMsPerOpLocal: 50, MaxMsPerOpLocal: 150, MinMsPerOpCICD: 80, MaxMsPerOpCICD: 400}, + "Users - XS": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 5, MinMsPerOpCICD: 2, MaxMsPerOpCICD: 15}, + "Users - S": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 5, MinMsPerOpCICD: 2, MaxMsPerOpCICD: 15}, + "Users - M": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 5, MinMsPerOpCICD: 2, MaxMsPerOpCICD: 15}, + "Users - L": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 5, MinMsPerOpCICD: 2, MaxMsPerOpCICD: 15}, + "Peers - L": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 5, MinMsPerOpCICD: 2, MaxMsPerOpCICD: 15}, + "Groups - L": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 5, MinMsPerOpCICD: 2, MaxMsPerOpCICD: 15}, + "Setup Keys - L": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 5, MinMsPerOpCICD: 2, MaxMsPerOpCICD: 15}, + "Users - XL": {MinMsPerOpLocal: 0, MaxMsPerOpLocal: 5, MinMsPerOpCICD: 2, MaxMsPerOpCICD: 15}, } log.SetOutput(io.Discard) diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index c8e42d20a..b20eb87bb 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -53,8 +53,8 @@ type MockAccountManager struct { SavePolicyFunc func(ctx context.Context, accountID, userID string, policy *types.Policy) (*types.Policy, error) DeletePolicyFunc func(ctx context.Context, accountID, policyID, userID string) error ListPoliciesFunc func(ctx context.Context, accountID, userID string) ([]*types.Policy, error) - GetUsersFromAccountFunc func(ctx context.Context, accountID, userID string) ([]*types.UserInfo, error) - GetAccountFromPATFunc func(ctx context.Context, pat string) (*types.Account, *types.User, *types.PersonalAccessToken, error) + GetUsersFromAccountFunc func(ctx context.Context, accountID, userID string) (map[string]*types.UserInfo, error) + GetPATInfoFunc func(ctx context.Context, token string) (*types.User, *types.PersonalAccessToken, string, string, error) MarkPATUsedFunc func(ctx context.Context, pat string) error 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) @@ -69,7 +69,7 @@ type MockAccountManager struct { SaveOrAddUserFunc func(ctx context.Context, accountID, userID string, user *types.User, addIfNotExists bool) (*types.UserInfo, error) SaveOrAddUsersFunc func(ctx context.Context, accountID, initiatorUserID string, update []*types.User, addIfNotExists bool) ([]*types.UserInfo, error) DeleteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error - DeleteRegularUsersFunc func(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string) error + DeleteRegularUsersFunc func(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error CreatePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) DeletePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) error GetPATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) (*types.PersonalAccessToken, error) @@ -110,6 +110,7 @@ type MockAccountManager struct { GetUserByIDFunc func(ctx context.Context, id string) (*types.User, error) GetAccountSettingsFunc func(ctx context.Context, accountID string, userID string) (*types.Settings, error) DeleteSetupKeyFunc func(ctx context.Context, accountID, userID, keyID string) error + BuildUserInfosForAccountFunc func(ctx context.Context, accountID, initiatorUserID string, accountUsers []*types.User) (map[string]*types.UserInfo, error) } func (am *MockAccountManager) UpdateAccountPeers(ctx context.Context, accountID string) { @@ -165,7 +166,7 @@ func (am *MockAccountManager) GetAllGroups(ctx context.Context, accountID, userI } // GetUsersFromAccount mock implementation of GetUsersFromAccount from server.AccountManager interface -func (am *MockAccountManager) GetUsersFromAccount(ctx context.Context, accountID string, userID string) ([]*types.UserInfo, error) { +func (am *MockAccountManager) GetUsersFromAccount(ctx context.Context, accountID string, userID string) (map[string]*types.UserInfo, error) { if am.GetUsersFromAccountFunc != nil { return am.GetUsersFromAccountFunc(ctx, accountID, userID) } @@ -238,12 +239,12 @@ func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey str return status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented") } -// GetAccountFromPAT mock implementation of GetAccountFromPAT from server.AccountManager interface -func (am *MockAccountManager) GetAccountFromPAT(ctx context.Context, pat string) (*types.Account, *types.User, *types.PersonalAccessToken, error) { - if am.GetAccountFromPATFunc != nil { - return am.GetAccountFromPATFunc(ctx, pat) +// GetPATInfo mock implementation of GetPATInfo from server.AccountManager interface +func (am *MockAccountManager) GetPATInfo(ctx context.Context, pat string) (*types.User, *types.PersonalAccessToken, string, string, error) { + if am.GetPATInfoFunc != nil { + return am.GetPATInfoFunc(ctx, pat) } - return nil, nil, nil, status.Errorf(codes.Unimplemented, "method GetAccountFromPAT is not implemented") + return nil, nil, "", "", status.Errorf(codes.Unimplemented, "method GetPATInfo is not implemented") } // DeleteAccount mock implementation of DeleteAccount from server.AccountManager interface @@ -550,9 +551,9 @@ func (am *MockAccountManager) DeleteUser(ctx context.Context, accountID string, } // DeleteRegularUsers mocks DeleteRegularUsers of the AccountManager interface -func (am *MockAccountManager) DeleteRegularUsers(ctx context.Context, accountID string, initiatorUserID string, targetUserIDs []string) error { +func (am *MockAccountManager) DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error { if am.DeleteRegularUsersFunc != nil { - return am.DeleteRegularUsersFunc(ctx, accountID, initiatorUserID, targetUserIDs) + return am.DeleteRegularUsersFunc(ctx, accountID, initiatorUserID, targetUserIDs, userInfos) } return status.Errorf(codes.Unimplemented, "method DeleteRegularUsers is not implemented") } @@ -849,3 +850,11 @@ func (am *MockAccountManager) GetPeerGroups(ctx context.Context, accountID, peer } return nil, status.Errorf(codes.Unimplemented, "method GetPeerGroups is not implemented") } + +// BuildUserInfosForAccount mocks BuildUserInfosForAccount of the AccountManager interface +func (am *MockAccountManager) BuildUserInfosForAccount(ctx context.Context, accountID, initiatorUserID string, accountUsers []*types.User) (map[string]*types.UserInfo, error) { + if am.BuildUserInfosForAccountFunc != nil { + return am.BuildUserInfosForAccountFunc(ctx, accountID, initiatorUserID, accountUsers) + } + return nil, status.Errorf(codes.Unimplemented, "method BuildUserInfosForAccount is not implemented") +} diff --git a/management/server/peer_test.go b/management/server/peer_test.go index a0417c996..6894d092d 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + nbAccount "github.com/netbirdio/netbird/management/server/account" "github.com/rs/xid" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -28,7 +29,6 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/domain" "github.com/netbirdio/netbird/management/proto" - nbAccount "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" @@ -1554,7 +1554,8 @@ func TestPeerAccountPeersUpdate(t *testing.T) { // Adding peer to group linked with policy should update account peers and send peer update t.Run("adding peer to group linked with policy", func(t *testing.T) { _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - Enabled: true, + AccountID: account.Id, + Enabled: true, Rules: []*types.PolicyRule{ { Enabled: true, diff --git a/management/server/status/error.go b/management/server/status/error.go index 7e384922d..96b103183 100644 --- a/management/server/status/error.go +++ b/management/server/status/error.go @@ -93,7 +93,7 @@ func NewPeerNotPartOfAccountError() error { // NewUserNotFoundError creates a new Error with NotFound type for a missing user func NewUserNotFoundError(userKey string) error { - return Errorf(NotFound, "user not found: %s", userKey) + return Errorf(NotFound, "user: %s not found", userKey) } // NewPeerNotRegisteredError creates a new Error with NotFound type for a missing peer @@ -191,3 +191,18 @@ func NewResourceNotPartOfNetworkError(resourceID, networkID string) error { func NewRouterNotPartOfNetworkError(routerID, networkID string) error { return Errorf(BadRequest, "router %s is not part of the network %s", routerID, networkID) } + +// NewServiceUserRoleInvalidError creates a new Error with InvalidArgument type for creating a service user with owner role +func NewServiceUserRoleInvalidError() error { + return Errorf(InvalidArgument, "can't create a service user with owner role") +} + +// NewOwnerDeletePermissionError creates a new Error with PermissionDenied type for attempting +// to delete a user with the owner role. +func NewOwnerDeletePermissionError() error { + return Errorf(PermissionDenied, "can't delete a user with the owner role") +} + +func NewPATNotFoundError(patID string) error { + return Errorf(NotFound, "PAT: %s not found", patID) +} diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 2179f0754..6a6753595 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "github.com/netbirdio/netbird/management/server/util" log "github.com/sirupsen/logrus" "gorm.io/driver/mysql" "gorm.io/driver/postgres" @@ -414,24 +415,16 @@ func (s *SqlStore) SavePeerLocation(ctx context.Context, lockStrength LockingStr } // SaveUsers saves the given list of users to the database. -// It updates existing users if a conflict occurs. -func (s *SqlStore) SaveUsers(accountID string, users map[string]*types.User) error { - usersToSave := make([]types.User, 0, len(users)) - for _, user := range users { - user.AccountID = accountID - for id, pat := range user.PATs { - pat.ID = id - user.PATsG = append(user.PATsG, *pat) - } - usersToSave = append(usersToSave, *user) - } - err := s.db.Session(&gorm.Session{FullSaveAssociations: true}). - Clauses(clause.OnConflict{UpdateAll: true}). - Create(&usersToSave).Error - if err != nil { - return status.Errorf(status.Internal, "failed to save users to store: %v", err) +func (s *SqlStore) SaveUsers(ctx context.Context, lockStrength LockingStrength, users []*types.User) error { + if len(users) == 0 { + return nil } + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}).Save(&users) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save users to store: %s", result.Error) + return status.Errorf(status.Internal, "failed to save users to store") + } return nil } @@ -439,7 +432,8 @@ func (s *SqlStore) SaveUsers(accountID string, users map[string]*types.User) err func (s *SqlStore) SaveUser(ctx context.Context, lockStrength LockingStrength, user *types.User) error { result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}).Save(user) if result.Error != nil { - return status.Errorf(status.Internal, "failed to save user to store: %v", result.Error) + log.WithContext(ctx).Errorf("failed to save user to store: %s", result.Error) + return status.Errorf(status.Internal, "failed to save user to store") } return nil } @@ -526,30 +520,17 @@ func (s *SqlStore) GetTokenIDByHashedToken(ctx context.Context, hashedToken stri return token.ID, nil } -func (s *SqlStore) GetUserByTokenID(ctx context.Context, tokenID string) (*types.User, error) { - var token types.PersonalAccessToken - result := s.db.First(&token, idQueryCondition, tokenID) +func (s *SqlStore) GetUserByPATID(ctx context.Context, lockStrength LockingStrength, patID string) (*types.User, error) { + var user types.User + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}). + Joins("JOIN personal_access_tokens ON personal_access_tokens.user_id = users.id"). + Where("personal_access_tokens.id = ?", patID).First(&user) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + return nil, status.NewPATNotFoundError(patID) } - log.WithContext(ctx).Errorf("error when getting token from the store: %s", result.Error) - return nil, status.NewGetAccountFromStoreError(result.Error) - } - - if token.UserID == "" { - return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") - } - - var user types.User - result = s.db.Preload("PATsG").First(&user, idQueryCondition, token.UserID) - if result.Error != nil { - return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") - } - - user.PATs = make(map[string]*types.PersonalAccessToken, len(user.PATsG)) - for _, pat := range user.PATsG { - user.PATs[pat.ID] = pat.Copy() + log.WithContext(ctx).Errorf("failed to get token user from the store: %s", result.Error) + return nil, status.NewGetUserFromStoreError() } return &user, nil @@ -557,8 +538,7 @@ func (s *SqlStore) GetUserByTokenID(ctx context.Context, tokenID string) (*types func (s *SqlStore) GetUserByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (*types.User, error) { var user types.User - result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}). - Preload(clause.Associations).First(&user, idQueryCondition, userID) + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}).First(&user, idQueryCondition, userID) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, status.NewUserNotFoundError(userID) @@ -569,6 +549,25 @@ func (s *SqlStore) GetUserByUserID(ctx context.Context, lockStrength LockingStre return &user, nil } +func (s *SqlStore) DeleteUser(ctx context.Context, lockStrength LockingStrength, accountID, userID string) error { + err := s.db.Transaction(func(tx *gorm.DB) error { + result := tx.Clauses(clause.Locking{Strength: string(lockStrength)}). + Delete(&types.PersonalAccessToken{}, "user_id = ?", userID) + if result.Error != nil { + return result.Error + } + + return tx.Clauses(clause.Locking{Strength: string(lockStrength)}). + Delete(&types.User{}, accountAndIDQueryCondition, accountID, userID).Error + }) + if err != nil { + log.WithContext(ctx).Errorf("failed to delete user from the store: %s", err) + return status.Errorf(status.Internal, "failed to delete user from store") + } + + return nil +} + func (s *SqlStore) GetAccountUsers(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.User, error) { var users []*types.User result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}).Find(&users, accountIDCondition, accountID) @@ -899,6 +898,20 @@ func (s *SqlStore) GetAccountSettings(ctx context.Context, lockStrength LockingS return accountSettings.Settings, nil } +func (s *SqlStore) GetAccountCreatedBy(ctx context.Context, lockStrength LockingStrength, accountID string) (string, error) { + var createdBy string + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}).Model(&types.Account{}). + Select("created_by").First(&createdBy, idQueryCondition, accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", status.NewAccountNotFoundError(accountID) + } + return "", status.NewGetAccountFromStoreError(result.Error) + } + + return createdBy, nil +} + // SaveUserLastLogin stores the last login time for a user in DB. func (s *SqlStore) SaveUserLastLogin(ctx context.Context, accountID, userID string, lastLogin time.Time) error { var user types.User @@ -2053,3 +2066,94 @@ func (s *SqlStore) DeleteNetworkResource(ctx context.Context, lockStrength Locki return nil } + +// GetPATByHashedToken returns a PersonalAccessToken by its hashed token. +func (s *SqlStore) GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.PersonalAccessToken, error) { + var pat types.PersonalAccessToken + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}).First(&pat, "hashed_token = ?", hashedToken) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewPATNotFoundError(hashedToken) + } + log.WithContext(ctx).Errorf("failed to get pat by hash from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get pat by hash from store") + } + + return &pat, nil +} + +// GetPATByID retrieves a personal access token by its ID and user ID. +func (s *SqlStore) GetPATByID(ctx context.Context, lockStrength LockingStrength, userID string, patID string) (*types.PersonalAccessToken, error) { + var pat types.PersonalAccessToken + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}). + First(&pat, "id = ? AND user_id = ?", patID, userID) + if err := result.Error; err != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewPATNotFoundError(patID) + } + log.WithContext(ctx).Errorf("failed to get pat from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get pat from store") + } + + return &pat, nil +} + +// GetUserPATs retrieves personal access tokens for a user. +func (s *SqlStore) GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types.PersonalAccessToken, error) { + var pats []*types.PersonalAccessToken + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}).Find(&pats, "user_id = ?", userID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to get user pat's from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get user pat's from store") + } + + return pats, nil +} + +// MarkPATUsed marks a personal access token as used. +func (s *SqlStore) MarkPATUsed(ctx context.Context, lockStrength LockingStrength, patID string) error { + patCopy := types.PersonalAccessToken{ + LastUsed: util.ToPtr(time.Now().UTC()), + } + + fieldsToUpdate := []string{"last_used"} + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}).Select(fieldsToUpdate). + Where(idQueryCondition, patID).Updates(&patCopy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to mark pat as used: %s", result.Error) + return status.Errorf(status.Internal, "failed to mark pat as used") + } + + if result.RowsAffected == 0 { + return status.NewPATNotFoundError(patID) + } + + return nil +} + +// SavePAT saves a personal access token to the database. +func (s *SqlStore) SavePAT(ctx context.Context, lockStrength LockingStrength, pat *types.PersonalAccessToken) error { + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}).Save(pat) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to save pat to the store: %s", err) + return status.Errorf(status.Internal, "failed to save pat to store") + } + + return nil +} + +// DeletePAT deletes a personal access token from the database. +func (s *SqlStore) DeletePAT(ctx context.Context, lockStrength LockingStrength, userID, patID string) error { + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}). + Delete(&types.PersonalAccessToken{}, "user_id = ? AND id = ?", userID, patID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to delete pat from the store: %s", err) + return status.Errorf(status.Internal, "failed to delete pat from store") + } + + if result.RowsAffected == 0 { + return status.NewPATNotFoundError(patID) + } + + return nil +} diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 9350da1c8..4dcdadf44 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -627,29 +627,6 @@ func TestSqlite_GetTokenIDByHashedToken(t *testing.T) { require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") } -func TestSqlite_GetUserByTokenID(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("The SQLite store is not properly supported by Windows yet") - } - - t.Setenv("NETBIRD_STORE_ENGINE", string(SqliteStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - id := "9dj38s35-63fb-11ec-90d6-0242ac120003" - - user, err := store.GetUserByTokenID(context.Background(), id) - require.NoError(t, err) - require.Equal(t, id, user.PATs[id].ID) - - _, err = store.GetUserByTokenID(context.Background(), "non-existing-id") - require.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") -} - func TestMigrate(t *testing.T) { if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { t.Skip("skip CI tests on darwin and windows") @@ -962,23 +939,6 @@ func TestPostgresql_GetTokenIDByHashedToken(t *testing.T) { require.Equal(t, id, token) } -func TestPostgresql_GetUserByTokenID(t *testing.T) { - if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { - t.Skip("skip CI tests on darwin and windows") - } - - t.Setenv("NETBIRD_STORE_ENGINE", string(PostgresStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - id := "9dj38s35-63fb-11ec-90d6-0242ac120003" - - user, err := store.GetUserByTokenID(context.Background(), id) - require.NoError(t, err) - require.Equal(t, id, user.PATs[id].ID) -} - func TestSqlite_GetTakenIPs(t *testing.T) { t.Setenv("NETBIRD_STORE_ENGINE", string(SqliteStoreEngine)) store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) @@ -1182,7 +1142,7 @@ func TestSqlite_CreateAndGetObjectInTransaction(t *testing.T) { assert.NoError(t, err) } -func TestSqlite_GetAccoundUsers(t *testing.T) { +func TestSqlStore_GetAccountUsers(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) t.Cleanup(cleanup) if err != nil { @@ -2915,3 +2875,326 @@ func TestSqlStore_DatabaseBlocking(t *testing.T) { t.Logf("Test completed") } + +func TestSqlStore_GetAccountCreatedBy(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + expectError bool + createdBy string + }{ + { + name: "existing account ID", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + expectError: false, + createdBy: "edafee4e-63fb-11ec-90d6-0242ac120003", + }, + { + name: "non-existing account ID", + accountID: "nonexistent", + expectError: true, + }, + { + name: "empty account ID", + accountID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + createdBy, err := store.GetAccountCreatedBy(context.Background(), LockingStrengthShare, tt.accountID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Empty(t, createdBy) + } else { + require.NoError(t, err) + require.NotNil(t, createdBy) + require.Equal(t, tt.createdBy, createdBy) + } + }) + } + +} + +func TestSqlStore_GetUserByUserID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + userID string + expectError bool + }{ + { + name: "retrieve existing user", + userID: "edafee4e-63fb-11ec-90d6-0242ac120003", + expectError: false, + }, + { + name: "retrieve non-existing user", + userID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty user ID", + userID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user, err := store.GetUserByUserID(context.Background(), LockingStrengthShare, tt.userID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, user) + } else { + require.NoError(t, err) + require.NotNil(t, user) + require.Equal(t, tt.userID, user.Id) + } + }) + } +} + +func TestSqlStore_GetUserByPATID(t *testing.T) { + store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanUp) + assert.NoError(t, err) + + id := "9dj38s35-63fb-11ec-90d6-0242ac120003" + + user, err := store.GetUserByPATID(context.Background(), LockingStrengthShare, id) + require.NoError(t, err) + require.Equal(t, "f4f6d672-63fb-11ec-90d6-0242ac120003", user.Id) +} + +func TestSqlStore_SaveUser(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + user := &types.User{ + Id: "user-id", + AccountID: accountID, + Role: types.UserRoleAdmin, + IsServiceUser: false, + AutoGroups: []string{"groupA", "groupB"}, + Blocked: false, + LastLogin: util.ToPtr(time.Now().UTC()), + CreatedAt: time.Now().UTC().Add(-time.Hour), + Issued: types.UserIssuedIntegration, + } + err = store.SaveUser(context.Background(), LockingStrengthUpdate, user) + require.NoError(t, err) + + saveUser, err := store.GetUserByUserID(context.Background(), LockingStrengthShare, user.Id) + require.NoError(t, err) + require.Equal(t, user.Id, saveUser.Id) + require.Equal(t, user.AccountID, saveUser.AccountID) + require.Equal(t, user.Role, saveUser.Role) + require.Equal(t, user.AutoGroups, saveUser.AutoGroups) + require.WithinDurationf(t, user.GetLastLogin(), saveUser.LastLogin.UTC(), time.Millisecond, "LastLogin should be equal") + require.WithinDurationf(t, user.CreatedAt, saveUser.CreatedAt.UTC(), time.Millisecond, "CreatedAt should be equal") + require.Equal(t, user.Issued, saveUser.Issued) + require.Equal(t, user.Blocked, saveUser.Blocked) + require.Equal(t, user.IsServiceUser, saveUser.IsServiceUser) +} + +func TestSqlStore_SaveUsers(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + accountUsers, err := store.GetAccountUsers(context.Background(), LockingStrengthShare, accountID) + require.NoError(t, err) + require.Len(t, accountUsers, 2) + + users := []*types.User{ + { + Id: "user-1", + AccountID: accountID, + Issued: "api", + AutoGroups: []string{"groupA", "groupB"}, + }, + { + Id: "user-2", + AccountID: accountID, + Issued: "integration", + AutoGroups: []string{"groupA"}, + }, + } + err = store.SaveUsers(context.Background(), LockingStrengthUpdate, users) + require.NoError(t, err) + + accountUsers, err = store.GetAccountUsers(context.Background(), LockingStrengthShare, accountID) + require.NoError(t, err) + require.Len(t, accountUsers, 4) +} + +func TestSqlStore_DeleteUser(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" + + err = store.DeleteUser(context.Background(), LockingStrengthUpdate, accountID, userID) + require.NoError(t, err) + + user, err := store.GetUserByUserID(context.Background(), LockingStrengthShare, userID) + require.Error(t, err) + require.Nil(t, user) + + userPATs, err := store.GetUserPATs(context.Background(), LockingStrengthShare, userID) + require.NoError(t, err) + require.Len(t, userPATs, 0) +} + +func TestSqlStore_GetPATByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" + + tests := []struct { + name string + patID string + expectError bool + }{ + { + name: "retrieve existing PAT", + patID: "9dj38s35-63fb-11ec-90d6-0242ac120003", + expectError: false, + }, + { + name: "retrieve non-existing PAT", + patID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty PAT ID", + patID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pat, err := store.GetPATByID(context.Background(), LockingStrengthShare, userID, tt.patID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, pat) + } else { + require.NoError(t, err) + require.NotNil(t, pat) + require.Equal(t, tt.patID, pat.ID) + } + }) + } +} + +func TestSqlStore_GetUserPATs(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + userPATs, err := store.GetUserPATs(context.Background(), LockingStrengthShare, "f4f6d672-63fb-11ec-90d6-0242ac120003") + require.NoError(t, err) + require.Len(t, userPATs, 1) +} + +func TestSqlStore_GetPATByHashedToken(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + pat, err := store.GetPATByHashedToken(context.Background(), LockingStrengthShare, "SoMeHaShEdToKeN") + require.NoError(t, err) + require.Equal(t, "9dj38s35-63fb-11ec-90d6-0242ac120003", pat.ID) +} + +func TestSqlStore_MarkPATUsed(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" + patID := "9dj38s35-63fb-11ec-90d6-0242ac120003" + + err = store.MarkPATUsed(context.Background(), LockingStrengthUpdate, patID) + require.NoError(t, err) + + pat, err := store.GetPATByID(context.Background(), LockingStrengthShare, userID, patID) + require.NoError(t, err) + now := time.Now().UTC() + require.WithinRange(t, pat.LastUsed.UTC(), now.Add(-15*time.Second), now, "LastUsed should be within 1 second of now") +} + +func TestSqlStore_SavePAT(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + userID := "edafee4e-63fb-11ec-90d6-0242ac120003" + + pat := &types.PersonalAccessToken{ + ID: "pat-id", + UserID: userID, + Name: "token", + HashedToken: "SoMeHaShEdToKeN", + ExpirationDate: util.ToPtr(time.Now().UTC().Add(12 * time.Hour)), + CreatedBy: userID, + CreatedAt: time.Now().UTC().Add(time.Hour), + LastUsed: util.ToPtr(time.Now().UTC().Add(-15 * time.Minute)), + } + err = store.SavePAT(context.Background(), LockingStrengthUpdate, pat) + require.NoError(t, err) + + savePAT, err := store.GetPATByID(context.Background(), LockingStrengthShare, userID, pat.ID) + require.NoError(t, err) + require.Equal(t, pat.ID, savePAT.ID) + require.Equal(t, pat.UserID, savePAT.UserID) + require.Equal(t, pat.HashedToken, savePAT.HashedToken) + require.Equal(t, pat.CreatedBy, savePAT.CreatedBy) + require.WithinDurationf(t, pat.GetExpirationDate(), savePAT.ExpirationDate.UTC(), time.Millisecond, "ExpirationDate should be equal") + require.WithinDurationf(t, pat.CreatedAt, savePAT.CreatedAt.UTC(), time.Millisecond, "CreatedAt should be equal") + require.WithinDurationf(t, pat.GetLastUsed(), savePAT.LastUsed.UTC(), time.Millisecond, "LastUsed should be equal") +} + +func TestSqlStore_DeletePAT(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" + patID := "9dj38s35-63fb-11ec-90d6-0242ac120003" + + err = store.DeletePAT(context.Background(), LockingStrengthUpdate, userID, patID) + require.NoError(t, err) + + pat, err := store.GetPATByID(context.Background(), LockingStrengthShare, userID, patID) + require.Error(t, err) + require.Nil(t, pat) +} diff --git a/management/server/store/store.go b/management/server/store/store.go index 4b4dcfb4f..6d3a409e6 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -59,21 +59,30 @@ type Store interface { GetAccountIDByPrivateDomain(ctx context.Context, lockStrength LockingStrength, domain string) (string, error) GetAccountSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.Settings, error) GetAccountDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.DNSSettings, error) + GetAccountCreatedBy(ctx context.Context, lockStrength LockingStrength, accountID string) (string, error) SaveAccount(ctx context.Context, account *types.Account) error DeleteAccount(ctx context.Context, account *types.Account) error UpdateAccountDomainAttributes(ctx context.Context, accountID string, domain string, category string, isPrimaryDomain bool) error SaveDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string, settings *types.DNSSettings) error - GetUserByTokenID(ctx context.Context, tokenID string) (*types.User, error) + GetUserByPATID(ctx context.Context, lockStrength LockingStrength, patID string) (*types.User, error) GetUserByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (*types.User, error) GetAccountUsers(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.User, error) - SaveUsers(accountID string, users map[string]*types.User) error + SaveUsers(ctx context.Context, lockStrength LockingStrength, users []*types.User) error SaveUser(ctx context.Context, lockStrength LockingStrength, user *types.User) error SaveUserLastLogin(ctx context.Context, accountID, userID string, lastLogin time.Time) error + DeleteUser(ctx context.Context, lockStrength LockingStrength, accountID, userID string) error GetTokenIDByHashedToken(ctx context.Context, secret string) (string, error) DeleteHashedPAT2TokenIDIndex(hashedToken string) error DeleteTokenID2UserIDIndex(tokenID string) error + GetPATByID(ctx context.Context, lockStrength LockingStrength, userID, patID string) (*types.PersonalAccessToken, error) + GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types.PersonalAccessToken, error) + GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.PersonalAccessToken, error) + MarkPATUsed(ctx context.Context, lockStrength LockingStrength, patID string) error + SavePAT(ctx context.Context, strength LockingStrength, pat *types.PersonalAccessToken) error + DeletePAT(ctx context.Context, strength LockingStrength, userID, patID string) error + GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Group, error) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types.Group, error) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types.Group, error) diff --git a/management/server/testdata/store.sql b/management/server/testdata/store.sql index 1c0767bde..41b8fa2f7 100644 --- a/management/server/testdata/store.sql +++ b/management/server/testdata/store.sql @@ -37,7 +37,7 @@ CREATE INDEX `idx_network_resources_id` ON `network_resources`(`id`); CREATE INDEX `idx_networks_id` ON `networks`(`id`); CREATE INDEX `idx_networks_account_id` ON `networks`(`account_id`); -INSERT INTO accounts VALUES('bf1c8084-ba50-4ce7-9439-34653001fc3b','','2024-10-02 16:03:06.778746+02:00','test.com','private',1,'af1c8024-ha40-4ce2-9418-34653101fc3c','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO accounts VALUES('bf1c8084-ba50-4ce7-9439-34653001fc3b','edafee4e-63fb-11ec-90d6-0242ac120003','2024-10-02 16:03:06.778746+02:00','test.com','private',1,'af1c8024-ha40-4ce2-9418-34653101fc3c','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL); INSERT INTO "groups" VALUES('cs1tnh0hhcjnqoiuebeg','bf1c8084-ba50-4ce7-9439-34653001fc3b','All','api','[]',0,''); INSERT INTO setup_keys VALUES('','bf1c8084-ba50-4ce7-9439-34653001fc3b','A2C8E62B-38F5-4553-B31E-DD66C696CEBB','Default key','reusable','2021-08-19 20:46:20.005936822+02:00','2321-09-18 20:46:20.005936822+02:00','2021-08-19 20:46:20.005936822+02:00',0,0,NULL,'["cs1tnh0hhcjnqoiuebeg"]',0,0); INSERT INTO users VALUES('a23efe53-63fb-11ec-90d6-0242ac120003','bf1c8084-ba50-4ce7-9439-34653001fc3b','owner',0,0,'','[]',0,NULL,'2024-10-02 16:03:06.779156+02:00','api',0,''); diff --git a/management/server/types/personal_access_token.go b/management/server/types/personal_access_token.go index ff157fcc6..0aa6b152b 100644 --- a/management/server/types/personal_access_token.go +++ b/management/server/types/personal_access_token.go @@ -75,7 +75,7 @@ type PersonalAccessTokenGenerated struct { // CreateNewPAT will generate a new PersonalAccessToken that can be assigned to a User. // Additionally, it will return the token in plain text once, to give to the user and only save a hashed version -func CreateNewPAT(name string, expirationInDays int, createdBy string) (*PersonalAccessTokenGenerated, error) { +func CreateNewPAT(name string, expirationInDays int, targetID, createdBy string) (*PersonalAccessTokenGenerated, error) { hashedToken, plainToken, err := generateNewToken() if err != nil { return nil, err @@ -84,6 +84,7 @@ func CreateNewPAT(name string, expirationInDays int, createdBy string) (*Persona return &PersonalAccessTokenGenerated{ PersonalAccessToken: PersonalAccessToken{ ID: xid.New().String(), + UserID: targetID, Name: name, HashedToken: hashedToken, ExpirationDate: util.ToPtr(currentTime.AddDate(0, 0, expirationInDays)), diff --git a/management/server/user.go b/management/server/user.go index 17770a423..6ba9b68d3 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -4,13 +4,10 @@ import ( "context" "errors" "fmt" - "slices" "strings" "time" "github.com/google/uuid" - log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/management/server/activity" nbContext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/idp" @@ -20,6 +17,7 @@ import ( "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/util" + log "github.com/sirupsen/logrus" ) // createServiceUser creates a new service user under the given account. @@ -27,30 +25,29 @@ func (am *DefaultAccountManager) createServiceUser(ctx context.Context, accountI unlock := am.Store.AcquireWriteLockByUID(ctx, accountID) defer unlock() - account, err := am.Store.GetAccount(ctx, accountID) + initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, initiatorUserID) if err != nil { - return nil, status.Errorf(status.NotFound, "account %s doesn't exist", accountID) + return nil, err } - executingUser := account.Users[initiatorUserID] - if executingUser == nil { - return nil, status.Errorf(status.NotFound, "user not found") + if initiatorUser.AccountID != accountID { + return nil, status.NewUserNotPartOfAccountError() } - if !executingUser.HasAdminPower() { - return nil, status.Errorf(status.PermissionDenied, "only users with admin power can create service users") + + if !initiatorUser.HasAdminPower() { + return nil, status.NewAdminPermissionError() } if role == types.UserRoleOwner { - return nil, status.Errorf(status.InvalidArgument, "can't create a service user with owner role") + return nil, status.NewServiceUserRoleInvalidError() } newUserID := uuid.New().String() newUser := types.NewUser(newUserID, role, true, nonDeletable, serviceUserName, autoGroups, types.UserIssuedAPI) + newUser.AccountID = accountID log.WithContext(ctx).Debugf("New User: %v", newUser) - account.Users[newUserID] = newUser - err = am.Store.SaveAccount(ctx, account) - if err != nil { + if err = am.Store.SaveUser(ctx, store.LockingStrengthUpdate, newUser); err != nil { return nil, err } @@ -87,40 +84,67 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u return nil, status.Errorf(status.PreconditionFailed, "IdP manager must be enabled to send user invites") } - if invite == nil { - return nil, fmt.Errorf("provided user update is nil") + if err := validateUserInvite(invite); err != nil { + return nil, err } - invitedRole := types.StrRoleToUserRole(invite.Role) - - switch { - case invite.Name == "": - return nil, status.Errorf(status.InvalidArgument, "name can't be empty") - case invite.Email == "": - return nil, status.Errorf(status.InvalidArgument, "email can't be empty") - case invitedRole == types.UserRoleOwner: - return nil, status.Errorf(status.InvalidArgument, "can't invite a user with owner role") - default: - } - - account, err := am.Store.GetAccount(ctx, accountID) + initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, userID) if err != nil { - return nil, status.Errorf(status.NotFound, "account %s doesn't exist", accountID) + return nil, err } - initiatorUser, err := account.FindUser(userID) - if err != nil { - return nil, status.Errorf(status.NotFound, "initiator user with ID %s doesn't exist", userID) + if initiatorUser.AccountID != accountID { + return nil, status.NewUserNotPartOfAccountError() } inviterID := userID if initiatorUser.IsServiceUser { - inviterID = account.CreatedBy + createdBy, err := am.Store.GetAccountCreatedBy(ctx, store.LockingStrengthShare, accountID) + if err != nil { + return nil, err + } + inviterID = createdBy } + idpUser, err := am.createNewIdpUser(ctx, accountID, inviterID, invite) + if err != nil { + return nil, err + } + + newUser := &types.User{ + Id: idpUser.ID, + AccountID: accountID, + Role: types.StrRoleToUserRole(invite.Role), + AutoGroups: invite.AutoGroups, + Issued: invite.Issued, + IntegrationReference: invite.IntegrationReference, + CreatedAt: time.Now().UTC(), + } + + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthShare, accountID) + if err != nil { + return nil, err + } + + if err = am.Store.SaveUser(ctx, store.LockingStrengthUpdate, newUser); err != nil { + return nil, err + } + + _, err = am.refreshCache(ctx, accountID) + if err != nil { + return nil, err + } + + am.StoreEvent(ctx, userID, newUser.Id, accountID, activity.UserInvited, nil) + + return newUser.ToUserInfo(idpUser, settings) +} + +// createNewIdpUser validates the invite and creates a new user in the IdP +func (am *DefaultAccountManager) createNewIdpUser(ctx context.Context, accountID string, inviterID string, invite *types.UserInfo) (*idp.UserData, error) { // inviterUser is the one who is inviting the new user - inviterUser, err := am.lookupUserInCache(ctx, inviterID, account) - if err != nil || inviterUser == nil { + inviterUser, err := am.lookupUserInCache(ctx, inviterID, accountID) + if err != nil { return nil, status.Errorf(status.NotFound, "inviter user with ID %s doesn't exist in IdP", inviterID) } @@ -143,34 +167,7 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u return nil, status.Errorf(status.UserAlreadyExists, "can't invite a user with an existing NetBird account") } - idpUser, err := am.idpManager.CreateUser(ctx, invite.Email, invite.Name, accountID, inviterUser.Email) - if err != nil { - return nil, err - } - - newUser := &types.User{ - Id: idpUser.ID, - Role: invitedRole, - AutoGroups: invite.AutoGroups, - Issued: invite.Issued, - IntegrationReference: invite.IntegrationReference, - CreatedAt: time.Now().UTC(), - } - account.Users[idpUser.ID] = newUser - - err = am.Store.SaveAccount(ctx, account) - if err != nil { - return nil, err - } - - _, err = am.refreshCache(ctx, account.Id) - if err != nil { - return nil, err - } - - am.StoreEvent(ctx, userID, newUser.Id, accountID, activity.UserInvited, nil) - - return newUser.ToUserInfo(idpUser, account.Settings) + return am.idpManager.CreateUser(ctx, invite.Email, invite.Name, accountID, inviterUser.Email) } func (am *DefaultAccountManager) GetUserByID(ctx context.Context, id string) (*types.User, error) { @@ -210,60 +207,51 @@ func (am *DefaultAccountManager) GetUser(ctx context.Context, claims jwtclaims.A // ListUsers returns lists of all users under the account. // It doesn't populate user information such as email or name. func (am *DefaultAccountManager) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) { - unlock := am.Store.AcquireWriteLockByUID(ctx, accountID) - defer unlock() - - account, err := am.Store.GetAccount(ctx, accountID) - if err != nil { - return nil, err - } - - users := make([]*types.User, 0, len(account.Users)) - for _, item := range account.Users { - users = append(users, item) - } - - return users, nil + return am.Store.GetAccountUsers(ctx, store.LockingStrengthShare, accountID) } -func (am *DefaultAccountManager) deleteServiceUser(ctx context.Context, account *types.Account, initiatorUserID string, targetUser *types.User) { +func (am *DefaultAccountManager) deleteServiceUser(ctx context.Context, accountID string, initiatorUserID string, targetUser *types.User) error { + if err := am.Store.DeleteUser(ctx, store.LockingStrengthUpdate, accountID, targetUser.Id); err != nil { + return err + } meta := map[string]any{"name": targetUser.ServiceUserName, "created_at": targetUser.CreatedAt} - am.StoreEvent(ctx, initiatorUserID, targetUser.Id, account.Id, activity.ServiceUserDeleted, meta) - delete(account.Users, targetUser.Id) + am.StoreEvent(ctx, initiatorUserID, targetUser.Id, accountID, activity.ServiceUserDeleted, meta) + return nil } // DeleteUser deletes a user from the given account. -func (am *DefaultAccountManager) DeleteUser(ctx context.Context, accountID, initiatorUserID string, targetUserID string) error { +func (am *DefaultAccountManager) DeleteUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error { if initiatorUserID == targetUserID { return status.Errorf(status.InvalidArgument, "self deletion is not allowed") } + unlock := am.Store.AcquireWriteLockByUID(ctx, accountID) defer unlock() - account, err := am.Store.GetAccount(ctx, accountID) + initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, initiatorUserID) if err != nil { return err } - executingUser := account.Users[initiatorUserID] - if executingUser == nil { - return status.Errorf(status.NotFound, "user not found") - } - if !executingUser.HasAdminPower() { - return status.Errorf(status.PermissionDenied, "only users with admin power can delete users") + if initiatorUser.AccountID != accountID { + return status.NewUserNotPartOfAccountError() } - targetUser := account.Users[targetUserID] - if targetUser == nil { - return status.Errorf(status.NotFound, "target user not found") + if !initiatorUser.HasAdminPower() { + return status.NewAdminPermissionError() + } + + targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, targetUserID) + if err != nil { + return err } if targetUser.Role == types.UserRoleOwner { - return status.Errorf(status.PermissionDenied, "unable to delete a user with owner role") + return status.NewOwnerDeletePermissionError() } // disable deleting integration user if the initiator is not admin service user - if targetUser.Issued == types.UserIssuedIntegration && !executingUser.IsServiceUser { + if targetUser.Issued == types.UserIssuedIntegration && !initiatorUser.IsServiceUser { return status.Errorf(status.PermissionDenied, "only integration service user can delete this user") } @@ -273,64 +261,26 @@ func (am *DefaultAccountManager) DeleteUser(ctx context.Context, accountID, init return status.Errorf(status.PermissionDenied, "service user is marked as non-deletable") } - am.deleteServiceUser(ctx, account, initiatorUserID, targetUser) - return am.Store.SaveAccount(ctx, account) + return am.deleteServiceUser(ctx, accountID, initiatorUserID, targetUser) } - return am.deleteRegularUser(ctx, account, initiatorUserID, targetUserID) -} - -func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, account *types.Account, initiatorUserID, targetUserID string) error { - meta, updateAccountPeers, err := am.prepareUserDeletion(ctx, account, initiatorUserID, targetUserID) + userInfo, err := am.getUserInfo(ctx, targetUser, accountID) if err != nil { return err } - delete(account.Users, targetUserID) - if updateAccountPeers { - account.Network.IncSerial() - } - - err = am.Store.SaveAccount(ctx, account) + updateAccountPeers, err := am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo) if err != nil { return err } - am.StoreEvent(ctx, initiatorUserID, targetUserID, account.Id, activity.UserDeleted, meta) if updateAccountPeers { - am.UpdateAccountPeers(ctx, account.Id) + am.UpdateAccountPeers(ctx, accountID) } return nil } -func (am *DefaultAccountManager) deleteUserPeers(ctx context.Context, initiatorUserID string, targetUserID string, account *types.Account) (bool, error) { - peers, err := account.FindUserPeers(targetUserID) - if err != nil { - return false, status.Errorf(status.Internal, "failed to find user peers") - } - - hadPeers := len(peers) > 0 - if !hadPeers { - return false, nil - } - - eventsToStore, err := deletePeers(ctx, am, am.Store, account.Id, initiatorUserID, peers) - if err != nil { - return false, err - } - - for _, storeEvent := range eventsToStore { - storeEvent() - } - - for _, peer := range peers { - account.DeletePeer(peer.ID) - } - - return hadPeers, nil -} - // InviteUser resend invitations to users who haven't activated their accounts prior to the expiration period. func (am *DefaultAccountManager) InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error { unlock := am.Store.AcquireWriteLockByUID(ctx, accountID) @@ -340,13 +290,17 @@ func (am *DefaultAccountManager) InviteUser(ctx context.Context, accountID strin return status.Errorf(status.PreconditionFailed, "IdP manager must be enabled to send user invites") } - account, err := am.Store.GetAccount(ctx, accountID) + initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, initiatorUserID) if err != nil { - return status.Errorf(status.NotFound, "account %s doesn't exist", accountID) + return err + } + + if initiatorUser.AccountID != accountID { + return status.NewUserNotPartOfAccountError() } // check if the user is already registered with this ID - user, err := am.lookupUserInCache(ctx, targetUserID, account) + user, err := am.lookupUserInCache(ctx, targetUserID, accountID) if err != nil { return err } @@ -384,35 +338,31 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string return nil, status.Errorf(status.InvalidArgument, "expiration has to be between 1 and 365") } - account, err := am.Store.GetAccount(ctx, accountID) + initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, initiatorUserID) if err != nil { return nil, err } - targetUser, ok := account.Users[targetUserID] - if !ok { - return nil, status.Errorf(status.NotFound, "user not found") + if initiatorUser.AccountID != accountID { + return nil, status.NewUserNotPartOfAccountError() } - executingUser, ok := account.Users[initiatorUserID] - if !ok { - return nil, status.Errorf(status.NotFound, "user not found") + targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, targetUserID) + if err != nil { + return nil, err } - if !(initiatorUserID == targetUserID || (executingUser.HasAdminPower() && targetUser.IsServiceUser)) { - return nil, status.Errorf(status.PermissionDenied, "no permission to create PAT for this user") + if initiatorUserID != targetUserID && !(initiatorUser.HasAdminPower() && targetUser.IsServiceUser) { + return nil, status.NewAdminPermissionError() } - pat, err := types.CreateNewPAT(tokenName, expiresIn, executingUser.Id) + pat, err := types.CreateNewPAT(tokenName, expiresIn, targetUserID, initiatorUser.Id) if err != nil { return nil, status.Errorf(status.Internal, "failed to create PAT: %v", err) } - targetUser.PATs[pat.ID] = &pat.PersonalAccessToken - - err = am.Store.SaveAccount(ctx, account) - if err != nil { - return nil, status.Errorf(status.Internal, "failed to save account: %v", err) + if err = am.Store.SavePAT(ctx, store.LockingStrengthUpdate, &pat.PersonalAccessToken); err != nil { + return nil, err } meta := map[string]any{"name": pat.Name, "is_service_user": targetUser.IsServiceUser, "user_name": targetUser.ServiceUserName} @@ -426,48 +376,36 @@ func (am *DefaultAccountManager) DeletePAT(ctx context.Context, accountID string unlock := am.Store.AcquireWriteLockByUID(ctx, accountID) defer unlock() - account, err := am.Store.GetAccount(ctx, accountID) + initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, initiatorUserID) if err != nil { - return status.Errorf(status.NotFound, "account not found: %s", err) + return err } - targetUser, ok := account.Users[targetUserID] - if !ok { - return status.Errorf(status.NotFound, "user not found") + if initiatorUser.AccountID != accountID { + return status.NewUserNotPartOfAccountError() } - executingUser, ok := account.Users[initiatorUserID] - if !ok { - return status.Errorf(status.NotFound, "user not found") + if initiatorUserID != targetUserID && initiatorUser.IsRegularUser() { + return status.NewAdminPermissionError() } - if !(initiatorUserID == targetUserID || (executingUser.HasAdminPower() && targetUser.IsServiceUser)) { - return status.Errorf(status.PermissionDenied, "no permission to delete PAT for this user") - } - - pat := targetUser.PATs[tokenID] - if pat == nil { - return status.Errorf(status.NotFound, "PAT not found") - } - - err = am.Store.DeleteTokenID2UserIDIndex(pat.ID) + pat, err := am.Store.GetPATByID(ctx, store.LockingStrengthShare, targetUserID, tokenID) if err != nil { - return status.Errorf(status.Internal, "Failed to delete token id index: %s", err) + return err } - err = am.Store.DeleteHashedPAT2TokenIDIndex(pat.HashedToken) + + targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, targetUserID) if err != nil { - return status.Errorf(status.Internal, "Failed to delete hashed token index: %s", err) + return err + } + + if err = am.Store.DeletePAT(ctx, store.LockingStrengthUpdate, targetUserID, tokenID); err != nil { + return err } meta := map[string]any{"name": pat.Name, "is_service_user": targetUser.IsServiceUser, "user_name": targetUser.ServiceUserName} am.StoreEvent(ctx, initiatorUserID, targetUserID, accountID, activity.PersonalAccessTokenDeleted, meta) - delete(targetUser.PATs, tokenID) - - err = am.Store.SaveAccount(ctx, account) - if err != nil { - return status.Errorf(status.Internal, "Failed to save account: %s", err) - } return nil } @@ -478,22 +416,15 @@ func (am *DefaultAccountManager) GetPAT(ctx context.Context, accountID string, i return nil, err } - targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, targetUserID) - if err != nil { - return nil, err + if initiatorUser.AccountID != accountID { + return nil, status.NewUserNotPartOfAccountError() } - if (initiatorUserID != targetUserID && !initiatorUser.IsAdminOrServiceUser()) || initiatorUser.AccountID != accountID { - return nil, status.Errorf(status.PermissionDenied, "no permission to get PAT for this user") + if initiatorUserID != targetUserID && initiatorUser.IsRegularUser() { + return nil, status.NewAdminPermissionError() } - for _, pat := range targetUser.PATsG { - if pat.ID == tokenID { - return pat.Copy(), nil - } - } - - return nil, status.Errorf(status.NotFound, "PAT not found") + return am.Store.GetPATByID(ctx, store.LockingStrengthShare, targetUserID, tokenID) } // GetAllPATs returns all PATs for a user @@ -503,21 +434,15 @@ func (am *DefaultAccountManager) GetAllPATs(ctx context.Context, accountID strin return nil, err } - targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, targetUserID) - if err != nil { - return nil, err + if initiatorUser.AccountID != accountID { + return nil, status.NewUserNotPartOfAccountError() } - if (initiatorUserID != targetUserID && !initiatorUser.IsAdminOrServiceUser()) || initiatorUser.AccountID != accountID { - return nil, status.Errorf(status.PermissionDenied, "no permission to get PAT for this user") + if initiatorUserID != targetUserID && initiatorUser.IsRegularUser() { + return nil, status.NewAdminPermissionError() } - pats := make([]*types.PersonalAccessToken, 0, len(targetUser.PATsG)) - for _, pat := range targetUser.PATsG { - pats = append(pats, pat.Copy()) - } - - return pats, nil + return am.Store.GetUserPATs(ctx, store.LockingStrengthShare, targetUserID) } // SaveUser saves updates to the given user. If the user doesn't exist, it will throw status.NotFound error. @@ -528,10 +453,6 @@ func (am *DefaultAccountManager) SaveUser(ctx context.Context, accountID, initia // SaveOrAddUser updates the given user. If addIfNotExists is set to true it will add user when no exist // Only User.AutoGroups, User.Role, and User.Blocked fields are allowed to be updated for now. func (am *DefaultAccountManager) SaveOrAddUser(ctx context.Context, accountID, initiatorUserID string, update *types.User, addIfNotExists bool) (*types.UserInfo, error) { - if update == nil { - return nil, status.Errorf(status.InvalidArgument, "provided user update is nil") - } - unlock := am.Store.AcquireWriteLockByUID(ctx, accountID) defer unlock() @@ -555,125 +476,113 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, return nil, nil //nolint:nilnil } - account, err := am.Store.GetAccount(ctx, accountID) + initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, initiatorUserID) if err != nil { return nil, err } - initiatorUser, err := account.FindUser(initiatorUserID) - if err != nil { - return nil, err + if initiatorUser.AccountID != accountID { + return nil, status.NewUserNotPartOfAccountError() } if !initiatorUser.HasAdminPower() || initiatorUser.IsBlocked() { - return nil, status.Errorf(status.PermissionDenied, "only users with admin power are authorized to perform user update operations") + return nil, status.NewAdminPermissionError() } - updatedUsers := make([]*types.UserInfo, 0, len(updates)) - var ( - expiredPeers []*nbpeer.Peer - userIDs []string - eventsToStore []func() - ) + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthShare, accountID) + if err != nil { + return nil, err + } - for _, update := range updates { - if update == nil { - return nil, status.Errorf(status.InvalidArgument, "provided user update is nil") - } + var updateAccountPeers bool + var peersToExpire []*nbpeer.Peer + var addUserEvents []func() + var usersToSave = make([]*types.User, 0, len(updates)) - userIDs = append(userIDs, update.Id) + groups, err := am.Store.GetAccountGroups(ctx, store.LockingStrengthShare, accountID) + if err != nil { + return nil, fmt.Errorf("error getting account groups: %w", err) + } - oldUser := account.Users[update.Id] - if oldUser == nil { - if !addIfNotExists { - return nil, status.Errorf(status.NotFound, "user to update doesn't exist: %s", update.Id) + groupsMap := make(map[string]*types.Group, len(groups)) + for _, group := range groups { + groupsMap[group.ID] = group + } + + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + for _, update := range updates { + if update == nil { + return status.Errorf(status.InvalidArgument, "provided user update is nil") } - // when addIfNotExists is set to true, the newUser will use all fields from the update input - oldUser = update - } - if err := validateUserUpdate(account, initiatorUser, oldUser, update); err != nil { - return nil, err - } - - // only auto groups, revoked status, and integration reference can be updated for now - newUser := oldUser.Copy() - newUser.Role = update.Role - newUser.Blocked = update.Blocked - newUser.AutoGroups = update.AutoGroups - // these two fields can't be set via API, only via direct call to the method - newUser.Issued = update.Issued - newUser.IntegrationReference = update.IntegrationReference - - transferredOwnerRole := handleOwnerRoleTransfer(account, initiatorUser, update) - account.Users[newUser.Id] = newUser - - if !oldUser.IsBlocked() && update.IsBlocked() { - // expire peers that belong to the user who's getting blocked - blockedPeers, err := account.FindUserPeers(update.Id) + userHadPeers, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate( + ctx, transaction, groupsMap, initiatorUser, update, addIfNotExists, settings, + ) if err != nil { - return nil, err + return fmt.Errorf("failed to process user update: %w", err) + } + usersToSave = append(usersToSave, updatedUser) + addUserEvents = append(addUserEvents, userEvents...) + peersToExpire = append(peersToExpire, userPeersToExpire...) + + if userHadPeers { + updateAccountPeers = true } - expiredPeers = append(expiredPeers, blockedPeers...) } - - peerGroupsAdded := make(map[string][]string) - peerGroupsRemoved := make(map[string][]string) - if update.AutoGroups != nil && account.Settings.GroupsPropagationEnabled { - removedGroups := util.Difference(oldUser.AutoGroups, update.AutoGroups) - // need force update all auto groups in any case they will not be duplicated - peerGroupsAdded = account.UserGroupsAddToPeers(oldUser.Id, update.AutoGroups...) - peerGroupsRemoved = account.UserGroupsRemoveFromPeers(oldUser.Id, removedGroups...) - } - - userUpdateEvents := am.prepareUserUpdateEvents(ctx, initiatorUser.Id, oldUser, newUser, account, transferredOwnerRole) - eventsToStore = append(eventsToStore, userUpdateEvents...) - - userGroupsEvents := am.prepareUserGroupsEvents(ctx, initiatorUser.Id, oldUser, newUser, account, peerGroupsAdded, peerGroupsRemoved) - eventsToStore = append(eventsToStore, userGroupsEvents...) - - updatedUserInfo, err := getUserInfo(ctx, am, newUser, account) - if err != nil { - return nil, err - } - updatedUsers = append(updatedUsers, updatedUserInfo) + return transaction.SaveUsers(ctx, store.LockingStrengthUpdate, usersToSave) + }) + if err != nil { + return nil, err } - if len(expiredPeers) > 0 { - if err := am.expireAndUpdatePeers(ctx, account.Id, expiredPeers); err != nil { + var updatedUsersInfo = make([]*types.UserInfo, 0, len(updates)) + + userInfos, err := am.GetUsersFromAccount(ctx, accountID, initiatorUserID) + if err != nil { + return nil, err + } + + for _, updatedUser := range usersToSave { + updatedUserInfo, ok := userInfos[updatedUser.Id] + if !ok || updatedUserInfo == nil { + return nil, fmt.Errorf("failed to get user: %s updated user info", updatedUser.Id) + } + updatedUsersInfo = append(updatedUsersInfo, updatedUserInfo) + } + + for _, addUserEvent := range addUserEvents { + addUserEvent() + } + + if len(peersToExpire) > 0 { + if err := am.expireAndUpdatePeers(ctx, accountID, peersToExpire); err != nil { log.WithContext(ctx).Errorf("failed update expired peers: %s", err) return nil, err } } - account.Network.IncSerial() - if err = am.Store.SaveAccount(ctx, account); err != nil { - return nil, err + if settings.GroupsPropagationEnabled && updateAccountPeers { + if err = am.Store.IncrementNetworkSerial(ctx, store.LockingStrengthUpdate, accountID); err != nil { + return nil, fmt.Errorf("failed to increment network serial: %w", err) + } + am.UpdateAccountPeers(ctx, accountID) } - if account.Settings.GroupsPropagationEnabled && areUsersLinkedToPeers(account, userIDs) { - am.UpdateAccountPeers(ctx, account.Id) - } - - for _, storeEvent := range eventsToStore { - storeEvent() - } - - return updatedUsers, nil + return updatedUsersInfo, nil } // prepareUserUpdateEvents prepares a list user update events based on the changes between the old and new user data. -func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, initiatorUserID string, oldUser, newUser *types.User, account *types.Account, transferredOwnerRole bool) []func() { +func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, accountID string, initiatorUserID string, oldUser, newUser *types.User, transferredOwnerRole bool) []func() { var eventsToStore []func() if oldUser.IsBlocked() != newUser.IsBlocked() { if newUser.IsBlocked() { eventsToStore = append(eventsToStore, func() { - am.StoreEvent(ctx, initiatorUserID, oldUser.Id, account.Id, activity.UserBlocked, nil) + am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.UserBlocked, nil) }) } else { eventsToStore = append(eventsToStore, func() { - am.StoreEvent(ctx, initiatorUserID, oldUser.Id, account.Id, activity.UserUnblocked, nil) + am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.UserUnblocked, nil) }) } } @@ -681,115 +590,126 @@ func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, in switch { case transferredOwnerRole: eventsToStore = append(eventsToStore, func() { - am.StoreEvent(ctx, initiatorUserID, oldUser.Id, account.Id, activity.TransferredOwnerRole, nil) + am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.TransferredOwnerRole, nil) }) case oldUser.Role != newUser.Role: eventsToStore = append(eventsToStore, func() { - am.StoreEvent(ctx, initiatorUserID, oldUser.Id, account.Id, activity.UserRoleUpdated, map[string]any{"role": newUser.Role}) + am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.UserRoleUpdated, map[string]any{"role": newUser.Role}) }) } return eventsToStore } -func (am *DefaultAccountManager) prepareUserGroupsEvents(ctx context.Context, initiatorUserID string, oldUser, newUser *types.User, account *types.Account, peerGroupsAdded, peerGroupsRemoved map[string][]string) []func() { - var eventsToStore []func() - if newUser.AutoGroups != nil { - removedGroups := util.Difference(oldUser.AutoGroups, newUser.AutoGroups) - addedGroups := util.Difference(newUser.AutoGroups, oldUser.AutoGroups) +func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transaction store.Store, groupsMap map[string]*types.Group, + initiatorUser, update *types.User, addIfNotExists bool, settings *types.Settings) (bool, *types.User, []*nbpeer.Peer, []func(), error) { - removedEvents := am.handleGroupRemovedFromUser(ctx, initiatorUserID, oldUser, newUser, account, removedGroups, peerGroupsRemoved) - eventsToStore = append(eventsToStore, removedEvents...) - - addedEvents := am.handleGroupAddedToUser(ctx, initiatorUserID, oldUser, newUser, account, addedGroups, peerGroupsAdded) - eventsToStore = append(eventsToStore, addedEvents...) + if update == nil { + return false, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil") } - return eventsToStore + + oldUser, err := getUserOrCreateIfNotExists(ctx, transaction, update, addIfNotExists) + if err != nil { + return false, nil, nil, nil, err + } + + if err := validateUserUpdate(groupsMap, initiatorUser, oldUser, update); err != nil { + return false, nil, nil, nil, err + } + + // only auto groups, revoked status, and integration reference can be updated for now + updatedUser := oldUser.Copy() + updatedUser.AccountID = initiatorUser.AccountID + updatedUser.Role = update.Role + updatedUser.Blocked = update.Blocked + updatedUser.AutoGroups = update.AutoGroups + // these two fields can't be set via API, only via direct call to the method + updatedUser.Issued = update.Issued + updatedUser.IntegrationReference = update.IntegrationReference + + transferredOwnerRole, err := handleOwnerRoleTransfer(ctx, transaction, initiatorUser, update) + if err != nil { + return false, nil, nil, nil, err + } + + userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthUpdate, updatedUser.AccountID, update.Id) + if err != nil { + return false, nil, nil, nil, err + } + + var peersToExpire []*nbpeer.Peer + + if !oldUser.IsBlocked() && update.IsBlocked() { + peersToExpire = userPeers + } + + if update.AutoGroups != nil && settings.GroupsPropagationEnabled { + removedGroups := util.Difference(oldUser.AutoGroups, update.AutoGroups) + updatedGroups, err := updateUserPeersInGroups(groupsMap, userPeers, update.AutoGroups, removedGroups) + if err != nil { + return false, nil, nil, nil, fmt.Errorf("error modifying user peers in groups: %w", err) + } + + if err = transaction.SaveGroups(ctx, store.LockingStrengthUpdate, updatedGroups); err != nil { + return false, nil, nil, nil, fmt.Errorf("error saving groups: %w", err) + } + } + + updateAccountPeers := len(userPeers) > 0 + userEventsToAdd := am.prepareUserUpdateEvents(ctx, updatedUser.AccountID, initiatorUser.Id, oldUser, updatedUser, transferredOwnerRole) + + return updateAccountPeers, updatedUser, peersToExpire, userEventsToAdd, nil } -func (am *DefaultAccountManager) handleGroupAddedToUser(ctx context.Context, initiatorUserID string, oldUser, newUser *types.User, account *types.Account, addedGroups []string, peerGroupsAdded map[string][]string) []func() { - var eventsToStore []func() - for _, g := range addedGroups { - group := account.GetGroup(g) - if group != nil { - eventsToStore = append(eventsToStore, func() { - am.StoreEvent(ctx, initiatorUserID, oldUser.Id, account.Id, activity.GroupAddedToUser, - map[string]any{"group": group.Name, "group_id": group.ID, "is_service_user": newUser.IsServiceUser, "user_name": newUser.ServiceUserName}) - }) +// getUserOrCreateIfNotExists retrieves the existing user or creates a new one if it doesn't exist. +func getUserOrCreateIfNotExists(ctx context.Context, transaction store.Store, update *types.User, addIfNotExists bool) (*types.User, error) { + existingUser, err := transaction.GetUserByUserID(ctx, store.LockingStrengthShare, update.Id) + if err != nil { + if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { + if !addIfNotExists { + return nil, status.Errorf(status.NotFound, "user to update doesn't exist: %s", update.Id) + } + return update, nil // use all fields from update if addIfNotExists is true } + return nil, err } - for groupID, peerIDs := range peerGroupsAdded { - group := account.GetGroup(groupID) - for _, peerID := range peerIDs { - peer := account.GetPeer(peerID) - eventsToStore = append(eventsToStore, func() { - meta := map[string]any{ - "group": group.Name, "group_id": group.ID, - "peer_ip": peer.IP.String(), "peer_fqdn": peer.FQDN(am.GetDNSDomain()), - } - am.StoreEvent(ctx, activity.SystemInitiator, peer.ID, account.Id, activity.GroupAddedToPeer, meta) - }) - } - } - return eventsToStore + return existingUser, nil } -func (am *DefaultAccountManager) handleGroupRemovedFromUser(ctx context.Context, initiatorUserID string, oldUser, newUser *types.User, account *types.Account, removedGroups []string, peerGroupsRemoved map[string][]string) []func() { - var eventsToStore []func() - for _, g := range removedGroups { - group := account.GetGroup(g) - if group != nil { - eventsToStore = append(eventsToStore, func() { - am.StoreEvent(ctx, initiatorUserID, oldUser.Id, account.Id, activity.GroupRemovedFromUser, - map[string]any{"group": group.Name, "group_id": group.ID, "is_service_user": newUser.IsServiceUser, "user_name": newUser.ServiceUserName}) - }) - - } else { - log.WithContext(ctx).Errorf("group %s not found while saving user activity event of account %s", g, account.Id) - } - } - for groupID, peerIDs := range peerGroupsRemoved { - group := account.GetGroup(groupID) - for _, peerID := range peerIDs { - peer := account.GetPeer(peerID) - eventsToStore = append(eventsToStore, func() { - meta := map[string]any{ - "group": group.Name, "group_id": group.ID, - "peer_ip": peer.IP.String(), "peer_fqdn": peer.FQDN(am.GetDNSDomain()), - } - am.StoreEvent(ctx, activity.SystemInitiator, peer.ID, account.Id, activity.GroupRemovedFromPeer, meta) - }) - } - } - return eventsToStore -} - -func handleOwnerRoleTransfer(account *types.Account, initiatorUser, update *types.User) bool { +func handleOwnerRoleTransfer(ctx context.Context, transaction store.Store, initiatorUser, update *types.User) (bool, error) { if initiatorUser.Role == types.UserRoleOwner && initiatorUser.Id != update.Id && update.Role == types.UserRoleOwner { newInitiatorUser := initiatorUser.Copy() newInitiatorUser.Role = types.UserRoleAdmin - account.Users[initiatorUser.Id] = newInitiatorUser - return true + + if err := transaction.SaveUser(ctx, store.LockingStrengthUpdate, newInitiatorUser); err != nil { + return false, err + } + return true, nil } - return false + return false, nil } // getUserInfo retrieves the UserInfo for a given User and Account. // If the AccountManager has a non-nil idpManager and the User is not a service user, // it will attempt to look up the UserData from the cache. -func getUserInfo(ctx context.Context, am *DefaultAccountManager, user *types.User, account *types.Account) (*types.UserInfo, error) { +func (am *DefaultAccountManager) getUserInfo(ctx context.Context, user *types.User, accountID string) (*types.UserInfo, error) { + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthShare, accountID) + if err != nil { + return nil, err + } + if !isNil(am.idpManager) && !user.IsServiceUser { - userData, err := am.lookupUserInCache(ctx, user.Id, account) + userData, err := am.lookupUserInCache(ctx, user.Id, accountID) if err != nil { return nil, err } - return user.ToUserInfo(userData, account.Settings) + return user.ToUserInfo(userData, settings) } - return user.ToUserInfo(nil, account.Settings) + return user.ToUserInfo(nil, settings) } // validateUserUpdate validates the update operation for a user. -func validateUserUpdate(account *types.Account, initiatorUser, oldUser, update *types.User) error { +func validateUserUpdate(groupsMap map[string]*types.Group, initiatorUser, oldUser, update *types.User) error { if initiatorUser.HasAdminPower() && initiatorUser.Id == update.Id && oldUser.Blocked != update.Blocked { return status.Errorf(status.PermissionDenied, "admins can't block or unblock themselves") } @@ -810,12 +730,12 @@ func validateUserUpdate(account *types.Account, initiatorUser, oldUser, update * } for _, newGroupID := range update.AutoGroups { - group, ok := account.Groups[newGroupID] + group, ok := groupsMap[newGroupID] if !ok { return status.Errorf(status.InvalidArgument, "provided group ID %s in the user %s update doesn't exist", newGroupID, update.Id) } - if group.Name == "All" { + if group.IsGroupAll() { return status.Errorf(status.InvalidArgument, "can't add All group to the user") } } @@ -864,22 +784,38 @@ func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, u // GetUsersFromAccount performs a batched request for users from IDP by account ID apply filter on what data to return // based on provided user role. -func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accountID, userID string) ([]*types.UserInfo, error) { - account, err := am.Store.GetAccount(ctx, accountID) +func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accountID, initiatorUserID string) (map[string]*types.UserInfo, error) { + accountUsers, err := am.Store.GetAccountUsers(ctx, store.LockingStrengthShare, accountID) if err != nil { return nil, err } - user, err := account.FindUser(userID) + initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, initiatorUserID) + if err != nil { + return nil, err + } + + if initiatorUser.AccountID != accountID { + return nil, status.NewUserNotPartOfAccountError() + } + + return am.BuildUserInfosForAccount(ctx, accountID, initiatorUserID, accountUsers) +} + +// BuildUserInfosForAccount builds user info for the given account. +func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, accountID, initiatorUserID string, accountUsers []*types.User) (map[string]*types.UserInfo, error) { + var queriedUsers []*idp.UserData + var err error + + initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, initiatorUserID) if err != nil { return nil, err } - queriedUsers := make([]*idp.UserData, 0) if !isNil(am.idpManager) { - users := make(map[string]userLoggedInOnce, len(account.Users)) + users := make(map[string]userLoggedInOnce, len(accountUsers)) usersFromIntegration := make([]*idp.UserData, 0) - for _, user := range account.Users { + for _, user := range accountUsers { if user.Issued == types.UserIssuedIntegration { key := user.IntegrationReference.CacheKey(accountID, user.Id) info, err := am.externalCacheManager.Get(am.ctx, key) @@ -904,33 +840,40 @@ func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accoun queriedUsers = append(queriedUsers, usersFromIntegration...) } - userInfos := make([]*types.UserInfo, 0) + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthShare, accountID) + if err != nil { + return nil, err + } + + userInfosMap := make(map[string]*types.UserInfo) // in case of self-hosted, or IDP doesn't return anything, we will return the locally stored userInfo if len(queriedUsers) == 0 { - for _, accountUser := range account.Users { - if !(user.HasAdminPower() || user.IsServiceUser || user.Id == accountUser.Id) { + for _, accountUser := range accountUsers { + if initiatorUser.IsRegularUser() && initiatorUser.Id != accountUser.Id { // if user is not an admin then show only current user and do not show other users continue } - info, err := accountUser.ToUserInfo(nil, account.Settings) + + info, err := accountUser.ToUserInfo(nil, settings) if err != nil { return nil, err } - userInfos = append(userInfos, info) + userInfosMap[accountUser.Id] = info } - return userInfos, nil + + return userInfosMap, nil } - for _, localUser := range account.Users { - if !(user.HasAdminPower() || user.IsServiceUser) && user.Id != localUser.Id { + for _, localUser := range accountUsers { + if initiatorUser.IsRegularUser() && initiatorUser.Id != localUser.Id { // if user is not an admin then show only current user and do not show other users continue } var info *types.UserInfo if queriedUser, contains := findUserInIDPUserdata(localUser.Id, queriedUsers); contains { - info, err = localUser.ToUserInfo(queriedUser, account.Settings) + info, err = localUser.ToUserInfo(queriedUser, settings) if err != nil { return nil, err } @@ -943,7 +886,7 @@ func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accoun dashboardViewPermissions := "full" if !localUser.HasAdminPower() { dashboardViewPermissions = "limited" - if account.Settings.RegularUsersViewBlocked { + if settings.RegularUsersViewBlocked { dashboardViewPermissions = "blocked" } } @@ -960,10 +903,10 @@ func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accoun Permissions: types.UserPermissions{DashboardView: dashboardViewPermissions}, } } - userInfos = append(userInfos, info) + userInfosMap[info.ID] = info } - return userInfos, nil + return userInfosMap, nil } // expireAndUpdatePeers expires all peers of the given user and updates them in the account @@ -1017,55 +960,34 @@ func (am *DefaultAccountManager) deleteUserFromIDP(ctx context.Context, targetUs return nil } -func (am *DefaultAccountManager) getEmailAndNameOfTargetUser(ctx context.Context, accountId, initiatorId, targetId string) (string, string, error) { - userInfos, err := am.GetUsersFromAccount(ctx, accountId, initiatorId) - if err != nil { - return "", "", err - } - for _, ui := range userInfos { - if ui.ID == targetId { - return ui.Email, ui.Name, nil - } - } - - return "", "", fmt.Errorf("user info not found for user: %s", targetId) -} - // DeleteRegularUsers deletes regular users from an account. // Note: This function does not acquire the global lock. // It is the caller's responsibility to ensure proper locking is in place before invoking this method. // // If an error occurs while deleting the user, the function skips it and continues deleting other users. // Errors are collected and returned at the end. -func (am *DefaultAccountManager) DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string) error { - account, err := am.Store.GetAccount(ctx, accountID) +func (am *DefaultAccountManager) DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error { + initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, initiatorUserID) if err != nil { return err } - executingUser := account.Users[initiatorUserID] - if executingUser == nil { - return status.Errorf(status.NotFound, "user not found") - } - if !executingUser.HasAdminPower() { - return status.Errorf(status.PermissionDenied, "only users with admin power can delete users") + if !initiatorUser.HasAdminPower() { + return status.NewAdminPermissionError() } - var ( - allErrors error - updateAccountPeers bool - ) + var allErrors error + var updateAccountPeers bool - deletedUsersMeta := make(map[string]map[string]any) for _, targetUserID := range targetUserIDs { if initiatorUserID == targetUserID { allErrors = errors.Join(allErrors, errors.New("self deletion is not allowed")) continue } - targetUser := account.Users[targetUserID] - if targetUser == nil { - allErrors = errors.Join(allErrors, fmt.Errorf("target user: %s not found", targetUserID)) + targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthShare, targetUserID) + if err != nil { + allErrors = errors.Join(allErrors, err) continue } @@ -1075,88 +997,97 @@ func (am *DefaultAccountManager) DeleteRegularUsers(ctx context.Context, account } // disable deleting integration user if the initiator is not admin service user - if targetUser.Issued == types.UserIssuedIntegration && !executingUser.IsServiceUser { + if targetUser.Issued == types.UserIssuedIntegration && !initiatorUser.IsServiceUser { allErrors = errors.Join(allErrors, errors.New("only integration service user can delete this user")) continue } - meta, hadPeers, err := am.prepareUserDeletion(ctx, account, initiatorUserID, targetUserID) - if err != nil { - allErrors = errors.Join(allErrors, fmt.Errorf("failed to delete user %s: %s", targetUserID, err)) + userInfo, ok := userInfos[targetUserID] + if !ok || userInfo == nil { + allErrors = errors.Join(allErrors, fmt.Errorf("user info not found for user: %s", targetUserID)) continue } - if hadPeers { - updateAccountPeers = true + userHadPeers, err := am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo) + if err != nil { + allErrors = errors.Join(allErrors, err) + continue } - delete(account.Users, targetUserID) - deletedUsersMeta[targetUserID] = meta - } - - if updateAccountPeers { - account.Network.IncSerial() - } - err = am.Store.SaveAccount(ctx, account) - if err != nil { - return fmt.Errorf("failed to delete users: %w", err) + if userHadPeers { + updateAccountPeers = true + } } if updateAccountPeers { am.UpdateAccountPeers(ctx, accountID) } - for targetUserID, meta := range deletedUsersMeta { - am.StoreEvent(ctx, initiatorUserID, targetUserID, account.Id, activity.UserDeleted, meta) - } - return allErrors } -func (am *DefaultAccountManager) prepareUserDeletion(ctx context.Context, account *types.Account, initiatorUserID, targetUserID string) (map[string]any, bool, error) { - tuEmail, tuName, err := am.getEmailAndNameOfTargetUser(ctx, account.Id, initiatorUserID, targetUserID) - if err != nil { - log.WithContext(ctx).Errorf("failed to resolve email address: %s", err) - return nil, false, err - } - +// deleteRegularUser deletes a specified user and their related peers from the account. +func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountID, initiatorUserID string, targetUserInfo *types.UserInfo) (bool, error) { if !isNil(am.idpManager) { // Delete if the user already exists in the IdP. Necessary in cases where a user account // was created where a user account was provisioned but the user did not sign in - _, err = am.idpManager.GetUserDataByID(ctx, targetUserID, idp.AppMetadata{WTAccountID: account.Id}) + _, err := am.idpManager.GetUserDataByID(ctx, targetUserInfo.ID, idp.AppMetadata{WTAccountID: accountID}) if err == nil { - err = am.deleteUserFromIDP(ctx, targetUserID, account.Id) + err = am.deleteUserFromIDP(ctx, targetUserInfo.ID, accountID) if err != nil { - log.WithContext(ctx).Debugf("failed to delete user from IDP: %s", targetUserID) - return nil, false, err + log.WithContext(ctx).Debugf("failed to delete user from IDP: %s", targetUserInfo.ID) + return false, err } } else { - log.WithContext(ctx).Debugf("skipped deleting user %s from IDP, error: %v", targetUserID, err) + log.WithContext(ctx).Debugf("skipped deleting user %s from IDP, error: %v", targetUserInfo.ID, err) } } - hadPeers, err := am.deleteUserPeers(ctx, initiatorUserID, targetUserID, account) + var addPeerRemovedEvents []func() + var updateAccountPeers bool + var targetUser *types.User + var err error + + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + targetUser, err = transaction.GetUserByUserID(ctx, store.LockingStrengthShare, targetUserInfo.ID) + if err != nil { + return fmt.Errorf("failed to get user to delete: %w", err) + } + + userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthShare, accountID, targetUserInfo.ID) + if err != nil { + return fmt.Errorf("failed to get user peers: %w", err) + } + + if len(userPeers) > 0 { + updateAccountPeers = true + addPeerRemovedEvents, err = deletePeers(ctx, am, transaction, accountID, targetUserInfo.ID, userPeers) + if err != nil { + return fmt.Errorf("failed to delete user peers: %w", err) + } + } + + if err = transaction.DeleteUser(ctx, store.LockingStrengthUpdate, accountID, targetUserInfo.ID); err != nil { + return fmt.Errorf("failed to delete user: %s %w", targetUserInfo.ID, err) + } + + return nil + }) if err != nil { - return nil, false, err + return false, err } - u, err := account.FindUser(targetUserID) - if err != nil { - log.WithContext(ctx).Errorf("failed to find user %s for deletion, this should never happen: %s", targetUserID, err) + for _, addPeerRemovedEvent := range addPeerRemovedEvents { + addPeerRemovedEvent() } + meta := map[string]any{"name": targetUserInfo.Name, "email": targetUserInfo.Email, "created_at": targetUser.CreatedAt} + am.StoreEvent(ctx, initiatorUserID, targetUser.Id, accountID, activity.UserDeleted, meta) - var tuCreatedAt time.Time - if u != nil { - tuCreatedAt = u.CreatedAt - } - - return map[string]any{"name": tuName, "email": tuEmail, "created_at": tuCreatedAt}, hadPeers, nil + return updateAccountPeers, nil } // updateUserPeersInGroups updates the user's peers in the specified groups by adding or removing them. -func (am *DefaultAccountManager) updateUserPeersInGroups(accountGroups map[string]*types.Group, peers []*nbpeer.Peer, groupsToAdd, - groupsToRemove []string) (groupsToUpdate []*types.Group, err error) { - +func updateUserPeersInGroups(accountGroups map[string]*types.Group, peers []*nbpeer.Peer, groupsToAdd, groupsToRemove []string) (groupsToUpdate []*types.Group, err error) { if len(groupsToAdd) == 0 && len(groupsToRemove) == 0 { return } @@ -1230,12 +1161,22 @@ func findUserInIDPUserdata(userID string, userData []*idp.UserData) (*idp.UserDa return nil, false } -// areUsersLinkedToPeers checks if any of the given userIDs are linked to any of the peers in the account. -func areUsersLinkedToPeers(account *types.Account, userIDs []string) bool { - for _, peer := range account.Peers { - if slices.Contains(userIDs, peer.UserID) { - return true - } +func validateUserInvite(invite *types.UserInfo) error { + if invite == nil { + return fmt.Errorf("provided user update is nil") } - return false + + invitedRole := types.StrRoleToUserRole(invite.Role) + + switch { + case invite.Name == "": + return status.Errorf(status.InvalidArgument, "name can't be empty") + case invite.Email == "": + return status.Errorf(status.InvalidArgument, "email can't be empty") + case invitedRole == types.UserRoleOwner: + return status.Errorf(status.InvalidArgument, "can't invite a user with owner role") + default: + } + + return nil } diff --git a/management/server/user_test.go b/management/server/user_test.go index a028d164b..4a532c8a6 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -11,6 +11,7 @@ import ( cacheStore "github.com/eko/gocache/v3/store" "github.com/google/go-cmp/cmp" "github.com/netbirdio/netbird/management/server/util" + "golang.org/x/exp/maps" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/store" @@ -45,7 +46,7 @@ const ( ) func TestUser_CreatePAT_ForSameUser(t *testing.T) { - store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) if err != nil { t.Fatalf("Error when creating store: %s", err) } @@ -53,13 +54,13 @@ func TestUser_CreatePAT_ForSameUser(t *testing.T) { account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "") - err = store.SaveAccount(context.Background(), account) + err = s.SaveAccount(context.Background(), account) if err != nil { t.Fatalf("Error when saving account: %s", err) } am := DefaultAccountManager{ - Store: store, + Store: s, eventStore: &activity.InMemoryEventStore{}, } @@ -81,7 +82,7 @@ func TestUser_CreatePAT_ForSameUser(t *testing.T) { assert.Equal(t, pat.ID, tokenID) - user, err := am.Store.GetUserByTokenID(context.Background(), tokenID) + user, err := am.Store.GetUserByPATID(context.Background(), store.LockingStrengthShare, tokenID) if err != nil { t.Fatalf("Error when getting user by token ID: %s", err) } @@ -855,7 +856,7 @@ func TestUser_DeleteUser_RegularUsers(t *testing.T) { { name: "Delete non-existent user", userIDs: []string{"non-existent-user"}, - expectedReasons: []string{"target user: non-existent-user not found"}, + expectedReasons: []string{"user: non-existent-user not found"}, expectedNotDeleted: []string{}, }, { @@ -867,7 +868,10 @@ func TestUser_DeleteUser_RegularUsers(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - err = am.DeleteRegularUsers(context.Background(), mockAccountID, mockUserID, tc.userIDs) + userInfos, err := am.BuildUserInfosForAccount(context.Background(), mockAccountID, mockUserID, maps.Values(account.Users)) + assert.NoError(t, err) + + err = am.DeleteRegularUsers(context.Background(), mockAccountID, mockUserID, tc.userIDs, userInfos) if len(tc.expectedReasons) > 0 { assert.Error(t, err) var foundExpectedErrors int From 8fb5a9ce1145cd53303de5bf1e33041a5d7de0ba Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 18 Feb 2025 02:08:03 +0300 Subject: [PATCH 19/29] [management] add batching support for SaveUsers and SaveGroups (#3341) Signed-off-by: bcmmbaga --- management/server/store/sql_store.go | 4 +- management/server/store/sql_store_test.go | 74 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 6a6753595..5c4ddf666 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -420,7 +420,7 @@ func (s *SqlStore) SaveUsers(ctx context.Context, lockStrength LockingStrength, return nil } - result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}).Save(&users) + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}, clause.OnConflict{UpdateAll: true}).Create(&users) if result.Error != nil { log.WithContext(ctx).Errorf("failed to save users to store: %s", result.Error) return status.Errorf(status.Internal, "failed to save users to store") @@ -444,7 +444,7 @@ func (s *SqlStore) SaveGroups(ctx context.Context, lockStrength LockingStrength, return nil } - result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}).Save(&groups) + result := s.db.Clauses(clause.Locking{Strength: string(lockStrength)}, clause.OnConflict{UpdateAll: true}).Create(&groups) if result.Error != nil { return status.Errorf(status.Internal, "failed to save groups to store: %v", result.Error) } diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 4dcdadf44..6e04c7d9d 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -1331,6 +1331,14 @@ func TestSqlStore_SaveGroups(t *testing.T) { } err = store.SaveGroups(context.Background(), LockingStrengthUpdate, groups) require.NoError(t, err) + + groups[1].Peers = []string{} + err = store.SaveGroups(context.Background(), LockingStrengthUpdate, groups) + require.NoError(t, err) + + group, err := store.GetGroupByID(context.Background(), LockingStrengthShare, accountID, groups[1].ID) + require.NoError(t, err) + require.Equal(t, groups[1], group) } func TestSqlStore_DeleteGroup(t *testing.T) { @@ -3046,6 +3054,14 @@ func TestSqlStore_SaveUsers(t *testing.T) { accountUsers, err = store.GetAccountUsers(context.Background(), LockingStrengthShare, accountID) require.NoError(t, err) require.Len(t, accountUsers, 4) + + users[1].AutoGroups = []string{"groupA", "groupC"} + err = store.SaveUsers(context.Background(), LockingStrengthUpdate, users) + require.NoError(t, err) + + user, err := store.GetUserByUserID(context.Background(), LockingStrengthShare, users[1].Id) + require.NoError(t, err) + require.Equal(t, users[1].AutoGroups, user.AutoGroups) } func TestSqlStore_DeleteUser(t *testing.T) { @@ -3198,3 +3214,61 @@ func TestSqlStore_DeletePAT(t *testing.T) { require.Error(t, err) require.Nil(t, pat) } + +func TestSqlStore_SaveUsers_LargeBatch(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + accountUsers, err := store.GetAccountUsers(context.Background(), LockingStrengthShare, accountID) + require.NoError(t, err) + require.Len(t, accountUsers, 2) + + usersToSave := make([]*types.User, 0) + + for i := 1; i <= 8000; i++ { + usersToSave = append(usersToSave, &types.User{ + Id: fmt.Sprintf("user-%d", i), + AccountID: accountID, + Role: types.UserRoleUser, + }) + } + + err = store.SaveUsers(context.Background(), LockingStrengthUpdate, usersToSave) + require.NoError(t, err) + + accountUsers, err = store.GetAccountUsers(context.Background(), LockingStrengthShare, accountID) + require.NoError(t, err) + require.Equal(t, 8002, len(accountUsers)) +} + +func TestSqlStore_SaveGroups_LargeBatch(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + accountGroups, err := store.GetAccountGroups(context.Background(), LockingStrengthShare, accountID) + require.NoError(t, err) + require.Len(t, accountGroups, 3) + + groupsToSave := make([]*types.Group, 0) + + for i := 1; i <= 8000; i++ { + groupsToSave = append(groupsToSave, &types.Group{ + ID: fmt.Sprintf("%d", i), + AccountID: accountID, + Name: fmt.Sprintf("group-%d", i), + }) + } + + err = store.SaveGroups(context.Background(), LockingStrengthUpdate, groupsToSave) + require.NoError(t, err) + + accountGroups, err = store.GetAccountGroups(context.Background(), LockingStrengthShare, accountID) + require.NoError(t, err) + require.Equal(t, 8003, len(accountGroups)) +} From f67e56d3b9f018481470cc9ea42af86cfce81df4 Mon Sep 17 00:00:00 2001 From: Karsa Date: Tue, 18 Feb 2025 02:21:44 +0100 Subject: [PATCH 20/29] [client][ui] added accessible tray icons (#3335) Added accessible tray icons with: - dark mode support on Windows and Linux, kudos to @burgosz for the PoC - template icon support on MacOS Also added appropriate connecting status icons --- .goreleaser_ui.yaml | 4 +- client/ui/client_ui.go | 178 +++++++++++++++--- .../ui/netbird-systemtray-connected-dark.ico | Bin 0 -> 105144 bytes .../ui/netbird-systemtray-connected-dark.png | Bin 0 -> 5272 bytes .../ui/netbird-systemtray-connected-macos.png | Bin 0 -> 3858 bytes client/ui/netbird-systemtray-connected.ico | Bin 5139 -> 105151 bytes client/ui/netbird-systemtray-connected.png | Bin 9105 -> 5287 bytes .../ui/netbird-systemtray-connecting-dark.ico | Bin 0 -> 105128 bytes .../ui/netbird-systemtray-connecting-dark.png | Bin 0 -> 5434 bytes .../netbird-systemtray-connecting-macos.png | Bin 0 -> 3843 bytes client/ui/netbird-systemtray-connecting.ico | Bin 0 -> 105091 bytes client/ui/netbird-systemtray-connecting.png | Bin 0 -> 5412 bytes .../netbird-systemtray-disconnected-macos.png | Bin 0 -> 3491 bytes client/ui/netbird-systemtray-disconnected.ico | Bin 5167 -> 104575 bytes client/ui/netbird-systemtray-disconnected.png | Bin 9816 -> 4800 bytes client/ui/netbird-systemtray-error-dark.ico | Bin 0 -> 105062 bytes client/ui/netbird-systemtray-error-dark.png | Bin 0 -> 5279 bytes client/ui/netbird-systemtray-error-macos.png | Bin 0 -> 3837 bytes client/ui/netbird-systemtray-error.ico | Bin 0 -> 105013 bytes client/ui/netbird-systemtray-error.png | Bin 0 -> 5260 bytes client/ui/netbird-systemtray-update-cloud.ico | Bin 3647 -> 0 bytes client/ui/netbird-systemtray-update-cloud.png | Bin 5652 -> 0 bytes ...tbird-systemtray-update-connected-dark.ico | Bin 0 -> 104704 bytes ...tbird-systemtray-update-connected-dark.png | Bin 0 -> 4867 bytes ...bird-systemtray-update-connected-macos.png | Bin 0 -> 3570 bytes .../netbird-systemtray-update-connected.ico | Bin 7678 -> 104698 bytes .../netbird-systemtray-update-connected.png | Bin 11471 -> 4842 bytes ...rd-systemtray-update-disconnected-dark.ico | Bin 0 -> 105086 bytes ...rd-systemtray-update-disconnected-dark.png | Bin 0 -> 5275 bytes ...d-systemtray-update-disconnected-macos.png | Bin 0 -> 3816 bytes ...netbird-systemtray-update-disconnected.ico | Bin 7966 -> 105115 bytes ...netbird-systemtray-update-disconnected.png | Bin 12437 -> 5298 bytes client/ui/netbird.png | Bin 0 -> 4800 bytes 33 files changed, 149 insertions(+), 33 deletions(-) create mode 100644 client/ui/netbird-systemtray-connected-dark.ico create mode 100644 client/ui/netbird-systemtray-connected-dark.png create mode 100644 client/ui/netbird-systemtray-connected-macos.png create mode 100644 client/ui/netbird-systemtray-connecting-dark.ico create mode 100644 client/ui/netbird-systemtray-connecting-dark.png create mode 100644 client/ui/netbird-systemtray-connecting-macos.png create mode 100644 client/ui/netbird-systemtray-connecting.ico create mode 100644 client/ui/netbird-systemtray-connecting.png create mode 100644 client/ui/netbird-systemtray-disconnected-macos.png create mode 100644 client/ui/netbird-systemtray-error-dark.ico create mode 100644 client/ui/netbird-systemtray-error-dark.png create mode 100644 client/ui/netbird-systemtray-error-macos.png create mode 100644 client/ui/netbird-systemtray-error.ico create mode 100644 client/ui/netbird-systemtray-error.png delete mode 100644 client/ui/netbird-systemtray-update-cloud.ico delete mode 100644 client/ui/netbird-systemtray-update-cloud.png create mode 100644 client/ui/netbird-systemtray-update-connected-dark.ico create mode 100644 client/ui/netbird-systemtray-update-connected-dark.png create mode 100644 client/ui/netbird-systemtray-update-connected-macos.png create mode 100644 client/ui/netbird-systemtray-update-disconnected-dark.ico create mode 100644 client/ui/netbird-systemtray-update-disconnected-dark.png create mode 100644 client/ui/netbird-systemtray-update-disconnected-macos.png create mode 100644 client/ui/netbird.png diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 06577f4e3..983aa0e78 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -53,7 +53,7 @@ nfpms: contents: - src: client/ui/netbird.desktop dst: /usr/share/applications/netbird.desktop - - src: client/ui/netbird-systemtray-connected.png + - src: client/ui/netbird.png dst: /usr/share/pixmaps/netbird.png dependencies: - netbird @@ -70,7 +70,7 @@ nfpms: contents: - src: client/ui/netbird.desktop dst: /usr/share/applications/netbird.desktop - - src: client/ui/netbird-systemtray-connected.png + - src: client/ui/netbird.png dst: /usr/share/pixmaps/netbird.png dependencies: - netbird diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index f22ee377b..1aa61a2b2 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -21,6 +21,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "fyne.io/systray" "github.com/cenkalti/backoff/v4" @@ -90,6 +91,14 @@ func main() { } client := newServiceClient(daemonAddr, a, showSettings, showRoutes) + settingsChangeChan := make(chan fyne.Settings) + a.Settings().AddChangeListener(settingsChangeChan) + go func() { + for range settingsChangeChan { + client.updateIcon() + } + }() + if showSettings || showRoutes { a.Run() } else { @@ -106,46 +115,108 @@ func main() { } } +//go:embed netbird.ico +var iconAboutICO []byte + +//go:embed netbird.png +var iconAboutPNG []byte + //go:embed netbird-systemtray-connected.ico var iconConnectedICO []byte //go:embed netbird-systemtray-connected.png var iconConnectedPNG []byte +//go:embed netbird-systemtray-connected-macos.png +var iconConnectedMacOS []byte + +//go:embed netbird-systemtray-connected-dark.ico +var iconConnectedDarkICO []byte + +//go:embed netbird-systemtray-connected-dark.png +var iconConnectedDarkPNG []byte + //go:embed netbird-systemtray-disconnected.ico var iconDisconnectedICO []byte //go:embed netbird-systemtray-disconnected.png var iconDisconnectedPNG []byte +//go:embed netbird-systemtray-disconnected-macos.png +var iconDisconnectedMacOS []byte + //go:embed netbird-systemtray-update-disconnected.ico var iconUpdateDisconnectedICO []byte //go:embed netbird-systemtray-update-disconnected.png var iconUpdateDisconnectedPNG []byte +//go:embed netbird-systemtray-update-disconnected-macos.png +var iconUpdateDisconnectedMacOS []byte + +//go:embed netbird-systemtray-update-disconnected-dark.ico +var iconUpdateDisconnectedDarkICO []byte + +//go:embed netbird-systemtray-update-disconnected-dark.png +var iconUpdateDisconnectedDarkPNG []byte + //go:embed netbird-systemtray-update-connected.ico var iconUpdateConnectedICO []byte //go:embed netbird-systemtray-update-connected.png var iconUpdateConnectedPNG []byte -//go:embed netbird-systemtray-update-cloud.ico -var iconUpdateCloudICO []byte +//go:embed netbird-systemtray-update-connected-macos.png +var iconUpdateConnectedMacOS []byte -//go:embed netbird-systemtray-update-cloud.png -var iconUpdateCloudPNG []byte +//go:embed netbird-systemtray-update-connected-dark.ico +var iconUpdateConnectedDarkICO []byte + +//go:embed netbird-systemtray-update-connected-dark.png +var iconUpdateConnectedDarkPNG []byte + +//go:embed netbird-systemtray-connecting.ico +var iconConnectingICO []byte + +//go:embed netbird-systemtray-connecting.png +var iconConnectingPNG []byte + +//go:embed netbird-systemtray-connecting-macos.png +var iconConnectingMacOS []byte + +//go:embed netbird-systemtray-connecting-dark.ico +var iconConnectingDarkICO []byte + +//go:embed netbird-systemtray-connecting-dark.png +var iconConnectingDarkPNG []byte + +//go:embed netbird-systemtray-error.ico +var iconErrorICO []byte + +//go:embed netbird-systemtray-error.png +var iconErrorPNG []byte + +//go:embed netbird-systemtray-error-macos.png +var iconErrorMacOS []byte + +//go:embed netbird-systemtray-error-dark.ico +var iconErrorDarkICO []byte + +//go:embed netbird-systemtray-error-dark.png +var iconErrorDarkPNG []byte type serviceClient struct { ctx context.Context addr string conn proto.DaemonServiceClient + icAbout []byte icConnected []byte icDisconnected []byte icUpdateConnected []byte icUpdateDisconnected []byte - icUpdateCloud []byte + icConnecting []byte + icError []byte // systray menu items mStatus *systray.MenuItem @@ -214,20 +285,7 @@ func newServiceClient(addr string, a fyne.App, showSettings bool, showRoutes boo update: version.NewUpdate(), } - if runtime.GOOS == "windows" { - s.icConnected = iconConnectedICO - s.icDisconnected = iconDisconnectedICO - s.icUpdateConnected = iconUpdateConnectedICO - s.icUpdateDisconnected = iconUpdateDisconnectedICO - s.icUpdateCloud = iconUpdateCloudICO - - } else { - s.icConnected = iconConnectedPNG - s.icDisconnected = iconDisconnectedPNG - s.icUpdateConnected = iconUpdateConnectedPNG - s.icUpdateDisconnected = iconUpdateDisconnectedPNG - s.icUpdateCloud = iconUpdateCloudPNG - } + s.setNewIcons() if showSettings { s.showSettingsUI() @@ -239,6 +297,63 @@ func newServiceClient(addr string, a fyne.App, showSettings bool, showRoutes boo return s } +func (s *serviceClient) setNewIcons() { + if runtime.GOOS == "windows" { + s.icAbout = iconAboutICO + if s.app.Settings().ThemeVariant() == theme.VariantDark { + s.icConnected = iconConnectedDarkICO + s.icDisconnected = iconDisconnectedICO + s.icUpdateConnected = iconUpdateConnectedDarkICO + s.icUpdateDisconnected = iconUpdateDisconnectedDarkICO + s.icConnecting = iconConnectingDarkICO + s.icError = iconErrorDarkICO + } else { + s.icConnected = iconConnectedICO + s.icDisconnected = iconDisconnectedICO + s.icUpdateConnected = iconUpdateConnectedICO + s.icUpdateDisconnected = iconUpdateDisconnectedICO + s.icConnecting = iconConnectingICO + s.icError = iconErrorICO + } + } else { + s.icAbout = iconAboutPNG + if s.app.Settings().ThemeVariant() == theme.VariantDark { + s.icConnected = iconConnectedDarkPNG + s.icDisconnected = iconDisconnectedPNG + s.icUpdateConnected = iconUpdateConnectedDarkPNG + s.icUpdateDisconnected = iconUpdateDisconnectedDarkPNG + s.icConnecting = iconConnectingDarkPNG + s.icError = iconErrorDarkPNG + } else { + s.icConnected = iconConnectedPNG + s.icDisconnected = iconDisconnectedPNG + s.icUpdateConnected = iconUpdateConnectedPNG + s.icUpdateDisconnected = iconUpdateDisconnectedPNG + s.icConnecting = iconConnectingPNG + s.icError = iconErrorPNG + } + } +} + +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() { // add settings window UI elements. s.wSettings = s.app.NewWindow("NetBird Settings") @@ -376,8 +491,10 @@ func (s *serviceClient) login() error { } func (s *serviceClient) menuUpClick() error { + systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { + systray.SetTemplateIcon(iconErrorMacOS, s.icError) log.Errorf("get client: %v", err) return err } @@ -407,6 +524,7 @@ func (s *serviceClient) menuUpClick() error { } func (s *serviceClient) menuDownClick() error { + systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { log.Errorf("get client: %v", err) @@ -458,9 +576,9 @@ func (s *serviceClient) updateStatus() error { s.connected = true s.sendNotification = true if s.isUpdateIconActive { - systray.SetIcon(s.icUpdateConnected) + systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) } else { - systray.SetIcon(s.icConnected) + systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) } systray.SetTooltip("NetBird (Connected)") s.mStatus.SetTitle("Connected") @@ -482,11 +600,9 @@ func (s *serviceClient) updateStatus() error { s.isUpdateIconActive = s.update.SetDaemonVersion(status.DaemonVersion) if !s.isUpdateIconActive { if systrayIconState { - systray.SetIcon(s.icConnected) - s.mAbout.SetIcon(s.icConnected) + systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) } else { - systray.SetIcon(s.icDisconnected) - s.mAbout.SetIcon(s.icDisconnected) + systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) } } @@ -517,9 +633,9 @@ func (s *serviceClient) updateStatus() error { func (s *serviceClient) setDisconnectedStatus() { s.connected = false if s.isUpdateIconActive { - systray.SetIcon(s.icUpdateDisconnected) + systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) } else { - systray.SetIcon(s.icDisconnected) + systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) } systray.SetTooltip("NetBird (Disconnected)") s.mStatus.SetTitle("Disconnected") @@ -529,7 +645,7 @@ func (s *serviceClient) setDisconnectedStatus() { } func (s *serviceClient) onTrayReady() { - systray.SetIcon(s.icDisconnected) + systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) systray.SetTooltip("NetBird") // setup systray menu items @@ -554,7 +670,7 @@ func (s *serviceClient) onTrayReady() { systray.AddSeparator() s.mAbout = systray.AddMenuItem("About", "About") - s.mAbout.SetIcon(s.icDisconnected) + s.mAbout.SetIcon(s.icAbout) versionString := normalizedVersion(version.NetbirdVersion()) s.mVersionUI = s.mAbout.AddSubMenuItem(fmt.Sprintf("GUI: %s", versionString), fmt.Sprintf("GUI Version: %s", versionString)) s.mVersionUI.Disable() @@ -771,9 +887,9 @@ func (s *serviceClient) onUpdateAvailable() { s.isUpdateIconActive = true if s.connected { - systray.SetIcon(s.icUpdateConnected) + systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) } else { - systray.SetIcon(s.icUpdateDisconnected) + systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) } } diff --git a/client/ui/netbird-systemtray-connected-dark.ico b/client/ui/netbird-systemtray-connected-dark.ico new file mode 100644 index 0000000000000000000000000000000000000000..0db8a08624e6f0f0e679fff0e69d9d182f64e41b GIT binary patch literal 105144 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 literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-connected-dark.png b/client/ui/netbird-systemtray-connected-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..f18a929a0c46fe6eb534932bb8af7dee80a15698 GIT binary patch literal 5272 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 b)IkE9NkTHqSjM4s{{jzZW@B1u>>2kzO3uwf literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-connected-macos.png b/client/ui/netbird-systemtray-connected-macos.png new file mode 100644 index 0000000000000000000000000000000000000000..ead210250d7a4b25461aa0311fcb6c13cec75057 GIT binary patch literal 3858 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 literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-connected.ico b/client/ui/netbird-systemtray-connected.ico index 80550aa37e20d9d134cda6c0cfdeb731f1765cfe..c16bec3f51e8e41d8263b2b4b5dc2095b51e1261 100644 GIT binary patch literal 105151 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

6A!aevjJ&cU%ypida~ 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 literal 5139 zcmb_ghgTEZ*PR5AraX#-UX@5SGy#DJp;wg>qzMTi(xiwWodf|3y@L&qB8W%`y(dU7 zDn%tKRiz_EA(RmKhPS>y;Wt@XGwa-Y=bpRIKKq;m03hJ!pRw zSom4M$1^6z`c@~SCts#h;O$0W`7Hq8>Ne5WL4`j5JvHk0{rXeJ&0{esiJBJo`VK<(qJ9+s9RG_enZ1Gsshm2! zF=e>al?^?^+C=~O6s~0@_z4nNl?86ne+^p2`!>bkeCUWdq?OsOtF`P)vXd08KomaK z^SSeo6T_web0r0515}?E&91886^!u#NKoXrv@4Ar9&;7GbE+@9Z3f;(!sC#w;X{8x zRL79``6|=d4_dPhNGDU3GDUZ{sJ#MNprm{D`H>;hq@eKf{>zW~^j~<6KChlW9xk{% z?;Tp#H~Tz4P}%b^Ir^~%Fxayhi`eh9HH;(Prp-iN>Cr9M4DFtX!!-tcr>Y&euLF`? z?XSKw2~DPRPT1h%0T+>ET9F+f8BCSiHDpqb|HXTaQMb@Z!0RyC6;k+VEqcA>`Xff* zM!uuBZlRJoC9&qu4OZ@n=%IF(_`OrOH{o*ItWsM6lcORSuC#?uJ}qawenQB<#y={! z5he4XBg5fxA(!YoINNU8n)QKOg|Z-(mg^@qI77StA-pww$x-Ov8p<(ZKJP$*Hx_^5jxuc1!*Z- z4!%HpQIme9ar)=O8D6VipTp#_TZ|*r_V3ktpZ?KPF3}f)t>_l{G2eg!m(3%%WB-YG zK+NJ?Vu`tUUOg5O9FJ7ZB4z3tYLvjBet|CZ)w(ogqXmteeYFy7q4}=U zs8%q^N(Lwv2^@Nd@3CxnCKF@$1OZD&+5*D#ZS3S43C$_iu37Pd|K@Yrp>OjtoZg(!={!U; z44C#m%^W1hC{jjXjU1<2nhM67#W4Qu5S3~ai+PynGi~_Xf4Y6uxp8m9FDndWiT@>t zJw1zlVJy2q(S2%W%Guk8eDL_*nes+I#;T~5dWj!1rpEl4o*>lQTV2y0>#?214Krgt z%^@IV%%11r_3`6E@Ly zi%HX*dc{QUc#8oriYLveCwBR3>o?ss-Ky}cj8j0Hn7^mL;qbSrmg%ff(_NOD<9lqw zo?>aOmkeZznc-Ih7dWVXB1ZjaVyS5CnWH!gNq14WhNt<@iiapl4bb&C8l^8_?% z4K+IfsTwGDv>ULPyEbI4eF(6x%g$|nM~j6vWOP+{X4Lh80gKM?j|MZuMj$oxYkAm% zWMYZ_=ZAOa`9kPeZhqbq$X;p&WUklCsJ22V zr)nfd2!eHgL*G2v4U&c4QuA9=#n0}uur&+xCo%oKd_X5v0#&lmN7rnSu#sXrQ2Fx} zT5E|2jNFF%pM%n@Ei%G;z3Ri5+{a9qRywXhVZxnT({W&}>z~ss!@DAzS)v9hqJD7KIs zHHkc1HaB89%Iw~6Yy6q?Lr8p9Rls6a#Fd5GUu*Hg(y&S>3OoCaTdlc4=ZAh#R*xS>5SHSbRs*Bwoy`jC; zUK@F5o&Hbu4%r_@DfK0v7Ua7|a%}n?Rk`w8$@e=O)L`9XUwAvQvM;)pso{e-hTL!W z?nB5LPxk)gEwA^j1IYBX`O6fu1cTX7-NLs;TdLBzopRRkHH%p6)g4Z_E61aL`30HB zlU0*yrvox$#J@6yT-BjeROBkyZ2mfZb0coW-BZU@6BV`1sOSeZRIoTNFxVGuLKc%g z-fIkGOp+9&8qA0PUN9YBrO-|5p{BR&-ZorwlGm~iDo^ZM5Re&;e^qSoZh~)F z021n@$>4~>ZsP~o>H%;DjBMSZ31X76u3+)Y_s^oi&%Wq8{T;E@qVZR4hHW_=U;ys5 zuH{jt66pRjOvKkiYe>uoU0ei+dU68;fZ^bk{zgWyjkD%65iUN>rxg48_TnKw<7IM2(?r)w88bJo&|YNe~15v;zF5Y zFG}o%TSp*=UGJ2935Y-@uQh=CQ_Av$GE@*B;4zT*ppd?$C6!i`Bn@;S(-qIg1T3Bf zX^2ATv36W`>!iYkXb`=pI7_fINMjS$DS8<$XRjc=Aj5dV=bUiR?|hwPqNU+);6XyZ zgvCPdj9SYEG$>);^j$v`INQi{a{5Bmo?*T9{7;QM7#F>=JpB12IRL4UhQg?cD)4kax9hhYUB46KmAcItUw`_1C-np+AVH7)98Ef?nnvCB`C1uM1T0Uwjvi(SKXSoA;1zO z%lJETM%ghQ0gx;?M&!(JZp8h8sm!!?uI6?$2dMd_k^s&*M!aTYb5dKPc}meK4U}fD z|6HQ4ejP3(NG-jvCsiO&Cg`$ma4R#b0su&>zy#`4LVt%f*$5-WVfO^hCrSV9v!k<^ zo~NlTfGT6Tvs%z0x;l=7f(;dD!4->R7@Bf+eY@vANTtve**p5zJsj%5jikVXz|C+* z$4NJX`9(+UK{Z+?R%@9$0=URTB&iqusz&XM{3TE!tat#?0&!9ver=!*!NR$jphiiQ zj)DLw2}sERxi?6T2a&g~QC^@n*w5Zm=!>?L1?bEgH4%v@vEIn}3vUzeo2TxZ-eeG|ve=Lf6XC#kudecgn0@ljxFcKG`E(2g~vIATbo=JC?QOkMYUi2(M zw;4;lPkLKpj6(&xTgP_Vd3fa`u<9~>W|bd3={SJ(rHPxE&f|4)R@^E5&qnhF$Ev-m zMYUi9ElK5GPSn8h3Gzt3%R@xO(B7o(^^KxIEBAS-N`q3X}BGe^|>Gk8}zdD;pBwIpNg^Ul|7u0|Nvz zOhwDG?yN89Q6bn$>;5>nbNo?@mSBM7DlUV#gE6` zbE$qZgdaOz%~XDl?oAem`=%c$5A5p2d=KSUXF!Qnlsxb8qutq~nT9Uy`0mfY4!E+s z*mFGosg~Q|@*+C>OQ7T_>h{IdBkKT0_O3RgW6OuBh)sLQCHC?DEa9%1A`Cx6{IbuE) zyH@V}kl7A^FI}24%pjm|-(y<*bN5k2!GRf<`aQR2i$@9|>s)`Lt^PeoWw#OekX*EBXo(3pG-iyFl&MFXd>AVlryd4UYCEbSV57zytIN#C9m z5j;5+ZGZBLc@aKIMMj^k!l;#*qX_FAb@MK0z-h5fgY^x24NS#I(n+|7g$1xm7|0{z zsI^>Nxe&=E2N`ICZNKMVTO7^@cZKk#eSrkCiEX|Ne@$!`Uo?JO?13z_l}Z@UU?Go`PF)H_*}j} zmoGR+-Pif~Pg6y>P#@u=5I3++KMlVs++f==xdg(r+(d`0$wP3j-9@+%k84-lmrAVn z%ZPW@a_^7v;Ot#HP}Rpr40Hyky2n-@woPjbmHI&`PyO`NLvQLrc%>ui^6WI1WU>1^ zDo0jUD?+%7Zp8D?6A`8_BrpXuZ9ked>YETQ#f?aYu1Y#osxB}$F}T~jOWr+-id8^M zN&jZUd>fCdQ!(e39unbZvL^sZh>}ifeDAdvO*6a+iR`xaw0wRzP+ti!TBrE*}JSx`$0{pba*K6E5{V~AJ{-0VM`$;gxS+62R3sUfMK zgDXE^V!KAz$|YEx1+u|@gZZy-2ikM!JlK-Qp<%31NZyP zXSy7;FoUV^q-jXNTJ8(X;@SIeYhM$q*o)c^%d`oA=es2#T+pWR4u66)SdqX)zb<|I z(5oB7q5&kR)irL{;oN&kvOvx97Z7}-gcGmF($X1b>z$^-31gDbF9UkLkzV~xa8Uv5 z$}qoNFR1T)jdYmdTy;uwt`b9n{DK+UkQ)akE*MMi%ef6)+#rFL-A~DO#b3hG zGr(EhA z-}4bpHhQ%du;$x+)$N%hmd=w1!hQ`q>Z#Bh`9zN@gsc0yH$LTA_^29mlTY7?|K{lk zxm{`c8-UPfpO*vO%p%#?&DAr<0WU4sieg zq+FaGd;tIgiVy%U3O<5jYEFX>#0BRgmjFOQX6uFk#ifc{T`u`L;efi=Dzjh%7Jk6v z002CtNbpaH0D!EGi^BoG1jxd~&?S|=I5EzCv$8N(eUWE*zS>WBKKHe}8sq#gEr*!$ zkj`M6TNc^1qBF`g3MhM}`}0Ti`i6;+*^=5M_Ov=&GoO%FbsPQyPS$w-ZgwmyMYJtz zV)oALJ;KD47s4^;FpmARbAR;4t9BhI7{~wnB4ykJ(yaKAFZ??yd`~sy%XBzpwyapU z`ep9>t$Z~vq`CCxP6!FE^5b1->)+p3%v5KpwDh-s6@)3Typw{$b3W+pmyT)m{kmdo z^`G&>zzyA@YX;^#tLz{_n9@WWx1?VU1;Dyt0@29tnS_fqsdnEnhhX*t5ft&(eySFP zQ6o=QgDeIGym^(S?zuYn9cDuAo>;ma+|En4V@7`on%tSZLj2O*?=?SF0R`&a*)kLC zQUd#m;S!OBHl_nBIbx*Z@o015i6vr0(8uqCk0g4tM^=OaCQS%Hs z&csS!^)czo{>F5AbtxdadXmP^|4ZnkM8-(AbM{wF?dGBM0mZ?_W2f!}#?p$9SBa&t zTktJrh5gcUzzXyG#v>Cafg7x=U%a(_Zr&Lf9`Ao?6aVUX z0dOlkynME4jG4*^o zDK#oQvjxFyJ`Kiw4j2{}+dOeVI+jxiJMg6iKi`L9?1y<)dayJh6u^583*%C*6LHWF<&Cd)aTlVX7PgNJT4Q}%mT^e=RFy{tB7%AKmCs+hv&M$9 zqhnsdHbspV^S_1-j``2c?1c<2w8z{){-~^#A=e=ZixEZf< zva%nm+x^7O{HqbT(m(vZtC8~WLh0gg+_w-nro7$sP1P+Z@1TD)&(W1Lz`z4 zq~Rk^7iiqbnOM@7osa(;@Y|JImk)6zUTJL+$ai`P5JQ&+^b<*F4Hb1I*@;T2yOKv$ zR2$}uy(ukI?eDHaCf`c9>pvKHbn%QK{Qf{fC&{U!qHidEKrAVWzVu0X+82)u+gUlf zpZBg_*4Y>(JqnXi@mHG@O)h3xmkeG@dSJy)H%*H+`D=R65-y;Uu z^)ji3f1t&7}V>k{>5l6=L;R48liW$v3Mdv?X75>O_nahAn!)Z{*^^ z_u(7)Pga!Kk%C`49i3q*$`?M>pZSJGzTQ_F2d}8;yemRq=MQbX>$B3d>38X1l4`ui zZ>~Sv=!{fLbJb9ro}tGJ2kvcp-^m1^x8FFZ3pR6ABFs@Y4Is&k=k^-GzhBOk;%DbV zS|8M=U@CXO9RnoCR!hbbb=b$oc8$fNxM^5JnUTKTYBlPIY~SQF3oUjl9xF7(?|Ym< zoJlPrlU;YAO*GtAjvL_``2Kv3i1X6lA5;Z} z7Ph;w=4_T~p%Za*RxY{qP*i6?5l_^_3`>fWAnt2$p1Dy8u7xKL+TLh3KJ*~*gqE4T ziROhl)~D=`HKP0r?^lm;H@&5wQ*4svND9j;uv)v=uf(d_6q=8eWaP}eAaJ(XZ20Bf z{@~S2qe>J!@ON|N&N%aV*^rds6z0^+_zs$=J*b{w)9fyr%Z1K}EXf9g3k+!>nu%kz*jVJ%eg{3UNmSwz+m?xmy^OscWk#@=qZ zG+PdnI#fwtC%$F;6+hr+g$Vg~@Os91UR+AR39)-02+N{VYNgkFml!zmo0|GQTk++G zV)9}OH|5MycLBZcTytT)UD_6SruDu?3vP2BEil%dHMDHxZ602a+yxm#afZg2c;&Oy z!BWWKN=0MO(oQQy-Oq)3ZOKOb<`B_r4Mb_ym+mztKCiG_L^ShxxV*3vZVVYoSUD_= zavU~!yM>J0bY<8sMq=D*VDjs&lf-XBST$SL?!{Re!x$lIewj5SdNgep)MCS|Uhj@r zX2_#q&PZU)(Wr>+uQ|Q=pTcnD}=!Ue>1(u@BM;gv*0ZdZzBbQIjlR*dM(TuI9o} zqgr3W+sY^PHze*@e~9&5TVF~LcSk-{aAA0A*DQ<`%t|`6tL1!KIVbLI(yUM|-uxwW za7=e)4{2l1YZ%g(6&>uo3tyz)R6Jyg4>g&GJe|O)90s))hW3Gjw6|nQ`t0@0GF1~O z4<<5B*tkU91jk8Eu9n8=)BF#{BW@o4KzI_Qp)WKdkA{#U=~ue!UKZ)c1{g-t0}>cB zoqS@w?+GKmxyap6Q~!wGpha~bR=rf`zdlP<%y!P&^lulA^%*gI*ogwt$9Vy7$JOU& zj3LH+^BWceu9k=*i7+iZ*P3F5&W)GKww)fxC^}Ujn@2jr@dKAtaiYk?|0E?VO@8mtj7#CiFCJH%w+deVzNGa%M3)A zHgeYPG_(|kcP*Hp!JMU+dDS>nN%!Oj0$iAnJ0=0YI9PbA#&h2bRE>Dz{Y{?|wB!fE z!mro3_U_@CJ&83G;pYHhNkwrdBcC*qqIEjdE8=C4nku&HK8x^qG2m8|N^Q?W+?(w6 z7;RGRwyGAvw4)_u++7ZJ!Fb;x|Bs=aK}vC%7ucbl=N+tbjt^pUAEZ?h72elWcK2-c zCC*$CUcBN#6N^4XYT%4{UH%mos9C>xXdGP3H_`nS_{US}iOLs!LpxPW3f*|w#7`jL z@}pn&vmqodXR^pxA<0OzIGql}ug&hfzlyW|`Zrz>g>MdFMhs_BiN#o8&5EC`sJpm|2 zQT->qzDqLpQxgv;Xc@rz-dppjZI0X-N;*a?C|IV9B7C}=Z?0Ve%%y1A<&kDA*HSLU z?P$n-+2M8S8G^K4svp*y^ujLCqbMs1pb3I`A*kO!`tQpC%jW)Y31YVDaw)JQAPpQ+ z|1MxUu?*guv~C(Er*S5pYIouA*$bve2b%<)SSaU2!_{qCZIeVfKR_x@+vKYuq)EV6 znwup3SGR^2L4>@{Ig~!Ux?I0D!oI0vO4~Ya8JdpxY5tab-HXSgEN4I9_}ucK7BbK_(1qF7jvY+ zcg5wb6EUOP&rPiDTQ=cY)Z?ovy-Bx>uR8e%3*~vD=#kZuYvN^9E!!MTrsuD2lqMQ0 zhtjGQF4rG$1xM!$$d776q7HzWqdSFI>R-6t9gk`_l5`6spA;H!tF0FIkKifAap;$# zpY^Fv=3!4=#AC@4L=R^`)v!cA%@l9;ie0~X{%`c~NllYcFbm`WR-Yf@HVS!s%ig3{ zh7!W!MRddJUX9OxutbaExBh0{X+u02%tpPquy_pJa9Y#5r>LIDTQ`1n&EDet@2!)S znjl)J`wcd%!!5ZNRd`InPl%OD82biIjxZ1kOAW~ACV@LUaP2g`pEipT&V*%X>yz#U z`^Q#tj|mR8VO^d{5vHq9ho2%9`S)_e$<)~)Rez{6U~9vx`8Y}s`o#7!MZ_Y6#f9jG zudlK1R@>~ZIF}!V3yVw8B-pV1z5R8P?uLjJu*W>Qtl(}z`o~r4{oHfiyC7&%Hdw@z zCGt{ZIp2v-Du@BsK(lvflX!9DN>P1t7hZWELIV;Ta`4)zrj8%y#^*Da)cZ?15w{srhdT#H8AV7AtvvOTDz_veCSzSJiv-fc=49Q;}4TRPJqyPBx zTBz^Wl4%t`;=~WmbPWagL*c?xAm|Jj6txLdCL3Q{k7wk-&@f`;5fqkF|I7-(mSw9L zwTR;%X%!O+DEoYzeL4HzKiI~%B-3wwd$6qrcwHW|k-y9iUr(TIcatIn1cE+W3Id_Z zFbU6}t8E86O9xR!girOT92I^uo3I*^x|w$LG1mv&%3-W}bILZylW-v$rp{mY-x$J7 zmTZQiNtV-z#Abdl@w*Kg0;6xkrBXE^gR_Ypn;|NmayX8)h6Ps%h3(Focss^4Kh2t2Jn2WylkGKDVnLlRlSbpD(M3|R--TqBb{}y z&KBZBB>yi5S-zwkU@&7OV#sQuYLh_AXY6n=bw%-Jhg%&|8kPQdV{Yk4Z&(ER4}XHn z6~(w&K~_|k%|-KIlSl4>^Zb{C3g2+t5gW{{1*;B~c!KPV|{skB>uRlxZPBKj%paF+m}oF+J0yF$dDzS`&H%Gmu>aJk`^e(oP=I+Ly_ z+Z1@-1y0bpk8;-K9*IFPeis=>$Bqvj3l?dfe+B2Ur1hV&!$q_m7XB`IM;2zms61t@ zRC;yYFlIB1B3xPD^max`5(@l8I2C0YKUe1+V>x5cS{O=-P%x+!A}c}kfe{Y@5~&C= zj9WES@zmg0PK5+vUyOE60;dtJPfDFw$`oF+8fSNB>88@pq`3lJ(P$qC_-8=pVTCkd z`UOZCYNj3JdK6E4JKx;P1?VCk9Ek*{W8n1K#=tqAwjAHQZ#NFMNv<&`lVk}bz`ihpnJ9At#LQ=#qGDLBW zi}BP;pJFxCpk(TeI-ugtB1Sr&bRPGo)k9gglGca3fQevajzMhN{0pu)Qglz(z{0l6 z^WD#j zLTaIE`$4-iZMa98j-?*JqIFVWWU6C_wF&IA7u9QqN?6i6<=PwNaRY|2hXR=U=#(@v zxS>gy@?9xCe&uE?8R? zsf*wOV$xy22~Ix9g>VfB)*@&Z1hxr91MsX`5X6fk0H#^&7AGY@X|LZ_LQ8`IpXN%o z$fF6t{@1Dlfe{Uy0i+!ZG>Dc$0AUeZU_cIl&5i@<`$uJI48GI)02H_~4YEjGy5|DJlpL#YYlAVdQXt^$1TB zbl2vJ(B8&Uir6{jqFx?!JdgT`^C9V7=Wa>1lrih2BUKQGX3`ZhuD#7O=5`c0yWP#U#CEg%G0aM_&j?7DXY4WQ&BjZNL%Psp=w$F|Mbph!VM8C)-WZ=_%Mnvm znvI%7lsnNps!uz&Szow+UgqM16ZL6f&L3)7d<=GKjSBZw!9W`XFNr!xZ zNCH@1PL{{eSYm)821oS7D@FzcfprG}TDp-z7_1+j1ogyw69TniUuvi@C;_Jpb5yZJ zSOyv5eF&z}A$Z&9<966+Kdc50rmMrH6^RA`0`MdZG%~J}4~2|3^4DEFABT4**p|!CjGm+w!QHrS%^c+Z1>c0)l>6 zfn@(plSJ_PldQk_wk`P)&R-n?&HuptoA&Rq|4;_CEG^MSL~Pi$duB%3uNo{2b*#FAvXVMpK?9FcQc%LeV(B4uta3 zz^EWlNOc8O1YSu&*%O0Rz^JIB6p$!&6;D-!vIfS>>jxALi#8#K1Yp2)5&|&Zcz95t z_Yc7~;b?tpGi{iXBI3^#Ykv&M3pCJ%9U}yWMgA$UBLv`WNtkUmk!lEaRSgxCh6Yka zNm)htPbCL@NGMo|+n7j%BI>7PJ1uB17?4=ZcAbI%KP2nF|#`myx0qipfPKkt6t`V)S%5)}HQDbN_~&k#Z};dtB+KS8XYBCHQ4&>Ig< zkKfAm_jSU5C&A&Oq{xc8dh0Z1CPKcfwk~=^iZM~DFPFM z*Y^f_1i1ps^9NT@xgUu-^mpwDAN+O}K*AIdV2%Dkn8t5}!G8-FzCAO3k5~)--#F3w zq41X>1KRyu1BVwl3*mnZ!{0dD9y|Y!pWkxv|F{DP{qH9Kh~Izd`j@VM#K1pN{^>!v8U$WAh|G-jWHi16$`IJ)vG695D?&5ZQzB8L}lxejEX z9A<1_K3seZS`K)B<$hW-PfH5$-1T(h?x{CL>sz1G)x%ecq8VMGIrraC_k*td?>~d5 zF`NzF{bXsC^h+?mUXFs$3SZMKFMKo?V}t{UOb9S}2RAf!%a^+rkG-3$*~oj^m>feh z+0;+X0K6+VTuCQ-h>u9YZqMb+5;Bfhpp;eeJJQbp6kF$bmA7{hZ;67%7T0ZbvZsi> z;&oE39Dw@O*+(Ql%Zm`guuGQhjoowfVslC#1+uc_C^$PDc<#{83{V{fkC!p1BO`RQNH_C zMk^c~op{(Mb>B;E>;uv?{6f3?jC{pAM7u6bsQLD-C^PthS7$P;^5slP?lv#SdT#Vk z>mq1XxfJ2V#})BuGl$Itia+r7&o$}~kxxnkXW!3{Sg#mg?P{Y@$+XrzKo2*gdd3qL zhl#`ubgo+QDsBxy8p;~U9p-$T@kcq8-Kk<#m)4Q;z)W_Z)t!x$70E6}Li|8S*FNAa z>ZzDz>A7`->s$5j8p*E)FDn4M9KNaLw?r47yE8N$xg@Pa&xcSF636?O_}QiSCUv_P z)>FqSm`_gu{F`^%7)MjL%pF+uSF74%|E#Y{5#9CL zx{Y7DmMiL17&jjR+xdNA^zP&Z@qS;IgnOxdBo9*Hbcf1*%MKcA%vrU&8P`+0D;|bh z6ydRtZ#pTs6@u8%MxE(k8@GLsyHd*>mM$B=_v*S3CJTGZou2kEor=SKdUeL^X@2(Q;Rn}`nGA3!SOqp_>4@$ZyKy8@i(^Tm za$#YQbJ2;9=Zqgd_AhWU35F@0bts>IQT9E}>T21yPf|77Iud-Oy4MW}fnmW1uO1EG zSQ4>1vwZYO(Jj#}mB$z7E59akOZ$c?KF1ZnpJ_4da+fdZNGj+3lAjo-&};11JAvO- zT?Wk`blD1fSe#T_%!+16wNU$7>$JX&Tm}5&#f`HaL^#9GJz&je!!F~cgkKCF?2d2^ zrB;xsSgKll^RO$)zDKq_5+2RTeW5S&!}&cuIXY!uRuBw!|IIqOrh`r@>}t@1j@AVO z&t)hNa!}(PbFanRiA6qjdd&PDkJjqteH|{ANT*xL^-8Zd>K^PoouzXupDVU+hZ9xM zt=weA`P7#iRXwM_4n@{yN}sA0bmM&Xxt_D30ZZM%1_;QVOf7u6yE9v0RP%`K+tu%1 zp7V~Zj&%67Cd{k-dfooZQO?F$+8h}EC`4guF7d)ar!jeC9<1H|>fl6>y zc5JVI|KsK5QoM;KmzAz#&?H;(Wc1>FWxD2L_OjfsL(guQ@;Na=x}!*+GWbb*tO)b- z?uVmP+g_-$!XJi4UJum%Z*#zYH+A7V4I)^2M8%y{HGtkLXQY#hTZq2MIPprEx zrA5@loLZ0A5mv9UaTch(B7FRzoYt1B7b)2|6uNO=gFasa8T^QM%v_)gI4r7cFqOk_ zi$q6G!Qu6F$2pxGO!vi0%>5(fvF`CeS9bT3PvJQ&)j14(oUpkZcRD59u4eHU-`nRW zx(dIlS+haSP@Rz)GjkOw!>&?PQRQqRcWY_y7NfvFbHH@U{Fg1ms5tlTXjEYKB^?7+ zt#-!Y<-D5B(Yr;r7>yjUMcrUy9!^-JYd>W@WP^Hsb9&t3?jy}$?Mt$8 zuRn$!-k4sFB(Aijpp(BS$%?A3Njx#B!nVO#(h9(08gnvgxJ%0=mSPeQHI|)OiwQ1} zA6m{1wz$U>5i%HVRCj*$A!i|{iD2ZB_QWeSF8a!={dB%K5*mn*5x<23nq$M z*Nccj$mrx3t`g)M_CU%MF|HqP?k2WisR=soCJ%)nB{b9- zO|h-<)m)VQmakg|5JDhhHFI;PIVAZvO&-C-$<rx%<26PBZ5^rj%YMJ*+cPxkBSVeEJX4cNm!-i{=@&*a z%+)Gii4-Aq`r(I=w5L4Asx>#E$5e{ZgR7dGJJ>VGPKB?^-SZ%2har+O$BRnvbM_)k9<6sivv+1mTik7m+K4%PYCeB1o*B_sP znNj<@4#QDJ=7csHwFx^#G|GQ+*7GC zgX{FV9yPY_kJsj`G9_93%B3vG?71seA5CsX7G2@iy{5WR16q|p2Jw^8lI(ln2{M(; zcWpgv)cr#lzs@OIW}p~*RojDod2PXCCrB8rwXva2pRbl)+-mDt-jQ;mSVsa51>(*F zB?j!Qrfld>x0-D~M(aRHBO*<08B#R@D$ftH!l3H4c zL&Xw&cChsR!uO&3Tvl5&Z+ZlYHf_&VW|85RRI9m=Nsm}5+vj5tH`U?2>=jl#BR^Hq z>8QiS9~t5Cs(E_LVjp0$?3yGW?^ePjqkzkE2aL0cheE?etmGy|0?bO9`6p zCATE5#I`fi4_Y>CJygp-`)D@rp*X&7wyt8WJ>d%XW&DADw@dqp;^Z+0pyt}B#qIV~ zUH4)j)4$#v4&e8&)HvJP^cW%yA9b_`N5rrsd~&N;dvFH0X3rYCH_pDA3z0zecC4ko zYoC(0TPx2!XHOwwsXd$xB^=ip?F+xp>^kw;R(PVY)A1v#-Y`<=L^<3=@~phhmoG8G z6AwBaW!d!7=fIQd@>$qZ3A^^aRYLZZ6C(pH?gTcyZ}05o)(97A<3wTWvI~U@7vkIw zjdX&2+L4KUiLY8~5A0Ar;rdO@H?^zfI|rp{by8MuR(In0ma_f(XUaz|A2lvwe^Zi7 zaVtX6C7f6i4f@}(XTw<=1O;Y}8WBCAvka@S?2_ z`+eV*@pG^FDcZHPxsWSoVd0ey!;T)p`GEc>tDWNc{WHtzCV;tKcZtC@hS)w7J}fQ0m>)~MDHbmIY%4ss#@L_t1bH+m0@mDweieyiLdPTrw+?gSR%9YI8@zv?Sbqk zIay^o^(MS5K@>N~yivh3pfCED26u|)JH)A;(gQp^1O~NDUJH^xUy)SdESwqSV==t~ zo_Yr*p2U>YmWb2+ckmVg?SbEufT?OqE85XdBtGrMndTrfpr5x8NEybC&RyE#rwBP` zCzrH8z?f)?S1*bRfMQ;sS6N0?mZ(!+N&J*NC!z_Dm|IL3wogqtgmH9Fi}3TtY+ilY zxEel)HDG30h3ku4U}QNtMh0GJ4!*~?xUSl8X8pXqK48m)C-m?AYhiz|@nK+~hHz&3zDFM_}}JI!JK?q2bhNs+fv@;g_9TOwHu` z2(Gq`Px0P$+I{P#d`_J5lsq%vx=aQ!KRnQ^@q)+^+@&)I9bPf)fM#!Ioxe7^sRr`x zl|MH7`%88=rr?ck-sGD)3uWam$D~Nq_+g>Yom$H%c2)|h00teK0d%)po%zgvU;pil-G}}P@Tsc z?0q;`_G(m{|H{WAn$z1FA9qoY2cEzXTXD*OCmws`Dc2*t5b=gd zX*&o(Xb~&dBUs&FAuKDuF7sqBkA+!K3^64+H=J)@Kj5@9YDY| zZAy@jIzS>hB_%g8ccwlH9ms8Rus+gw{8y{zQY6-dvGT%$~H*}8O?vy)N2 zgIQARP@u+c)a7*w@$r2fCU>f?Jmo_$o;JYuq|)l}f-HZ|x~@4{o^q{%ip5fMW_OPC zd(P+gwL8ePZE7-gE_aFu>v0V+&sS|cyR|a{Jef#|Z&Sj1Ra(o}41lxu2f%ejh&|dQ zrBeiwHkKJ4OB=9qczB+}1MH)M$15v1+BuHR#0+qd%`uAX7B}9nYRa3c`VFstdf2Ow zdGC9zuCSi(6#l!tovgKqp@4sVAz*{~_)d;%cbq-_3t`|gVZM9T#tzcBYBGPTiJw2cNKmib_rcPK z8Nhb`$v=F5p=4?;TpDiF5bT)e5qo-iOMr5%-bs&6He`JtF8l%<)u!j3R2NpHQu7OM zolL(GIXgBcon7kLU+3@s>S%W9`{38sFMHGJz2w*LR`VgEQqlqS-^RIVq8HUY753!B znS-zMX*P6k+5jrN^4^<#R7G}kofIS+E>97%z3@5IY{QzFSO*>k27nFr<8e~6xkz;Z z<2*%dR*7y1*sQ}+wqgZYwyVKBhM7JTD|`SlO@7^J&6*8AT2iypIG!SIR#=*%-1+g? zK13)&=);1J9Z+oCHrF9K<5$GqAG2e~Z z(#AT4dsahrl4&3sCb2W~Xs;_Hs5rD+R?Ikk*j2vOc)|%hb{3r^lJD2wTx-%A;QrtL e^aLWrPBB^N)^gtu9k!L;1~fB1Zd796k@#OS_F8=a diff --git a/client/ui/netbird-systemtray-connecting-dark.ico b/client/ui/netbird-systemtray-connecting-dark.ico new file mode 100644 index 0000000000000000000000000000000000000000..615d40f075b41f3b3c9241611cc4b249d0851899 GIT binary patch literal 105128 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 OF~=dJ2Ok^g7W{u+(t)V} literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-connecting-dark.png b/client/ui/netbird-systemtray-connecting-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a665eb61cc066b42388ce7acd0858d0321a7b708 GIT binary patch literal 5434 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 literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-connecting-macos.png b/client/ui/netbird-systemtray-connecting-macos.png new file mode 100644 index 0000000000000000000000000000000000000000..0fe7fa0dbe50bcf8c08e45c7cf13b7e6e2348335 GIT binary patch literal 3843 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 OY{ZbUgZ~;x5BxvcE4?TH literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-connecting.png b/client/ui/netbird-systemtray-connecting.png new file mode 100644 index 0000000000000000000000000000000000000000..4f607c997df29ed0104e0e824155f65a1098a3a5 GIT binary patch literal 5412 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 literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-disconnected-macos.png b/client/ui/netbird-systemtray-disconnected-macos.png new file mode 100644 index 0000000000000000000000000000000000000000..36b9a488f16673b1fcb1c87037d3d1606dfb1e5b GIT binary patch literal 3491 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 VFbHp#EYMSdj14UGKVEZ=`9J&Jr*QxP literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-disconnected.ico b/client/ui/netbird-systemtray-disconnected.ico index aa75268b0c7c110352eed1b76b21a97e8e24b909..dcb9f4bf83dcfcb18d4858d5cf553895b092eca0 100644 GIT binary patch literal 104575 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 Uw;PvXstfzveZt6u;jD=N1N46qGXMYp literal 5167 zcmb_gi9b}|`#-~EmnFLpvJ@eaHCrT+M3#^tiW)US))_O(7TG>2OV%QZX^bTiV-!-7 zErYC;NXEX5F=qN*-@oB^?(4qpecf~Kd7kq=@8@~m=K=tOj=cs0hk&FA0AkSn0|$F+ zejX_v=$7B+tfk{#@7~G90bM+UUtR~`z_g8}`K5^b#fkpl1FnS}E6Y>Ie55Z2C-@;7 zvN*{Cf?l{v5u=1x-%4i{;v|Ia++NjCI}RH;8|gi&%&iteTb{-iD8@#e5%fl0J{F4yRjrm`iIxln9tr8n8os*2z-H^tO7v;6;PnDz~}B;{2a zs`d5JvRT+At5P0(CHVYe_%}TB@x6In!T8lbZOod1C@&;t@_+#$mbb4-N_BJLq8;*) zZD`8bTRwN@x!G}5^(}Xn*>K^-n?^^uG{A(DOOM`8JG%EBFW>_;ozFRG-M7j#b_!|s z#hX?EJx+9ZQrB!5vdDO!1n`SpOpO*V-YhRTltZ)p?2f!`uXNTJMgP+(3H&UVPcWVF zn?uhPPfcxn8YHi^dNE4FHju2RPOs9^wtz;Cm+exh9&2*(pKvcw6(vk=yRWCBk}2Ky z5m$l3v+d4?xURwYj=2voVleMCCi&k-&S)zkleAg^Ov)4hj(zHc=!hX!l@nh zgX{xwZzwLW6xNjVDYd=a(0j)0#l%~v@pr}bES_e@S9_I*OpfCqKz&S_cKh_gm~njF zqlmTb200ly20!Gc-0vxwf9j=8+A&9!g08CB~8jp#LyQW7}g z?osjT&^E_7wRS8C^1Tq&cXpz3r@7v1LTuD-;diw)7Uydw6^n{VjFVvS2ebI5`|mTZ z9b5Y@ag^&`gEjNi)cRkk5u=^@fGe#V{XJ2_$WZ6yLJWy6Q&PXbes#Dv4{S3U5U%@P zhGxCGiSHi3IAx#8JPQ6if8(0T(}Z}*hU2+M^ty25E)_YpP&t9(|7KOXACLXZbh%>r zH zB!{l`Lg}k{)UBpa9rN5}hLS+$e(9njV>cL*xl+2vBhzHnc{dB6= zNYm^x663{=vP~n144PfRl?&1@BCj0Cl)EL6@H_==N4WpKpS<>ap}}H#mzHu6Sfe)5 z9G5rJX6EkFmI&jdPaii#%+9l$CUvFrrr2Jv)8qngD6X13E05>zI-FI2>@)8OsGeru zL+`hJLwd{ie_9Ddv2#Ag^94tC9afrEZWJj6eVnqvy1$J<&M38(M;-&R7{(^rx*~l0 zdPApE(k_^0=zJA~IE4B5Z*IS&FEyO%5lA4!fN6ybzZNNDwh_VsOiYPGgfML*wumut zVyP2hIy(7Qk5JD_K*h`%bJtBKEAmCrHn8Hu?^PoeYUpDfWv1tMF*w&A{FEI@UEA-O z)`&ZTDF>T<8JsSwnldIJ|L^wLKedlaxnLwQt_0x(+%8H zpUnSu;JSE|^i0B?o5CB5e~ZZa@;&+uFK{?YW~E@XxYWgovueqi1-NX&)Q)C?Sy-La zG1bkeL<_87oxoUv%bC-B1v7RE8xuGv+DJbX3n#?jQt$pXgs20K^F8@(qvyCu%jI?2 zhR$Cg6iPf|#}>D)Oti#5J~%XvdWc?UC;O}`g~d#^wN0AyR6Z-YD$C&KkpasS%}&dq zhH@w1^A+3`0%R02Dkk9l7n>VtIg+}vdT&A=XPKi&qJPrTbx1(XHJ$66aIdx(+ zZT8&05qcyiLvu?AZaC}QuVr&?GV_)h4+?kWBpLnQd@#Lyovo*rLRudb zARthpx%vW>Pmv)G#XWGX+>o8TZu4w)cO5IOq7dqBLr=i?g4wPT3Jlj?+Q=Z)Bt!$jK z#Q3K>-Q#}nzZ70-cs+e_b-IN^Dgmd|DZ@xzsHXo1^_Csql7<2nOjrmdS;m;jcT^4q zl!a|9mtX1|3-RW%d`uZZIaEQ_6pBEBNNM8bo~REaNHx*v4o%Iz8{rS}Z3YVUZw*<` zl1~8;#+~!+W#YR@QyX_A)nu&0XT9%_VSeVyChNmtVE|5(;bkKGk2Mky$kZKs8T|bK zn%qoU*I+4uCZ|jUz$>0S^&gsl$9cfF^$vAeRtui6;wa0IiDs4}rq?z-us$Vc*9HzU ztU3hsFFWcYE*_Qzz$1R3nwEZQ&{iT@Uh7aBM0ZDS0RDs;VtumBwB1w1E`4P6kF|N- zAB>g7*RMV|&A1Ax9S$2Pe~h_!@H}TQwgA^goGSjt2DWwK5u85g{ae(rZRs<}o|JgL zG$u!x6fo0+M@Zi>?yR8K#5%&ZSed=ioC7cE$qJhQj3^0a*AZyxkN&D2q|PpLI7**n z?MQM1oV-?r{X5^q5zVR(v)!H(=K?>BpfaFI#_+?!xGi0=w`owAfQqw3UxB0B?A#O0t5lQ^lW9q&DXySqM$X+8e(JMWQgU zVFA3e$z1sRfileW(C;!jTeYHrn5tu+iOK-yRg%>4&+SE{;TCTtaky(d2A345p{{Z2 zcuU%#o9G^P33no?;uKa&)Qi^w)pwvF@4`T}^Pj_-9*JMmneQErnkgziH+O=tc9VoS zV8tObGyMl!Iuy|O@1j|$%Sv-``&Mf@4m7A!Gsq?uD zK%nU7pCg)lLl>9q_o+isT4Mrm=mwrqxAn(QuD8t3Boxm@>e&(G)m7L)mw#nOk1pay zpSw}`hm6!=hQKd70O&|j!-yZ-bJG$BPa+I!A zrKCBXd4{;EPOgVa{2V*zvajr$ti3X5TV3ev4|@VFUFHLb)Ac-(n7*Hf*CJZ}=~o%@ zR@XsU@e3S)jb!nBH4$#M9wGwnlD|(swB?^xE)O90xRlJs+_(P6Ww@BODtr-NUn`Ae zYK!lY(6cdBe?b>vb3^jrE|pKimy@a&4=|^JJ(JE zz&Kru8JQhH*|{U@q~?nZ8XeX}tzNK$Y9sg-LjP2~&eF9LzgI*m=%nS{PDJY$POx$m zUiDXadIS|*5XM;$rqUw*g$yMC4K`3+>2R0;8P^Y#eX(vKDaqjfe14CzxTMuY9i4^q zvP|FqC^}@CvA?Faw6ZIdF&4uZuRmD`m5I3XPcDj!68H8aOcg5e!2Ed4mm`XU;EK`NNEIr60Hy}gBayDpl|Mpw>ZcsL;k!|{6B^5gI24NW1kjz^oD13-sF07 zzMoXa{XOkU7W@^)y)thy`!Jg0kiyeQNc3F=54G&M{`c&iKt?%K&iQbH&*H$xnCmz2 zim2wB(@;&`Uib*Ojs#W;7A^UDm11zWdxo(p4N~up0|kkpqwRT`MlZwS(qWQS^yGUm z!Hb&kw7Yv|w*EBpH45eQL%C1_`07_U#9g2f6CKS*{5kYD;T4CqKxfdmqoJXk^3b?) zx>pq)EKf$eVrHe-z3ukur)g7ajxJ*BMnfW&uWMlsD{r=J6xw~E&PX7#r4^HK3x2(2 zR*i>)9LLk~e(1ADh@jA*ZHY;FLd{wHbYx@GBvyih41Y81YKo&cPtlHl6s$fM^gW@9 z(w%M6^eMV78glD9B$6#=K0HT=C-1xT)!9zt!A<}^T@QYT8qNp#@$=KIfGdmt-0!?f z8g2ZqLa873wR_l7R%GG|SWqyF~G+CJG4f8P>m^Qc@!>Ms9}y>atv}Bl<%`WaUr;dHkA-uXaee5FxtP`K?4T<4(@i)Py2!R- z=cXbzafJ@UXh^O|a76PDrra(__u^ zCz##)YF9M6`zIYcGolHHcm-{ghE#o9t1aP=_0(1{taX)Q7ZbgYy)5}vl_0LwX18<} z6Fp`98*h+c_67R(M+-}6g*D_9h{s>Dnt4}>us#ys;{T3%;Gy}dT}jxc0HPTQnf~!( zPUKM<^Zn0UGjy_(e=uTAQWX2wzPg?ibv%uTa3d{0N56h=k;yHR65lh0-I8n6q(4lC z1+fXyjTeX2MR)^6r5-Iu=Vtna@;3cylnmly+OA^U%IAJly10=ahs5 z!2Bo0usRNDPK(Tdn<&=0!4q`a16bc&X`beR7q%z#Jq(ahlJ?M-Pn3!dKoQ!0w*~9@ z^=skpTkVP>y&^Z8C$ZXmIN1Zz*yq)@)1if2msGqz&Ba9^rJ|&s5)4P&AoP3O}?>-Zj6T<%W{lZ?dA=@pVw4!siL2ANf40! zSmwNb?cOpYS6#%X$F(N-0Dv_0>=j5UW!AgMQ_xD9dN4p9B_c%PufodsLb z!Za4s^O#8Pk@JvON~0sk46UB*X(_6VzLV;EBBDtf!;$eDvZYbL4Ndh*?gX5Ad-qy2 zRotq=@g7AAfB(sRS^g_Gb*S@N?kV;zhz6=p8hD7a-8egry)6-E#rR>{=9- zS0Br9sBnDtsi_GB@#B!kmWW?pHbjsl^&g=ih?@QI=Ov-qkTOeBLlWe+f;}fLVlr=D z*Ka8FhVqsm6!Qx2Q%zI!r9osRU0Z(1MSivUS{jr3(+?O&1+{)5A`Gum_k54f=L2PT zZ$mRoQiSvDE7C=X1>ffD%F_(##Ebd%WVhrSu|uyJl%2a#Wg$dCk9Jo;&>>p7h((uw zhde$j@1QOg(bC$H&jW<$;~P>hrP+L%;FhH(SMq+0^j`f6hYC;_x0C8m@!iDMck7br zEU^#;u(lH__q64vs3CK|1qE$DegGgF1(q}4#*+M}a(+LZf>AgDfz~i)bA_#n9-O!d zBd3}&HWP6w@AEVhM_dJ`{Gt7)g5@kMNbyr}ObPDu(~yy95`$TpLwiih;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@wb54ahmQdEkBw#lr8IP zk3_3MC`yQ=gc`%|8NKWI{?7TG_ni0p`|F)^9P`}wb${;b`dpvux}SSa*;tvZgKdTZ z0I<%?)X)w91i-HX0CWxb&pGI(2l%fk!rqZ(M-4*+1o?Y-`!W!$qX7&ABitLD4DV;! z3`jH~#JHYGXI?urq!?t*-H~H0CB1&Tr?^9DZ5DL&K6|KmZX8}tx0rPf-q!H*es8#y z(6rk&?L~-)^*5H;ZhJ!o;qKYq%kLkatlW(vZDku%v)8^b-n4>CO1>Ya40n=wfwhwr z7C)hR)u<|>N{>3|eKPd|#apWelRjK4x!2$6to=EcgQB-HO@#V0RG4sa4-I+xaTn>k zJ59FBwAX88o4O9{Iw+}DVCoLW7Vm9fU}I)r@LM=w5LY74>6kX@%eS~XmF@poJQN<- zZj(LrT4coWmQ5Ti?||2fU*0}lpJ!%}HZroKQc8Zw@iwKB{9*F7Dl}FR@mSm{32isB zlC~#VX8jrN?8l+ej|#7gGcF$qZ5G&nFNK9yq6e$(`6Q_{mnY{UlX0oAJINh(b@am) z-MXQc@QVvodM3qbB-z&+6G!)w)JH-_CM+%9*I3I_Rm_xhw{Ibg$2O-rXShU-I`BG6*x6n)!9vf_O==w3!W|q$o_!`I`_Y<+0Leti&2=s>@jSbV*}C< zde*jOO~+mMwf$RL3^V69Zdq1p!=zbk>s=$GHD((FdpLVy<(C=1uEw`~{ejan#`l$~J6#<#^7n1HpSq=%I!=iuL?t)h~f%VKu z!y34_?kp{wr6#=NE)n%%FmTXSg8% zSg-31>dVn`FNNmML{sVh?hJG|GXNAG0PNBW51`U~7%YT4!_(VO2RTz+k3@LWb&w7k zmRQRG1BREkX+#iXUxbxCEy9PUMMvuC!ghsIKmaC#MMZ=&ef@$d;X24wTnhM|Ka4>l zR!vwwI!H%L8-#&>5CcI(6VX_dQMh*~9;pjM>_bdX*wRsaQq2@4BD zhY`^JL7o_#mX;O2mn}`qENy;Y!Kc8}n;EdW z3MBh4mMm|NKgjxvZ~U3naQ^HFxcqnAzgYhk`>HW$Wobz<^rwaJ-7_=PLGt&f(EVxN zbjs>i91f2q(y0WL7Li6q5vepPij37FqVOIhvIdssPQ!VS{sd*_7tEsi(HMLv5FG6d z;%K^KNmMG0hSH?c87LwNOG0U388j4uPGorCumle*LF-Qt)dC+a_<62Q z@Cm2vwlUK|;?dYYMr?ekEDvyj4sx%zUr6{L6ZYOr#y%F6&nAw9CurhHSWTQJmZYhr z@rTg?Mo=)QL_Q`CizaBU&hXPh0fPaFrSf$O0<4aMu}}ta723?j)Egmh&VMYo`U_8y+7UC z`rEko{~`rE8A~RT815)Ko=id!v7oM~I6M(WqtOW@9GOTay8j^Qcl2O?4^|j8h_Txf zkG2m}fquZG3(3B&yNLnPwynlwCE zAw+_vCMZ!V5k=J?;!t!C4-Xm^j|Y|WL+$@eh?@Kmaat5S>HjZ89G;*-Ced&xEu1FU z6UaC%lslHFiJ~$z2_6iR23Z6DqYD3l5Xq?jl>M(++=bzH{@--F3-ce!{$cQ^qXNwS zk1_BF0#7u|??>9NvH<=4Z+?EQsQ=~`2*kge{4IX}rR!h1{uTp&%lKb){Y%&1V&HEX z|EsQlj4s$8mtuw=_%0g;UU{EhSMvg|%c23M2ZI4%?MD8e0FZe_1{@S&nOPc%j6=mm zC3Y^pOtk`lO_pYcyY0jKr?ahn<$ELB*fU+~Rh=$Lb~_4fRS^j?5A!T9tTD3B6@&>9 z%^F~@Z&;n{N4W*Mn8sGx=4p!bUX*K2aE{0`-H%tcyCd*^L&KBZf`;aI*Y5|a^#|_m z?3vFs0Ln_QJ0h#tC#$t*YPYmAOq1B5p~H`ANMCqYe3Ldg)L&lU@rGtdGunpyd;UNF zi4MWW0h~wE3upz&Jz|8PigCuS9_9*2yU4^xA5F)Wpn|~d*N@9A65`X^%nE7Z*M?Q{ z>pIRqQo?8F$t;{q%GZ_718(58gJ(Bw4Y{)Oo8`962`~K)#B*4uN9$ry02bZJZCr`z z>{CqNRNs_%Hn*{T`PF5`&2lSiBm~989?m(RsxTvp(-WQ363yBL14_RR!vVNcd4{?3 zm|Y5XVpKN;nlqcVwK#kE{?`?bV69DUCyBr@SLRaD%I5RZhSi_)Q{xpkrg1KG zxf%!qXJ?GutmqTcJ_5h;hwqEe8ibImm-cz-SCvo}p6;V?n0ze)k(&Rh)lZs=>PTIsp9A03p5pwVhjyC;rjVvLb(j>-yENszN93rggD*KlEQ5VkeX+MgX~6 z{amtlng8_7LOrTUdIe3>^A*rHNgig*;r7AAcioubopO;KqE02CfboFYrl`)7Q}qKJ zOrT^#woit*nsI&YZsGhT-eA<@H%kPgIX6pDN#0D)sb>p;a?BKznPT$rhU4yf;@=6a z_CUpOHS4^D!sqx!%FePJoTlmZc}j}HHl&^T7J5=pv*S!h?7Ep7_J+E+A^Yo62b%0@ zDf{E>7EW3?tIkd;$>g(aTuS%_eJBPzNOy86omMdjPx4OO+40zw4-_$(5{`fHa zDo&GA>hEE`ZUe2h+*78dvH~e}d)ujHr=1Vl$SatHS-J89lJ}mauS}^P6|?9}tF0gS zl8~*uRjL*F{z_!}#;@t;UANiA6LVnu4C~ydO5B&O+RdrCPu-blj7~VNuEvwC*RoSr zn0f1YuwbnVboR6PwhULQxkAH}?%c)(sZF#T3-=s2EzG3#v9soz;K9tRlpNa;lgd5w z_BqXi#i5xt9KYnjj1kDz@0$;|!gtHIafDhHbqc*6zk6JE^A?1)G;M%=-xv_M{i6E>lu)ZFPfHD)iG_GQJsSvT>gJ(UEVgH0M0YFrTouFh5<_}RW0LYl#jQRcYH zCs$D;l0q-<)W->-d7jjP6a~#?O=v?>a`MVP^(b!n+C3#XlkgKx4%<^bOrv6N9y$-F z$a9%h!k4nE!aFW3tHfz!y9aP5;|t`!L{j8xoi^l2KqH44jZnVNgM&cTco zxABtBaU%y~S4_6iXm?{v_qP+$t>&&U7XTsnP&ziEsP1eR!|fwA9q^LjmZ1Q6%v3On=rXdNOjt=x2uy z(3D>!o#0s*rl7td(OF#~2CAG8QD$Y=emeghMak=B7BtN}UvE_LN!Uk1Dfd%89PWlCTG@mJ8mqke_Z1F8s>%yCcdjsE)jO#aO@!ZST#Bd z`O}Tw>-pVMh2@S0JtI=gE?WH=H30dO+>Dj{#}E!?mUA=m{$_RzA+D(~AXo8)zI>nF z^;TCCNkHPsY`W*nTK8*7f$KBc&^6-ssy3-L+8#TkvQtt}wg|W)#tnm9{Q9KVBIkS3 z)dVQV;NH;n8@(>U#fO5zCMk*0F3+!y_jMNU7aCs|m-S6{ zp>nitW6R8BI88+2?g_BTxU6Kqqo5+o6OSO2l}prY`?|uWVPXan!4h@XJ4Q=G1sprK zh!?yXlK=WP0`7adPw!DlPC3l2XoHM)@ZkH8VUp)kJ3Xa_+)?*ihWj_z>lM3O zROxUnV3ZRmp<@ra~)LX%zK#g?;kJq_!Ja7Z3heuueHpEL4wh* z@tNtoYu6_r@%zkzV+yl_EY;}@@jEyk)Rh2)I&m<)9PU-@fN@Ay3_G}l;YDx^$7tO0Bw?>sf0SVit3{0bqvi#(W2a?w+-o-OMXN9|+Pc(%` zZVH><4yzKal$A+}Gk8U-*C)x-p079^vtwd6yysxbVbNYKHrLV5djn%_6g7n8*xEx& z=K0IiJaSu9$pHYx@Ai}U?=r>tlcgzBJ!72^`HX#UUT+z>l0TVgME%0>748u{EEw@g zT{nLFakH|57jqX@))1H7rGZ5Kg+uGfs{;5S0fTdH$=e`V#g#4U6PCa1VAjRD?G;(`(tUa++w$0utB6&q!7=fV zX1?d%EeZ@pHS-w#Aya71yb@>LgSb>|MEH zoYNi+y)R{`bMAD#LWQ>V^c_L41`M`a4U>4mhh`D;V25&Xc&KMSI$}M^DOLy)I@mEk z5a!(cwj^i2WWxhl-HW={2(rN zd9j9#ghcO%yt8L$&xNN}&wLI&gWDU{P0;ji3Fk6)q^*&->G0vXbL(v41?E|WjqqO< zZ(H^aQv|$DMI$sb=V;BH7T6wVC&zWvP6Ex`v&{j5uFiXP9tEEnDc4v9&(b;HJ-?@kaf89M6A z(fjpZZTm^LL~*;)#Z!@}&ehicQ2KQJ<$Z2nB-ivUAMRM!oE~ji@y$F$xTjGHBY-i& zwDy?QK24myo;!WI-X0pg4%u^?gq#1sl2A1|ePk}xV!-W-u)tkI$Pw$ayy`rOl5jAw zA1h~+cb+T^H{+U?uwD@{0x7%)CPc`$uJJ8A(?*Uzp)i8<<<;EVnrm-oTB1g8aK=^mwErE6Y>>+EYYMX*(e89-dgOV{Ngx;nA$=EIj7P0oh)fWG%E@?>a zT8W-%Rr^T0-EP+=g}mVX%ADi7fd_8%sYeW#lgO_}vJDLoH*J|hnS=fU%A<-mRJa@S zL@QC0+bv*Ab)Qkh4c2HQ3cI=21jnP_j4bx~osk%w)}4Kg39DOY+QeBe2&ZfJHql<*~aUZo_>BK`pD#3O&8F_>LI8aMcTnZt$MychcrM}rz z2mcLOB2O})EwufBrh8XJx@v0R^6;klpfjK7j2;fkuUdHG41MG`ko=So@e1(>V=LDY8LBT_{x#? zFDF%S^#P>2pU0$ePey`1WW>Pif_YXk!cB}5HNhqtd``Dw*L^-JY^)?PI`?2Gr)E}) ze<2~h+IwYadhGq$6T%&D@lg8t-rHx5=1qj*?0MJjr`J?!)DTH>)ory41?+{F!HE#n z=sSV3k+(n0Ob!p%znLJy8!e{;j;PM7A_W9`&hfUh>NG9|8uLUJ^dXHkh1`g-`h~G9 z87@dHhIb-`7n`Ub#nuZHdDD9u`T6q+q404L!CWQsmF(Wjyx!5uD=-m+;P-p%8OdF9 z8xgWfkO5D&%jx=TIRDiJzXSM!)MBPN$Z3wVti zVwRS7i5WdOxK#Ub$%4&0(U}N= zF?ATXwQ`##D5QE9Nh#nTyrlTL< zM9c2gt!_4g9$?m;UrIWagCgBcdu=1?iyVE#o>{kRCKaZi0Be1<=wkwjSbGz4Xa~~2 zJ8yRi4*?85JJsbNKQ}#nBG2^J(oEO!xc5Ab7W}*X6#`f(+4r52XH!Na-*0bN>?gX0{J-*0x~_FM&ceUNL+u#c)VG%qydRe!3{Qw1VP`wTT*OZE$j5;w(w ztxVr~KBsEY?!2aJ;8~)B62Fhlp~O|#X-RC)*8Hi%+*(LwQ{hh8&1~v0W@jJQTDcHJ z?ORF$!mV|DR@OZY@f*3Xs0t_~uXx#k!5TFjzzD3OOmCIt8Dy5OMEGiK3giZZ**X%b zti#&{1XRLcmeROLjVs-R}7!F@gZ0aN-sZ*9A!hJZ$#2za_nkq z=`A?lr2kUZ)d;vIJgSaXmMb&vl_r4|V8p!n6(Rg{`^8h=wRZ%ti zW66^-wV)TS8k=w}hi-eiZqanT3Cz*iDZGA@#We65qimGI-`hsO>(R1algc`H@uga@ zd4YUi=54P$pAcX+Eg|qOh@bdORkIY{?G50$2fAUJ+2!DjJUg6|`iRfIhg;;vPcL6Q zyoP>QEry}IxQETs-M?l3o-(PXfk+#H@lNL};ExZzl@~fH&kyIUU%1ifN&ONJ1G#VG zVB{~}>dcc#GJX7hWoo*jJ_ZUN3c60X?=l%!`O9Z@MJML)vBn) zjlZcJ$XLaO@n#zcUp6}YlHKK7H diff --git a/client/ui/netbird-systemtray-error-dark.ico b/client/ui/netbird-systemtray-error-dark.ico new file mode 100644 index 0000000000000000000000000000000000000000..083816188d695c0a083ba7dcd78c1f31e9f06080 GIT binary patch literal 105062 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 literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-error-macos.png b/client/ui/netbird-systemtray-error-macos.png new file mode 100644 index 0000000000000000000000000000000000000000..9a9998bcfd1f2f4b686f2c08ac4be42198111d83 GIT binary patch literal 3837 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 Z(%>Cos#cWtK%X^)%YJvqTKk}k{{iS8*lPd) literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-update-cloud.ico b/client/ui/netbird-systemtray-update-cloud.ico deleted file mode 100644 index b87c6f4b55620eea0b397d6243063e22315933f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3647 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 UPWN{{D&hjh2Il(Jy7=h-0rzr)wg3PC diff --git a/client/ui/netbird-systemtray-update-connected-dark.ico b/client/ui/netbird-systemtray-update-connected-dark.ico new file mode 100644 index 0000000000000000000000000000000000000000..b11bb54927fe232adf7924216fbbce9569d5156d GIT binary patch literal 104704 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 literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-update-connected-dark.png b/client/ui/netbird-systemtray-update-connected-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..52ae621ac0f35138d31b68201d70940a61d947e1 GIT binary patch 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&(_ literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-update-connected-macos.png b/client/ui/netbird-systemtray-update-connected-macos.png new file mode 100644 index 0000000000000000000000000000000000000000..8a6b2f2db85a6b6e312015ce634ec49096c45059 GIT binary patch literal 3570 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 MdJg!tzfZ*f0jyzlfdBvi literal 7678 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 My6&w?HM_9?0TFN?z5oCK diff --git a/client/ui/netbird-systemtray-update-connected.png b/client/ui/netbird-systemtray-update-connected.png index a0c4533406c399aacba6184ad7e74be9e20a0cf0..90bb0b7f1975c585b48a5088710678521a236ccd 100644 GIT binary patch literal 4842 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 WuRrb?z6NGGiIKzHh8`Q@9rAy92WzMR literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-update-disconnected-dark.png b/client/ui/netbird-systemtray-update-disconnected-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..9e05351f1fe9e64bb78700907bb63875c0e8b5e1 GIT binary patch literal 5275 zcmbtYi8qvO*nfr@+vK%mE8EyZSt=yU%viD%#+J$&MUkblXUq`k#amjGB8DVUMhV$w zEXh`uP%<%O7iNrQhS|RH4}9l4=X=h1&i&ly{O;@bJJ)kx*L~gBzbI!rQIUfp004+O z*xR@P0EqVq0)zy3gU^lfVBR1cWqf1t)w%Z4px*+TKO&6ez*irMgR4s!9zS~;j z>#oIug5U!j`*m<>tQc$;oF-QN<=icxUF7tIbgMsOUv8@#_*NYbn7SCGG&(Uly2aqC z%Bc$Hq#3yX%6&Ha%Z5k)|9=c@#SyrB$xrdpT8~d7!wwAHQorid^75wkRnM09Ucxec zi^6;WI7ODp$oo-~ZHaY zJg~M@v>zdOuWlp8vzhr{V|yg*X`H<;8=rhM2}3)*0<>zkQ2$+v@*CJOuR=r!JJgq9 z{sRE#<~nNe{zfXjCn{u@E)L*4&oPutby-f<0QRhcD6O`{PHd!|h#WNCI(f~Pz4(!8 zazqX2^Sid7T+vC?t!A)W(}{*?%fHrRAgmFSzG1Gr=z2ZTQOvw89BwFgFE}|HI6UZu zevZyf`6qWg`SKVDp;qv>uaK~e-yd(cxwu<$UkyNnhh!uuEj3YF%^q!VvMdE{5#{Oi ztcnjTsVuU~a_xa{q0C$r0C71ZZbPt}-a?Q1HCj|GB=m*xmfN|E{uvN%{t^zK8PLFLXlD7dd4}Q+STyXeQ_FT<^B=}`D$6_=3eFu6I|`XH znak>XP0yi=Tu_wdHWwxN6zjYjSAUsZ`6?vwz<*zsyX5t+9;eKDgjO>;E`Hx`qK1?Q zEoAMa{n-5T?TY8zv&v^U`ev7l>}JKTqnFvU?cSM3rT<92hL7M!f3ACN1>s*O_alSm z^0ou_$qCYFc83@cs}|Xv!TWI_tSn<)_Ax;!4_0tPMJ3D=J4hbEkCOc0$Z?7!WC zLf2=gfCUQvik(TXBX`0t3{^4{EYB}K5;UATelFaar$>A<<8*4Q9@<>Nt*gI(8?GjF zr?K^S(#G)g{)&qczzJf-=?idD-AxstPMnNWtjIDEgX-*gp<6_-B)kbvT&G`eHc<}= zB1E`4Er4*Fv$-+tft26suyVzAitmazY7;63pLiWQO{s-rqWz_BF>LKd=*n@hk$%{t z&GglYHqJzn+Kd?!CTBi>I-D&yf^DtSKSTSdv17xYrAPkw%GIPQ38B96(-O7c7=mA9jh$ug>5Lj1%H3n3bC7-9l3pe-b$Wo-*Qns6M235 z(Z7)Jt+O6s*F(Lq?f&u4>)3iMbWQSpB+PlCHdQJ7I_yOAQ@HiC{$Zy*6@&z3ROCI< z^0##?%(3%+pdAbPF3Ym8OHo9gCnDW0MXYWkWjDs@ZrN?PvH*H96U z?+=`g9B)s7DTJR`7PqnGL^m?F2DoL(yJsM2lw%LIw>=w~H0wX6t=w;4q7g4-3EeJXX`8R2W7Bniedg0FwoA6W}%ejUq?>4<2REeXM|dIg*B zr@nK^sP`0Sq{7Z6x+=>jmFyP$xmD}VkhzNdxtzUIQSu#yk_H=(U=F@8%ZTWvq77!2qW!Fb~A7wu2;+;Cw4!q^Eo(fg&sKxI?>47GsOGnp&j1Tq;9W%@GW z%0a*F*5C=Poy{^bm5tGtxD~PdkwGQ_`K!ECa6IwoU~XHKOTl2w3Yg`|8vLHycw$uw zhZsvOZ0d;nPg(R1Y)XHo&Y!-Gl9QR$rh;DH_MdasMl7AS?cWtB!j7JxzF*-Y4==w#}K z#AMadpDD3-%`GJ_Ny+z$_>6|ebw4A48u0I%o{VEEcBKpZS|wZ7emGR({}IUi^s0VB z4~h}kY?T0~G{mDn>9L${-e_n=JlI}pC!25}SYaiLCoS&cXO_yTCf^*cd~wF1`$@^t z7qy}uRBmvfM1dPuFlLyylYB&t>WaU0{?b<@3fHHW2MX?7QaY6S#%jBV(tfp}*fu^> zdBs5~il8+E!})O(`E;X66b*>2U`m?HzB-Hf%1q~nA7q2jWS~ss{FFNhFG4u&N;qR6 zbV6>!(^d-1T%b+T^IBOfj&C;4)ctV%jh~je%taRyZ0owUrorPtKk@ZG^ow)&fG?~ zdrEd8bv>SzEn6&$#64HV)fH(^9EkD}i!yDpy7bXOi55&re`6KwlfAQ?ro-PIsVD%% zyq^Xc2cDtSqo>#3ta{CR(s+oh28E4Lh_6`Gb?AGXqUGHVN`22-P3^tMA>qWM`edg( zo#XOiyN6D#*MIvEr}3}us)Fo%aXzb{@4Rx@Oqo$()^yMse0sHMX)%JJ06PFvROL#RN1%L6z7MpMkK^U5^K_yei|F^uS3^==OfQ(~nCDXRITG?QAU#&g&e9-L>#E@O z^A2d75Ijr2x0fsVr}vk3+jE&YX4x`Sg3m@Lf!|SA)-hLJ+KA27NDNY-&smpB-Mz9b}x$oSyJ=`Is*FNgA)=QE`B`c7EZj(aGj+%UyV=ah0H#_w;m=GE2=iBsDLSoTgcW`_D1G1R;KEn%s zFxNRNyh1!OMgo}cOV)7kdm`o=u@fZ2I`kwjSeO^Md?w5h%`2sRvzjtQi=k#x;FvJ= z(M>lqTA1V9*sZRTPdgi39pzcXD%)e(;T10Ovxh;1-JtaWmCyGsuztR0tY$?J8!+$z zTcpbdxW7f_1Ik8r)Y8(jhYBN;m5DJyOJq!OGz?YdYKC?1%+C=l4nxc{_t2ly>RcD!njdkt=lqylbONoT=pqEZg8R%LtHOkK=FqD9Y@`ew!ft`g?j zoNfbB7zyOH{qRuP@#T|$i>0Br5-rO*&6+&5ms}lVK&1dwVd9%fDbCKrT6KZl(MNaE zaFay<=Sm{P?>4T=#uFlS(i?d+8u1^;-U=f1ByW=R@9-i*BnAWq_&v)+Y|-`pK0Aar z<$w9?K{^J{bfm76^e1_UG#&z*DFXrRxcRb)_g1{0+w+Xo?HS9A|9Gyr>Q0U2S(_i* zr?tr@(%!xR2zTeadwR*OMwjP2b61vm<_@0t-B0x3Hb_kG{gg63JTyRXu(i){;=6nL zr$yP0KVq{SbU$5e#JG8*b3lD!YMdYABn1Ow1>$ktj;HD~i5bKPRs?_l?maDcoJAUk znPQ$2=Zd3L$n`4SjX)?Mx&ynub+7u6*4{J{vMUcr&X^l!$1Y_XMHmfo;38Vr!Sz5fMn2V zAu&YQNp#~XW7x#*yg%87|ySiT`TN5~sKPD-IGO@?T zt}zdI8>7GrJCA<+K!fc;!PUREkJSa>glOn1m3mkJRWiWr`$gZak2ouiAc%1E;;VX-ZY+P)hj%mF?&I&a{60?((%d>Fdm7~+%% z0e@2bCq6>#0j0yrc$rTK2#KD?|E(nA6o+2^jYMZBDQ6p1Kyd;H#agx_e(ZzKCdIPqX}GL8oK&rs4r{> z7~DS%d&+>_B}>A!Fi)myVYJuXg4iY91hv*AOqP%CNrp+- z^ASkma_te!;U1vXi`(DN9H>u@H^Cqj-Hn9t1F*+b#fSCImg4+3R%V~eQHkID`5}Rq zzl7yq0&>uaj+TT35slYqc2U+%ynWpAU zb=7kF`(fR|MMR~t-~9fZ=GIJ*_vdNQ7hko%7|X6Z~$e& z&4FUFY}}=?4ap{#-|3;=^B67zgX!=Rd8tad%v6a5X4&%}kYy=$Z^=GDLYgBZ+;B(^ z&8sMQgqLP677sxHr44D%>BtAv%+wxU%~Zid%ka=5054tLt)|x7!))6gZ=l_!m-)Qw z2=`qf*v0YWV3xj!D%qs&quyXOkJ1UaTelk0PATDKh0OU;$hAE_VE}T!+BrYc7CFll z&0Xd@o0)vs^(+5Woxdh4uYzRiknaWmyX|1z;+qhZer54SA7$7aAjK09$j8J+&9};G z_f!Fe$-W|uJm$d6-0f52GB<%M?o@4rTR8lfpAAMB&|eJj{SAzlm68UXC&_y9z^H~>iA0e~C>Fn~={0RZ)V{~P50 d(wW0Lv-y*A*FK-s$qgR9OY2{tqh82QdHu literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-update-disconnected-macos.png b/client/ui/netbird-systemtray-update-disconnected-macos.png new file mode 100644 index 0000000000000000000000000000000000000000..8b190034eac6588e58a36365880d83d9ea8d8db0 GIT binary patch literal 3816 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 literal 7966 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 diff --git a/client/ui/netbird-systemtray-update-disconnected.png b/client/ui/netbird-systemtray-update-disconnected.png index 3fbe88953aac6d630a053a45c5d8a51ea2a0e4e3..3adc3903436f78a5b7eab298c5e78d52a51d2fa7 100644 GIT binary patch literal 5298 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 Date: Tue, 18 Feb 2025 10:45:41 +0100 Subject: [PATCH 21/29] [misc] Run management benchmark jobs on file changes (#3343) They will always run on Main --- .github/workflows/golang-test-linux.yml | 12 ++++++++++++ management/README.md | 1 - 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index a4a3da66c..b1ec0a896 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -13,10 +13,19 @@ concurrency: jobs: build-cache: runs-on: ubuntu-22.04 + outputs: + management: ${{ steps.filter.outputs.management }} steps: - name: Checkout code uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + management: + - 'management/**' + - name: Install Go uses: actions/setup-go@v5 with: @@ -198,6 +207,7 @@ jobs: benchmark: needs: [ build-cache ] + if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }} strategy: fail-fast: false matrix: @@ -258,6 +268,7 @@ jobs: api_benchmark: needs: [ build-cache ] + if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }} strategy: fail-fast: false matrix: @@ -318,6 +329,7 @@ jobs: api_integration_test: needs: [ build-cache ] + if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }} strategy: fail-fast: false matrix: diff --git a/management/README.md b/management/README.md index f0eb0cb70..1122a9e76 100644 --- a/management/README.md +++ b/management/README.md @@ -111,4 +111,3 @@ Generate gRpc code: #!/bin/bash protoc -I proto/ proto/management.proto --go_out=. --go-grpc_out=. ``` - From 50926bdbb4441f4b48d9ab341c41f203e082fc69 Mon Sep 17 00:00:00 2001 From: hakansa <43675540+hakansa@users.noreply.github.com> Date: Tue, 18 Feb 2025 13:17:34 +0300 Subject: [PATCH 22/29] [client] [ui] issue when changing setting in GUI while peer session is expired (#3334) * [client] [ui] fix issue when changing settings in GUI while peer session is expired --- client/ui/client_ui.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 1aa61a2b2..30fb8d764 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -732,7 +732,6 @@ func (s *serviceClient) onTrayReady() { } if err := s.updateConfig(); err != nil { log.Errorf("failed to update config: %v", err) - return } case <-s.mAutoConnect.ClickedCh: if s.mAutoConnect.Checked() { @@ -742,7 +741,6 @@ func (s *serviceClient) onTrayReady() { } if err := s.updateConfig(); err != nil { log.Errorf("failed to update config: %v", err) - return } case <-s.mEnableRosenpass.ClickedCh: if s.mEnableRosenpass.Checked() { @@ -752,7 +750,6 @@ func (s *serviceClient) onTrayReady() { } if err := s.updateConfig(); err != nil { log.Errorf("failed to update config: %v", err) - return } case <-s.mAdvancedSettings.ClickedCh: s.mAdvancedSettings.Disable() @@ -967,17 +964,20 @@ func (s *serviceClient) updateConfig() error { // restartClient restarts the client connection. func (s *serviceClient) restartClient(loginRequest *proto.LoginRequest) error { + ctx, cancel := context.WithTimeout(s.ctx, defaultFailTimeout) + defer cancel() + client, err := s.getSrvClient(failFastTimeout) if err != nil { return err } - _, err = client.Login(s.ctx, loginRequest) + _, err = client.Login(ctx, loginRequest) if err != nil { return err } - _, err = client.Up(s.ctx, &proto.UpRequest{}) + _, err = client.Up(ctx, &proto.UpRequest{}) if err != nil { return err } From c974c12d652f276492f0e66f49aff08239cc5b2f Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 18 Feb 2025 14:23:34 +0100 Subject: [PATCH 23/29] [signal] Fix registry not found (#3342) --- signal/server/signal.go | 1 + 1 file changed, 1 insertion(+) diff --git a/signal/server/signal.go b/signal/server/signal.go index 05cc43276..3cae7e860 100644 --- a/signal/server/signal.go +++ b/signal/server/signal.go @@ -160,6 +160,7 @@ func (s *Server) forwardMessageToPeer(ctx context.Context, msg *proto.EncryptedM s.metrics.MessageForwardFailures.Add(ctx, 1, metric.WithAttributes(attribute.String(labelType, labelTypeNotConnected))) log.Debugf("message from peer [%s] can't be forwarded to peer [%s] because destination peer is not connected", msg.Key, msg.RemoteKey) // todo respond to the sender? + return } s.metrics.GetRegistrationDelay.Record(ctx, float64(time.Since(getRegistrationStart).Nanoseconds())/1e6, metric.WithAttributes(attribute.String(labelType, labelTypeStream), attribute.String(labelRegistrationStatus, labelRegistrationFound))) From 2a864832c6820b2bf6c64d8b79fc83cc10950687 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 18 Feb 2025 15:24:17 +0100 Subject: [PATCH 24/29] [management] remove gorm preparestmt from all DB connections (#3292) --- management/server/account_test.go | 16 ++++++++-------- management/server/geolocation/database.go | 1 - management/server/geolocation/store.go | 3 +-- management/server/migration/migration_test.go | 4 +--- management/server/store/sql_store.go | 13 ++++--------- management/server/store/store.go | 2 +- 6 files changed, 15 insertions(+), 24 deletions(-) diff --git a/management/server/account_test.go b/management/server/account_test.go index 0a7f9119b..eb36dbd84 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -3018,11 +3018,11 @@ func BenchmarkSyncAndMarkPeer(b *testing.B) { minMsPerOpCICD float64 maxMsPerOpCICD float64 }{ - {"Small", 50, 5, 1, 5, 3, 19}, - {"Medium", 500, 100, 7, 22, 10, 90}, - {"Large", 5000, 200, 65, 110, 60, 240}, + {"Small", 50, 5, 1, 5, 3, 24}, + {"Medium", 500, 100, 7, 22, 10, 135}, + {"Large", 5000, 200, 65, 110, 60, 320}, {"Small single", 50, 10, 1, 4, 3, 80}, - {"Medium single", 500, 10, 7, 13, 10, 37}, + {"Medium single", 500, 10, 7, 13, 10, 43}, {"Large 5", 5000, 15, 65, 80, 60, 220}, } @@ -3087,8 +3087,8 @@ func BenchmarkLoginPeer_ExistingPeer(b *testing.B) { maxMsPerOpCICD float64 }{ {"Small", 50, 5, 2, 10, 3, 35}, - {"Medium", 500, 100, 5, 40, 20, 110}, - {"Large", 5000, 200, 60, 100, 120, 260}, + {"Medium", 500, 100, 5, 40, 20, 140}, + {"Large", 5000, 200, 60, 100, 120, 320}, {"Small single", 50, 10, 2, 10, 5, 40}, {"Medium single", 500, 10, 5, 40, 10, 60}, {"Large 5", 5000, 15, 60, 100, 60, 180}, @@ -3163,9 +3163,9 @@ func BenchmarkLoginPeer_NewPeer(b *testing.B) { }{ {"Small", 50, 5, 7, 20, 10, 80}, {"Medium", 500, 100, 5, 40, 30, 140}, - {"Large", 5000, 200, 80, 120, 140, 300}, + {"Large", 5000, 200, 80, 120, 140, 390}, {"Small single", 50, 10, 7, 20, 10, 80}, - {"Medium single", 500, 10, 5, 40, 20, 60}, + {"Medium single", 500, 10, 5, 40, 20, 85}, {"Large 5", 5000, 15, 80, 120, 80, 200}, } diff --git a/management/server/geolocation/database.go b/management/server/geolocation/database.go index 21ae93b9d..97ab398fb 100644 --- a/management/server/geolocation/database.go +++ b/management/server/geolocation/database.go @@ -123,7 +123,6 @@ func importCsvToSqlite(dataDir string, csvFile string, geonamesdbFile string) er db, err := gorm.Open(sqlite.Open(path.Join(dataDir, geonamesdbFile)), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), CreateBatchSize: 1000, - PrepareStmt: true, }) if err != nil { return err diff --git a/management/server/geolocation/store.go b/management/server/geolocation/store.go index 1f94bf47e..5af8276b5 100644 --- a/management/server/geolocation/store.go +++ b/management/server/geolocation/store.go @@ -132,8 +132,7 @@ func connectDB(ctx context.Context, filePath string) (*gorm.DB, error) { } db, err := gorm.Open(sqlite.Open(storeStr), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - PrepareStmt: true, + Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { return nil, err diff --git a/management/server/migration/migration_test.go b/management/server/migration/migration_test.go index a645ae325..e907d6853 100644 --- a/management/server/migration/migration_test.go +++ b/management/server/migration/migration_test.go @@ -21,9 +21,7 @@ import ( func setupDatabase(t *testing.T) *gorm.DB { t.Helper() - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ - PrepareStmt: true, - }) + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) require.NoError(t, err, "Failed to open database") return db diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 5c4ddf666..947694420 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -969,7 +969,7 @@ func NewSqliteStore(ctx context.Context, dataDir string, metrics telemetry.AppMe } file := filepath.Join(dataDir, storeStr) - db, err := gorm.Open(sqlite.Open(file), getGormConfig(SqliteStoreEngine)) + db, err := gorm.Open(sqlite.Open(file), getGormConfig()) if err != nil { return nil, err } @@ -979,7 +979,7 @@ func NewSqliteStore(ctx context.Context, dataDir string, metrics telemetry.AppMe // NewPostgresqlStore creates a new Postgres store. func NewPostgresqlStore(ctx context.Context, dsn string, metrics telemetry.AppMetrics) (*SqlStore, error) { - db, err := gorm.Open(postgres.Open(dsn), getGormConfig(PostgresStoreEngine)) + db, err := gorm.Open(postgres.Open(dsn), getGormConfig()) if err != nil { return nil, err } @@ -989,7 +989,7 @@ func NewPostgresqlStore(ctx context.Context, dsn string, metrics telemetry.AppMe // NewMysqlStore creates a new MySQL store. func NewMysqlStore(ctx context.Context, dsn string, metrics telemetry.AppMetrics) (*SqlStore, error) { - db, err := gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), getGormConfig(MysqlStoreEngine)) + db, err := gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), getGormConfig()) if err != nil { return nil, err } @@ -997,15 +997,10 @@ func NewMysqlStore(ctx context.Context, dsn string, metrics telemetry.AppMetrics return NewSqlStore(ctx, db, MysqlStoreEngine, metrics) } -func getGormConfig(engine Engine) *gorm.Config { - prepStmt := true - if engine == SqliteStoreEngine { - prepStmt = false - } +func getGormConfig() *gorm.Config { return &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), CreateBatchSize: 400, - PrepareStmt: prepStmt, } } diff --git a/management/server/store/store.go b/management/server/store/store.go index 6d3a409e6..29ed22fa5 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -328,7 +328,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( } file := filepath.Join(dataDir, storeStr) - db, err := gorm.Open(sqlite.Open(file), getGormConfig(kind)) + db, err := gorm.Open(sqlite.Open(file), getGormConfig()) if err != nil { return nil, nil, err } From 27b3891b14354f9c4bce3e9050fc9c9e43fcd431 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 19 Feb 2025 12:35:30 +0100 Subject: [PATCH 25/29] [client] Set up local dns policy additionally if a gpo policy is detected (#3336) --- client/internal/dns/host_windows.go | 30 ++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index 0cd078472..58b0a14de 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -131,11 +131,30 @@ func (r *registryConfigurator) addDNSSetupForAll(ip string) error { func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip string) error { // if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored // see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745 - policyPath := dnsPolicyConfigMatchPath if r.gpo { - policyPath = gpoDnsPolicyConfigMatchPath + if err := r.configureDNSPolicy(gpoDnsPolicyConfigMatchPath, domains, ip); err != nil { + return fmt.Errorf("configure GPO DNS policy: %w", err) + } + + if err := r.configureDNSPolicy(dnsPolicyConfigMatchPath, domains, ip); err != nil { + return fmt.Errorf("configure local DNS policy: %w", err) + } + + if err := refreshGroupPolicy(); err != nil { + log.Warnf("failed to refresh group policy: %v", err) + } + } else { + if err := r.configureDNSPolicy(dnsPolicyConfigMatchPath, domains, ip); err != nil { + return fmt.Errorf("configure local DNS policy: %w", err) + } } + log.Infof("added %d match domains. Domain list: %s", len(domains), domains) + return nil +} + +// configureDNSPolicy handles the actual configuration of a DNS policy at the specified path +func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip string) error { if err := removeRegistryKeyFromDNSPolicyConfig(policyPath); err != nil { return fmt.Errorf("remove existing dns policy: %w", err) } @@ -162,13 +181,6 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip string) er return fmt.Errorf("set %s: %w", dnsPolicyConfigConfigOptionsKey, err) } - if r.gpo { - if err := refreshGroupPolicy(); err != nil { - log.Warnf("failed to refresh group policy: %v", err) - } - } - - log.Infof("added %d match domains. Domain list: %s", len(domains), domains) return nil } From 7e6beee7f6bb18865c544c959f8dd5defe4b3762 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 19 Feb 2025 19:13:45 +0100 Subject: [PATCH 26/29] [management] optimize test execution (#3204) --- .github/workflows/golang-test-linux.yml | 150 ++- .golangci.yaml | 2 +- management/client/client_test.go | 5 +- management/server/dns_test.go | 14 +- management/server/group_test.go | 6 +- management/server/management_suite_test.go | 13 - management/server/management_test.go | 1046 ++++++++++++-------- management/server/nameserver_test.go | 13 +- management/server/route_test.go | 5 +- management/server/store/sql_store_test.go | 244 +++-- management/server/store/store.go | 152 ++- management/server/testutil/store.go | 4 +- management/server/types/user.go | 2 +- relay/client/dialer/ws/ws.go | 2 +- relay/server/listener/ws/conn.go | 2 +- relay/server/listener/ws/listener.go | 2 +- 16 files changed, 1019 insertions(+), 643 deletions(-) delete mode 100644 management/server/management_suite_test.go diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index b1ec0a896..efe1a2654 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -1,4 +1,4 @@ -name: Test Code Linux +name: Linux on: push: @@ -12,6 +12,7 @@ concurrency: jobs: build-cache: + name: "Build Cache" runs-on: ubuntu-22.04 outputs: management: ${{ steps.filter.outputs.management }} @@ -47,7 +48,6 @@ jobs: key: ${{ runner.os }}-gotest-cache-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-gotest-cache-${{ hashFiles('**/go.sum') }} - - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' @@ -98,6 +98,7 @@ jobs: run: CGO_ENABLED=1 GOARCH=386 go build -o relay-386 . test: + name: "Client / Unit" needs: [build-cache] strategy: fail-fast: false @@ -143,9 +144,116 @@ 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 /management) + 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) + + test_relay: + name: "Relay / Unit" + needs: [build-cache] + strategy: + fail-fast: false + matrix: + arch: [ '386','amd64' ] + runs-on: ubuntu-22.04 + steps: + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: "1.23.x" + cache: false + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get Go environment + run: | + echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: | + ${{ env.cache }} + ${{ env.modcache }} + key: ${{ runner.os }}-gotest-cache-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-gotest-cache- + + - name: Install dependencies + run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev + + - name: Install 32-bit libpcap + if: matrix.arch == '386' + run: sudo dpkg --add-architecture i386 && sudo apt update && sudo apt-get install -y libpcap0.8-dev:i386 + + - name: Install modules + run: go mod tidy + + - name: check git status + run: git --no-pager diff --exit-code + + - name: Test + run: | + CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ + go test \ + -exec 'sudo' \ + -timeout 10m ./signal/... + + test_signal: + name: "Signal / Unit" + needs: [build-cache] + strategy: + fail-fast: false + matrix: + arch: [ '386','amd64' ] + runs-on: ubuntu-22.04 + steps: + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: "1.23.x" + cache: false + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get Go environment + run: | + echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: | + ${{ env.cache }} + ${{ env.modcache }} + key: ${{ runner.os }}-gotest-cache-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-gotest-cache- + + - name: Install dependencies + run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev + + - name: Install 32-bit libpcap + if: matrix.arch == '386' + run: sudo dpkg --add-architecture i386 && sudo apt update && sudo apt-get install -y libpcap0.8-dev:i386 + + - name: Install modules + run: go mod tidy + + - name: check git status + run: git --no-pager diff --exit-code + + - name: Test + run: | + CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ + go test \ + -exec 'sudo' \ + -timeout 10m ./signal/... test_management: + name: "Management / Unit" needs: [ build-cache ] strategy: fail-fast: false @@ -203,9 +311,15 @@ jobs: run: docker pull mlsmaycon/warmed-mysql:8 - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -p 1 -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 10m $(go list ./... | grep /management) + run: | + CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ + NETBIRD_STORE_ENGINE=${{ matrix.store }} \ + go test -tags=devcert \ + -exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \ + -timeout 10m ./management/... benchmark: + name: "Management / Benchmark" needs: [ build-cache ] if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }} strategy: @@ -264,9 +378,15 @@ jobs: run: docker pull mlsmaycon/warmed-mysql:8 - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags devcert -run=^$ -bench=. -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 20m ./... + run: | + CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ + NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true \ + go test -tags devcert -run=^$ -bench=. \ + -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \ + -timeout 20m ./... api_benchmark: + name: "Management / Benchmark (API)" needs: [ build-cache ] if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }} strategy: @@ -323,11 +443,19 @@ jobs: - name: download mysql image if: matrix.store == 'mysql' run: docker pull mlsmaycon/warmed-mysql:8 - + - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -run=^$ -tags=benchmark -bench=. -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 30m $(go list -tags=benchmark ./... | grep /management) + run: | + CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ + NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true \ + go test -tags=benchmark \ + -run=^$ \ + -bench=. \ + -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \ + -timeout 20m ./management/... api_integration_test: + name: "Management / Integration" needs: [ build-cache ] if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }} strategy: @@ -375,9 +503,15 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=integration -p 1 -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 30m $(go list -tags=integration ./... | grep /management) + run: | + CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ + NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true \ + go test -tags=integration \ + -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \ + -timeout 10m ./management/... test_client_on_docker: + name: "Client (Docker) / Unit" needs: [ build-cache ] runs-on: ubuntu-20.04 steps: diff --git a/.golangci.yaml b/.golangci.yaml index 44b03d0e1..461677c2e 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -103,7 +103,7 @@ linters: - predeclared # predeclared finds code that shadows one of Go's predeclared identifiers - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint. - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed - - thelper # thelper detects Go test helpers without t.Helper() call and checks the consistency of test helpers. + # - thelper # thelper detects Go test helpers without t.Helper() call and checks the consistency of test helpers. - wastedassign # wastedassign finds wasted assignment statements issues: # Maximum count of issues with the same text. diff --git a/management/client/client_test.go b/management/client/client_test.go index 3e498a5ea..b4ee58298 100644 --- a/management/client/client_test.go +++ b/management/client/client_test.go @@ -258,8 +258,11 @@ func TestClient_Sync(t *testing.T) { ch := make(chan *mgmtProto.SyncResponse, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { - err = client.Sync(context.Background(), info, func(msg *mgmtProto.SyncResponse) error { + err = client.Sync(ctx, info, func(msg *mgmtProto.SyncResponse) error { ch <- msg return nil }) diff --git a/management/server/dns_test.go b/management/server/dns_test.go index 6fb9f6a29..c40f62324 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -42,7 +42,7 @@ func TestGetDNSSettings(t *testing.T) { account, err := initTestDNSAccount(t, am) if err != nil { - t.Fatal("failed to init testing account") + t.Fatalf("failed to init testing account: %s", err) } dnsSettings, err := am.GetDNSSettings(context.Background(), account.Id, dnsAdminUserID) @@ -124,12 +124,12 @@ func TestSaveDNSSettings(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { am, err := createDNSManager(t) if err != nil { - t.Error("failed to create account manager") + t.Fatalf("failed to create account manager") } account, err := initTestDNSAccount(t, am) if err != nil { - t.Error("failed to init testing account") + t.Fatalf("failed to init testing account: %v", err) } err = am.SaveDNSSettings(context.Background(), account.Id, testCase.userID, testCase.inputSettings) @@ -156,22 +156,22 @@ func TestGetNetworkMap_DNSConfigSync(t *testing.T) { am, err := createDNSManager(t) if err != nil { - t.Error("failed to create account manager") + t.Fatalf("failed to create account manager: %s", err) } account, err := initTestDNSAccount(t, am) if err != nil { - t.Error("failed to init testing account") + t.Fatalf("failed to init testing account: %s", err) } peer1, err := account.FindPeerByPubKey(dnsPeer1Key) if err != nil { - t.Error("failed to init testing account") + t.Fatalf("failed to init testing account: %s", err) } peer2, err := account.FindPeerByPubKey(dnsPeer2Key) if err != nil { - t.Error("failed to init testing account") + t.Fatalf("failed to init testing account: %s", err) } newAccountDNSConfig, err := am.GetNetworkMap(context.Background(), peer1.ID) diff --git a/management/server/group_test.go b/management/server/group_test.go index cc90f187b..b21b5e834 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -29,7 +29,7 @@ func TestDefaultAccountManager_CreateGroup(t *testing.T) { _, account, err := initTestGroupAccount(am) if err != nil { - t.Error("failed to init testing account") + t.Fatalf("failed to init testing account: %s", err) } for _, group := range account.Groups { group.Issued = types.GroupIssuedIntegration @@ -59,12 +59,12 @@ func TestDefaultAccountManager_CreateGroup(t *testing.T) { func TestDefaultAccountManager_DeleteGroup(t *testing.T) { am, err := createManager(t) if err != nil { - t.Error("failed to create account manager") + t.Fatalf("failed to create account manager: %s", err) } _, account, err := initTestGroupAccount(am) if err != nil { - t.Error("failed to init testing account") + t.Fatalf("failed to init testing account: %s", err) } testCases := []struct { diff --git a/management/server/management_suite_test.go b/management/server/management_suite_test.go deleted file mode 100644 index cc99624a0..000000000 --- a/management/server/management_suite_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package server_test - -import ( - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - - "testing" -) - -func TestManagement(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Management Service Suite") -} diff --git a/management/server/management_test.go b/management/server/management_test.go index 43a6e40d5..1b91b3447 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -6,13 +6,13 @@ import ( "net" "os" "runtime" - sync2 "sync" + "sync" + "testing" "time" pb "github.com/golang/protobuf/proto" //nolint - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -30,424 +30,77 @@ import ( const ( ValidSetupKey = "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" - AccountKey = "bf1c8084-ba50-4ce7-9439-34653001fc3b" ) -var _ = Describe("Management service", func() { - var ( - addr string - s *grpc.Server - dataDir string - client mgmtProto.ManagementServiceClient - serverPubKey wgtypes.Key - conn *grpc.ClientConn - ) - - BeforeEach(func() { - level, _ := log.ParseLevel("Debug") - log.SetLevel(level) - var err error - dataDir, err = os.MkdirTemp("", "netbird_mgmt_test_tmp_*") - Expect(err).NotTo(HaveOccurred()) - - var listener net.Listener - - config := &server.Config{} - _, err = util.ReadJson("testdata/management.json", config) - Expect(err).NotTo(HaveOccurred()) - config.Datadir = dataDir - - s, listener = startServer(config, dataDir, "testdata/store.sql") - addr = listener.Addr().String() - client, conn = createRawClient(addr) - - // s public key - resp, err := client.GetServerKey(context.TODO(), &mgmtProto.Empty{}) - Expect(err).NotTo(HaveOccurred()) - serverPubKey, err = wgtypes.ParseKey(resp.Key) - Expect(err).NotTo(HaveOccurred()) - }) - - AfterEach(func() { - s.Stop() - err := conn.Close() - Expect(err).NotTo(HaveOccurred()) - os.RemoveAll(dataDir) - }) - - Context("when calling IsHealthy endpoint", func() { - Specify("a non-error result is returned", func() { - healthy, err := client.IsHealthy(context.TODO(), &mgmtProto.Empty{}) - - Expect(err).NotTo(HaveOccurred()) - Expect(healthy).ToNot(BeNil()) - }) - }) - - Context("when calling Sync endpoint", func() { - Context("when there is a new peer registered", func() { - Specify("a proper configuration is returned", func() { - key, _ := wgtypes.GenerateKey() - loginPeerWithValidSetupKey(serverPubKey, key, client) - - syncReq := &mgmtProto.SyncRequest{Meta: &mgmtProto.PeerSystemMeta{}} - encryptedBytes, err := encryption.EncryptMessage(serverPubKey, key, syncReq) - Expect(err).NotTo(HaveOccurred()) - - sync, err := client.Sync(context.TODO(), &mgmtProto.EncryptedMessage{ - WgPubKey: key.PublicKey().String(), - Body: encryptedBytes, - }) - Expect(err).NotTo(HaveOccurred()) - - encryptedResponse := &mgmtProto.EncryptedMessage{} - err = sync.RecvMsg(encryptedResponse) - Expect(err).NotTo(HaveOccurred()) - - resp := &mgmtProto.SyncResponse{} - err = encryption.DecryptMessage(serverPubKey, key, encryptedResponse.Body, resp) - Expect(err).NotTo(HaveOccurred()) - - expectedSignalConfig := &mgmtProto.HostConfig{ - Uri: "signal.netbird.io:10000", - Protocol: mgmtProto.HostConfig_HTTP, - } - expectedStunsConfig := &mgmtProto.HostConfig{ - Uri: "stun:stun.netbird.io:3468", - Protocol: mgmtProto.HostConfig_UDP, - } - expectedTRUNHost := &mgmtProto.HostConfig{ - Uri: "turn:stun.netbird.io:3468", - Protocol: mgmtProto.HostConfig_UDP, - } - - Expect(resp.NetbirdConfig.Signal).To(BeEquivalentTo(expectedSignalConfig)) - Expect(resp.NetbirdConfig.Stuns).To(ConsistOf(expectedStunsConfig)) - // TURN validation is special because credentials are dynamically generated - Expect(resp.NetbirdConfig.Turns).To(HaveLen(1)) - actualTURN := resp.NetbirdConfig.Turns[0] - Expect(len(actualTURN.User) > 0).To(BeTrue()) - Expect(actualTURN.HostConfig).To(BeEquivalentTo(expectedTRUNHost)) - Expect(len(resp.NetworkMap.OfflinePeers) == 0).To(BeTrue()) - }) - }) - - Context("when there are 3 peers registered under one account", func() { - Specify("a list containing other 2 peers is returned", func() { - key, _ := wgtypes.GenerateKey() - key1, _ := wgtypes.GenerateKey() - key2, _ := wgtypes.GenerateKey() - loginPeerWithValidSetupKey(serverPubKey, key, client) - loginPeerWithValidSetupKey(serverPubKey, key1, client) - loginPeerWithValidSetupKey(serverPubKey, key2, client) - - messageBytes, err := pb.Marshal(&mgmtProto.SyncRequest{Meta: &mgmtProto.PeerSystemMeta{}}) - Expect(err).NotTo(HaveOccurred()) - encryptedBytes, err := encryption.Encrypt(messageBytes, serverPubKey, key) - Expect(err).NotTo(HaveOccurred()) - - sync, err := client.Sync(context.TODO(), &mgmtProto.EncryptedMessage{ - WgPubKey: key.PublicKey().String(), - Body: encryptedBytes, - }) - Expect(err).NotTo(HaveOccurred()) - - encryptedResponse := &mgmtProto.EncryptedMessage{} - err = sync.RecvMsg(encryptedResponse) - Expect(err).NotTo(HaveOccurred()) - decryptedBytes, err := encryption.Decrypt(encryptedResponse.Body, serverPubKey, key) - Expect(err).NotTo(HaveOccurred()) - - resp := &mgmtProto.SyncResponse{} - err = pb.Unmarshal(decryptedBytes, resp) - Expect(err).NotTo(HaveOccurred()) - - Expect(resp.GetRemotePeers()).To(HaveLen(2)) - peers := []string{resp.GetRemotePeers()[0].WgPubKey, resp.GetRemotePeers()[1].WgPubKey} - Expect(peers).To(ContainElements(key1.PublicKey().String(), key2.PublicKey().String())) - }) - }) - - Context("when there is a new peer registered", func() { - Specify("an update is returned", func() { - // register only a single peer - key, _ := wgtypes.GenerateKey() - loginPeerWithValidSetupKey(serverPubKey, key, client) - - messageBytes, err := pb.Marshal(&mgmtProto.SyncRequest{Meta: &mgmtProto.PeerSystemMeta{}}) - Expect(err).NotTo(HaveOccurred()) - encryptedBytes, err := encryption.Encrypt(messageBytes, serverPubKey, key) - Expect(err).NotTo(HaveOccurred()) - - sync, err := client.Sync(context.TODO(), &mgmtProto.EncryptedMessage{ - WgPubKey: key.PublicKey().String(), - Body: encryptedBytes, - }) - Expect(err).NotTo(HaveOccurred()) - - // after the initial sync call we have 0 peer updates - encryptedResponse := &mgmtProto.EncryptedMessage{} - err = sync.RecvMsg(encryptedResponse) - Expect(err).NotTo(HaveOccurred()) - decryptedBytes, err := encryption.Decrypt(encryptedResponse.Body, serverPubKey, key) - Expect(err).NotTo(HaveOccurred()) - resp := &mgmtProto.SyncResponse{} - err = pb.Unmarshal(decryptedBytes, resp) - Expect(resp.GetRemotePeers()).To(HaveLen(0)) - - wg := sync2.WaitGroup{} - wg.Add(1) - - // continue listening on updates for a peer - go func() { - err = sync.RecvMsg(encryptedResponse) - - decryptedBytes, err = encryption.Decrypt(encryptedResponse.Body, serverPubKey, key) - Expect(err).NotTo(HaveOccurred()) - resp = &mgmtProto.SyncResponse{} - err = pb.Unmarshal(decryptedBytes, resp) - wg.Done() - }() - - // register a new peer - key1, _ := wgtypes.GenerateKey() - loginPeerWithValidSetupKey(serverPubKey, key1, client) - - wg.Wait() - - Expect(err).NotTo(HaveOccurred()) - Expect(resp.GetRemotePeers()).To(HaveLen(1)) - Expect(resp.GetRemotePeers()[0].WgPubKey).To(BeEquivalentTo(key1.PublicKey().String())) - }) - }) - }) - - Context("when calling GetServerKey endpoint", func() { - Specify("a public Wireguard key of the service is returned", func() { - resp, err := client.GetServerKey(context.TODO(), &mgmtProto.Empty{}) - - Expect(err).NotTo(HaveOccurred()) - Expect(resp).ToNot(BeNil()) - Expect(resp.Key).ToNot(BeNil()) - Expect(resp.ExpiresAt).ToNot(BeNil()) - - // check if the key is a valid Wireguard key - key, err := wgtypes.ParseKey(resp.Key) - Expect(err).NotTo(HaveOccurred()) - Expect(key).ToNot(BeNil()) - }) - }) - - Context("when calling Login endpoint", func() { - Context("with an invalid setup key", func() { - Specify("an error is returned", func() { - key, _ := wgtypes.GenerateKey() - message, err := encryption.EncryptMessage(serverPubKey, key, &mgmtProto.LoginRequest{SetupKey: "invalid setup key", - Meta: &mgmtProto.PeerSystemMeta{}}) - Expect(err).NotTo(HaveOccurred()) - - resp, err := client.Login(context.TODO(), &mgmtProto.EncryptedMessage{ - WgPubKey: key.PublicKey().String(), - Body: message, - }) - - Expect(err).To(HaveOccurred()) - Expect(resp).To(BeNil()) - }) - }) - - Context("with a valid setup key", func() { - It("a non error result is returned", func() { - key, _ := wgtypes.GenerateKey() - resp := loginPeerWithValidSetupKey(serverPubKey, key, client) - - Expect(resp).ToNot(BeNil()) - }) - }) - - Context("with a registered peer", func() { - It("a non error result is returned", func() { - key, _ := wgtypes.GenerateKey() - regResp := loginPeerWithValidSetupKey(serverPubKey, key, client) - Expect(regResp).NotTo(BeNil()) - - // just login without registration - message, err := encryption.EncryptMessage(serverPubKey, key, &mgmtProto.LoginRequest{Meta: &mgmtProto.PeerSystemMeta{}}) - Expect(err).NotTo(HaveOccurred()) - loginResp, err := client.Login(context.TODO(), &mgmtProto.EncryptedMessage{ - WgPubKey: key.PublicKey().String(), - Body: message, - }) - - Expect(err).NotTo(HaveOccurred()) - - decryptedResp := &mgmtProto.LoginResponse{} - err = encryption.DecryptMessage(serverPubKey, key, loginResp.Body, decryptedResp) - Expect(err).NotTo(HaveOccurred()) - - expectedSignalConfig := &mgmtProto.HostConfig{ - Uri: "signal.netbird.io:10000", - Protocol: mgmtProto.HostConfig_HTTP, - } - expectedStunsConfig := &mgmtProto.HostConfig{ - Uri: "stun:stun.netbird.io:3468", - Protocol: mgmtProto.HostConfig_UDP, - } - expectedTurnsConfig := &mgmtProto.ProtectedHostConfig{ - HostConfig: &mgmtProto.HostConfig{ - Uri: "turn:stun.netbird.io:3468", - Protocol: mgmtProto.HostConfig_UDP, - }, - User: "some_user", - Password: "some_password", - } - - Expect(decryptedResp.GetNetbirdConfig().Signal).To(BeEquivalentTo(expectedSignalConfig)) - Expect(decryptedResp.GetNetbirdConfig().Stuns).To(ConsistOf(expectedStunsConfig)) - Expect(decryptedResp.GetNetbirdConfig().Turns).To(ConsistOf(expectedTurnsConfig)) - }) - }) - }) - - Context("when there are 10 peers registered under one account", func() { - Context("when there are 10 more peers registered under the same account", func() { - Specify("all of the 10 peers will get updates of 10 newly registered peers", func() { - initialPeers := 10 - additionalPeers := 10 - - var peers []wgtypes.Key - for i := 0; i < initialPeers; i++ { - key, _ := wgtypes.GenerateKey() - loginPeerWithValidSetupKey(serverPubKey, key, client) - peers = append(peers, key) - } - - wg := sync2.WaitGroup{} - wg.Add(initialPeers + initialPeers*additionalPeers) - - var clients []mgmtProto.ManagementService_SyncClient - for _, peer := range peers { - messageBytes, err := pb.Marshal(&mgmtProto.SyncRequest{Meta: &mgmtProto.PeerSystemMeta{}}) - Expect(err).NotTo(HaveOccurred()) - encryptedBytes, err := encryption.Encrypt(messageBytes, serverPubKey, peer) - Expect(err).NotTo(HaveOccurred()) - - // open stream - sync, err := client.Sync(context.TODO(), &mgmtProto.EncryptedMessage{ - WgPubKey: peer.PublicKey().String(), - Body: encryptedBytes, - }) - Expect(err).NotTo(HaveOccurred()) - clients = append(clients, sync) - - // receive stream - peer := peer - go func() { - for { - encryptedResponse := &mgmtProto.EncryptedMessage{} - err = sync.RecvMsg(encryptedResponse) - if err != nil { - break - } - decryptedBytes, err := encryption.Decrypt(encryptedResponse.Body, serverPubKey, peer) - Expect(err).NotTo(HaveOccurred()) - - resp := &mgmtProto.SyncResponse{} - err = pb.Unmarshal(decryptedBytes, resp) - Expect(err).NotTo(HaveOccurred()) - if len(resp.GetRemotePeers()) > 0 { - // only consider peer updates - wg.Done() - } - } - }() - } - - time.Sleep(1 * time.Second) - for i := 0; i < additionalPeers; i++ { - key, _ := wgtypes.GenerateKey() - loginPeerWithValidSetupKey(serverPubKey, key, client) - r := rand.New(rand.NewSource(time.Now().UnixNano())) - n := r.Intn(200) - time.Sleep(time.Duration(n) * time.Millisecond) - } - - wg.Wait() - - for _, syncClient := range clients { - err := syncClient.CloseSend() - Expect(err).NotTo(HaveOccurred()) - } - }) - }) - }) - - Context("when there are peers registered under one account concurrently", func() { - Specify("then there are no duplicate IPs", func() { - initialPeers := 30 - - ipChannel := make(chan string, 20) - for i := 0; i < initialPeers; i++ { - go func() { - defer GinkgoRecover() - key, _ := wgtypes.GenerateKey() - loginPeerWithValidSetupKey(serverPubKey, key, client) - syncReq := &mgmtProto.SyncRequest{Meta: &mgmtProto.PeerSystemMeta{}} - encryptedBytes, err := encryption.EncryptMessage(serverPubKey, key, syncReq) - Expect(err).NotTo(HaveOccurred()) - - // open stream - sync, err := client.Sync(context.TODO(), &mgmtProto.EncryptedMessage{ - WgPubKey: key.PublicKey().String(), - Body: encryptedBytes, - }) - Expect(err).NotTo(HaveOccurred()) - encryptedResponse := &mgmtProto.EncryptedMessage{} - err = sync.RecvMsg(encryptedResponse) - Expect(err).NotTo(HaveOccurred()) - - resp := &mgmtProto.SyncResponse{} - err = encryption.DecryptMessage(serverPubKey, key, encryptedResponse.Body, resp) - Expect(err).NotTo(HaveOccurred()) - - ipChannel <- resp.GetPeerConfig().Address - }() - } - - ips := make(map[string]struct{}) - for ip := range ipChannel { - if _, ok := ips[ip]; ok { - Fail("found duplicate IP: " + ip) - } - ips[ip] = struct{}{} - if len(ips) == initialPeers { - break - } - } - close(ipChannel) - }) - }) - - Context("after login two peers", func() { - Specify("then they receive the same network", func() { - key, _ := wgtypes.GenerateKey() - firstLogin := loginPeerWithValidSetupKey(serverPubKey, key, client) - key, _ = wgtypes.GenerateKey() - secondLogin := loginPeerWithValidSetupKey(serverPubKey, key, client) - - _, firstLoginNetwork, err := net.ParseCIDR(firstLogin.GetPeerConfig().GetAddress()) - Expect(err).NotTo(HaveOccurred()) - _, secondLoginNetwork, err := net.ParseCIDR(secondLogin.GetPeerConfig().GetAddress()) - Expect(err).NotTo(HaveOccurred()) - - Expect(secondLoginNetwork.String()).To(BeEquivalentTo(firstLoginNetwork.String())) - }) - }) -}) - -func loginPeerWithValidSetupKey(serverPubKey wgtypes.Key, key wgtypes.Key, client mgmtProto.ManagementServiceClient) *mgmtProto.LoginResponse { - defer GinkgoRecover() - +type testSuite struct { + t *testing.T + addr string + grpcServer *grpc.Server + dataDir string + client mgmtProto.ManagementServiceClient + serverPubKey wgtypes.Key + conn *grpc.ClientConn +} + +func setupTest(t *testing.T) *testSuite { + t.Helper() + level, _ := log.ParseLevel("Debug") + log.SetLevel(level) + + ts := &testSuite{t: t} + + var err error + ts.dataDir, err = os.MkdirTemp("", "netbird_mgmt_test_tmp_*") + if err != nil { + t.Fatalf("failed to create temp directory: %v", err) + } + + config := &server.Config{} + _, err = util.ReadJson("testdata/management.json", config) + if err != nil { + t.Fatalf("failed to read management.json: %v", err) + } + config.Datadir = ts.dataDir + + var listener net.Listener + ts.grpcServer, listener = startServer(t, config, ts.dataDir, "testdata/store.sql") + ts.addr = listener.Addr().String() + + ts.client, ts.conn = createRawClient(t, ts.addr) + + resp, err := ts.client.GetServerKey(context.TODO(), &mgmtProto.Empty{}) + if err != nil { + t.Fatalf("failed to get server key: %v", err) + } + + serverKey, err := wgtypes.ParseKey(resp.Key) + if err != nil { + t.Fatalf("failed to parse server key: %v", err) + } + ts.serverPubKey = serverKey + + return ts +} + +func tearDownTest(t *testing.T, ts *testSuite) { + t.Helper() + ts.grpcServer.Stop() + if err := ts.conn.Close(); err != nil { + t.Fatalf("failed to close client connection: %v", err) + } + time.Sleep(100 * time.Millisecond) + if err := os.RemoveAll(ts.dataDir); err != nil { + t.Fatalf("failed to remove data directory %s: %v", ts.dataDir, err) + } +} + +func loginPeerWithValidSetupKey( + t *testing.T, + serverPubKey wgtypes.Key, + key wgtypes.Key, + client mgmtProto.ManagementServiceClient, +) *mgmtProto.LoginResponse { + t.Helper() meta := &mgmtProto.PeerSystemMeta{ Hostname: key.PublicKey().String(), GoOS: runtime.GOOS, @@ -457,23 +110,30 @@ func loginPeerWithValidSetupKey(serverPubKey wgtypes.Key, key wgtypes.Key, clien Kernel: "kernel", NetbirdVersion: "", } - message, err := encryption.EncryptMessage(serverPubKey, key, &mgmtProto.LoginRequest{SetupKey: ValidSetupKey, Meta: meta}) - Expect(err).NotTo(HaveOccurred()) + msgToEncrypt := &mgmtProto.LoginRequest{SetupKey: ValidSetupKey, Meta: meta} + message, err := encryption.EncryptMessage(serverPubKey, key, msgToEncrypt) + if err != nil { + t.Fatalf("failed to encrypt login request: %v", err) + } resp, err := client.Login(context.TODO(), &mgmtProto.EncryptedMessage{ WgPubKey: key.PublicKey().String(), Body: message, }) - - Expect(err).NotTo(HaveOccurred()) + if err != nil { + t.Fatalf("login request failed: %v", err) + } loginResp := &mgmtProto.LoginResponse{} err = encryption.DecryptMessage(serverPubKey, key, resp.Body, loginResp) - Expect(err).NotTo(HaveOccurred()) + if err != nil { + t.Fatalf("failed to decrypt login response: %v", err) + } return loginResp } -func createRawClient(addr string) (mgmtProto.ManagementServiceClient, *grpc.ClientConn) { +func createRawClient(t *testing.T, addr string) (mgmtProto.ManagementServiceClient, *grpc.ClientConn) { + t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -484,17 +144,27 @@ func createRawClient(addr string) (mgmtProto.ManagementServiceClient, *grpc.Clie Time: 10 * time.Second, Timeout: 2 * time.Second, })) - Expect(err).NotTo(HaveOccurred()) + if err != nil { + t.Fatalf("failed to dial gRPC server: %v", err) + } return mgmtProto.NewManagementServiceClient(conn), conn } -func startServer(config *server.Config, dataDir string, testFile string) (*grpc.Server, net.Listener) { +func startServer( + t *testing.T, + config *server.Config, + dataDir string, + testFile string, +) (*grpc.Server, net.Listener) { + t.Helper() lis, err := net.Listen("tcp", ":0") - Expect(err).NotTo(HaveOccurred()) + if err != nil { + t.Fatalf("failed to listen on a random port: %v", err) + } s := grpc.NewServer() - store, _, err := store.NewTestStoreFromSQL(context.Background(), testFile, dataDir) + str, _, err := store.NewTestStoreFromSQL(context.Background(), testFile, dataDir) if err != nil { log.Fatalf("failed creating a store: %s: %v", config.Datadir, err) } @@ -504,23 +174,529 @@ func startServer(config *server.Config, dataDir string, testFile string) (*grpc. metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) if err != nil { - log.Fatalf("failed creating metrics: %v", err) + t.Fatalf("failed creating metrics: %v", err) } - accountManager, err := server.BuildManager(context.Background(), store, peersUpdateManager, nil, "", "netbird.selfhosted", eventStore, nil, false, server.MocIntegratedValidator{}, metrics) + accountManager, err := server.BuildManager( + context.Background(), + str, + peersUpdateManager, + nil, + "", + "netbird.selfhosted", + eventStore, + nil, + false, + server.MocIntegratedValidator{}, + metrics, + ) if err != nil { - log.Fatalf("failed creating a manager: %v", err) + t.Fatalf("failed creating an account manager: %v", err) } secretsManager := server.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay) - mgmtServer, err := server.NewServer(context.Background(), config, accountManager, settings.NewManager(store), peersUpdateManager, secretsManager, nil, nil) - Expect(err).NotTo(HaveOccurred()) + mgmtServer, err := server.NewServer( + context.Background(), + config, + accountManager, + settings.NewManager(str), + peersUpdateManager, + secretsManager, + nil, + nil, + ) + if err != nil { + t.Fatalf("failed creating management server: %v", err) + } + mgmtProto.RegisterManagementServiceServer(s, mgmtServer) + go func() { if err := s.Serve(lis); err != nil { - Expect(err).NotTo(HaveOccurred()) + t.Errorf("failed to serve gRPC: %v", err) + return } }() return s, lis } + +func TestIsHealthy(t *testing.T) { + ts := setupTest(t) + defer tearDownTest(t, ts) + + healthy, err := ts.client.IsHealthy(context.TODO(), &mgmtProto.Empty{}) + if err != nil { + t.Fatalf("IsHealthy call returned an error: %v", err) + } + if healthy == nil { + t.Fatal("IsHealthy returned a nil response") + } +} + +func TestSyncNewPeerConfiguration(t *testing.T) { + ts := setupTest(t) + defer tearDownTest(t, ts) + + peerKey, _ := wgtypes.GenerateKey() + loginPeerWithValidSetupKey(t, ts.serverPubKey, peerKey, ts.client) + + syncReq := &mgmtProto.SyncRequest{Meta: &mgmtProto.PeerSystemMeta{}} + encryptedBytes, err := encryption.EncryptMessage(ts.serverPubKey, peerKey, syncReq) + if err != nil { + t.Fatalf("failed to encrypt sync request: %v", err) + } + + syncStream, err := ts.client.Sync(context.TODO(), &mgmtProto.EncryptedMessage{ + WgPubKey: peerKey.PublicKey().String(), + Body: encryptedBytes, + }) + if err != nil { + t.Fatalf("failed to call Sync: %v", err) + } + + encryptedResponse := &mgmtProto.EncryptedMessage{} + err = syncStream.RecvMsg(encryptedResponse) + if err != nil { + t.Fatalf("failed to receive sync response message: %v", err) + } + + resp := &mgmtProto.SyncResponse{} + err = encryption.DecryptMessage(ts.serverPubKey, peerKey, encryptedResponse.Body, resp) + if err != nil { + t.Fatalf("failed to decrypt sync response: %v", err) + } + + expectedSignalConfig := &mgmtProto.HostConfig{ + Uri: "signal.netbird.io:10000", + Protocol: mgmtProto.HostConfig_HTTP, + } + expectedStunsConfig := &mgmtProto.HostConfig{ + Uri: "stun:stun.netbird.io:3468", + Protocol: mgmtProto.HostConfig_UDP, + } + expectedTRUNHost := &mgmtProto.HostConfig{ + Uri: "turn:stun.netbird.io:3468", + Protocol: mgmtProto.HostConfig_UDP, + } + + assert.NotNil(t, resp.NetbirdConfig) + assert.Equal(t, resp.NetbirdConfig.Signal, expectedSignalConfig) + assert.Contains(t, resp.NetbirdConfig.Stuns, expectedStunsConfig) + assert.Equal(t, len(resp.NetbirdConfig.Turns), 1) + actualTURN := resp.NetbirdConfig.Turns[0] + assert.Greater(t, len(actualTURN.User), 0) + assert.Equal(t, actualTURN.HostConfig, expectedTRUNHost) + assert.Equal(t, len(resp.NetworkMap.OfflinePeers), 0) +} + +func TestSyncThreePeers(t *testing.T) { + ts := setupTest(t) + defer tearDownTest(t, ts) + + peerKey, _ := wgtypes.GenerateKey() + peerKey1, _ := wgtypes.GenerateKey() + peerKey2, _ := wgtypes.GenerateKey() + + loginPeerWithValidSetupKey(t, ts.serverPubKey, peerKey, ts.client) + loginPeerWithValidSetupKey(t, ts.serverPubKey, peerKey1, ts.client) + loginPeerWithValidSetupKey(t, ts.serverPubKey, peerKey2, ts.client) + + syncReq := &mgmtProto.SyncRequest{Meta: &mgmtProto.PeerSystemMeta{}} + syncBytes, err := pb.Marshal(syncReq) + if err != nil { + t.Fatalf("failed to marshal sync request: %v", err) + } + encryptedBytes, err := encryption.Encrypt(syncBytes, ts.serverPubKey, peerKey) + if err != nil { + t.Fatalf("failed to encrypt sync request: %v", err) + } + + syncStream, err := ts.client.Sync(context.TODO(), &mgmtProto.EncryptedMessage{ + WgPubKey: peerKey.PublicKey().String(), + Body: encryptedBytes, + }) + if err != nil { + t.Fatalf("failed to call Sync: %v", err) + } + + encryptedResponse := &mgmtProto.EncryptedMessage{} + err = syncStream.RecvMsg(encryptedResponse) + if err != nil { + t.Fatalf("failed to receive sync response: %v", err) + } + + decryptedBytes, err := encryption.Decrypt(encryptedResponse.Body, ts.serverPubKey, peerKey) + if err != nil { + t.Fatalf("failed to decrypt sync response: %v", err) + } + + resp := &mgmtProto.SyncResponse{} + err = pb.Unmarshal(decryptedBytes, resp) + if err != nil { + t.Fatalf("failed to unmarshal sync response: %v", err) + } + + if len(resp.GetRemotePeers()) != 2 { + t.Fatalf("expected 2 remote peers, got %d", len(resp.GetRemotePeers())) + } + + var found1, found2 bool + for _, rp := range resp.GetRemotePeers() { + if rp.WgPubKey == peerKey1.PublicKey().String() { + found1 = true + } else if rp.WgPubKey == peerKey2.PublicKey().String() { + found2 = true + } + } + if !found1 || !found2 { + t.Fatalf("did not find the expected peer keys %s, %s among %v", + peerKey1.PublicKey().String(), + peerKey2.PublicKey().String(), + resp.GetRemotePeers()) + } +} + +func TestSyncNewPeerUpdate(t *testing.T) { + ts := setupTest(t) + defer tearDownTest(t, ts) + + peerKey, _ := wgtypes.GenerateKey() + loginPeerWithValidSetupKey(t, ts.serverPubKey, peerKey, ts.client) + + syncReq := &mgmtProto.SyncRequest{Meta: &mgmtProto.PeerSystemMeta{}} + syncBytes, err := pb.Marshal(syncReq) + if err != nil { + t.Fatalf("failed to marshal sync request: %v", err) + } + + encryptedBytes, err := encryption.Encrypt(syncBytes, ts.serverPubKey, peerKey) + if err != nil { + t.Fatalf("failed to encrypt sync request: %v", err) + } + + syncStream, err := ts.client.Sync(context.TODO(), &mgmtProto.EncryptedMessage{ + WgPubKey: peerKey.PublicKey().String(), + Body: encryptedBytes, + }) + if err != nil { + t.Fatalf("failed to call Sync: %v", err) + } + + encryptedResponse := &mgmtProto.EncryptedMessage{} + err = syncStream.RecvMsg(encryptedResponse) + if err != nil { + t.Fatalf("failed to receive first sync response: %v", err) + } + + decryptedBytes, err := encryption.Decrypt(encryptedResponse.Body, ts.serverPubKey, peerKey) + if err != nil { + t.Fatalf("failed to decrypt first sync response: %v", err) + } + + resp := &mgmtProto.SyncResponse{} + if err := pb.Unmarshal(decryptedBytes, resp); err != nil { + t.Fatalf("failed to unmarshal first sync response: %v", err) + } + + if len(resp.GetRemotePeers()) != 0 { + t.Fatalf("expected 0 remote peers at first sync, got %d", len(resp.GetRemotePeers())) + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + encryptedResponse := &mgmtProto.EncryptedMessage{} + err = syncStream.RecvMsg(encryptedResponse) + if err != nil { + t.Errorf("failed to receive second sync response: %v", err) + return + } + + decryptedBytes, err := encryption.Decrypt(encryptedResponse.Body, ts.serverPubKey, peerKey) + if err != nil { + t.Errorf("failed to decrypt second sync response: %v", err) + return + } + err = pb.Unmarshal(decryptedBytes, resp) + if err != nil { + t.Errorf("failed to unmarshal second sync response: %v", err) + return + } + }() + + newPeerKey, _ := wgtypes.GenerateKey() + loginPeerWithValidSetupKey(t, ts.serverPubKey, newPeerKey, ts.client) + + wg.Wait() + + if len(resp.GetRemotePeers()) != 1 { + t.Fatalf("expected exactly 1 remote peer update, got %d", len(resp.GetRemotePeers())) + } + if resp.GetRemotePeers()[0].WgPubKey != newPeerKey.PublicKey().String() { + t.Fatalf("expected new peer key %s, got %s", + newPeerKey.PublicKey().String(), + resp.GetRemotePeers()[0].WgPubKey) + } +} + +func TestGetServerKey(t *testing.T) { + ts := setupTest(t) + defer tearDownTest(t, ts) + + resp, err := ts.client.GetServerKey(context.TODO(), &mgmtProto.Empty{}) + if err != nil { + t.Fatalf("GetServerKey returned error: %v", err) + } + if resp == nil { + t.Fatal("GetServerKey returned nil response") + } + if resp.Key == "" { + t.Fatal("GetServerKey returned empty key") + } + if resp.ExpiresAt.AsTime().IsZero() { + t.Fatal("GetServerKey returned 0 for ExpiresAt") + } + + _, err = wgtypes.ParseKey(resp.Key) + if err != nil { + t.Fatalf("GetServerKey returned an invalid WG key: %v", err) + } +} + +func TestLoginInvalidSetupKey(t *testing.T) { + ts := setupTest(t) + defer tearDownTest(t, ts) + + peerKey, _ := wgtypes.GenerateKey() + request := &mgmtProto.LoginRequest{ + SetupKey: "invalid setup key", + Meta: &mgmtProto.PeerSystemMeta{}, + } + encryptedMsg, err := encryption.EncryptMessage(ts.serverPubKey, peerKey, request) + if err != nil { + t.Fatalf("failed to encrypt login request: %v", err) + } + + resp, err := ts.client.Login(context.TODO(), &mgmtProto.EncryptedMessage{ + WgPubKey: peerKey.PublicKey().String(), + Body: encryptedMsg, + }) + if err == nil { + t.Fatal("expected error for invalid setup key but got nil") + } + if resp != nil { + t.Fatalf("expected nil response for invalid setup key but got: %+v", resp) + } +} + +func TestLoginValidSetupKey(t *testing.T) { + ts := setupTest(t) + defer tearDownTest(t, ts) + + peerKey, _ := wgtypes.GenerateKey() + resp := loginPeerWithValidSetupKey(t, ts.serverPubKey, peerKey, ts.client) + if resp == nil { + t.Fatal("loginPeerWithValidSetupKey returned nil, expected a valid response") + } +} + +func TestLoginRegisteredPeer(t *testing.T) { + ts := setupTest(t) + defer tearDownTest(t, ts) + + peerKey, _ := wgtypes.GenerateKey() + regResp := loginPeerWithValidSetupKey(t, ts.serverPubKey, peerKey, ts.client) + if regResp == nil { + t.Fatal("registration with valid setup key failed") + } + + loginReq := &mgmtProto.LoginRequest{Meta: &mgmtProto.PeerSystemMeta{}} + encryptedLogin, err := encryption.EncryptMessage(ts.serverPubKey, peerKey, loginReq) + if err != nil { + t.Fatalf("failed to encrypt login request: %v", err) + } + loginRespEnc, err := ts.client.Login(context.TODO(), &mgmtProto.EncryptedMessage{ + WgPubKey: peerKey.PublicKey().String(), + Body: encryptedLogin, + }) + if err != nil { + t.Fatalf("login call returned an error: %v", err) + } + + loginResp := &mgmtProto.LoginResponse{} + err = encryption.DecryptMessage(ts.serverPubKey, peerKey, loginRespEnc.Body, loginResp) + if err != nil { + t.Fatalf("failed to decrypt login response: %v", err) + } + + expectedSignalConfig := &mgmtProto.HostConfig{ + Uri: "signal.netbird.io:10000", + Protocol: mgmtProto.HostConfig_HTTP, + } + expectedStunsConfig := &mgmtProto.HostConfig{ + Uri: "stun:stun.netbird.io:3468", + Protocol: mgmtProto.HostConfig_UDP, + } + expectedTurnsConfig := &mgmtProto.ProtectedHostConfig{ + HostConfig: &mgmtProto.HostConfig{ + Uri: "turn:stun.netbird.io:3468", + Protocol: mgmtProto.HostConfig_UDP, + }, + User: "some_user", + Password: "some_password", + } + + assert.NotNil(t, loginResp.GetNetbirdConfig()) + assert.Equal(t, loginResp.GetNetbirdConfig().Signal, expectedSignalConfig) + assert.Contains(t, loginResp.GetNetbirdConfig().Stuns, expectedStunsConfig) + assert.Contains(t, loginResp.GetNetbirdConfig().Turns, expectedTurnsConfig) +} + +func TestSync10PeersGetUpdates(t *testing.T) { + ts := setupTest(t) + defer tearDownTest(t, ts) + + initialPeers := 10 + additionalPeers := 10 + + var peers []wgtypes.Key + for i := 0; i < initialPeers; i++ { + key, _ := wgtypes.GenerateKey() + loginPeerWithValidSetupKey(t, ts.serverPubKey, key, ts.client) + peers = append(peers, key) + } + + var wg sync.WaitGroup + wg.Add(initialPeers + initialPeers*additionalPeers) + + var syncClients []mgmtProto.ManagementService_SyncClient + for _, pk := range peers { + syncReq := &mgmtProto.SyncRequest{Meta: &mgmtProto.PeerSystemMeta{}} + msgBytes, err := pb.Marshal(syncReq) + if err != nil { + t.Fatalf("failed to marshal SyncRequest: %v", err) + } + encBytes, err := encryption.Encrypt(msgBytes, ts.serverPubKey, pk) + if err != nil { + t.Fatalf("failed to encrypt SyncRequest: %v", err) + } + + s, err := ts.client.Sync(context.TODO(), &mgmtProto.EncryptedMessage{ + WgPubKey: pk.PublicKey().String(), + Body: encBytes, + }) + if err != nil { + t.Fatalf("failed to call Sync for peer: %v", err) + } + syncClients = append(syncClients, s) + + go func(pk wgtypes.Key, syncStream mgmtProto.ManagementService_SyncClient) { + for { + encMsg := &mgmtProto.EncryptedMessage{} + err := syncStream.RecvMsg(encMsg) + if err != nil { + return + } + decryptedBytes, decErr := encryption.Decrypt(encMsg.Body, ts.serverPubKey, pk) + if decErr != nil { + t.Errorf("failed to decrypt SyncResponse for peer %s: %v", pk.PublicKey().String(), decErr) + return + } + resp := &mgmtProto.SyncResponse{} + umErr := pb.Unmarshal(decryptedBytes, resp) + if umErr != nil { + t.Errorf("failed to unmarshal SyncResponse for peer %s: %v", pk.PublicKey().String(), umErr) + return + } + // We only count if there's a new peer update + if len(resp.GetRemotePeers()) > 0 { + wg.Done() + } + } + }(pk, s) + } + + time.Sleep(500 * time.Millisecond) + for i := 0; i < additionalPeers; i++ { + key, _ := wgtypes.GenerateKey() + loginPeerWithValidSetupKey(t, ts.serverPubKey, key, ts.client) + r := rand.New(rand.NewSource(time.Now().UnixNano())) + n := r.Intn(200) + time.Sleep(time.Duration(n) * time.Millisecond) + } + + wg.Wait() + + for _, sc := range syncClients { + err := sc.CloseSend() + if err != nil { + t.Fatalf("failed to close sync client: %v", err) + } + } +} + +func TestConcurrentPeersNoDuplicateIPs(t *testing.T) { + ts := setupTest(t) + defer tearDownTest(t, ts) + + initialPeers := 30 + ipChan := make(chan string, initialPeers) + + var wg sync.WaitGroup + wg.Add(initialPeers) + + for i := 0; i < initialPeers; i++ { + go func() { + defer wg.Done() + key, _ := wgtypes.GenerateKey() + loginPeerWithValidSetupKey(t, ts.serverPubKey, key, ts.client) + + syncReq := &mgmtProto.SyncRequest{Meta: &mgmtProto.PeerSystemMeta{}} + encryptedBytes, err := encryption.EncryptMessage(ts.serverPubKey, key, syncReq) + if err != nil { + t.Errorf("failed to encrypt sync request: %v", err) + return + } + + s, err := ts.client.Sync(context.TODO(), &mgmtProto.EncryptedMessage{ + WgPubKey: key.PublicKey().String(), + Body: encryptedBytes, + }) + if err != nil { + t.Errorf("failed to call Sync: %v", err) + return + } + + encResp := &mgmtProto.EncryptedMessage{} + if err = s.RecvMsg(encResp); err != nil { + t.Errorf("failed to receive sync response: %v", err) + return + } + + resp := &mgmtProto.SyncResponse{} + if err = encryption.DecryptMessage(ts.serverPubKey, key, encResp.Body, resp); err != nil { + t.Errorf("failed to decrypt sync response: %v", err) + return + } + ipChan <- resp.GetPeerConfig().Address + }() + } + + wg.Wait() + close(ipChan) + + ipMap := make(map[string]bool) + for ip := range ipChan { + if ipMap[ip] { + t.Fatalf("found duplicate IP: %s", ip) + } + ipMap[ip] = true + } + + // Ensure we collected all peers + if len(ipMap) != initialPeers { + t.Fatalf("expected %d unique IPs, got %d", initialPeers, len(ipMap)) + } +} diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index 0743db513..497d9af4f 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -379,12 +379,12 @@ func TestCreateNameServerGroup(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { am, err := createNSManager(t) if err != nil { - t.Error("failed to create account manager") + t.Fatalf("failed to create account manager: %s", err) } account, err := initTestNSAccount(t, am) if err != nil { - t.Error("failed to init testing account") + t.Fatalf("failed to init testing account: %s", err) } outNSGroup, err := am.CreateNameServerGroup( @@ -607,12 +607,12 @@ func TestSaveNameServerGroup(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { am, err := createNSManager(t) if err != nil { - t.Error("failed to create account manager") + t.Fatalf("failed to create account manager: %s", err) } account, err := initTestNSAccount(t, am) if err != nil { - t.Error("failed to init testing account") + t.Fatalf("failed to init testing account: %s", err) } account.NameServerGroups[testCase.existingNSGroup.ID] = testCase.existingNSGroup @@ -706,7 +706,7 @@ func TestDeleteNameServerGroup(t *testing.T) { account, err := initTestNSAccount(t, am) if err != nil { - t.Error("failed to init testing account") + t.Fatalf("failed to init testing account: %s", err) } account.NameServerGroups[testingNSGroup.ID] = testingNSGroup @@ -741,7 +741,7 @@ func TestGetNameServerGroup(t *testing.T) { account, err := initTestNSAccount(t, am) if err != nil { - t.Error("failed to init testing account") + t.Fatalf("failed to init testing account: %s", err) } foundGroup, err := am.GetNameServerGroup(context.Background(), account.Id, testUserID, existingNSGroupID) @@ -761,6 +761,7 @@ func TestGetNameServerGroup(t *testing.T) { func createNSManager(t *testing.T) (*DefaultAccountManager, error) { t.Helper() + store, err := createNSStore(t) if err != nil { return nil, err diff --git a/management/server/route_test.go b/management/server/route_test.go index 1c5c56f60..40e0f41b0 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -13,12 +13,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/management/domain" + "github.com/netbirdio/netbird/management/server/activity" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" - - "github.com/netbirdio/netbird/management/domain" - "github.com/netbirdio/netbird/management/server/activity" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/telemetry" diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 6e04c7d9d..dd240ce6c 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -37,40 +37,44 @@ import ( nbroute "github.com/netbirdio/netbird/route" ) -func TestSqlite_NewStore(t *testing.T) { +func runTestForAllEngines(t *testing.T, testDataFile string, f func(t *testing.T, store Store)) { + t.Helper() + for _, engine := range supportedEngines { + if os.Getenv("NETBIRD_STORE_ENGINE") != "" && os.Getenv("NETBIRD_STORE_ENGINE") != string(engine) { + continue + } + t.Setenv("NETBIRD_STORE_ENGINE", string(engine)) + store, cleanUp, err := NewTestStoreFromSQL(context.Background(), testDataFile, t.TempDir()) + t.Cleanup(cleanUp) + assert.NoError(t, err) + t.Run(string(engine), func(t *testing.T) { + f(t, store) + }) + os.Unsetenv("NETBIRD_STORE_ENGINE") + } +} + +func Test_NewStore(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") } - t.Setenv("NETBIRD_STORE_ENGINE", string(SqliteStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - if len(store.GetAllAccounts(context.Background())) != 0 { - t.Errorf("expected to create a new empty Accounts map when creating a new FileStore") - } + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Errorf("expected to create a new Store") + } + if len(store.GetAllAccounts(context.Background())) != 0 { + t.Errorf("expected to create a new empty Accounts map when creating a new FileStore") + } + }) } -func TestSqlite_SaveAccount_Large(t *testing.T) { +func Test_SaveAccount_Large(t *testing.T) { if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { t.Skip("skip CI tests on darwin and windows") } - t.Run("SQLite", func(t *testing.T) { - t.Setenv("NETBIRD_STORE_ENGINE", string(SqliteStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - runLargeTest(t, store) - }) - - // create store outside to have a better time counter for the test - t.Setenv("NETBIRD_STORE_ENGINE", string(SqliteStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - t.Run("PostgreSQL", func(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { runLargeTest(t, store) }) } @@ -215,77 +219,74 @@ func randomIPv4() net.IP { return net.IP(b) } -func TestSqlite_SaveAccount(t *testing.T) { +func Test_SaveAccount(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") } - t.Setenv("NETBIRD_STORE_ENGINE", string(SqliteStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + account := newAccountWithId(context.Background(), "account_id", "testuser", "") + setupKey, _ := types.GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + account.Peers["testpeer"] = &nbpeer.Peer{ + Key: "peerkey", + IP: net.IP{127, 0, 0, 1}, + Meta: nbpeer.PeerSystemMeta{}, + Name: "peer name", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } - account := newAccountWithId(context.Background(), "account_id", "testuser", "") - setupKey, _ := types.GenerateDefaultSetupKey() - account.SetupKeys[setupKey.Key] = setupKey - account.Peers["testpeer"] = &nbpeer.Peer{ - Key: "peerkey", - IP: net.IP{127, 0, 0, 1}, - Meta: nbpeer.PeerSystemMeta{}, - Name: "peer name", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - } + err := store.SaveAccount(context.Background(), account) + require.NoError(t, err) - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) + account2 := newAccountWithId(context.Background(), "account_id2", "testuser2", "") + setupKey, _ = types.GenerateDefaultSetupKey() + account2.SetupKeys[setupKey.Key] = setupKey + account2.Peers["testpeer2"] = &nbpeer.Peer{ + Key: "peerkey2", + IP: net.IP{127, 0, 0, 2}, + Meta: nbpeer.PeerSystemMeta{}, + Name: "peer name 2", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } - account2 := newAccountWithId(context.Background(), "account_id2", "testuser2", "") - setupKey, _ = types.GenerateDefaultSetupKey() - account2.SetupKeys[setupKey.Key] = setupKey - account2.Peers["testpeer2"] = &nbpeer.Peer{ - Key: "peerkey2", - IP: net.IP{127, 0, 0, 2}, - Meta: nbpeer.PeerSystemMeta{}, - Name: "peer name 2", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - } + err = store.SaveAccount(context.Background(), account2) + require.NoError(t, err) - err = store.SaveAccount(context.Background(), account2) - require.NoError(t, err) + if len(store.GetAllAccounts(context.Background())) != 2 { + t.Errorf("expecting 2 Accounts to be stored after SaveAccount()") + } - if len(store.GetAllAccounts(context.Background())) != 2 { - t.Errorf("expecting 2 Accounts to be stored after SaveAccount()") - } + a, err := store.GetAccount(context.Background(), account.Id) + if a == nil { + t.Errorf("expecting Account to be stored after SaveAccount(): %v", err) + } - a, err := store.GetAccount(context.Background(), account.Id) - if a == nil { - t.Errorf("expecting Account to be stored after SaveAccount(): %v", err) - } + if a != nil && len(a.Policies) != 1 { + t.Errorf("expecting Account to have one policy stored after SaveAccount(), got %d", len(a.Policies)) + } - if a != nil && len(a.Policies) != 1 { - t.Errorf("expecting Account to have one policy stored after SaveAccount(), got %d", len(a.Policies)) - } + if a != nil && len(a.Policies[0].Rules) != 1 { + t.Errorf("expecting Account to have one policy rule stored after SaveAccount(), got %d", len(a.Policies[0].Rules)) + return + } - if a != nil && len(a.Policies[0].Rules) != 1 { - t.Errorf("expecting Account to have one policy rule stored after SaveAccount(), got %d", len(a.Policies[0].Rules)) - return - } + if a, err := store.GetAccountByPeerPubKey(context.Background(), "peerkey"); a == nil { + t.Errorf("expecting PeerKeyID2AccountID index updated after SaveAccount(): %v", err) + } - if a, err := store.GetAccountByPeerPubKey(context.Background(), "peerkey"); a == nil { - t.Errorf("expecting PeerKeyID2AccountID index updated after SaveAccount(): %v", err) - } + if a, err := store.GetAccountByUser(context.Background(), "testuser"); a == nil { + t.Errorf("expecting UserID2AccountID index updated after SaveAccount(): %v", err) + } - if a, err := store.GetAccountByUser(context.Background(), "testuser"); a == nil { - t.Errorf("expecting UserID2AccountID index updated after SaveAccount(): %v", err) - } + if a, err := store.GetAccountByPeerID(context.Background(), "testpeer"); a == nil { + t.Errorf("expecting PeerID2AccountID index updated after SaveAccount(): %v", err) + } - if a, err := store.GetAccountByPeerID(context.Background(), "testpeer"); a == nil { - t.Errorf("expecting PeerID2AccountID index updated after SaveAccount(): %v", err) - } - - if a, err := store.GetAccountBySetupKey(context.Background(), setupKey.Key); a == nil { - t.Errorf("expecting SetupKeyID2AccountID index updated after SaveAccount(): %v", err) - } + if a, err := store.GetAccountBySetupKey(context.Background(), setupKey.Key); a == nil { + t.Errorf("expecting SetupKeyID2AccountID index updated after SaveAccount(): %v", err) + } + }) } func TestSqlite_DeleteAccount(t *testing.T) { @@ -402,27 +403,24 @@ func TestSqlite_DeleteAccount(t *testing.T) { } } -func TestSqlite_GetAccount(t *testing.T) { +func Test_GetAccount(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") } - t.Setenv("NETBIRD_STORE_ENGINE", string(SqliteStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + id := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - id := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + account, err := store.GetAccount(context.Background(), id) + require.NoError(t, err) + require.Equal(t, id, account.Id, "account id should match") - account, err := store.GetAccount(context.Background(), id) - require.NoError(t, err) - require.Equal(t, id, account.Id, "account id should match") - - _, err = store.GetAccount(context.Background(), "non-existing-account") - assert.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + _, err = store.GetAccount(context.Background(), "non-existing-account") + assert.Error(t, err) + parsedErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + }) } func TestSqlStore_SavePeer(t *testing.T) { @@ -580,51 +578,45 @@ func TestSqlStore_SavePeerLocation(t *testing.T) { require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") } -func TestSqlite_TestGetAccountByPrivateDomain(t *testing.T) { +func Test_TestGetAccountByPrivateDomain(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") } - t.Setenv("NETBIRD_STORE_ENGINE", string(SqliteStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + existingDomain := "test.com" - existingDomain := "test.com" + account, err := store.GetAccountByPrivateDomain(context.Background(), existingDomain) + require.NoError(t, err, "should found account") + require.Equal(t, existingDomain, account.Domain, "domains should match") - account, err := store.GetAccountByPrivateDomain(context.Background(), existingDomain) - require.NoError(t, err, "should found account") - require.Equal(t, existingDomain, account.Domain, "domains should match") - - _, err = store.GetAccountByPrivateDomain(context.Background(), "missing-domain.com") - require.Error(t, err, "should return error on domain lookup") - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + _, err = store.GetAccountByPrivateDomain(context.Background(), "missing-domain.com") + require.Error(t, err, "should return error on domain lookup") + parsedErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + }) } -func TestSqlite_GetTokenIDByHashedToken(t *testing.T) { +func Test_GetTokenIDByHashedToken(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") } - t.Setenv("NETBIRD_STORE_ENGINE", string(SqliteStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + hashed := "SoMeHaShEdToKeN" + id := "9dj38s35-63fb-11ec-90d6-0242ac120003" - hashed := "SoMeHaShEdToKeN" - id := "9dj38s35-63fb-11ec-90d6-0242ac120003" + token, err := store.GetTokenIDByHashedToken(context.Background(), hashed) + require.NoError(t, err) + require.Equal(t, id, token) - token, err := store.GetTokenIDByHashedToken(context.Background(), hashed) - require.NoError(t, err) - require.Equal(t, id, token) - - _, err = store.GetTokenIDByHashedToken(context.Background(), "non-existing-hash") - require.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + _, err = store.GetTokenIDByHashedToken(context.Background(), "non-existing-hash") + require.Error(t, err) + parsedErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + }) } func TestMigrate(t *testing.T) { diff --git a/management/server/store/store.go b/management/server/store/store.go index 29ed22fa5..e074c4c60 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -9,11 +9,16 @@ import ( "os" "path" "path/filepath" + "regexp" "runtime" + "slices" "strings" "time" + "github.com/google/uuid" log "github.com/sirupsen/logrus" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -193,6 +198,8 @@ const ( mysqlDsnEnv = "NETBIRD_STORE_ENGINE_MYSQL_DSN" ) +var supportedEngines = []Engine{SqliteStoreEngine, PostgresStoreEngine, MysqlStoreEngine} + func getStoreEngineFromEnv() Engine { // NETBIRD_STORE_ENGINE supposed to be used in tests. Otherwise, rely on the config file. kind, ok := os.LookupEnv("NETBIRD_STORE_ENGINE") @@ -201,7 +208,7 @@ func getStoreEngineFromEnv() Engine { } value := Engine(strings.ToLower(kind)) - if value == SqliteStoreEngine || value == PostgresStoreEngine || value == MysqlStoreEngine { + if slices.Contains(supportedEngines, value) { return value } @@ -349,51 +356,126 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( } func getSqlStoreEngine(ctx context.Context, store *SqlStore, kind Engine) (Store, func(), error) { - if kind == PostgresStoreEngine { - cleanUp, err := testutil.CreatePostgresTestContainer() - if err != nil { - return nil, nil, err + var cleanup func() + var err error + switch kind { + case PostgresStoreEngine: + store, cleanup, err = newReusedPostgresStore(ctx, store, kind) + case MysqlStoreEngine: + store, cleanup, err = newReusedMysqlStore(ctx, store, kind) + default: + cleanup = func() { + // sqlite doesn't need to be cleaned up } - - dsn, ok := os.LookupEnv(postgresDsnEnv) - if !ok { - return nil, nil, fmt.Errorf("%s is not set", postgresDsnEnv) - } - - store, err = NewPostgresqlStoreFromSqlStore(ctx, store, dsn, nil) - if err != nil { - return nil, nil, err - } - - return store, cleanUp, nil } - - if kind == MysqlStoreEngine { - cleanUp, err := testutil.CreateMysqlTestContainer() - if err != nil { - return nil, nil, err - } - - dsn, ok := os.LookupEnv(mysqlDsnEnv) - if !ok { - return nil, nil, fmt.Errorf("%s is not set", mysqlDsnEnv) - } - - store, err = NewMysqlStoreFromSqlStore(ctx, store, dsn, nil) - if err != nil { - return nil, nil, err - } - - return store, cleanUp, nil + if err != nil { + return nil, cleanup, fmt.Errorf("failed to create test store: %v", err) } closeConnection := func() { + cleanup() store.Close(ctx) } return store, closeConnection, nil } +func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind Engine) (*SqlStore, func(), error) { + if envDsn, ok := os.LookupEnv(postgresDsnEnv); !ok || envDsn == "" { + var err error + _, err = testutil.CreatePostgresTestContainer() + if err != nil { + return nil, nil, err + } + } + + dsn, ok := os.LookupEnv(postgresDsnEnv) + if !ok { + return nil, nil, fmt.Errorf("%s is not set", postgresDsnEnv) + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + return nil, nil, fmt.Errorf("failed to open postgres connection: %v", err) + } + + dsn, cleanup, err := createRandomDB(dsn, db, kind) + if err != nil { + return nil, cleanup, err + } + + store, err = NewPostgresqlStoreFromSqlStore(ctx, store, dsn, nil) + if err != nil { + return nil, cleanup, err + } + + return store, cleanup, nil +} + +func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind Engine) (*SqlStore, func(), error) { + if envDsn, ok := os.LookupEnv(mysqlDsnEnv); !ok || envDsn == "" { + var err error + _, err = testutil.CreateMysqlTestContainer() + if err != nil { + return nil, nil, err + } + } + + dsn, ok := os.LookupEnv(mysqlDsnEnv) + if !ok { + return nil, nil, fmt.Errorf("%s is not set", mysqlDsnEnv) + } + + db, err := gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{}) + if err != nil { + return nil, nil, fmt.Errorf("failed to open mysql connection: %v", err) + } + + dsn, cleanup, err := createRandomDB(dsn, db, kind) + if err != nil { + return nil, cleanup, err + } + + store, err = NewMysqlStoreFromSqlStore(ctx, store, dsn, nil) + if err != nil { + return nil, nil, err + } + + return store, cleanup, nil +} + +func createRandomDB(dsn string, db *gorm.DB, engine Engine) (string, func(), error) { + dbName := fmt.Sprintf("test_db_%s", strings.ReplaceAll(uuid.New().String(), "-", "_")) + + if err := db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)).Error; err != nil { + return "", nil, fmt.Errorf("failed to create database: %v", err) + } + + var err error + cleanup := func() { + switch engine { + case PostgresStoreEngine: + err = db.Exec(fmt.Sprintf("DROP DATABASE %s WITH (FORCE)", dbName)).Error + case MysqlStoreEngine: + // err = killMySQLConnections(dsn, dbName) + err = db.Exec(fmt.Sprintf("DROP DATABASE %s", dbName)).Error + } + if err != nil { + log.Errorf("failed to drop database %s: %v", dbName, err) + panic(err) + } + sqlDB, _ := db.DB() + _ = sqlDB.Close() + } + + return replaceDBName(dsn, dbName), cleanup, nil +} + +func replaceDBName(dsn, newDBName string) string { + re := regexp.MustCompile(`(?P
    [:/@])(?P[^/?]+)(?P\?|$)`)
    +	return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`)
    +}
    +
     func loadSQL(db *gorm.DB, filepath string) error {
     	sqlContent, err := os.ReadFile(filepath)
     	if err != nil {
    diff --git a/management/server/testutil/store.go b/management/server/testutil/store.go
    index 16438cab8..8672efa7f 100644
    --- a/management/server/testutil/store.go
    +++ b/management/server/testutil/store.go
    @@ -22,7 +22,7 @@ func CreateMysqlTestContainer() (func(), error) {
     	myContainer, err := mysql.RunContainer(ctx,
     		testcontainers.WithImage("mlsmaycon/warmed-mysql:8"),
     		mysql.WithDatabase("testing"),
    -		mysql.WithUsername("testing"),
    +		mysql.WithUsername("root"),
     		mysql.WithPassword("testing"),
     		testcontainers.WithWaitStrategy(
     			wait.ForLog("/usr/sbin/mysqld: ready for connections").
    @@ -34,6 +34,7 @@ func CreateMysqlTestContainer() (func(), error) {
     	}
     
     	cleanup := func() {
    +		os.Unsetenv("NETBIRD_STORE_ENGINE_MYSQL_DSN")
     		timeoutCtx, cancelFunc := context.WithTimeout(ctx, 1*time.Second)
     		defer cancelFunc()
     		if err = myContainer.Terminate(timeoutCtx); err != nil {
    @@ -68,6 +69,7 @@ func CreatePostgresTestContainer() (func(), error) {
     	}
     
     	cleanup := func() {
    +		os.Unsetenv("NETBIRD_STORE_ENGINE_POSTGRES_DSN")
     		timeoutCtx, cancelFunc := context.WithTimeout(ctx, 1*time.Second)
     		defer cancelFunc()
     		if err = pgContainer.Terminate(timeoutCtx); err != nil {
    diff --git a/management/server/types/user.go b/management/server/types/user.go
    index 348fbfb22..5f7a4f2cb 100644
    --- a/management/server/types/user.go
    +++ b/management/server/types/user.go
    @@ -80,7 +80,7 @@ type User struct {
     	// AutoGroups is a list of Group IDs to auto-assign to peers registered by this user
     	AutoGroups []string                        `gorm:"serializer:json"`
     	PATs       map[string]*PersonalAccessToken `gorm:"-"`
    -	PATsG      []PersonalAccessToken           `json:"-" gorm:"foreignKey:UserID;references:id"`
    +	PATsG      []PersonalAccessToken           `json:"-" gorm:"foreignKey:UserID;references:id;constraint:OnDelete:CASCADE;"`
     	// Blocked indicates whether the user is blocked. Blocked users can't use the system.
     	Blocked bool
     	// LastLogin is the last time the user logged in to IdP
    diff --git a/relay/client/dialer/ws/ws.go b/relay/client/dialer/ws/ws.go
    index b007e24bb..cb525865b 100644
    --- a/relay/client/dialer/ws/ws.go
    +++ b/relay/client/dialer/ws/ws.go
    @@ -11,8 +11,8 @@ import (
     	"net/url"
     	"strings"
     
    -	log "github.com/sirupsen/logrus"
     	"github.com/coder/websocket"
    +	log "github.com/sirupsen/logrus"
     
     	"github.com/netbirdio/netbird/relay/server/listener/ws"
     	"github.com/netbirdio/netbird/util/embeddedroots"
    diff --git a/relay/server/listener/ws/conn.go b/relay/server/listener/ws/conn.go
    index 3466b2abd..3ec08945b 100644
    --- a/relay/server/listener/ws/conn.go
    +++ b/relay/server/listener/ws/conn.go
    @@ -8,8 +8,8 @@ import (
     	"sync"
     	"time"
     
    -	log "github.com/sirupsen/logrus"
     	"github.com/coder/websocket"
    +	log "github.com/sirupsen/logrus"
     )
     
     const (
    diff --git a/relay/server/listener/ws/listener.go b/relay/server/listener/ws/listener.go
    index 4597669dc..3a95951ee 100644
    --- a/relay/server/listener/ws/listener.go
    +++ b/relay/server/listener/ws/listener.go
    @@ -8,8 +8,8 @@ import (
     	"net"
     	"net/http"
     
    -	log "github.com/sirupsen/logrus"
     	"github.com/coder/websocket"
    +	log "github.com/sirupsen/logrus"
     )
     
     // URLPath is the path for the websocket connection.
    
    From 33cf9535b328c06d27385e5476653a40aba0fd84 Mon Sep 17 00:00:00 2001
    From: Carlos Hernandez 
    Date: Thu, 20 Feb 2025 02:55:44 -0700
    Subject: [PATCH 27/29] [client] Use go build to embed less icons (#3351)
    
    ---
     client/ui/client_ui.go     | 122 +++++--------------------------------
     client/ui/icons.go         |  43 +++++++++++++
     client/ui/icons_windows.go |  41 +++++++++++++
     3 files changed, 99 insertions(+), 107 deletions(-)
     create mode 100644 client/ui/icons.go
     create mode 100644 client/ui/icons_windows.go
    
    diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go
    index 30fb8d764..618160128 100644
    --- a/client/ui/client_ui.go
    +++ b/client/ui/client_ui.go
    @@ -83,7 +83,7 @@ func main() {
     	}
     
     	a := app.NewWithID("NetBird")
    -	a.SetIcon(fyne.NewStaticResource("netbird", iconDisconnectedPNG))
    +	a.SetIcon(fyne.NewStaticResource("netbird", iconDisconnected))
     
     	if errorMSG != "" {
     		showErrorMSG(errorMSG)
    @@ -115,96 +115,24 @@ func main() {
     	}
     }
     
    -//go:embed netbird.ico
    -var iconAboutICO []byte
    -
    -//go:embed netbird.png
    -var iconAboutPNG []byte
    -
    -//go:embed netbird-systemtray-connected.ico
    -var iconConnectedICO []byte
    -
    -//go:embed netbird-systemtray-connected.png
    -var iconConnectedPNG []byte
    -
     //go:embed netbird-systemtray-connected-macos.png
     var iconConnectedMacOS []byte
     
    -//go:embed netbird-systemtray-connected-dark.ico
    -var iconConnectedDarkICO []byte
    -
    -//go:embed netbird-systemtray-connected-dark.png
    -var iconConnectedDarkPNG []byte
    -
    -//go:embed netbird-systemtray-disconnected.ico
    -var iconDisconnectedICO []byte
    -
    -//go:embed netbird-systemtray-disconnected.png
    -var iconDisconnectedPNG []byte
    -
     //go:embed netbird-systemtray-disconnected-macos.png
     var iconDisconnectedMacOS []byte
     
    -//go:embed netbird-systemtray-update-disconnected.ico
    -var iconUpdateDisconnectedICO []byte
    -
    -//go:embed netbird-systemtray-update-disconnected.png
    -var iconUpdateDisconnectedPNG []byte
    -
     //go:embed netbird-systemtray-update-disconnected-macos.png
     var iconUpdateDisconnectedMacOS []byte
     
    -//go:embed netbird-systemtray-update-disconnected-dark.ico
    -var iconUpdateDisconnectedDarkICO []byte
    -
    -//go:embed netbird-systemtray-update-disconnected-dark.png
    -var iconUpdateDisconnectedDarkPNG []byte
    -
    -//go:embed netbird-systemtray-update-connected.ico
    -var iconUpdateConnectedICO []byte
    -
    -//go:embed netbird-systemtray-update-connected.png
    -var iconUpdateConnectedPNG []byte
    -
     //go:embed netbird-systemtray-update-connected-macos.png
     var iconUpdateConnectedMacOS []byte
     
    -//go:embed netbird-systemtray-update-connected-dark.ico
    -var iconUpdateConnectedDarkICO []byte
    -
    -//go:embed netbird-systemtray-update-connected-dark.png
    -var iconUpdateConnectedDarkPNG []byte
    -
    -//go:embed netbird-systemtray-connecting.ico
    -var iconConnectingICO []byte
    -
    -//go:embed netbird-systemtray-connecting.png
    -var iconConnectingPNG []byte
    -
     //go:embed netbird-systemtray-connecting-macos.png
     var iconConnectingMacOS []byte
     
    -//go:embed netbird-systemtray-connecting-dark.ico
    -var iconConnectingDarkICO []byte
    -
    -//go:embed netbird-systemtray-connecting-dark.png
    -var iconConnectingDarkPNG []byte
    -
    -//go:embed netbird-systemtray-error.ico
    -var iconErrorICO []byte
    -
    -//go:embed netbird-systemtray-error.png
    -var iconErrorPNG []byte
    -
     //go:embed netbird-systemtray-error-macos.png
     var iconErrorMacOS []byte
     
    -//go:embed netbird-systemtray-error-dark.ico
    -var iconErrorDarkICO []byte
    -
    -//go:embed netbird-systemtray-error-dark.png
    -var iconErrorDarkPNG []byte
    -
     type serviceClient struct {
     	ctx  context.Context
     	addr string
    @@ -298,40 +226,21 @@ func newServiceClient(addr string, a fyne.App, showSettings bool, showRoutes boo
     }
     
     func (s *serviceClient) setNewIcons() {
    -	if runtime.GOOS == "windows" {
    -		s.icAbout = iconAboutICO
    -		if s.app.Settings().ThemeVariant() == theme.VariantDark {
    -			s.icConnected = iconConnectedDarkICO
    -			s.icDisconnected = iconDisconnectedICO
    -			s.icUpdateConnected = iconUpdateConnectedDarkICO
    -			s.icUpdateDisconnected = iconUpdateDisconnectedDarkICO
    -			s.icConnecting = iconConnectingDarkICO
    -			s.icError = iconErrorDarkICO
    -		} else {
    -			s.icConnected = iconConnectedICO
    -			s.icDisconnected = iconDisconnectedICO
    -			s.icUpdateConnected = iconUpdateConnectedICO
    -			s.icUpdateDisconnected = iconUpdateDisconnectedICO
    -			s.icConnecting = iconConnectingICO
    -			s.icError = iconErrorICO
    -		}
    +	s.icAbout = iconAbout
    +	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.icAbout = iconAboutPNG
    -		if s.app.Settings().ThemeVariant() == theme.VariantDark {
    -			s.icConnected = iconConnectedDarkPNG
    -			s.icDisconnected = iconDisconnectedPNG
    -			s.icUpdateConnected = iconUpdateConnectedDarkPNG
    -			s.icUpdateDisconnected = iconUpdateDisconnectedDarkPNG
    -			s.icConnecting = iconConnectingDarkPNG
    -			s.icError = iconErrorDarkPNG
    -		} else {
    -			s.icConnected = iconConnectedPNG
    -			s.icDisconnected = iconDisconnectedPNG
    -			s.icUpdateConnected = iconUpdateConnectedPNG
    -			s.icUpdateDisconnected = iconUpdateDisconnectedPNG
    -			s.icConnecting = iconConnectingPNG
    -			s.icError = iconErrorPNG
    -		}
    +		s.icConnected = iconConnected
    +		s.icDisconnected = iconDisconnected
    +		s.icUpdateConnected = iconUpdateConnected
    +		s.icUpdateDisconnected = iconUpdateDisconnected
    +		s.icConnecting = iconConnecting
    +		s.icError = iconError
     	}
     }
     
    @@ -622,7 +531,6 @@ func (s *serviceClient) updateStatus() error {
     		Stop:                backoff.Stop,
     		Clock:               backoff.SystemClock,
     	})
    -
     	if err != nil {
     		return err
     	}
    diff --git a/client/ui/icons.go b/client/ui/icons.go
    new file mode 100644
    index 000000000..6f3a9dbc9
    --- /dev/null
    +++ b/client/ui/icons.go
    @@ -0,0 +1,43 @@
    +//go:build !(linux && 386) && !windows
    +
    +package main
    +
    +import (
    +	_ "embed"
    +)
    +
    +//go:embed netbird.png
    +var iconAbout []byte
    +
    +//go:embed netbird-systemtray-connected.png
    +var iconConnected []byte
    +
    +//go:embed netbird-systemtray-connected-dark.png
    +var iconConnectedDark []byte
    +
    +//go:embed netbird-systemtray-disconnected.png
    +var iconDisconnected []byte
    +
    +//go:embed netbird-systemtray-update-disconnected.png
    +var iconUpdateDisconnected []byte
    +
    +//go:embed netbird-systemtray-update-disconnected-dark.png
    +var iconUpdateDisconnectedDark []byte
    +
    +//go:embed netbird-systemtray-update-connected.png
    +var iconUpdateConnected []byte
    +
    +//go:embed netbird-systemtray-update-connected-dark.png
    +var iconUpdateConnectedDark []byte
    +
    +//go:embed netbird-systemtray-connecting.png
    +var iconConnecting []byte
    +
    +//go:embed netbird-systemtray-connecting-dark.png
    +var iconConnectingDark []byte
    +
    +//go:embed netbird-systemtray-error.png
    +var iconError []byte
    +
    +//go:embed netbird-systemtray-error-dark.png
    +var iconErrorDark []byte
    diff --git a/client/ui/icons_windows.go b/client/ui/icons_windows.go
    new file mode 100644
    index 000000000..a2a924763
    --- /dev/null
    +++ b/client/ui/icons_windows.go
    @@ -0,0 +1,41 @@
    +package main
    +
    +import (
    + _ "embed"
    +)
    +
    +//go:embed netbird.ico
    +var iconAbout []byte
    +
    +//go:embed netbird-systemtray-connected.ico
    +var iconConnected []byte
    +
    +//go:embed netbird-systemtray-connected-dark.ico
    +var iconConnectedDark []byte
    +
    +//go:embed netbird-systemtray-disconnected.ico
    +var iconDisconnected []byte
    +
    +//go:embed netbird-systemtray-update-disconnected.ico
    +var iconUpdateDisconnected []byte
    +
    +//go:embed netbird-systemtray-update-disconnected-dark.ico
    +var iconUpdateDisconnectedDark []byte
    +
    +//go:embed netbird-systemtray-update-connected.ico
    +var iconUpdateConnected []byte
    +
    +//go:embed netbird-systemtray-update-connected-dark.ico
    +var iconUpdateConnectedDark []byte
    +
    +//go:embed netbird-systemtray-connecting.ico
    +var iconConnecting []byte
    +
    +//go:embed netbird-systemtray-connecting-dark.ico
    +var iconConnectingDark []byte
    +
    +//go:embed netbird-systemtray-error.ico
    +var iconError []byte
    +
    +//go:embed netbird-systemtray-error-dark.ico
    +var iconErrorDark []byte
    
    From 87311074f1a67f3f54ed0fce25c51cfeda11f7e6 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?C=C3=A9sar=20Gon=C3=A7alves?= 
    Date: Thu, 20 Feb 2025 09:56:22 +0000
    Subject: [PATCH 28/29] [misc] improvement(template): add traefik labels to
     relay (#3333)
    
    ---
     infrastructure_files/docker-compose.yml.tmpl.traefik | 4 ++++
     1 file changed, 4 insertions(+)
    
    diff --git a/infrastructure_files/docker-compose.yml.tmpl.traefik b/infrastructure_files/docker-compose.yml.tmpl.traefik
    index 71471c3ef..dcd3f955c 100644
    --- a/infrastructure_files/docker-compose.yml.tmpl.traefik
    +++ b/infrastructure_files/docker-compose.yml.tmpl.traefik
    @@ -67,6 +67,10 @@ services:
           options:
             max-size: "500m"
             max-file: "2"
    +    labels:
    +    - traefik.enable=true
    +    - traefik.http.routers.netbird-relay.rule=Host(`$NETBIRD_DOMAIN`) && PathPrefix(`/relay`)
    +    - traefik.http.services.netbird-relay.loadbalancer.server.port=$NETBIRD_RELAY_PORT
     
       # Management
       management:
    
    From 62a0c358f9ea9e1c69dd30a5328551f18e18f8a7 Mon Sep 17 00:00:00 2001
    From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
    Date: Thu, 20 Feb 2025 11:00:02 +0100
    Subject: [PATCH 29/29] [client] Add UI client event notifications (#3207)
    
    ---
     client/cmd/status.go                   |   35 +-
     client/cmd/status_event.go             |   69 ++
     client/cmd/status_test.go              |   28 +-
     client/internal/config.go              |   14 +
     client/internal/dns/upstream.go        |    9 +
     client/internal/peer/status.go         |  125 ++
     client/internal/routemanager/client.go |   76 +-
     client/proto/daemon.pb.go              | 1496 ++++++++++++++++--------
     client/proto/daemon.proto              |   42 +
     client/proto/daemon_grpc.pb.go         |  102 +-
     client/server/event.go                 |   36 +
     client/server/server.go                |   29 +-
     client/ui/client_ui.go                 |   48 +-
     client/ui/event/event.go               |  151 +++
     14 files changed, 1685 insertions(+), 575 deletions(-)
     create mode 100644 client/cmd/status_event.go
     create mode 100644 client/server/event.go
     create mode 100644 client/ui/event/event.go
    
    diff --git a/client/cmd/status.go b/client/cmd/status.go
    index fa4bff77b..bf4588ce4 100644
    --- a/client/cmd/status.go
    +++ b/client/cmd/status.go
    @@ -39,7 +39,6 @@ type peerStateDetailOutput struct {
     	TransferSent           int64            `json:"transferSent" yaml:"transferSent"`
     	Latency                time.Duration    `json:"latency" yaml:"latency"`
     	RosenpassEnabled       bool             `json:"quantumResistance" yaml:"quantumResistance"`
    -	Routes                 []string         `json:"routes" yaml:"routes"`
     	Networks               []string         `json:"networks" yaml:"networks"`
     }
     
    @@ -98,9 +97,9 @@ type statusOutputOverview struct {
     	FQDN                string                     `json:"fqdn" yaml:"fqdn"`
     	RosenpassEnabled    bool                       `json:"quantumResistance" yaml:"quantumResistance"`
     	RosenpassPermissive bool                       `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"`
    -	Routes              []string                   `json:"routes" yaml:"routes"`
     	Networks            []string                   `json:"networks" yaml:"networks"`
     	NSServerGroups      []nsServerGroupStateOutput `json:"dnsServers" yaml:"dnsServers"`
    +	Events              []systemEventOutput        `json:"events" yaml:"events"`
     }
     
     var (
    @@ -284,9 +283,9 @@ func convertToStatusOutputOverview(resp *proto.StatusResponse) statusOutputOverv
     		FQDN:                pbFullStatus.GetLocalPeerState().GetFqdn(),
     		RosenpassEnabled:    pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(),
     		RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(),
    -		Routes:              pbFullStatus.GetLocalPeerState().GetNetworks(),
     		Networks:            pbFullStatus.GetLocalPeerState().GetNetworks(),
     		NSServerGroups:      mapNSGroups(pbFullStatus.GetDnsServers()),
    +		Events:              mapEvents(pbFullStatus.GetEvents()),
     	}
     
     	if anonymizeFlag {
    @@ -393,7 +392,6 @@ func mapPeers(peers []*proto.PeerState) peersStateOutput {
     			TransferSent:           transferSent,
     			Latency:                pbPeerState.GetLatency().AsDuration(),
     			RosenpassEnabled:       pbPeerState.GetRosenpassEnabled(),
    -			Routes:                 pbPeerState.GetNetworks(),
     			Networks:               pbPeerState.GetNetworks(),
     		}
     
    @@ -559,7 +557,6 @@ func parseGeneralSummary(overview statusOutputOverview, showURL bool, showRelays
     			"NetBird IP: %s\n"+
     			"Interface type: %s\n"+
     			"Quantum resistance: %s\n"+
    -			"Routes: %s\n"+
     			"Networks: %s\n"+
     			"Peers count: %s\n",
     		fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
    @@ -574,7 +571,6 @@ func parseGeneralSummary(overview statusOutputOverview, showURL bool, showRelays
     		interfaceTypeString,
     		rosenpassEnabledStatus,
     		networks,
    -		networks,
     		peersCountString,
     	)
     	return summary
    @@ -582,13 +578,17 @@ func parseGeneralSummary(overview statusOutputOverview, showURL bool, showRelays
     
     func parseToFullDetailSummary(overview statusOutputOverview) string {
     	parsedPeersString := parsePeers(overview.Peers, overview.RosenpassEnabled, overview.RosenpassPermissive)
    +	parsedEventsString := parseEvents(overview.Events)
     	summary := parseGeneralSummary(overview, true, true, true)
     
     	return fmt.Sprintf(
     		"Peers detail:"+
    +			"%s\n"+
    +			"Events:"+
     			"%s\n"+
     			"%s",
     		parsedPeersString,
    +		parsedEventsString,
     		summary,
     	)
     }
    @@ -657,7 +657,6 @@ func parsePeers(peers peersStateOutput, rosenpassEnabled, rosenpassPermissive bo
     				"  Last WireGuard handshake: %s\n"+
     				"  Transfer status (received/sent) %s/%s\n"+
     				"  Quantum resistance: %s\n"+
    -				"  Routes: %s\n"+
     				"  Networks: %s\n"+
     				"  Latency: %s\n",
     			peerState.FQDN,
    @@ -676,7 +675,6 @@ func parsePeers(peers peersStateOutput, rosenpassEnabled, rosenpassPermissive bo
     			toIEC(peerState.TransferSent),
     			rosenpassEnabledStatus,
     			networks,
    -			networks,
     			peerState.Latency.String(),
     		)
     
    @@ -825,14 +823,6 @@ func anonymizePeerDetail(a *anonymize.Anonymizer, peer *peerStateDetailOutput) {
     	for i, route := range peer.Networks {
     		peer.Networks[i] = a.AnonymizeRoute(route)
     	}
    -
    -	for i, route := range peer.Routes {
    -		peer.Routes[i] = a.AnonymizeIPString(route)
    -	}
    -
    -	for i, route := range peer.Routes {
    -		peer.Routes[i] = a.AnonymizeRoute(route)
    -	}
     }
     
     func anonymizeOverview(a *anonymize.Anonymizer, overview *statusOutputOverview) {
    @@ -870,9 +860,14 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *statusOutputOverview)
     		overview.Networks[i] = a.AnonymizeRoute(route)
     	}
     
    -	for i, route := range overview.Routes {
    -		overview.Routes[i] = a.AnonymizeRoute(route)
    -	}
    -
     	overview.FQDN = a.AnonymizeDomain(overview.FQDN)
    +
    +	for i, event := range overview.Events {
    +		overview.Events[i].Message = a.AnonymizeString(event.Message)
    +		overview.Events[i].UserMessage = a.AnonymizeString(event.UserMessage)
    +
    +		for k, v := range event.Metadata {
    +			event.Metadata[k] = a.AnonymizeString(v)
    +		}
    +	}
     }
    diff --git a/client/cmd/status_event.go b/client/cmd/status_event.go
    new file mode 100644
    index 000000000..9331570e6
    --- /dev/null
    +++ b/client/cmd/status_event.go
    @@ -0,0 +1,69 @@
    +package cmd
    +
    +import (
    +	"fmt"
    +	"sort"
    +	"strings"
    +	"time"
    +
    +	"github.com/netbirdio/netbird/client/proto"
    +)
    +
    +type systemEventOutput struct {
    +	ID          string            `json:"id" yaml:"id"`
    +	Severity    string            `json:"severity" yaml:"severity"`
    +	Category    string            `json:"category" yaml:"category"`
    +	Message     string            `json:"message" yaml:"message"`
    +	UserMessage string            `json:"userMessage" yaml:"userMessage"`
    +	Timestamp   time.Time         `json:"timestamp" yaml:"timestamp"`
    +	Metadata    map[string]string `json:"metadata" yaml:"metadata"`
    +}
    +
    +func mapEvents(protoEvents []*proto.SystemEvent) []systemEventOutput {
    +	events := make([]systemEventOutput, len(protoEvents))
    +	for i, event := range protoEvents {
    +		events[i] = systemEventOutput{
    +			ID:          event.GetId(),
    +			Severity:    event.GetSeverity().String(),
    +			Category:    event.GetCategory().String(),
    +			Message:     event.GetMessage(),
    +			UserMessage: event.GetUserMessage(),
    +			Timestamp:   event.GetTimestamp().AsTime(),
    +			Metadata:    event.GetMetadata(),
    +		}
    +	}
    +	return events
    +}
    +
    +func parseEvents(events []systemEventOutput) string {
    +	if len(events) == 0 {
    +		return " No events recorded"
    +	}
    +
    +	var eventsString strings.Builder
    +	for _, event := range events {
    +		timeStr := timeAgo(event.Timestamp)
    +
    +		metadataStr := ""
    +		if len(event.Metadata) > 0 {
    +			pairs := make([]string, 0, len(event.Metadata))
    +			for k, v := range event.Metadata {
    +				pairs = append(pairs, fmt.Sprintf("%s: %s", k, v))
    +			}
    +			sort.Strings(pairs)
    +			metadataStr = fmt.Sprintf("\n    Metadata: %s", strings.Join(pairs, ", "))
    +		}
    +
    +		eventsString.WriteString(fmt.Sprintf("\n  [%s] %s (%s)"+
    +			"\n    Message: %s"+
    +			"\n    Time: %s%s",
    +			event.Severity,
    +			event.Category,
    +			event.ID,
    +			event.Message,
    +			timeStr,
    +			metadataStr,
    +		))
    +	}
    +	return eventsString.String()
    +}
    diff --git a/client/cmd/status_test.go b/client/cmd/status_test.go
    index 1f1e95726..1e240d192 100644
    --- a/client/cmd/status_test.go
    +++ b/client/cmd/status_test.go
    @@ -146,9 +146,6 @@ var overview = statusOutputOverview{
     				LastWireguardHandshake: time.Date(2001, 1, 1, 1, 1, 2, 0, time.UTC),
     				TransferReceived:       200,
     				TransferSent:           100,
    -				Routes: []string{
    -					"10.1.0.0/24",
    -				},
     				Networks: []string{
     					"10.1.0.0/24",
     				},
    @@ -176,6 +173,7 @@ var overview = statusOutputOverview{
     			},
     		},
     	},
    +	Events:        []systemEventOutput{},
     	CliVersion:    version.NetbirdVersion(),
     	DaemonVersion: "0.14.1",
     	ManagementState: managementStateOutput{
    @@ -230,9 +228,6 @@ var overview = statusOutputOverview{
     			Error:   "timeout",
     		},
     	},
    -	Routes: []string{
    -		"10.10.0.0/24",
    -	},
     	Networks: []string{
     		"10.10.0.0/24",
     	},
    @@ -299,9 +294,6 @@ func TestParsingToJSON(t *testing.T) {
                     "transferSent": 100,
     				"latency": 10000000,
                     "quantumResistance": false,
    -                "routes": [
    -                  "10.1.0.0/24"
    -                ],
                     "networks": [
                       "10.1.0.0/24"
                     ]
    @@ -327,7 +319,6 @@ func TestParsingToJSON(t *testing.T) {
                     "transferSent": 1000,
     				"latency": 10000000,
                     "quantumResistance": false,
    -                "routes": null,
                     "networks": null
                   }
                 ]
    @@ -366,9 +357,6 @@ func TestParsingToJSON(t *testing.T) {
               "fqdn": "some-localhost.awesome-domain.com",
               "quantumResistance": false,
               "quantumResistancePermissive": false,
    -          "routes": [
    -            "10.10.0.0/24"
    -          ],
               "networks": [
                 "10.10.0.0/24"
               ],
    @@ -393,7 +381,8 @@ func TestParsingToJSON(t *testing.T) {
                   "enabled": false,
                   "error": "timeout"
                 }
    -          ]
    +          ],
    +          "events": []
             }`
     	// @formatter:on
     
    @@ -429,8 +418,6 @@ func TestParsingToYAML(t *testing.T) {
               transferSent: 100
               latency: 10ms
               quantumResistance: false
    -          routes:
    -            - 10.1.0.0/24
               networks:
                 - 10.1.0.0/24
             - fqdn: peer-2.awesome-domain.com
    @@ -451,7 +438,6 @@ func TestParsingToYAML(t *testing.T) {
               transferSent: 1000
               latency: 10ms
               quantumResistance: false
    -          routes: []
               networks: []
     cliVersion: development
     daemonVersion: 0.14.1
    @@ -479,8 +465,6 @@ usesKernelInterface: true
     fqdn: some-localhost.awesome-domain.com
     quantumResistance: false
     quantumResistancePermissive: false
    -routes:
    -    - 10.10.0.0/24
     networks:
         - 10.10.0.0/24
     dnsServers:
    @@ -497,6 +481,7 @@ dnsServers:
             - example.net
           enabled: false
           error: timeout
    +events: []
     `
     
     	assert.Equal(t, expectedYAML, yaml)
    @@ -526,7 +511,6 @@ func TestParsingToDetail(t *testing.T) {
       Last WireGuard handshake: %s
       Transfer status (received/sent) 200 B/100 B
       Quantum resistance: false
    -  Routes: 10.1.0.0/24
       Networks: 10.1.0.0/24
       Latency: 10ms
     
    @@ -543,10 +527,10 @@ func TestParsingToDetail(t *testing.T) {
       Last WireGuard handshake: %s
       Transfer status (received/sent) 2.0 KiB/1000 B
       Quantum resistance: false
    -  Routes: -
       Networks: -
       Latency: 10ms
     
    +Events: No events recorded
     OS: %s/%s
     Daemon version: 0.14.1
     CLI version: %s
    @@ -562,7 +546,6 @@ FQDN: some-localhost.awesome-domain.com
     NetBird IP: 192.168.178.100/16
     Interface type: Kernel
     Quantum resistance: false
    -Routes: 10.10.0.0/24
     Networks: 10.10.0.0/24
     Peers count: 2/2 Connected
     `, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
    @@ -584,7 +567,6 @@ FQDN: some-localhost.awesome-domain.com
     NetBird IP: 192.168.178.100/16
     Interface type: Kernel
     Quantum resistance: false
    -Routes: 10.10.0.0/24
     Networks: 10.10.0.0/24
     Peers count: 2/2 Connected
     `
    diff --git a/client/internal/config.go b/client/internal/config.go
    index 3196c4e04..5703539cc 100644
    --- a/client/internal/config.go
    +++ b/client/internal/config.go
    @@ -68,6 +68,8 @@ type ConfigInput struct {
     	DisableFirewall     *bool
     
     	BlockLANAccess *bool
    +
    +	DisableNotifications *bool
     }
     
     // Config Configuration type
    @@ -93,6 +95,8 @@ type Config struct {
     
     	BlockLANAccess bool
     
    +	DisableNotifications bool
    +
     	// SSHKey is a private SSH key in a PEM format
     	SSHKey string
     
    @@ -469,6 +473,16 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
     		updated = true
     	}
     
    +	if input.DisableNotifications != nil && *input.DisableNotifications != config.DisableNotifications {
    +		if *input.DisableNotifications {
    +			log.Infof("disabling notifications")
    +		} else {
    +			log.Infof("enabling notifications")
    +		}
    +		config.DisableNotifications = *input.DisableNotifications
    +		updated = true
    +	}
    +
     	if input.ClientCertKeyPath != "" {
     		config.ClientCertKeyPath = input.ClientCertKeyPath
     		updated = true
    diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go
    index 4c69a173d..d269107e3 100644
    --- a/client/internal/dns/upstream.go
    +++ b/client/internal/dns/upstream.go
    @@ -19,6 +19,7 @@ import (
     	log "github.com/sirupsen/logrus"
     
     	"github.com/netbirdio/netbird/client/internal/peer"
    +	"github.com/netbirdio/netbird/client/proto"
     )
     
     const (
    @@ -230,6 +231,14 @@ func (u *upstreamResolverBase) probeAvailability() {
     	// didn't find a working upstream server, let's disable and try later
     	if !success {
     		u.disable(errors.ErrorOrNil())
    +
    +		u.statusRecorder.PublishEvent(
    +			proto.SystemEvent_WARNING,
    +			proto.SystemEvent_DNS,
    +			"All upstream servers failed",
    +			"Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
    +			map[string]string{"upstreams": strings.Join(u.upstreamServers, ", ")},
    +		)
     	}
     }
     
    diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
    index 311ddbd7f..e9976270c 100644
    --- a/client/internal/peer/status.go
    +++ b/client/internal/peer/status.go
    @@ -7,21 +7,31 @@ import (
     	"sync"
     	"time"
     
    +	"github.com/google/uuid"
    +	log "github.com/sirupsen/logrus"
     	"golang.org/x/exp/maps"
     	"google.golang.org/grpc/codes"
     	gstatus "google.golang.org/grpc/status"
    +	"google.golang.org/protobuf/types/known/timestamppb"
     
     	"github.com/netbirdio/netbird/client/iface/configurer"
     	"github.com/netbirdio/netbird/client/internal/relay"
    +	"github.com/netbirdio/netbird/client/proto"
     	"github.com/netbirdio/netbird/management/domain"
     	relayClient "github.com/netbirdio/netbird/relay/client"
     )
     
    +const eventQueueSize = 10
    +
     type ResolvedDomainInfo struct {
     	Prefixes     []netip.Prefix
     	ParentDomain domain.Domain
     }
     
    +type EventListener interface {
    +	OnEvent(event *proto.SystemEvent)
    +}
    +
     // State contains the latest state of a peer
     type State struct {
     	Mux                        *sync.RWMutex
    @@ -157,6 +167,10 @@ type Status struct {
     	peerListChangedForNotification bool
     
     	relayMgr *relayClient.Manager
    +
    +	eventMux     sync.RWMutex
    +	eventStreams map[string]chan *proto.SystemEvent
    +	eventQueue   *EventQueue
     }
     
     // NewRecorder returns a new Status instance
    @@ -164,6 +178,8 @@ func NewRecorder(mgmAddress string) *Status {
     	return &Status{
     		peers:                 make(map[string]State),
     		changeNotify:          make(map[string]chan struct{}),
    +		eventStreams:          make(map[string]chan *proto.SystemEvent),
    +		eventQueue:            NewEventQueue(eventQueueSize),
     		offlinePeers:          make([]State, 0),
     		notifier:              newNotifier(),
     		mgmAddress:            mgmAddress,
    @@ -806,3 +822,112 @@ func (d *Status) notifyAddressChanged() {
     func (d *Status) numOfPeers() int {
     	return len(d.peers) + len(d.offlinePeers)
     }
    +
    +// PublishEvent adds an event to the queue and distributes it to all subscribers
    +func (d *Status) PublishEvent(
    +	severity proto.SystemEvent_Severity,
    +	category proto.SystemEvent_Category,
    +	msg string,
    +	userMsg string,
    +	metadata map[string]string,
    +) {
    +	event := &proto.SystemEvent{
    +		Id:          uuid.New().String(),
    +		Severity:    severity,
    +		Category:    category,
    +		Message:     msg,
    +		UserMessage: userMsg,
    +		Metadata:    metadata,
    +		Timestamp:   timestamppb.Now(),
    +	}
    +
    +	d.eventMux.Lock()
    +	defer d.eventMux.Unlock()
    +
    +	d.eventQueue.Add(event)
    +
    +	for _, stream := range d.eventStreams {
    +		select {
    +		case stream <- event:
    +		default:
    +			log.Debugf("event stream buffer full, skipping event: %v", event)
    +		}
    +	}
    +
    +	log.Debugf("event published: %v", event)
    +}
    +
    +// SubscribeToEvents returns a new event subscription
    +func (d *Status) SubscribeToEvents() *EventSubscription {
    +	d.eventMux.Lock()
    +	defer d.eventMux.Unlock()
    +
    +	id := uuid.New().String()
    +	stream := make(chan *proto.SystemEvent, 10)
    +	d.eventStreams[id] = stream
    +
    +	return &EventSubscription{
    +		id:     id,
    +		events: stream,
    +	}
    +}
    +
    +// UnsubscribeFromEvents removes an event subscription
    +func (d *Status) UnsubscribeFromEvents(sub *EventSubscription) {
    +	if sub == nil {
    +		return
    +	}
    +
    +	d.eventMux.Lock()
    +	defer d.eventMux.Unlock()
    +
    +	if stream, exists := d.eventStreams[sub.id]; exists {
    +		close(stream)
    +		delete(d.eventStreams, sub.id)
    +	}
    +}
    +
    +// GetEventHistory returns all events in the queue
    +func (d *Status) GetEventHistory() []*proto.SystemEvent {
    +	return d.eventQueue.GetAll()
    +}
    +
    +type EventQueue struct {
    +	maxSize int
    +	events  []*proto.SystemEvent
    +	mutex   sync.RWMutex
    +}
    +
    +func NewEventQueue(size int) *EventQueue {
    +	return &EventQueue{
    +		maxSize: size,
    +		events:  make([]*proto.SystemEvent, 0, size),
    +	}
    +}
    +
    +func (q *EventQueue) Add(event *proto.SystemEvent) {
    +	q.mutex.Lock()
    +	defer q.mutex.Unlock()
    +
    +	q.events = append(q.events, event)
    +
    +	if len(q.events) > q.maxSize {
    +		q.events = q.events[len(q.events)-q.maxSize:]
    +	}
    +}
    +
    +func (q *EventQueue) GetAll() []*proto.SystemEvent {
    +	q.mutex.RLock()
    +	defer q.mutex.RUnlock()
    +
    +	return slices.Clone(q.events)
    +}
    +
    +type EventSubscription struct {
    +	id     string
    +	events chan *proto.SystemEvent
    +}
    +
    +func (s *EventSubscription) Events() <-chan *proto.SystemEvent {
    +	return s.events
    +}
    diff --git a/client/internal/routemanager/client.go b/client/internal/routemanager/client.go
    index faf0fadaa..3238dd831 100644
    --- a/client/internal/routemanager/client.go
    +++ b/client/internal/routemanager/client.go
    @@ -19,6 +19,7 @@ import (
     	"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
     	"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
     	"github.com/netbirdio/netbird/client/internal/routemanager/static"
    +	"github.com/netbirdio/netbird/client/proto"
     	"github.com/netbirdio/netbird/route"
     )
     
    @@ -28,6 +29,15 @@ const (
     	handlerTypeStatic
     )
     
    +type reason int
    +
    +const (
    +	reasonUnknown reason = iota
    +	reasonRouteUpdate
    +	reasonPeerUpdate
    +	reasonShutdown
    +)
    +
     type routerPeerStatus struct {
     	connected bool
     	relayed   bool
    @@ -255,7 +265,7 @@ func (c *clientNetwork) removeRouteFromWireGuardPeer() error {
     	return nil
     }
     
    -func (c *clientNetwork) removeRouteFromPeerAndSystem() error {
    +func (c *clientNetwork) removeRouteFromPeerAndSystem(rsn reason) error {
     	if c.currentChosen == nil {
     		return nil
     	}
    @@ -269,17 +279,19 @@ func (c *clientNetwork) removeRouteFromPeerAndSystem() error {
     		merr = multierror.Append(merr, fmt.Errorf("remove route: %w", err))
     	}
     
    +	c.disconnectEvent(rsn)
    +
     	return nberrors.FormatErrorOrNil(merr)
     }
     
    -func (c *clientNetwork) recalculateRouteAndUpdatePeerAndSystem() error {
    +func (c *clientNetwork) recalculateRouteAndUpdatePeerAndSystem(rsn reason) error {
     	routerPeerStatuses := c.getRouterPeerStatuses()
     
     	newChosenID := c.getBestRouteFromStatuses(routerPeerStatuses)
     
     	// If no route is chosen, remove the route from the peer and system
     	if newChosenID == "" {
    -		if err := c.removeRouteFromPeerAndSystem(); err != nil {
    +		if err := c.removeRouteFromPeerAndSystem(rsn); err != nil {
     			return fmt.Errorf("remove route for peer %s: %w", c.currentChosen.Peer, err)
     		}
     
    @@ -319,6 +331,58 @@ func (c *clientNetwork) recalculateRouteAndUpdatePeerAndSystem() error {
     	return nil
     }
     
    +func (c *clientNetwork) disconnectEvent(rsn reason) {
    +	var defaultRoute bool
    +	for _, r := range c.routes {
    +		if r.Network.Bits() == 0 {
    +			defaultRoute = true
    +			break
    +		}
    +	}
    +
    +	if !defaultRoute {
    +		return
    +	}
    +
    +	var severity proto.SystemEvent_Severity
    +	var message string
    +	var userMessage string
    +	meta := make(map[string]string)
    +
    +	switch rsn {
    +	case reasonShutdown:
    +		severity = proto.SystemEvent_INFO
    +		message = "Default route removed"
    +		userMessage = "Exit node disconnected."
    +		meta["network"] = c.handler.String()
    +	case reasonRouteUpdate:
    +		severity = proto.SystemEvent_INFO
    +		message = "Default route updated due to configuration change"
    +		meta["network"] = c.handler.String()
    +	case reasonPeerUpdate:
    +		severity = proto.SystemEvent_WARNING
    +		message = "Default route disconnected due to peer unreachability"
    +		userMessage = "Exit node connection lost. Your internet access might be affected."
    +		if c.currentChosen != nil {
    +			meta["peer"] = c.currentChosen.Peer
    +			meta["network"] = c.handler.String()
    +		}
    +	default:
    +		severity = proto.SystemEvent_ERROR
    +		message = "Default route disconnected for unknown reason"
    +		userMessage = "Exit node disconnected for unknown reasons."
    +		meta["network"] = c.handler.String()
    +	}
    +
    +	c.statusRecorder.PublishEvent(
    +		severity,
    +		proto.SystemEvent_NETWORK,
    +		message,
    +		userMessage,
    +		meta,
    +	)
    +}
    +
     func (c *clientNetwork) sendUpdateToClientNetworkWatcher(update routesUpdate) {
     	go func() {
     		c.routeUpdate <- update
    @@ -361,12 +425,12 @@ func (c *clientNetwork) peersStateAndUpdateWatcher() {
     		select {
     		case <-c.ctx.Done():
     			log.Debugf("Stopping watcher for network [%v]", c.handler)
    -			if err := c.removeRouteFromPeerAndSystem(); err != nil {
    +			if err := c.removeRouteFromPeerAndSystem(reasonShutdown); err != nil {
     				log.Errorf("Failed to remove routes for [%v]: %v", c.handler, err)
     			}
     			return
     		case <-c.peerStateUpdate:
    -			err := c.recalculateRouteAndUpdatePeerAndSystem()
    +			err := c.recalculateRouteAndUpdatePeerAndSystem(reasonPeerUpdate)
     			if err != nil {
     				log.Errorf("Failed to recalculate routes for network [%v]: %v", c.handler, err)
     			}
    @@ -385,7 +449,7 @@ func (c *clientNetwork) peersStateAndUpdateWatcher() {
     
     			if isTrueRouteUpdate {
     				log.Debug("Client network update contains different routes, recalculating routes")
    -				err := c.recalculateRouteAndUpdatePeerAndSystem()
    +				err := c.recalculateRouteAndUpdatePeerAndSystem(reasonRouteUpdate)
     				if err != nil {
     					log.Errorf("Failed to recalculate routes for network [%v]: %v", c.handler, err)
     				}
    diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
    index c9651efed..b40f6beea 100644
    --- a/client/proto/daemon.pb.go
    +++ b/client/proto/daemon.pb.go
    @@ -87,6 +87,110 @@ func (LogLevel) EnumDescriptor() ([]byte, []int) {
     	return file_daemon_proto_rawDescGZIP(), []int{0}
     }
     
    +type SystemEvent_Severity int32
    +
    +const (
    +	SystemEvent_INFO     SystemEvent_Severity = 0
    +	SystemEvent_WARNING  SystemEvent_Severity = 1
    +	SystemEvent_ERROR    SystemEvent_Severity = 2
    +	SystemEvent_CRITICAL SystemEvent_Severity = 3
    +)
    +
    +// Enum value maps for SystemEvent_Severity.
    +var (
    +	SystemEvent_Severity_name = map[int32]string{
    +		0: "INFO",
    +		1: "WARNING",
    +		2: "ERROR",
    +		3: "CRITICAL",
    +	}
    +	SystemEvent_Severity_value = map[string]int32{
    +		"INFO":     0,
    +		"WARNING":  1,
    +		"ERROR":    2,
    +		"CRITICAL": 3,
    +	}
    +)
    +
    +func (x SystemEvent_Severity) Enum() *SystemEvent_Severity {
    +	p := new(SystemEvent_Severity)
    +	*p = x
    +	return p
    +}
    +
    +func (x SystemEvent_Severity) String() string {
    +	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
    +}
    +
    +func (SystemEvent_Severity) Descriptor() protoreflect.EnumDescriptor {
    +	return file_daemon_proto_enumTypes[1].Descriptor()
    +}
    +
    +func (SystemEvent_Severity) Type() protoreflect.EnumType {
    +	return &file_daemon_proto_enumTypes[1]
    +}
    +
    +func (x SystemEvent_Severity) Number() protoreflect.EnumNumber {
    +	return protoreflect.EnumNumber(x)
    +}
    +
    +// Deprecated: Use SystemEvent_Severity.Descriptor instead.
    +func (SystemEvent_Severity) EnumDescriptor() ([]byte, []int) {
    +	return file_daemon_proto_rawDescGZIP(), []int{45, 0}
    +}
    +
    +type SystemEvent_Category int32
    +
    +const (
    +	SystemEvent_NETWORK        SystemEvent_Category = 0
    +	SystemEvent_DNS            SystemEvent_Category = 1
    +	SystemEvent_AUTHENTICATION SystemEvent_Category = 2
    +	SystemEvent_CONNECTIVITY   SystemEvent_Category = 3
    +)
    +
    +// Enum value maps for SystemEvent_Category.
    +var (
    +	SystemEvent_Category_name = map[int32]string{
    +		0: "NETWORK",
    +		1: "DNS",
    +		2: "AUTHENTICATION",
    +		3: "CONNECTIVITY",
    +	}
    +	SystemEvent_Category_value = map[string]int32{
    +		"NETWORK":        0,
    +		"DNS":            1,
    +		"AUTHENTICATION": 2,
    +		"CONNECTIVITY":   3,
    +	}
    +)
    +
    +func (x SystemEvent_Category) Enum() *SystemEvent_Category {
    +	p := new(SystemEvent_Category)
    +	*p = x
    +	return p
    +}
    +
    +func (x SystemEvent_Category) String() string {
    +	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
    +}
    +
    +func (SystemEvent_Category) Descriptor() protoreflect.EnumDescriptor {
    +	return file_daemon_proto_enumTypes[2].Descriptor()
    +}
    +
    +func (SystemEvent_Category) Type() protoreflect.EnumType {
    +	return &file_daemon_proto_enumTypes[2]
    +}
    +
    +func (x SystemEvent_Category) Number() protoreflect.EnumNumber {
    +	return protoreflect.EnumNumber(x)
    +}
    +
    +// Deprecated: Use SystemEvent_Category.Descriptor instead.
    +func (SystemEvent_Category) EnumDescriptor() ([]byte, []int) {
    +	return file_daemon_proto_rawDescGZIP(), []int{45, 1}
    +}
    +
     type LoginRequest struct {
     	state         protoimpl.MessageState
     	sizeCache     protoimpl.SizeCache
    @@ -127,6 +231,7 @@ type LoginRequest struct {
     	DisableDns           *bool                `protobuf:"varint,22,opt,name=disable_dns,json=disableDns,proto3,oneof" json:"disable_dns,omitempty"`
     	DisableFirewall      *bool                `protobuf:"varint,23,opt,name=disable_firewall,json=disableFirewall,proto3,oneof" json:"disable_firewall,omitempty"`
     	BlockLanAccess       *bool                `protobuf:"varint,24,opt,name=block_lan_access,json=blockLanAccess,proto3,oneof" json:"block_lan_access,omitempty"`
    +	DisableNotifications *bool                `protobuf:"varint,25,opt,name=disable_notifications,json=disableNotifications,proto3,oneof" json:"disable_notifications,omitempty"`
     }
     
     func (x *LoginRequest) Reset() {
    @@ -330,6 +435,13 @@ func (x *LoginRequest) GetBlockLanAccess() bool {
     	return false
     }
     
    +func (x *LoginRequest) GetDisableNotifications() bool {
    +	if x != nil && x.DisableNotifications != nil {
    +		return *x.DisableNotifications
    +	}
    +	return false
    +}
    +
     type LoginResponse struct {
     	state         protoimpl.MessageState
     	sizeCache     protoimpl.SizeCache
    @@ -810,13 +922,14 @@ type GetConfigResponse struct {
     	// preSharedKey settings value.
     	PreSharedKey string `protobuf:"bytes,4,opt,name=preSharedKey,proto3" json:"preSharedKey,omitempty"`
     	// adminURL settings value.
    -	AdminURL            string `protobuf:"bytes,5,opt,name=adminURL,proto3" json:"adminURL,omitempty"`
    -	InterfaceName       string `protobuf:"bytes,6,opt,name=interfaceName,proto3" json:"interfaceName,omitempty"`
    -	WireguardPort       int64  `protobuf:"varint,7,opt,name=wireguardPort,proto3" json:"wireguardPort,omitempty"`
    -	DisableAutoConnect  bool   `protobuf:"varint,9,opt,name=disableAutoConnect,proto3" json:"disableAutoConnect,omitempty"`
    -	ServerSSHAllowed    bool   `protobuf:"varint,10,opt,name=serverSSHAllowed,proto3" json:"serverSSHAllowed,omitempty"`
    -	RosenpassEnabled    bool   `protobuf:"varint,11,opt,name=rosenpassEnabled,proto3" json:"rosenpassEnabled,omitempty"`
    -	RosenpassPermissive bool   `protobuf:"varint,12,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
    +	AdminURL             string `protobuf:"bytes,5,opt,name=adminURL,proto3" json:"adminURL,omitempty"`
    +	InterfaceName        string `protobuf:"bytes,6,opt,name=interfaceName,proto3" json:"interfaceName,omitempty"`
    +	WireguardPort        int64  `protobuf:"varint,7,opt,name=wireguardPort,proto3" json:"wireguardPort,omitempty"`
    +	DisableAutoConnect   bool   `protobuf:"varint,9,opt,name=disableAutoConnect,proto3" json:"disableAutoConnect,omitempty"`
    +	ServerSSHAllowed     bool   `protobuf:"varint,10,opt,name=serverSSHAllowed,proto3" json:"serverSSHAllowed,omitempty"`
    +	RosenpassEnabled     bool   `protobuf:"varint,11,opt,name=rosenpassEnabled,proto3" json:"rosenpassEnabled,omitempty"`
    +	RosenpassPermissive  bool   `protobuf:"varint,12,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
    +	DisableNotifications bool   `protobuf:"varint,13,opt,name=disable_notifications,json=disableNotifications,proto3" json:"disable_notifications,omitempty"`
     }
     
     func (x *GetConfigResponse) Reset() {
    @@ -928,6 +1041,13 @@ func (x *GetConfigResponse) GetRosenpassPermissive() bool {
     	return false
     }
     
    +func (x *GetConfigResponse) GetDisableNotifications() bool {
    +	if x != nil {
    +		return x.DisableNotifications
    +	}
    +	return false
    +}
    +
     // PeerState contains the latest state of a peer
     type PeerState struct {
     	state         protoimpl.MessageState
    @@ -1475,6 +1595,7 @@ type FullStatus struct {
     	Peers           []*PeerState     `protobuf:"bytes,4,rep,name=peers,proto3" json:"peers,omitempty"`
     	Relays          []*RelayState    `protobuf:"bytes,5,rep,name=relays,proto3" json:"relays,omitempty"`
     	DnsServers      []*NSGroupState  `protobuf:"bytes,6,rep,name=dns_servers,json=dnsServers,proto3" json:"dns_servers,omitempty"`
    +	Events          []*SystemEvent   `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"`
     }
     
     func (x *FullStatus) Reset() {
    @@ -1551,6 +1672,13 @@ func (x *FullStatus) GetDnsServers() []*NSGroupState {
     	return nil
     }
     
    +func (x *FullStatus) GetEvents() []*SystemEvent {
    +	if x != nil {
    +		return x.Events
    +	}
    +	return nil
    +}
    +
     type ListNetworksRequest struct {
     	state         protoimpl.MessageState
     	sizeCache     protoimpl.SizeCache
    @@ -2895,6 +3023,224 @@ func (x *TracePacketResponse) GetFinalDisposition() bool {
     	return false
     }
     
    +type SubscribeRequest struct {
    +	state         protoimpl.MessageState
    +	sizeCache     protoimpl.SizeCache
    +	unknownFields protoimpl.UnknownFields
    +}
    +
    +func (x *SubscribeRequest) Reset() {
    +	*x = SubscribeRequest{}
    +	if protoimpl.UnsafeEnabled {
    +		mi := &file_daemon_proto_msgTypes[44]
    +		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
    +		ms.StoreMessageInfo(mi)
    +	}
    +}
    +
    +func (x *SubscribeRequest) String() string {
    +	return protoimpl.X.MessageStringOf(x)
    +}
    +
    +func (*SubscribeRequest) ProtoMessage() {}
    +
    +func (x *SubscribeRequest) ProtoReflect() protoreflect.Message {
    +	mi := &file_daemon_proto_msgTypes[44]
    +	if protoimpl.UnsafeEnabled && x != nil {
    +		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
    +		if ms.LoadMessageInfo() == nil {
    +			ms.StoreMessageInfo(mi)
    +		}
    +		return ms
    +	}
    +	return mi.MessageOf(x)
    +}
    +
    +// Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead.
    +func (*SubscribeRequest) Descriptor() ([]byte, []int) {
    +	return file_daemon_proto_rawDescGZIP(), []int{44}
    +}
    +
    +type SystemEvent struct {
    +	state         protoimpl.MessageState
    +	sizeCache     protoimpl.SizeCache
    +	unknownFields protoimpl.UnknownFields
    +
    +	Id          string                 `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
    +	Severity    SystemEvent_Severity   `protobuf:"varint,2,opt,name=severity,proto3,enum=daemon.SystemEvent_Severity" json:"severity,omitempty"`
    +	Category    SystemEvent_Category   `protobuf:"varint,3,opt,name=category,proto3,enum=daemon.SystemEvent_Category" json:"category,omitempty"`
    +	Message     string                 `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
    +	UserMessage string                 `protobuf:"bytes,5,opt,name=userMessage,proto3" json:"userMessage,omitempty"`
    +	Timestamp   *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
    +	Metadata    map[string]string      `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
    +}
    +
    +func (x *SystemEvent) Reset() {
    +	*x = SystemEvent{}
    +	if protoimpl.UnsafeEnabled {
    +		mi := &file_daemon_proto_msgTypes[45]
    +		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
    +		ms.StoreMessageInfo(mi)
    +	}
    +}
    +
    +func (x *SystemEvent) String() string {
    +	return protoimpl.X.MessageStringOf(x)
    +}
    +
    +func (*SystemEvent) ProtoMessage() {}
    +
    +func (x *SystemEvent) ProtoReflect() protoreflect.Message {
    +	mi := &file_daemon_proto_msgTypes[45]
    +	if protoimpl.UnsafeEnabled && x != nil {
    +		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
    +		if ms.LoadMessageInfo() == nil {
    +			ms.StoreMessageInfo(mi)
    +		}
    +		return ms
    +	}
    +	return mi.MessageOf(x)
    +}
    +
    +// Deprecated: Use SystemEvent.ProtoReflect.Descriptor instead.
    +func (*SystemEvent) Descriptor() ([]byte, []int) {
    +	return file_daemon_proto_rawDescGZIP(), []int{45}
    +}
    +
    +func (x *SystemEvent) GetId() string {
    +	if x != nil {
    +		return x.Id
    +	}
    +	return ""
    +}
    +
    +func (x *SystemEvent) GetSeverity() SystemEvent_Severity {
    +	if x != nil {
    +		return x.Severity
    +	}
    +	return SystemEvent_INFO
    +}
    +
    +func (x *SystemEvent) GetCategory() SystemEvent_Category {
    +	if x != nil {
    +		return x.Category
    +	}
    +	return SystemEvent_NETWORK
    +}
    +
    +func (x *SystemEvent) GetMessage() string {
    +	if x != nil {
    +		return x.Message
    +	}
    +	return ""
    +}
    +
    +func (x *SystemEvent) GetUserMessage() string {
    +	if x != nil {
    +		return x.UserMessage
    +	}
    +	return ""
    +}
    +
    +func (x *SystemEvent) GetTimestamp() *timestamppb.Timestamp {
    +	if x != nil {
    +		return x.Timestamp
    +	}
    +	return nil
    +}
    +
    +func (x *SystemEvent) GetMetadata() map[string]string {
    +	if x != nil {
    +		return x.Metadata
    +	}
    +	return nil
    +}
    +
    +type GetEventsRequest struct {
    +	state         protoimpl.MessageState
    +	sizeCache     protoimpl.SizeCache
    +	unknownFields protoimpl.UnknownFields
    +}
    +
    +func (x *GetEventsRequest) Reset() {
    +	*x = GetEventsRequest{}
    +	if protoimpl.UnsafeEnabled {
    +		mi := &file_daemon_proto_msgTypes[46]
    +		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
    +		ms.StoreMessageInfo(mi)
    +	}
    +}
    +
    +func (x *GetEventsRequest) String() string {
    +	return protoimpl.X.MessageStringOf(x)
    +}
    +
    +func (*GetEventsRequest) ProtoMessage() {}
    +
    +func (x *GetEventsRequest) ProtoReflect() protoreflect.Message {
    +	mi := &file_daemon_proto_msgTypes[46]
    +	if protoimpl.UnsafeEnabled && x != nil {
    +		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
    +		if ms.LoadMessageInfo() == nil {
    +			ms.StoreMessageInfo(mi)
    +		}
    +		return ms
    +	}
    +	return mi.MessageOf(x)
    +}
    +
    +// Deprecated: Use GetEventsRequest.ProtoReflect.Descriptor instead.
    +func (*GetEventsRequest) Descriptor() ([]byte, []int) {
    +	return file_daemon_proto_rawDescGZIP(), []int{46}
    +}
    +
    +type GetEventsResponse struct {
    +	state         protoimpl.MessageState
    +	sizeCache     protoimpl.SizeCache
    +	unknownFields protoimpl.UnknownFields
    +
    +	Events []*SystemEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"`
    +}
    +
    +func (x *GetEventsResponse) Reset() {
    +	*x = GetEventsResponse{}
    +	if protoimpl.UnsafeEnabled {
    +		mi := &file_daemon_proto_msgTypes[47]
    +		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
    +		ms.StoreMessageInfo(mi)
    +	}
    +}
    +
    +func (x *GetEventsResponse) String() string {
    +	return protoimpl.X.MessageStringOf(x)
    +}
    +
    +func (*GetEventsResponse) ProtoMessage() {}
    +
    +func (x *GetEventsResponse) ProtoReflect() protoreflect.Message {
    +	mi := &file_daemon_proto_msgTypes[47]
    +	if protoimpl.UnsafeEnabled && x != nil {
    +		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
    +		if ms.LoadMessageInfo() == nil {
    +			ms.StoreMessageInfo(mi)
    +		}
    +		return ms
    +	}
    +	return mi.MessageOf(x)
    +}
    +
    +// Deprecated: Use GetEventsResponse.ProtoReflect.Descriptor instead.
    +func (*GetEventsResponse) Descriptor() ([]byte, []int) {
    +	return file_daemon_proto_rawDescGZIP(), []int{47}
    +}
    +
    +func (x *GetEventsResponse) GetEvents() []*SystemEvent {
    +	if x != nil {
    +		return x.Events
    +	}
    +	return nil
    +}
    +
     var File_daemon_proto protoreflect.FileDescriptor
     
     var file_daemon_proto_rawDesc = []byte{
    @@ -2905,7 +3251,7 @@ var file_daemon_proto_rawDesc = []byte{
     	0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74,
     	0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
     	0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74,
    -	0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x95, 0x0b, 0x0a, 0x0c, 0x4c, 0x6f,
    +	0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe9, 0x0b, 0x0a, 0x0c, 0x4c, 0x6f,
     	0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65,
     	0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65,
     	0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x12, 0x26, 0x0a, 0x0c, 0x70, 0x72, 0x65, 0x53, 0x68, 0x61,
    @@ -2976,409 +3322,468 @@ var file_daemon_proto_rawDesc = []byte{
     	0x61, 0x6c, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x2d, 0x0a, 0x10, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f,
     	0x6c, 0x61, 0x6e, 0x5f, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x18, 0x20, 0x01, 0x28, 0x08,
     	0x48, 0x0d, 0x52, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x61, 0x6e, 0x41, 0x63, 0x63, 0x65,
    -	0x73, 0x73, 0x88, 0x01, 0x01, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70,
    -	0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x69,
    -	0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x42, 0x10, 0x0a, 0x0e,
    -	0x5f, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x42, 0x17,
    -	0x0a, 0x15, 0x5f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x50, 0x72, 0x65, 0x53, 0x68,
    -	0x61, 0x72, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x64, 0x69, 0x73, 0x61,
    -	0x62, 0x6c, 0x65, 0x41, 0x75, 0x74, 0x6f, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x42, 0x13,
    -	0x0a, 0x11, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f,
    -	0x77, 0x65, 0x64, 0x42, 0x16, 0x0a, 0x14, 0x5f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73,
    -	0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x42, 0x11, 0x0a, 0x0f, 0x5f,
    -	0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x42, 0x13,
    -	0x0a, 0x11, 0x5f, 0x64, 0x6e, 0x73, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x74, 0x65, 0x72,
    -	0x76, 0x61, 0x6c, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x5f,
    -	0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x42, 0x18, 0x0a,
    -	0x16, 0x5f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72,
    -	0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x64, 0x69, 0x73, 0x61,
    -	0x62, 0x6c, 0x65, 0x5f, 0x64, 0x6e, 0x73, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x64, 0x69, 0x73, 0x61,
    -	0x62, 0x6c, 0x65, 0x5f, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x42, 0x13, 0x0a, 0x11,
    -	0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x6c, 0x61, 0x6e, 0x5f, 0x61, 0x63, 0x63, 0x65, 0x73,
    -	0x73, 0x22, 0xb5, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
    -	0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x6e, 0x65, 0x65, 0x64, 0x73, 0x53, 0x53, 0x4f, 0x4c,
    -	0x6f, 0x67, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x6e, 0x65, 0x65, 0x64,
    -	0x73, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65,
    -	0x72, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65,
    -	0x72, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63,
    -	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x52, 0x49, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f,
    -	0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x52, 0x49, 0x12,
    -	0x38, 0x0a, 0x17, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55,
    -	0x52, 0x49, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
    -	0x52, 0x17, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x52,
    -	0x49, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0x4d, 0x0a, 0x13, 0x57, 0x61, 0x69,
    -	0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
    -	0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01,
    -	0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1a, 0x0a, 0x08,
    -	0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
    -	0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x16, 0x0a, 0x14, 0x57, 0x61, 0x69, 0x74,
    -	0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
    -	0x22, 0x0b, 0x0a, 0x09, 0x55, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x0c, 0x0a,
    -	0x0a, 0x55, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x3d, 0x0a, 0x0d, 0x53,
    -	0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x11,
    -	0x67, 0x65, 0x74, 0x46, 0x75, 0x6c, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75,
    -	0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x67, 0x65, 0x74, 0x46, 0x75, 0x6c, 0x6c,
    -	0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x82, 0x01, 0x0a, 0x0e, 0x53,
    -	0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a,
    -	0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73,
    -	0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x32, 0x0a, 0x0a, 0x66, 0x75, 0x6c, 0x6c, 0x53, 0x74, 0x61,
    -	0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x61, 0x65, 0x6d,
    -	0x6f, 0x6e, 0x2e, 0x46, 0x75, 0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x0a, 0x66,
    -	0x75, 0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x61, 0x65,
    -	0x6d, 0x6f, 0x6e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
    -	0x52, 0x0d, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22,
    -	0x0d, 0x0a, 0x0b, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x0e,
    -	0x0a, 0x0c, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x12,
    -	0x0a, 0x10, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65,
    -	0x73, 0x74, 0x22, 0xb9, 0x03, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
    -	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x6d, 0x61, 0x6e, 0x61,
    -	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x55, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
    -	0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x55, 0x72, 0x6c, 0x12, 0x1e,
    -	0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01,
    -	0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x18,
    -	0x0a, 0x07, 0x6c, 0x6f, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
    -	0x07, 0x6c, 0x6f, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x70, 0x72, 0x65, 0x53,
    -	0x68, 0x61, 0x72, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
    -	0x70, 0x72, 0x65, 0x53, 0x68, 0x61, 0x72, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08,
    -	0x61, 0x64, 0x6d, 0x69, 0x6e, 0x55, 0x52, 0x4c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
    -	0x61, 0x64, 0x6d, 0x69, 0x6e, 0x55, 0x52, 0x4c, 0x12, 0x24, 0x0a, 0x0d, 0x69, 0x6e, 0x74, 0x65,
    -	0x72, 0x66, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52,
    -	0x0d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x24,
    -	0x0a, 0x0d, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18,
    -	0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64,
    -	0x50, 0x6f, 0x72, 0x74, 0x12, 0x2e, 0x0a, 0x12, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x41,
    -	0x75, 0x74, 0x6f, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08,
    -	0x52, 0x12, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x75, 0x74, 0x6f, 0x43, 0x6f, 0x6e,
    -	0x6e, 0x65, 0x63, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53,
    -	0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10,
    -	0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64,
    -	0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61,
    -	0x62, 0x6c, 0x65, 0x64, 0x18, 0x0b, 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,
    -	0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73,
    -	0x69, 0x76, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e,
    -	0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x22, 0xde,
    -	0x05, 0x0a, 0x09, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02,
    -	0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06,
    -	0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x75,
    -	0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x6e, 0x53, 0x74, 0x61, 0x74,
    -	0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x6e, 0x53, 0x74,
    -	0x61, 0x74, 0x75, 0x73, 0x12, 0x46, 0x0a, 0x10, 0x63, 0x6f, 0x6e, 0x6e, 0x53, 0x74, 0x61, 0x74,
    -	0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x04, 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, 0x10, 0x63, 0x6f, 0x6e, 0x6e,
    -	0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x18, 0x0a, 0x07,
    -	0x72, 0x65, 0x6c, 0x61, 0x79, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x72,
    -	0x65, 0x6c, 0x61, 0x79, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x49,
    +	0x73, 0x73, 0x88, 0x01, 0x01, 0x12, 0x38, 0x0a, 0x15, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
    +	0x5f, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x19,
    +	0x20, 0x01, 0x28, 0x08, 0x48, 0x0e, 0x52, 0x14, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x4e,
    +	0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x88, 0x01, 0x01, 0x42,
    +	0x13, 0x0a, 0x11, 0x5f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61,
    +	0x62, 0x6c, 0x65, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61,
    +	0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x77, 0x69, 0x72, 0x65, 0x67,
    +	0x75, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x42, 0x17, 0x0a, 0x15, 0x5f, 0x6f, 0x70, 0x74,
    +	0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x50, 0x72, 0x65, 0x53, 0x68, 0x61, 0x72, 0x65, 0x64, 0x4b, 0x65,
    +	0x79, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x75, 0x74,
    +	0x6f, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x73, 0x65, 0x72,
    +	0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x42, 0x16, 0x0a,
    +	0x14, 0x5f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69,
    +	0x73, 0x73, 0x69, 0x76, 0x65, 0x42, 0x11, 0x0a, 0x0f, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72,
    +	0x6b, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x64, 0x6e, 0x73,
    +	0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x42, 0x18, 0x0a,
    +	0x16, 0x5f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74,
    +	0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x64, 0x69, 0x73, 0x61,
    +	0x62, 0x6c, 0x65, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65,
    +	0x73, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x64, 0x6e,
    +	0x73, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x66, 0x69,
    +	0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b,
    +	0x5f, 0x6c, 0x61, 0x6e, 0x5f, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x42, 0x18, 0x0a, 0x16, 0x5f,
    +	0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61,
    +	0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xb5, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52,
    +	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x6e, 0x65, 0x65, 0x64, 0x73,
    +	0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d,
    +	0x6e, 0x65, 0x65, 0x64, 0x73, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1a, 0x0a,
    +	0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
    +	0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x76, 0x65, 0x72,
    +	0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x52, 0x49, 0x18, 0x03, 0x20, 0x01,
    +	0x28, 0x09, 0x52, 0x0f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
    +	0x55, 0x52, 0x49, 0x12, 0x38, 0x0a, 0x17, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74,
    +	0x69, 0x6f, 0x6e, 0x55, 0x52, 0x49, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x04,
    +	0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69,
    +	0x6f, 0x6e, 0x55, 0x52, 0x49, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0x4d, 0x0a,
    +	0x13, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71,
    +	0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64, 0x65,
    +	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64, 0x65,
    +	0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01,
    +	0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x16, 0x0a, 0x14,
    +	0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70,
    +	0x6f, 0x6e, 0x73, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x55, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
    +	0x74, 0x22, 0x0c, 0x0a, 0x0a, 0x55, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
    +	0x3d, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
    +	0x12, 0x2c, 0x0a, 0x11, 0x67, 0x65, 0x74, 0x46, 0x75, 0x6c, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53,
    +	0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x67, 0x65, 0x74,
    +	0x46, 0x75, 0x6c, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x82,
    +	0x01, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
    +	0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28,
    +	0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x32, 0x0a, 0x0a, 0x66, 0x75, 0x6c,
    +	0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e,
    +	0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x46, 0x75, 0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75,
    +	0x73, 0x52, 0x0a, 0x66, 0x75, 0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x24, 0x0a,
    +	0x0d, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03,
    +	0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x56, 0x65, 0x72, 0x73,
    +	0x69, 0x6f, 0x6e, 0x22, 0x0d, 0x0a, 0x0b, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
    +	0x73, 0x74, 0x22, 0x0e, 0x0a, 0x0c, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
    +	0x73, 0x65, 0x22, 0x12, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
    +	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xee, 0x03, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x43, 0x6f,
    +	0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x0d,
    +	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x55, 0x72, 0x6c, 0x18, 0x01, 0x20,
    +	0x01, 0x28, 0x09, 0x52, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x55,
    +	0x72, 0x6c, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, 0x6c, 0x65,
    +	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69,
    +	0x6c, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6c, 0x6f, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x18, 0x03, 0x20,
    +	0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x6f, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c,
    +	0x70, 0x72, 0x65, 0x53, 0x68, 0x61, 0x72, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01,
    +	0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x65, 0x53, 0x68, 0x61, 0x72, 0x65, 0x64, 0x4b, 0x65, 0x79,
    +	0x12, 0x1a, 0x0a, 0x08, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x55, 0x52, 0x4c, 0x18, 0x05, 0x20, 0x01,
    +	0x28, 0x09, 0x52, 0x08, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x55, 0x52, 0x4c, 0x12, 0x24, 0x0a, 0x0d,
    +	0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20,
    +	0x01, 0x28, 0x09, 0x52, 0x0d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x4e, 0x61,
    +	0x6d, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50,
    +	0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x72, 0x65, 0x67,
    +	0x75, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2e, 0x0a, 0x12, 0x64, 0x69, 0x73, 0x61,
    +	0x62, 0x6c, 0x65, 0x41, 0x75, 0x74, 0x6f, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x18, 0x09,
    +	0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x75, 0x74,
    +	0x6f, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76,
    +	0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01,
    +	0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c,
    +	0x6f, 0x77, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73,
    +	0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0b, 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, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72,
    +	0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x72,
    +	0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69,
    +	0x76, 0x65, 0x12, 0x33, 0x0a, 0x15, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x6e, 0x6f,
    +	0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28,
    +	0x08, 0x52, 0x14, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69,
    +	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xde, 0x05, 0x0a, 0x09, 0x50, 0x65, 0x65, 0x72,
    +	0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28,
    +	0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18,
    +	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a,
    +	0x0a, 0x63, 0x6f, 0x6e, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28,
    +	0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x46, 0x0a,
    +	0x10, 0x63, 0x6f, 0x6e, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74,
    +	0x65, 0x18, 0x04, 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, 0x10, 0x63, 0x6f, 0x6e, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55,
    +	0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x65, 0x64,
    +	0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x65, 0x64, 0x12,
    +	0x34, 0x0a, 0x15, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69,
    +	0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15,
    +	0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74,
    +	0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x36, 0x0a, 0x16, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49,
     	0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18,
    -	0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x49, 0x63, 0x65, 0x43,
    -	0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x36, 0x0a, 0x16,
    -	0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61,
    -	0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x16, 0x72, 0x65,
    -	0x6d, 0x6f, 0x74, 0x65, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65,
    -	0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x09, 0x20, 0x01,
    -	0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x3c, 0x0a, 0x19, 0x6c, 0x6f, 0x63, 0x61,
    -	0x6c, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x64,
    -	0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x6c, 0x6f, 0x63,
    -	0x61, 0x6c, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e,
    -	0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65,
    -	0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x64, 0x70,
    -	0x6f, 0x69, 0x6e, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1a, 0x72, 0x65, 0x6d, 0x6f,
    -	0x74, 0x65, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e,
    -	0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x52, 0x0a, 0x16, 0x6c, 0x61, 0x73, 0x74, 0x57, 0x69,
    -	0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65,
    -	0x18, 0x0c, 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, 0x16, 0x6c, 0x61, 0x73, 0x74, 0x57, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72,
    -	0x64, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x62, 0x79,
    -	0x74, 0x65, 0x73, 0x52, 0x78, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x62, 0x79, 0x74,
    -	0x65, 0x73, 0x52, 0x78, 0x12, 0x18, 0x0a, 0x07, 0x62, 0x79, 0x74, 0x65, 0x73, 0x54, 0x78, 0x18,
    -	0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x62, 0x79, 0x74, 0x65, 0x73, 0x54, 0x78, 0x12, 0x2a,
    -	0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c,
    -	0x65, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70,
    -	0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x6e, 0x65,
    -	0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x6e, 0x65,
    -	0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x12, 0x33, 0x0a, 0x07, 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63,
    -	0x79, 0x18, 0x11, 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, 0x07, 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x22, 0x0a, 0x0c, 0x72,
    -	0x65, 0x6c, 0x61, 0x79, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x12, 0x20, 0x01, 0x28,
    -	0x09, 0x52, 0x0c, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22,
    -	0xf0, 0x01, 0x0a, 0x0e, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61,
    -	0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02,
    -	0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01,
    -	0x28, 0x09, 0x52, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x0f, 0x6b, 0x65,
    -	0x72, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x18, 0x03, 0x20,
    -	0x01, 0x28, 0x08, 0x52, 0x0f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x74, 0x65, 0x72,
    -	0x66, 0x61, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01,
    -	0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65,
    -	0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 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, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73,
    -	0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28,
    -	0x08, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d,
    -	0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72,
    -	0x6b, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72,
    -	0x6b, 0x73, 0x22, 0x53, 0x0a, 0x0b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74,
    -	0x65, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x52, 0x4c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
    -	0x55, 0x52, 0x4c, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64,
    -	0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65,
    -	0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
    -	0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x57, 0x0a, 0x0f, 0x4d, 0x61, 0x6e, 0x61, 0x67,
    -	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x52,
    +	0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x16, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x63, 0x65,
    +	0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a,
    +	0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64,
    +	0x6e, 0x12, 0x3c, 0x0a, 0x19, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e,
    +	0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x0a,
    +	0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x49, 0x63, 0x65, 0x43, 0x61,
    +	0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12,
    +	0x3e, 0x0a, 0x1a, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64,
    +	0x69, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x0b, 0x20,
    +	0x01, 0x28, 0x09, 0x52, 0x1a, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x63, 0x65, 0x43, 0x61,
    +	0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12,
    +	0x52, 0x0a, 0x16, 0x6c, 0x61, 0x73, 0x74, 0x57, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64,
    +	0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x18, 0x0c, 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, 0x16, 0x6c, 0x61, 0x73,
    +	0x74, 0x57, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68,
    +	0x61, 0x6b, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x62, 0x79, 0x74, 0x65, 0x73, 0x52, 0x78, 0x18, 0x0d,
    +	0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x62, 0x79, 0x74, 0x65, 0x73, 0x52, 0x78, 0x12, 0x18, 0x0a,
    +	0x07, 0x62, 0x79, 0x74, 0x65, 0x73, 0x54, 0x78, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07,
    +	0x62, 0x79, 0x74, 0x65, 0x73, 0x54, 0x78, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e,
    +	0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28,
    +	0x08, 0x52, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62,
    +	0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x18,
    +	0x10, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x12,
    +	0x33, 0x0a, 0x07, 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x11, 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, 0x07, 0x6c, 0x61, 0x74,
    +	0x65, 0x6e, 0x63, 0x79, 0x12, 0x22, 0x0a, 0x0c, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x41, 0x64, 0x64,
    +	0x72, 0x65, 0x73, 0x73, 0x18, 0x12, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x6c, 0x61,
    +	0x79, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0xf0, 0x01, 0x0a, 0x0e, 0x4c, 0x6f, 0x63,
    +	0x61, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49,
    +	0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x70,
    +	0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x75, 0x62,
    +	0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x0f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x74,
    +	0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x6b, 0x65,
    +	0x72, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x12, 0x12, 0x0a,
    +	0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64,
    +	0x6e, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e,
    +	0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 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, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73,
    +	0x73, 0x69, 0x76, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65,
    +	0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x12,
    +	0x1a, 0x0a, 0x08, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28,
    +	0x09, 0x52, 0x08, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x22, 0x53, 0x0a, 0x0b, 0x53,
    +	0x69, 0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x52,
     	0x4c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x52, 0x4c, 0x12, 0x1c, 0x0a, 0x09,
     	0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52,
     	0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72,
     	0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72,
    -	0x22, 0x52, 0x0a, 0x0a, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10,
    -	0x0a, 0x03, 0x55, 0x52, 0x49, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x52, 0x49,
    -	0x12, 0x1c, 0x0a, 0x09, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x02, 0x20,
    -	0x01, 0x28, 0x08, 0x52, 0x09, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x14,
    -	0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65,
    -	0x72, 0x72, 0x6f, 0x72, 0x22, 0x72, 0x0a, 0x0c, 0x4e, 0x53, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x53,
    -	0x74, 0x61, 0x74, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18,
    -	0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18,
    -	0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52,
    -	0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62,
    -	0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c,
    -	0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28,
    -	0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xd2, 0x02, 0x0a, 0x0a, 0x46, 0x75, 0x6c,
    -	0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x41, 0x0a, 0x0f, 0x6d, 0x61, 0x6e, 0x61, 0x67,
    -	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
    -	0x32, 0x17, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65,
    -	0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x0f, 0x6d, 0x61, 0x6e, 0x61, 0x67,
    -	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x35, 0x0a, 0x0b, 0x73, 0x69,
    -	0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
    -	0x13, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x53,
    -	0x74, 0x61, 0x74, 0x65, 0x52, 0x0b, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74,
    -	0x65, 0x12, 0x3e, 0x0a, 0x0e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74,
    -	0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x64, 0x61, 0x65, 0x6d,
    -	0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74,
    -	0x65, 0x52, 0x0e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74,
    -	0x65, 0x12, 0x27, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b,
    -	0x32, 0x11, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74,
    -	0x61, 0x74, 0x65, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2a, 0x0a, 0x06, 0x72, 0x65,
    -	0x6c, 0x61, 0x79, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x61, 0x65,
    -	0x6d, 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x06,
    -	0x72, 0x65, 0x6c, 0x61, 0x79, 0x73, 0x12, 0x35, 0x0a, 0x0b, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65,
    -	0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x64, 0x61,
    -	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4e, 0x53, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x53, 0x74, 0x61, 0x74,
    -	0x65, 0x52, 0x0a, 0x64, 0x6e, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x22, 0x15, 0x0a,
    -	0x13, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71,
    -	0x75, 0x65, 0x73, 0x74, 0x22, 0x3f, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77,
    -	0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x27, 0x0a, 0x06,
    -	0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x64,
    -	0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x06, 0x72,
    -	0x6f, 0x75, 0x74, 0x65, 0x73, 0x22, 0x61, 0x0a, 0x15, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e,
    -	0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1e,
    -	0x0a, 0x0a, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x49, 0x44, 0x73, 0x18, 0x01, 0x20, 0x03,
    -	0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x49, 0x44, 0x73, 0x12, 0x16,
    -	0x0a, 0x06, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06,
    -	0x61, 0x70, 0x70, 0x65, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x6c, 0x6c, 0x18, 0x03, 0x20,
    -	0x01, 0x28, 0x08, 0x52, 0x03, 0x61, 0x6c, 0x6c, 0x22, 0x18, 0x0a, 0x16, 0x53, 0x65, 0x6c, 0x65,
    -	0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
    -	0x73, 0x65, 0x22, 0x1a, 0x0a, 0x06, 0x49, 0x50, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03,
    -	0x69, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x69, 0x70, 0x73, 0x22, 0xf9,
    -	0x01, 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44,
    -	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x61,
    -	0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65,
    -	0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01,
    -	0x28, 0x08, 0x52, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07,
    -	0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64,
    -	0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x42, 0x0a, 0x0b, 0x72, 0x65, 0x73, 0x6f, 0x6c, 0x76,
    -	0x65, 0x64, 0x49, 0x50, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x64, 0x61,
    -	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x52, 0x65, 0x73,
    -	0x6f, 0x6c, 0x76, 0x65, 0x64, 0x49, 0x50, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0b, 0x72,
    -	0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x64, 0x49, 0x50, 0x73, 0x1a, 0x4e, 0x0a, 0x10, 0x52, 0x65,
    -	0x73, 0x6f, 0x6c, 0x76, 0x65, 0x64, 0x49, 0x50, 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, 0x24, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
    -	0x0e, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x49, 0x50, 0x4c, 0x69, 0x73, 0x74, 0x52,
    -	0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x6a, 0x0a, 0x12, 0x44, 0x65,
    -	0x62, 0x75, 0x67, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
    -	0x12, 0x1c, 0x0a, 0x09, 0x61, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x18, 0x01, 0x20,
    -	0x01, 0x28, 0x08, 0x52, 0x09, 0x61, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x12, 0x16,
    -	0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
    -	0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d,
    -	0x49, 0x6e, 0x66, 0x6f, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x79, 0x73, 0x74,
    -	0x65, 0x6d, 0x49, 0x6e, 0x66, 0x6f, 0x22, 0x29, 0x0a, 0x13, 0x44, 0x65, 0x62, 0x75, 0x67, 0x42,
    -	0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x12, 0x0a,
    -	0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74,
    -	0x68, 0x22, 0x14, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c,
    -	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x3d, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4c, 0x6f,
    -	0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26,
    -	0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x10, 0x2e,
    -	0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52,
    -	0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x22, 0x3c, 0x0a, 0x12, 0x53, 0x65, 0x74, 0x4c, 0x6f, 0x67,
    -	0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x05,
    -	0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x10, 0x2e, 0x64, 0x61,
    -	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c,
    -	0x65, 0x76, 0x65, 0x6c, 0x22, 0x15, 0x0a, 0x13, 0x53, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65,
    -	0x76, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1b, 0x0a, 0x05, 0x53,
    -	0x74, 0x61, 0x74, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01,
    -	0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x13, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74,
    -	0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x3b, 0x0a,
    -	0x12, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
    -	0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20,
    -	0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61,
    -	0x74, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x65, 0x73, 0x22, 0x44, 0x0a, 0x11, 0x43, 0x6c,
    -	0x65, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
    -	0x1d, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20,
    -	0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x74, 0x61, 0x74, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x10,
    -	0x0a, 0x03, 0x61, 0x6c, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x61, 0x6c, 0x6c,
    -	0x22, 0x3b, 0x0a, 0x12, 0x43, 0x6c, 0x65, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65,
    -	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6c, 0x65, 0x61, 0x6e, 0x65,
    -	0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d,
    -	0x63, 0x6c, 0x65, 0x61, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x22, 0x45, 0x0a,
    -	0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75,
    -	0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x6e, 0x61, 0x6d,
    -	0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x74, 0x61, 0x74, 0x65, 0x4e, 0x61,
    -	0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x6c, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52,
    -	0x03, 0x61, 0x6c, 0x6c, 0x22, 0x3c, 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74,
    -	0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x64,
    -	0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20,
    -	0x01, 0x28, 0x05, 0x52, 0x0d, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x53, 0x74, 0x61, 0x74,
    -	0x65, 0x73, 0x22, 0x3b, 0x0a, 0x1f, 0x53, 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
    -	0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65,
    -	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64,
    -	0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22,
    -	0x22, 0x0a, 0x20, 0x53, 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70,
    -	0x50, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
    -	0x6e, 0x73, 0x65, 0x22, 0x76, 0x0a, 0x08, 0x54, 0x43, 0x50, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x12,
    -	0x10, 0x0a, 0x03, 0x73, 0x79, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x73, 0x79,
    -	0x6e, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03,
    -	0x61, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x66, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08,
    -	0x52, 0x03, 0x66, 0x69, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x72, 0x73, 0x74, 0x18, 0x04, 0x20, 0x01,
    -	0x28, 0x08, 0x52, 0x03, 0x72, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x73, 0x68, 0x18, 0x05,
    -	0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x70, 0x73, 0x68, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x67,
    -	0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x75, 0x72, 0x67, 0x22, 0x80, 0x03, 0x0a, 0x12,
    -	0x54, 0x72, 0x61, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65,
    -	0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18,
    -	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12,
    -	0x25, 0x0a, 0x0e, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
    -	0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61,
    -	0x74, 0x69, 0x6f, 0x6e, 0x49, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63,
    -	0x6f, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63,
    -	0x6f, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x72,
    -	0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50,
    -	0x6f, 0x72, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69,
    -	0x6f, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0f, 0x64,
    -	0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1c,
    -	0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28,
    -	0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x32, 0x0a, 0x09,
    -	0x74, 0x63, 0x70, 0x5f, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32,
    -	0x10, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x54, 0x43, 0x50, 0x46, 0x6c, 0x61, 0x67,
    -	0x73, 0x48, 0x00, 0x52, 0x08, 0x74, 0x63, 0x70, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x88, 0x01, 0x01,
    -	0x12, 0x20, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x08, 0x20,
    -	0x01, 0x28, 0x0d, 0x48, 0x01, 0x52, 0x08, 0x69, 0x63, 0x6d, 0x70, 0x54, 0x79, 0x70, 0x65, 0x88,
    -	0x01, 0x01, 0x12, 0x20, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18,
    -	0x09, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x02, 0x52, 0x08, 0x69, 0x63, 0x6d, 0x70, 0x43, 0x6f, 0x64,
    -	0x65, 0x88, 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x74, 0x63, 0x70, 0x5f, 0x66, 0x6c, 0x61,
    -	0x67, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x74, 0x79, 0x70, 0x65,
    -	0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x22, 0x9f,
    -	0x01, 0x0a, 0x0a, 0x54, 0x72, 0x61, 0x63, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x12, 0x12, 0x0a,
    -	0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d,
    -	0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01,
    -	0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x61,
    -	0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x61, 0x6c,
    -	0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12, 0x32, 0x0a, 0x12, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64,
    -	0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28,
    -	0x09, 0x48, 0x00, 0x52, 0x11, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x44,
    -	0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x88, 0x01, 0x01, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x66, 0x6f,
    -	0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73,
    -	0x22, 0x6e, 0x0a, 0x13, 0x54, 0x72, 0x61, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x52,
    -	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65,
    -	0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e,
    -	0x2e, 0x54, 0x72, 0x61, 0x63, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61,
    -	0x67, 0x65, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x66, 0x69, 0x6e, 0x61, 0x6c, 0x5f, 0x64, 0x69, 0x73,
    -	0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10,
    -	0x66, 0x69, 0x6e, 0x61, 0x6c, 0x44, 0x69, 0x73, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e,
    -	0x2a, 0x62, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x0b, 0x0a, 0x07,
    -	0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x50, 0x41, 0x4e,
    -	0x49, 0x43, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x46, 0x41, 0x54, 0x41, 0x4c, 0x10, 0x02, 0x12,
    -	0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x57, 0x41,
    -	0x52, 0x4e, 0x10, 0x04, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x05, 0x12, 0x09,
    -	0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x06, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41,
    -	0x43, 0x45, 0x10, 0x07, 0x32, 0xdd, 0x09, 0x0a, 0x0d, 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x53,
    -	0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x36, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12,
    -	0x14, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65,
    -	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c,
    -	0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x4b,
    -	0x0a, 0x0c, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1b,
    -	0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c,
    -	0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x64, 0x61,
    -	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69,
    -	0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x2d, 0x0a, 0x02, 0x55,
    -	0x70, 0x12, 0x11, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x55, 0x70, 0x52, 0x65, 0x71,
    -	0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x55, 0x70,
    -	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x39, 0x0a, 0x06, 0x53, 0x74,
    -	0x61, 0x74, 0x75, 0x73, 0x12, 0x15, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x74,
    -	0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x64, 0x61,
    -	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
    -	0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x04, 0x44, 0x6f, 0x77, 0x6e, 0x12, 0x13, 0x2e,
    -	0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
    -	0x73, 0x74, 0x1a, 0x14, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x6f, 0x77, 0x6e,
    -	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x42, 0x0a, 0x09, 0x47, 0x65,
    -	0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e,
    -	0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
    -	0x74, 0x1a, 0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f,
    -	0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x4b,
    -	0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x12, 0x1b,
    -	0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77,
    -	0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x64, 0x61,
    -	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
    -	0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x51, 0x0a, 0x0e, 0x53,
    -	0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x12, 0x1d, 0x2e,
    -	0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74,
    -	0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x64,
    -	0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77,
    -	0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x53,
    -	0x0a, 0x10, 0x44, 0x65, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
    -	0x6b, 0x73, 0x12, 0x1d, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65,
    -	0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
    -	0x74, 0x1a, 0x1e, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65, 0x63,
    -	0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
    -	0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x42, 0x75, 0x6e, 0x64,
    -	0x6c, 0x65, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x62, 0x75,
    -	0x67, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b,
    -	0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x62, 0x75, 0x67, 0x42, 0x75, 0x6e,
    -	0x64, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a,
    -	0x0b, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1a, 0x2e, 0x64,
    -	0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65,
    -	0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f,
    -	0x6e, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x73,
    -	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x4c, 0x6f,
    -	0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e,
    +	0x22, 0x57, 0x0a, 0x0f, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74,
    +	0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x52, 0x4c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
    +	0x52, 0x03, 0x55, 0x52, 0x4c, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74,
    +	0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63,
    +	0x74, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01,
    +	0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x52, 0x0a, 0x0a, 0x52, 0x65, 0x6c,
    +	0x61, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x52, 0x49, 0x18, 0x01,
    +	0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x52, 0x49, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x76, 0x61,
    +	0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x61, 0x76,
    +	0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72,
    +	0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x72, 0x0a,
    +	0x0c, 0x4e, 0x53, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x18, 0x0a,
    +	0x07, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07,
    +	0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69,
    +	0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
    +	0x73, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01,
    +	0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65,
    +	0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f,
    +	0x72, 0x22, 0xff, 0x02, 0x0a, 0x0a, 0x46, 0x75, 0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
    +	0x12, 0x41, 0x0a, 0x0f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74,
    +	0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x64, 0x61, 0x65, 0x6d,
    +	0x6f, 0x6e, 0x2e, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61,
    +	0x74, 0x65, 0x52, 0x0f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74,
    +	0x61, 0x74, 0x65, 0x12, 0x35, 0x0a, 0x0b, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61,
    +	0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f,
    +	0x6e, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x0b, 0x73,
    +	0x69, 0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x3e, 0x0a, 0x0e, 0x6c, 0x6f,
    +	0x63, 0x61, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01,
    +	0x28, 0x0b, 0x32, 0x16, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x63, 0x61,
    +	0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x0e, 0x6c, 0x6f, 0x63, 0x61,
    +	0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x27, 0x0a, 0x05, 0x70, 0x65,
    +	0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x64, 0x61, 0x65, 0x6d,
    +	0x6f, 0x6e, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x70, 0x65,
    +	0x65, 0x72, 0x73, 0x12, 0x2a, 0x0a, 0x06, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x73, 0x18, 0x05, 0x20,
    +	0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x6c,
    +	0x61, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x06, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x73, 0x12,
    +	0x35, 0x0a, 0x0b, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x06,
    +	0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4e, 0x53,
    +	0x47, 0x72, 0x6f, 0x75, 0x70, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x0a, 0x64, 0x6e, 0x73, 0x53,
    +	0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x2b, 0x0a, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73,
    +	0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e,
    +	0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x65, 0x76, 0x65,
    +	0x6e, 0x74, 0x73, 0x22, 0x15, 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f,
    +	0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x3f, 0x0a, 0x14, 0x4c, 0x69,
    +	0x73, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
    +	0x73, 0x65, 0x12, 0x27, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03,
    +	0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77,
    +	0x6f, 0x72, 0x6b, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x22, 0x61, 0x0a, 0x15, 0x53,
    +	0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71,
    +	0x75, 0x65, 0x73, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x49,
    +	0x44, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72,
    +	0x6b, 0x49, 0x44, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x64, 0x18, 0x02,
    +	0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03,
    +	0x61, 0x6c, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x61, 0x6c, 0x6c, 0x22, 0x18,
    +	0x0a, 0x16, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73,
    +	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1a, 0x0a, 0x06, 0x49, 0x50, 0x4c, 0x69,
    +	0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52,
    +	0x03, 0x69, 0x70, 0x73, 0x22, 0xf9, 0x01, 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
    +	0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44,
    +	0x12, 0x14, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
    +	0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74,
    +	0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74,
    +	0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x04, 0x20,
    +	0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x42, 0x0a, 0x0b,
    +	0x72, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x64, 0x49, 0x50, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28,
    +	0x0b, 0x32, 0x20, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
    +	0x72, 0x6b, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x64, 0x49, 0x50, 0x73, 0x45, 0x6e,
    +	0x74, 0x72, 0x79, 0x52, 0x0b, 0x72, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x64, 0x49, 0x50, 0x73,
    +	0x1a, 0x4e, 0x0a, 0x10, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x64, 0x49, 0x50, 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, 0x24, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
    +	0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x49,
    +	0x50, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01,
    +	0x22, 0x6a, 0x0a, 0x12, 0x44, 0x65, 0x62, 0x75, 0x67, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52,
    +	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x6e, 0x6f, 0x6e, 0x79, 0x6d,
    +	0x69, 0x7a, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x61, 0x6e, 0x6f, 0x6e, 0x79,
    +	0x6d, 0x69, 0x7a, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02,
    +	0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1e, 0x0a, 0x0a,
    +	0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08,
    +	0x52, 0x0a, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x49, 0x6e, 0x66, 0x6f, 0x22, 0x29, 0x0a, 0x13,
    +	0x44, 0x65, 0x62, 0x75, 0x67, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
    +	0x6e, 0x73, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28,
    +	0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x14, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4c, 0x6f,
    +	0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x3d, 0x0a,
    +	0x13, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70,
    +	0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, 0x20,
    +	0x01, 0x28, 0x0e, 0x32, 0x10, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67,
    +	0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x22, 0x3c, 0x0a, 0x12,
     	0x53, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65,
    -	0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x4c,
    +	0x73, 0x74, 0x12, 0x26, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28,
    +	0x0e, 0x32, 0x10, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65,
    +	0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x22, 0x15, 0x0a, 0x13, 0x53, 0x65,
    +	0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
    +	0x65, 0x22, 0x1b, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61,
    +	0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x13,
    +	0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75,
    +	0x65, 0x73, 0x74, 0x22, 0x3b, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65,
    +	0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x74, 0x61,
    +	0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x64, 0x61, 0x65, 0x6d,
    +	0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x65, 0x73,
    +	0x22, 0x44, 0x0a, 0x11, 0x43, 0x6c, 0x65, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65,
    +	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x6e,
    +	0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x74, 0x61, 0x74, 0x65,
    +	0x4e, 0x61, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x6c, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28,
    +	0x08, 0x52, 0x03, 0x61, 0x6c, 0x6c, 0x22, 0x3b, 0x0a, 0x12, 0x43, 0x6c, 0x65, 0x61, 0x6e, 0x53,
    +	0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e,
    +	0x63, 0x6c, 0x65, 0x61, 0x6e, 0x65, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x73, 0x18, 0x01,
    +	0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x63, 0x6c, 0x65, 0x61, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61,
    +	0x74, 0x65, 0x73, 0x22, 0x45, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, 0x61,
    +	0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x74, 0x61,
    +	0x74, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73,
    +	0x74, 0x61, 0x74, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x6c, 0x6c, 0x18,
    +	0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x61, 0x6c, 0x6c, 0x22, 0x3c, 0x0a, 0x13, 0x44, 0x65,
    +	0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
    +	0x65, 0x12, 0x25, 0x0a, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x73, 0x74, 0x61,
    +	0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x64, 0x65, 0x6c, 0x65, 0x74,
    +	0x65, 0x64, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x22, 0x3b, 0x0a, 0x1f, 0x53, 0x65, 0x74, 0x4e,
    +	0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74,
    +	0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x65,
    +	0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e,
    +	0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x22, 0x0a, 0x20, 0x53, 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77,
    +	0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63,
    +	0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x76, 0x0a, 0x08, 0x54, 0x43, 0x50,
    +	0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x79, 0x6e, 0x18, 0x01, 0x20, 0x01,
    +	0x28, 0x08, 0x52, 0x03, 0x73, 0x79, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02,
    +	0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x66, 0x69, 0x6e,
    +	0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x66, 0x69, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x72,
    +	0x73, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x72, 0x73, 0x74, 0x12, 0x10, 0x0a,
    +	0x03, 0x70, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x70, 0x73, 0x68, 0x12,
    +	0x10, 0x0a, 0x03, 0x75, 0x72, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x75, 0x72,
    +	0x67, 0x22, 0x80, 0x03, 0x0a, 0x12, 0x54, 0x72, 0x61, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65,
    +	0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72,
    +	0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75,
    +	0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61,
    +	0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x64,
    +	0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x70, 0x12, 0x1a, 0x0a, 0x08,
    +	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
    +	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72,
    +	0x63, 0x65, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x73,
    +	0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x64, 0x65, 0x73,
    +	0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20,
    +	0x01, 0x28, 0x0d, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e,
    +	0x50, 0x6f, 0x72, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f,
    +	0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69,
    +	0x6f, 0x6e, 0x12, 0x32, 0x0a, 0x09, 0x74, 0x63, 0x70, 0x5f, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18,
    +	0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x54,
    +	0x43, 0x50, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x48, 0x00, 0x52, 0x08, 0x74, 0x63, 0x70, 0x46, 0x6c,
    +	0x61, 0x67, 0x73, 0x88, 0x01, 0x01, 0x12, 0x20, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x74,
    +	0x79, 0x70, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x01, 0x52, 0x08, 0x69, 0x63, 0x6d,
    +	0x70, 0x54, 0x79, 0x70, 0x65, 0x88, 0x01, 0x01, 0x12, 0x20, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70,
    +	0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x02, 0x52, 0x08, 0x69,
    +	0x63, 0x6d, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x74,
    +	0x63, 0x70, 0x5f, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x69, 0x63, 0x6d,
    +	0x70, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x69, 0x63, 0x6d, 0x70, 0x5f,
    +	0x63, 0x6f, 0x64, 0x65, 0x22, 0x9f, 0x01, 0x0a, 0x0a, 0x54, 0x72, 0x61, 0x63, 0x65, 0x53, 0x74,
    +	0x61, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
    +	0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61,
    +	0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
    +	0x65, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01,
    +	0x28, 0x08, 0x52, 0x07, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12, 0x32, 0x0a, 0x12, 0x66,
    +	0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c,
    +	0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x11, 0x66, 0x6f, 0x72, 0x77, 0x61,
    +	0x72, 0x64, 0x69, 0x6e, 0x67, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x88, 0x01, 0x01, 0x42,
    +	0x15, 0x0a, 0x13, 0x5f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x64,
    +	0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x22, 0x6e, 0x0a, 0x13, 0x54, 0x72, 0x61, 0x63, 0x65, 0x50,
    +	0x61, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a,
    +	0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e,
    +	0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x54, 0x72, 0x61, 0x63, 0x65, 0x53, 0x74, 0x61, 0x67,
    +	0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x66, 0x69, 0x6e,
    +	0x61, 0x6c, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02,
    +	0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x66, 0x69, 0x6e, 0x61, 0x6c, 0x44, 0x69, 0x73, 0x70, 0x6f,
    +	0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x12, 0x0a, 0x10, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72,
    +	0x69, 0x62, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x87, 0x04, 0x0a, 0x0b, 0x53,
    +	0x79, 0x73, 0x74, 0x65, 0x6d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64,
    +	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x38, 0x0a, 0x08, 0x73, 0x65,
    +	0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x64,
    +	0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x45, 0x76, 0x65, 0x6e,
    +	0x74, 0x2e, 0x53, 0x65, 0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x52, 0x08, 0x73, 0x65, 0x76, 0x65,
    +	0x72, 0x69, 0x74, 0x79, 0x12, 0x38, 0x0a, 0x08, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
    +	0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e,
    +	0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x61, 0x74, 0x65,
    +	0x67, 0x6f, 0x72, 0x79, 0x52, 0x08, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x12, 0x18,
    +	0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
    +	0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72,
    +	0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x75,
    +	0x73, 0x65, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69,
    +	0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x06, 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, 0x74, 0x69, 0x6d, 0x65, 0x73,
    +	0x74, 0x61, 0x6d, 0x70, 0x12, 0x3d, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
    +	0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e,
    +	0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61,
    +	0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64,
    +	0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45,
    +	0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
    +	0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
    +	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01,
    +	0x22, 0x3a, 0x0a, 0x08, 0x53, 0x65, 0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x12, 0x08, 0x0a, 0x04,
    +	0x49, 0x4e, 0x46, 0x4f, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x57, 0x41, 0x52, 0x4e, 0x49, 0x4e,
    +	0x47, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x02, 0x12, 0x0c,
    +	0x0a, 0x08, 0x43, 0x52, 0x49, 0x54, 0x49, 0x43, 0x41, 0x4c, 0x10, 0x03, 0x22, 0x46, 0x0a, 0x08,
    +	0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x12, 0x0b, 0x0a, 0x07, 0x4e, 0x45, 0x54, 0x57,
    +	0x4f, 0x52, 0x4b, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x44, 0x4e, 0x53, 0x10, 0x01, 0x12, 0x12,
    +	0x0a, 0x0e, 0x41, 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, 0x49, 0x4f, 0x4e,
    +	0x10, 0x02, 0x12, 0x10, 0x0a, 0x0c, 0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54, 0x49, 0x56, 0x49,
    +	0x54, 0x59, 0x10, 0x03, 0x22, 0x12, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74,
    +	0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x40, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x45,
    +	0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a,
    +	0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e,
    +	0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x45, 0x76, 0x65,
    +	0x6e, 0x74, 0x52, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2a, 0x62, 0x0a, 0x08, 0x4c, 0x6f,
    +	0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57,
    +	0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x50, 0x41, 0x4e, 0x49, 0x43, 0x10, 0x01, 0x12, 0x09,
    +	0x0a, 0x05, 0x46, 0x41, 0x54, 0x41, 0x4c, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52,
    +	0x4f, 0x52, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x57, 0x41, 0x52, 0x4e, 0x10, 0x04, 0x12, 0x08,
    +	0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x05, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55,
    +	0x47, 0x10, 0x06, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, 0x43, 0x45, 0x10, 0x07, 0x32, 0xe7,
    +	0x0a, 0x0a, 0x0d, 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
    +	0x12, 0x36, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x14, 0x2e, 0x64, 0x61, 0x65, 0x6d,
    +	0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
    +	0x15, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65,
    +	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0c, 0x57, 0x61, 0x69, 0x74,
    +	0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f,
    +	0x6e, 0x2e, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65,
    +	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x57,
    +	0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
    +	0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x2d, 0x0a, 0x02, 0x55, 0x70, 0x12, 0x11, 0x2e, 0x64, 0x61,
    +	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x55, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12,
    +	0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x55, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
    +	0x73, 0x65, 0x22, 0x00, 0x12, 0x39, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x15,
    +	0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65,
    +	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53,
    +	0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12,
    +	0x33, 0x0a, 0x04, 0x44, 0x6f, 0x77, 0x6e, 0x12, 0x13, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e,
    +	0x2e, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x64,
    +	0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
    +	0x73, 0x65, 0x22, 0x00, 0x12, 0x42, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
    +	0x67, 0x12, 0x18, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f,
    +	0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x64, 0x61,
    +	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65,
    +	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74,
    +	0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x12, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f,
    +	0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65,
    +	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c,
    +	0x69, 0x73, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
    +	0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x51, 0x0a, 0x0e, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e,
    +	0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x12, 0x1d, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e,
    +	0x2e, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52,
    +	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e,
    +	0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65,
    +	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x53, 0x0a, 0x10, 0x44, 0x65, 0x73, 0x65,
    +	0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x12, 0x1d, 0x2e, 0x64,
    +	0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77,
    +	0x6f, 0x72, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x64, 0x61,
    +	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f,
    +	0x72, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a,
    +	0x0b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x12, 0x1a, 0x2e, 0x64,
    +	0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x62, 0x75, 0x67, 0x42, 0x75, 0x6e, 0x64, 0x6c,
    +	0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f,
    +	0x6e, 0x2e, 0x44, 0x65, 0x62, 0x75, 0x67, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x73,
    +	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x4c, 0x6f,
    +	0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e,
    +	0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65,
    +	0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x4c,
     	0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
    -	0x00, 0x12, 0x45, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x12,
    -	0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61,
    -	0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x64, 0x61, 0x65,
    -	0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65,
    -	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x45, 0x0a, 0x0a, 0x43, 0x6c, 0x65, 0x61,
    -	0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e,
    -	0x43, 0x6c, 0x65, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
    -	0x74, 0x1a, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x43, 0x6c, 0x65, 0x61, 0x6e,
    -	0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12,
    -	0x48, 0x0a, 0x0b, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1a,
    -	0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74,
    -	0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65,
    -	0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52,
    -	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x6f, 0x0a, 0x18, 0x53, 0x65, 0x74,
    -	0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73,
    -	0x74, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x27, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53,
    -	0x65, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73,
    -	0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28,
    -	0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f,
    -	0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x65,
    -	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x54, 0x72,
    -	0x61, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d,
    -	0x6f, 0x6e, 0x2e, 0x54, 0x72, 0x61, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65,
    -	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x54,
    -	0x72, 0x61, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
    -	0x73, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06,
    -	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
    +	0x00, 0x12, 0x48, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c,
    +	0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x6f, 0x67,
    +	0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64,
    +	0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65,
    +	0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x45, 0x0a, 0x0a, 0x4c,
    +	0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x12, 0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d,
    +	0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71,
    +	0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x69,
    +	0x73, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
    +	0x22, 0x00, 0x12, 0x45, 0x0a, 0x0a, 0x43, 0x6c, 0x65, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65,
    +	0x12, 0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x43, 0x6c, 0x65, 0x61, 0x6e, 0x53,
    +	0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x64, 0x61,
    +	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x43, 0x6c, 0x65, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52,
    +	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x44, 0x65, 0x6c,
    +	0x65, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f,
    +	0x6e, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71,
    +	0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x65,
    +	0x6c, 0x65, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
    +	0x65, 0x22, 0x00, 0x12, 0x6f, 0x0a, 0x18, 0x53, 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
    +	0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x65, 0x12,
    +	0x27, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77,
    +	0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63,
    +	0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f,
    +	0x6e, 0x2e, 0x53, 0x65, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x50,
    +	0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
    +	0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x54, 0x72, 0x61, 0x63, 0x65, 0x50, 0x61, 0x63,
    +	0x6b, 0x65, 0x74, 0x12, 0x1a, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x54, 0x72, 0x61,
    +	0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
    +	0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x54, 0x72, 0x61, 0x63, 0x65, 0x50, 0x61,
    +	0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x44,
    +	0x0a, 0x0f, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74,
    +	0x73, 0x12, 0x18, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63,
    +	0x72, 0x69, 0x62, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x64, 0x61,
    +	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x45, 0x76, 0x65, 0x6e, 0x74,
    +	0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74,
    +	0x73, 0x12, 0x18, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x45, 0x76,
    +	0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x64, 0x61,
    +	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65,
    +	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f,
    +	0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
     }
     
     var (
    @@ -3393,117 +3798,134 @@ func file_daemon_proto_rawDescGZIP() []byte {
     	return file_daemon_proto_rawDescData
     }
     
    -var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
    -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 45)
    +var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
    +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 50)
     var file_daemon_proto_goTypes = []interface{}{
     	(LogLevel)(0),                            // 0: daemon.LogLevel
    -	(*LoginRequest)(nil),                     // 1: daemon.LoginRequest
    -	(*LoginResponse)(nil),                    // 2: daemon.LoginResponse
    -	(*WaitSSOLoginRequest)(nil),              // 3: daemon.WaitSSOLoginRequest
    -	(*WaitSSOLoginResponse)(nil),             // 4: daemon.WaitSSOLoginResponse
    -	(*UpRequest)(nil),                        // 5: daemon.UpRequest
    -	(*UpResponse)(nil),                       // 6: daemon.UpResponse
    -	(*StatusRequest)(nil),                    // 7: daemon.StatusRequest
    -	(*StatusResponse)(nil),                   // 8: daemon.StatusResponse
    -	(*DownRequest)(nil),                      // 9: daemon.DownRequest
    -	(*DownResponse)(nil),                     // 10: daemon.DownResponse
    -	(*GetConfigRequest)(nil),                 // 11: daemon.GetConfigRequest
    -	(*GetConfigResponse)(nil),                // 12: daemon.GetConfigResponse
    -	(*PeerState)(nil),                        // 13: daemon.PeerState
    -	(*LocalPeerState)(nil),                   // 14: daemon.LocalPeerState
    -	(*SignalState)(nil),                      // 15: daemon.SignalState
    -	(*ManagementState)(nil),                  // 16: daemon.ManagementState
    -	(*RelayState)(nil),                       // 17: daemon.RelayState
    -	(*NSGroupState)(nil),                     // 18: daemon.NSGroupState
    -	(*FullStatus)(nil),                       // 19: daemon.FullStatus
    -	(*ListNetworksRequest)(nil),              // 20: daemon.ListNetworksRequest
    -	(*ListNetworksResponse)(nil),             // 21: daemon.ListNetworksResponse
    -	(*SelectNetworksRequest)(nil),            // 22: daemon.SelectNetworksRequest
    -	(*SelectNetworksResponse)(nil),           // 23: daemon.SelectNetworksResponse
    -	(*IPList)(nil),                           // 24: daemon.IPList
    -	(*Network)(nil),                          // 25: daemon.Network
    -	(*DebugBundleRequest)(nil),               // 26: daemon.DebugBundleRequest
    -	(*DebugBundleResponse)(nil),              // 27: daemon.DebugBundleResponse
    -	(*GetLogLevelRequest)(nil),               // 28: daemon.GetLogLevelRequest
    -	(*GetLogLevelResponse)(nil),              // 29: daemon.GetLogLevelResponse
    -	(*SetLogLevelRequest)(nil),               // 30: daemon.SetLogLevelRequest
    -	(*SetLogLevelResponse)(nil),              // 31: daemon.SetLogLevelResponse
    -	(*State)(nil),                            // 32: daemon.State
    -	(*ListStatesRequest)(nil),                // 33: daemon.ListStatesRequest
    -	(*ListStatesResponse)(nil),               // 34: daemon.ListStatesResponse
    -	(*CleanStateRequest)(nil),                // 35: daemon.CleanStateRequest
    -	(*CleanStateResponse)(nil),               // 36: daemon.CleanStateResponse
    -	(*DeleteStateRequest)(nil),               // 37: daemon.DeleteStateRequest
    -	(*DeleteStateResponse)(nil),              // 38: daemon.DeleteStateResponse
    -	(*SetNetworkMapPersistenceRequest)(nil),  // 39: daemon.SetNetworkMapPersistenceRequest
    -	(*SetNetworkMapPersistenceResponse)(nil), // 40: daemon.SetNetworkMapPersistenceResponse
    -	(*TCPFlags)(nil),                         // 41: daemon.TCPFlags
    -	(*TracePacketRequest)(nil),               // 42: daemon.TracePacketRequest
    -	(*TraceStage)(nil),                       // 43: daemon.TraceStage
    -	(*TracePacketResponse)(nil),              // 44: daemon.TracePacketResponse
    -	nil,                                      // 45: daemon.Network.ResolvedIPsEntry
    -	(*durationpb.Duration)(nil),              // 46: google.protobuf.Duration
    -	(*timestamppb.Timestamp)(nil),            // 47: google.protobuf.Timestamp
    +	(SystemEvent_Severity)(0),                // 1: daemon.SystemEvent.Severity
    +	(SystemEvent_Category)(0),                // 2: daemon.SystemEvent.Category
    +	(*LoginRequest)(nil),                     // 3: daemon.LoginRequest
    +	(*LoginResponse)(nil),                    // 4: daemon.LoginResponse
    +	(*WaitSSOLoginRequest)(nil),              // 5: daemon.WaitSSOLoginRequest
    +	(*WaitSSOLoginResponse)(nil),             // 6: daemon.WaitSSOLoginResponse
    +	(*UpRequest)(nil),                        // 7: daemon.UpRequest
    +	(*UpResponse)(nil),                       // 8: daemon.UpResponse
    +	(*StatusRequest)(nil),                    // 9: daemon.StatusRequest
    +	(*StatusResponse)(nil),                   // 10: daemon.StatusResponse
    +	(*DownRequest)(nil),                      // 11: daemon.DownRequest
    +	(*DownResponse)(nil),                     // 12: daemon.DownResponse
    +	(*GetConfigRequest)(nil),                 // 13: daemon.GetConfigRequest
    +	(*GetConfigResponse)(nil),                // 14: daemon.GetConfigResponse
    +	(*PeerState)(nil),                        // 15: daemon.PeerState
    +	(*LocalPeerState)(nil),                   // 16: daemon.LocalPeerState
    +	(*SignalState)(nil),                      // 17: daemon.SignalState
    +	(*ManagementState)(nil),                  // 18: daemon.ManagementState
    +	(*RelayState)(nil),                       // 19: daemon.RelayState
    +	(*NSGroupState)(nil),                     // 20: daemon.NSGroupState
    +	(*FullStatus)(nil),                       // 21: daemon.FullStatus
    +	(*ListNetworksRequest)(nil),              // 22: daemon.ListNetworksRequest
    +	(*ListNetworksResponse)(nil),             // 23: daemon.ListNetworksResponse
    +	(*SelectNetworksRequest)(nil),            // 24: daemon.SelectNetworksRequest
    +	(*SelectNetworksResponse)(nil),           // 25: daemon.SelectNetworksResponse
    +	(*IPList)(nil),                           // 26: daemon.IPList
    +	(*Network)(nil),                          // 27: daemon.Network
    +	(*DebugBundleRequest)(nil),               // 28: daemon.DebugBundleRequest
    +	(*DebugBundleResponse)(nil),              // 29: daemon.DebugBundleResponse
    +	(*GetLogLevelRequest)(nil),               // 30: daemon.GetLogLevelRequest
    +	(*GetLogLevelResponse)(nil),              // 31: daemon.GetLogLevelResponse
    +	(*SetLogLevelRequest)(nil),               // 32: daemon.SetLogLevelRequest
    +	(*SetLogLevelResponse)(nil),              // 33: daemon.SetLogLevelResponse
    +	(*State)(nil),                            // 34: daemon.State
    +	(*ListStatesRequest)(nil),                // 35: daemon.ListStatesRequest
    +	(*ListStatesResponse)(nil),               // 36: daemon.ListStatesResponse
    +	(*CleanStateRequest)(nil),                // 37: daemon.CleanStateRequest
    +	(*CleanStateResponse)(nil),               // 38: daemon.CleanStateResponse
    +	(*DeleteStateRequest)(nil),               // 39: daemon.DeleteStateRequest
    +	(*DeleteStateResponse)(nil),              // 40: daemon.DeleteStateResponse
    +	(*SetNetworkMapPersistenceRequest)(nil),  // 41: daemon.SetNetworkMapPersistenceRequest
    +	(*SetNetworkMapPersistenceResponse)(nil), // 42: daemon.SetNetworkMapPersistenceResponse
    +	(*TCPFlags)(nil),                         // 43: daemon.TCPFlags
    +	(*TracePacketRequest)(nil),               // 44: daemon.TracePacketRequest
    +	(*TraceStage)(nil),                       // 45: daemon.TraceStage
    +	(*TracePacketResponse)(nil),              // 46: daemon.TracePacketResponse
    +	(*SubscribeRequest)(nil),                 // 47: daemon.SubscribeRequest
    +	(*SystemEvent)(nil),                      // 48: daemon.SystemEvent
    +	(*GetEventsRequest)(nil),                 // 49: daemon.GetEventsRequest
    +	(*GetEventsResponse)(nil),                // 50: daemon.GetEventsResponse
    +	nil,                                      // 51: daemon.Network.ResolvedIPsEntry
    +	nil,                                      // 52: daemon.SystemEvent.MetadataEntry
    +	(*durationpb.Duration)(nil),              // 53: google.protobuf.Duration
    +	(*timestamppb.Timestamp)(nil),            // 54: google.protobuf.Timestamp
     }
     var file_daemon_proto_depIdxs = []int32{
    -	46, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
    -	19, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
    -	47, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
    -	47, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
    -	46, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
    -	16, // 5: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
    -	15, // 6: daemon.FullStatus.signalState:type_name -> daemon.SignalState
    -	14, // 7: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState
    -	13, // 8: daemon.FullStatus.peers:type_name -> daemon.PeerState
    -	17, // 9: daemon.FullStatus.relays:type_name -> daemon.RelayState
    -	18, // 10: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState
    -	25, // 11: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
    -	45, // 12: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
    -	0,  // 13: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel
    -	0,  // 14: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel
    -	32, // 15: daemon.ListStatesResponse.states:type_name -> daemon.State
    -	41, // 16: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags
    -	43, // 17: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
    -	24, // 18: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
    -	1,  // 19: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
    -	3,  // 20: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
    -	5,  // 21: daemon.DaemonService.Up:input_type -> daemon.UpRequest
    -	7,  // 22: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
    -	9,  // 23: daemon.DaemonService.Down:input_type -> daemon.DownRequest
    -	11, // 24: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
    -	20, // 25: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
    -	22, // 26: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
    -	22, // 27: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
    -	26, // 28: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
    -	28, // 29: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
    -	30, // 30: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
    -	33, // 31: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
    -	35, // 32: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
    -	37, // 33: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
    -	39, // 34: daemon.DaemonService.SetNetworkMapPersistence:input_type -> daemon.SetNetworkMapPersistenceRequest
    -	42, // 35: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
    -	2,  // 36: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
    -	4,  // 37: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
    -	6,  // 38: daemon.DaemonService.Up:output_type -> daemon.UpResponse
    -	8,  // 39: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
    -	10, // 40: daemon.DaemonService.Down:output_type -> daemon.DownResponse
    -	12, // 41: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
    -	21, // 42: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
    -	23, // 43: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
    -	23, // 44: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
    -	27, // 45: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
    -	29, // 46: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
    -	31, // 47: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
    -	34, // 48: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
    -	36, // 49: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
    -	38, // 50: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
    -	40, // 51: daemon.DaemonService.SetNetworkMapPersistence:output_type -> daemon.SetNetworkMapPersistenceResponse
    -	44, // 52: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
    -	36, // [36:53] is the sub-list for method output_type
    -	19, // [19:36] is the sub-list for method input_type
    -	19, // [19:19] is the sub-list for extension type_name
    -	19, // [19:19] is the sub-list for extension extendee
    -	0,  // [0:19] is the sub-list for field type_name
    +	53, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
    +	21, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
    +	54, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
    +	54, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
    +	53, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
    +	18, // 5: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
    +	17, // 6: daemon.FullStatus.signalState:type_name -> daemon.SignalState
    +	16, // 7: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState
    +	15, // 8: daemon.FullStatus.peers:type_name -> daemon.PeerState
    +	19, // 9: daemon.FullStatus.relays:type_name -> daemon.RelayState
    +	20, // 10: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState
    +	48, // 11: daemon.FullStatus.events:type_name -> daemon.SystemEvent
    +	27, // 12: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
    +	51, // 13: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
    +	0,  // 14: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel
    +	0,  // 15: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel
    +	34, // 16: daemon.ListStatesResponse.states:type_name -> daemon.State
    +	43, // 17: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags
    +	45, // 18: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
    +	1,  // 19: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
    +	2,  // 20: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
    +	54, // 21: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
    +	52, // 22: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
    +	48, // 23: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
    +	26, // 24: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
    +	3,  // 25: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
    +	5,  // 26: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
    +	7,  // 27: daemon.DaemonService.Up:input_type -> daemon.UpRequest
    +	9,  // 28: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
    +	11, // 29: daemon.DaemonService.Down:input_type -> daemon.DownRequest
    +	13, // 30: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
    +	22, // 31: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
    +	24, // 32: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
    +	24, // 33: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
    +	28, // 34: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
    +	30, // 35: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
    +	32, // 36: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
    +	35, // 37: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
    +	37, // 38: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
    +	39, // 39: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
    +	41, // 40: daemon.DaemonService.SetNetworkMapPersistence:input_type -> daemon.SetNetworkMapPersistenceRequest
    +	44, // 41: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
    +	47, // 42: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
    +	49, // 43: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
    +	4,  // 44: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
    +	6,  // 45: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
    +	8,  // 46: daemon.DaemonService.Up:output_type -> daemon.UpResponse
    +	10, // 47: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
    +	12, // 48: daemon.DaemonService.Down:output_type -> daemon.DownResponse
    +	14, // 49: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
    +	23, // 50: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
    +	25, // 51: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
    +	25, // 52: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
    +	29, // 53: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
    +	31, // 54: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
    +	33, // 55: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
    +	36, // 56: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
    +	38, // 57: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
    +	40, // 58: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
    +	42, // 59: daemon.DaemonService.SetNetworkMapPersistence:output_type -> daemon.SetNetworkMapPersistenceResponse
    +	46, // 60: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
    +	48, // 61: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
    +	50, // 62: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
    +	44, // [44:63] is the sub-list for method output_type
    +	25, // [25:44] is the sub-list for method input_type
    +	25, // [25:25] is the sub-list for extension type_name
    +	25, // [25:25] is the sub-list for extension extendee
    +	0,  // [0:25] is the sub-list for field type_name
     }
     
     func init() { file_daemon_proto_init() }
    @@ -4040,6 +4462,54 @@ func file_daemon_proto_init() {
     				return nil
     			}
     		}
    +		file_daemon_proto_msgTypes[44].Exporter = func(v interface{}, i int) interface{} {
    +			switch v := v.(*SubscribeRequest); i {
    +			case 0:
    +				return &v.state
    +			case 1:
    +				return &v.sizeCache
    +			case 2:
    +				return &v.unknownFields
    +			default:
    +				return nil
    +			}
    +		}
    +		file_daemon_proto_msgTypes[45].Exporter = func(v interface{}, i int) interface{} {
    +			switch v := v.(*SystemEvent); i {
    +			case 0:
    +				return &v.state
    +			case 1:
    +				return &v.sizeCache
    +			case 2:
    +				return &v.unknownFields
    +			default:
    +				return nil
    +			}
    +		}
    +		file_daemon_proto_msgTypes[46].Exporter = func(v interface{}, i int) interface{} {
    +			switch v := v.(*GetEventsRequest); i {
    +			case 0:
    +				return &v.state
    +			case 1:
    +				return &v.sizeCache
    +			case 2:
    +				return &v.unknownFields
    +			default:
    +				return nil
    +			}
    +		}
    +		file_daemon_proto_msgTypes[47].Exporter = func(v interface{}, i int) interface{} {
    +			switch v := v.(*GetEventsResponse); i {
    +			case 0:
    +				return &v.state
    +			case 1:
    +				return &v.sizeCache
    +			case 2:
    +				return &v.unknownFields
    +			default:
    +				return nil
    +			}
    +		}
     	}
     	file_daemon_proto_msgTypes[0].OneofWrappers = []interface{}{}
     	file_daemon_proto_msgTypes[41].OneofWrappers = []interface{}{}
    @@ -4049,8 +4519,8 @@ func file_daemon_proto_init() {
     		File: protoimpl.DescBuilder{
     			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
     			RawDescriptor: file_daemon_proto_rawDesc,
    -			NumEnums:      1,
    -			NumMessages:   45,
    +			NumEnums:      3,
    +			NumMessages:   50,
     			NumExtensions: 0,
     			NumServices:   1,
     		},
    diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
    index 412449076..92a289c41 100644
    --- a/client/proto/daemon.proto
    +++ b/client/proto/daemon.proto
    @@ -59,6 +59,10 @@ service DaemonService {
       rpc SetNetworkMapPersistence(SetNetworkMapPersistenceRequest) returns (SetNetworkMapPersistenceResponse) {}
     
       rpc TracePacket(TracePacketRequest) returns (TracePacketResponse) {}
    +
    +  rpc SubscribeEvents(SubscribeRequest) returns (stream SystemEvent) {}
    +
    +  rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {}
     }
     
     
    @@ -116,6 +120,8 @@ message LoginRequest {
       optional bool disable_firewall = 23;
     
       optional bool block_lan_access = 24;
    +
    +  optional bool disable_notifications = 25;
     }
     
     message LoginResponse {
    @@ -181,6 +187,8 @@ message GetConfigResponse {
       bool rosenpassEnabled = 11;
     
       bool rosenpassPermissive = 12;
    +
    +  bool disable_notifications = 13;
     }
     
     // PeerState contains the latest state of a peer
    @@ -251,6 +259,8 @@ message FullStatus {
       repeated PeerState peers = 4;
       repeated RelayState relays = 5;
       repeated NSGroupState dns_servers = 6;
    +
    +  repeated SystemEvent events = 7;
     }
     
     message ListNetworksRequest {
    @@ -391,3 +401,35 @@ message TracePacketResponse {
       repeated TraceStage stages = 1;
       bool final_disposition = 2;
     }
    +
    +message SubscribeRequest{}
    +
    +message SystemEvent {
    +  enum Severity {
    +    INFO = 0;
    +    WARNING = 1;
    +    ERROR = 2;
    +    CRITICAL = 3;
    +  }
    +
    +  enum Category {
    +    NETWORK = 0;
    +    DNS = 1;
    +    AUTHENTICATION = 2;
    +    CONNECTIVITY = 3;
    +  }
    +
    +  string id = 1;
    +  Severity severity = 2;
    +  Category category = 3;
    +  string message = 4;
    +  string userMessage = 5;
    +  google.protobuf.Timestamp timestamp = 6;
    +  map metadata = 7;
    +}
    +
    +message GetEventsRequest {}
    +
    +message GetEventsResponse {
    +  repeated SystemEvent events = 1;
    +}
    diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go
    index 9dcb543a8..0cb2a7c59 100644
    --- a/client/proto/daemon_grpc.pb.go
    +++ b/client/proto/daemon_grpc.pb.go
    @@ -52,6 +52,8 @@ type DaemonServiceClient interface {
     	// SetNetworkMapPersistence enables or disables network map persistence
     	SetNetworkMapPersistence(ctx context.Context, in *SetNetworkMapPersistenceRequest, opts ...grpc.CallOption) (*SetNetworkMapPersistenceResponse, error)
     	TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error)
    +	SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error)
    +	GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error)
     }
     
     type daemonServiceClient struct {
    @@ -215,6 +217,47 @@ func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRe
     	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...)
    +	if err != nil {
    +		return nil, err
    +	}
    +	x := &daemonServiceSubscribeEventsClient{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
    +}
    +
    +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
    +}
    +
    +func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error) {
    +	out := new(GetEventsResponse)
    +	err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetEvents", in, out, opts...)
    +	if err != nil {
    +		return nil, err
    +	}
    +	return out, nil
    +}
    +
     // DaemonServiceServer is the server API for DaemonService service.
     // All implementations must embed UnimplementedDaemonServiceServer
     // for forward compatibility
    @@ -253,6 +296,8 @@ type DaemonServiceServer interface {
     	// SetNetworkMapPersistence enables or disables network map persistence
     	SetNetworkMapPersistence(context.Context, *SetNetworkMapPersistenceRequest) (*SetNetworkMapPersistenceResponse, error)
     	TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error)
    +	SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error
    +	GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error)
     	mustEmbedUnimplementedDaemonServiceServer()
     }
     
    @@ -311,6 +356,12 @@ func (UnimplementedDaemonServiceServer) SetNetworkMapPersistence(context.Context
     func (UnimplementedDaemonServiceServer) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) {
     	return nil, status.Errorf(codes.Unimplemented, "method TracePacket not implemented")
     }
    +func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error {
    +	return status.Errorf(codes.Unimplemented, "method SubscribeEvents not implemented")
    +}
    +func (UnimplementedDaemonServiceServer) GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) {
    +	return nil, status.Errorf(codes.Unimplemented, "method GetEvents not implemented")
    +}
     func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {}
     
     // UnsafeDaemonServiceServer may be embedded to opt out of forward compatibility for this service.
    @@ -630,6 +681,45 @@ func _DaemonService_TracePacket_Handler(srv interface{}, ctx context.Context, de
     	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 {
    +		return err
    +	}
    +	return srv.(DaemonServiceServer).SubscribeEvents(m, &daemonServiceSubscribeEventsServer{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)
    +}
    +
    +func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
    +	in := new(GetEventsRequest)
    +	if err := dec(in); err != nil {
    +		return nil, err
    +	}
    +	if interceptor == nil {
    +		return srv.(DaemonServiceServer).GetEvents(ctx, in)
    +	}
    +	info := &grpc.UnaryServerInfo{
    +		Server:     srv,
    +		FullMethod: "/daemon.DaemonService/GetEvents",
    +	}
    +	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
    +		return srv.(DaemonServiceServer).GetEvents(ctx, req.(*GetEventsRequest))
    +	}
    +	return interceptor(ctx, in, info, handler)
    +}
    +
     // DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service.
     // It's only intended for direct use with grpc.RegisterService,
     // and not to be introspected or modified (even as a copy)
    @@ -705,7 +795,17 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
     			MethodName: "TracePacket",
     			Handler:    _DaemonService_TracePacket_Handler,
     		},
    +		{
    +			MethodName: "GetEvents",
    +			Handler:    _DaemonService_GetEvents_Handler,
    +		},
    +	},
    +	Streams: []grpc.StreamDesc{
    +		{
    +			StreamName:    "SubscribeEvents",
    +			Handler:       _DaemonService_SubscribeEvents_Handler,
    +			ServerStreams: true,
    +		},
     	},
    -	Streams:  []grpc.StreamDesc{},
     	Metadata: "daemon.proto",
     }
    diff --git a/client/server/event.go b/client/server/event.go
    new file mode 100644
    index 000000000..9a4e0fbf5
    --- /dev/null
    +++ b/client/server/event.go
    @@ -0,0 +1,36 @@
    +package server
    +
    +import (
    +	"context"
    +
    +	log "github.com/sirupsen/logrus"
    +
    +	"github.com/netbirdio/netbird/client/proto"
    +)
    +
    +func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.DaemonService_SubscribeEventsServer) error {
    +	subscription := s.statusRecorder.SubscribeToEvents()
    +	defer func() {
    +		s.statusRecorder.UnsubscribeFromEvents(subscription)
    +		log.Debug("client unsubscribed from events")
    +	}()
    +
    +	log.Debug("client subscribed to events")
    +
    +	for {
    +		select {
    +		case event := <-subscription.Events():
    +			if err := stream.Send(event); err != nil {
    +				log.Warnf("error sending event to %v: %v", req, err)
    +				return err
    +			}
    +		case <-stream.Context().Done():
    +			return nil
    +		}
    +	}
    +}
    +
    +func (s *Server) GetEvents(context.Context, *proto.GetEventsRequest) (*proto.GetEventsResponse, error) {
    +	events := s.statusRecorder.GetEventHistory()
    +	return &proto.GetEventsResponse{Events: events}, nil
    +}
    diff --git a/client/server/server.go b/client/server/server.go
    index 42420d1c1..9250b3e8b 100644
    --- a/client/server/server.go
    +++ b/client/server/server.go
    @@ -404,6 +404,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
     		s.latestConfigInput.BlockLANAccess = msg.BlockLanAccess
     	}
     
    +	if msg.DisableNotifications != nil {
    +		inputConfig.DisableNotifications = msg.DisableNotifications
    +		s.latestConfigInput.DisableNotifications = msg.DisableNotifications
    +	}
    +
     	s.mutex.Unlock()
     
     	if msg.OptionalPreSharedKey != nil {
    @@ -687,6 +692,7 @@ func (s *Server) Status(
     
     		fullStatus := s.statusRecorder.GetFullStatus()
     		pbFullStatus := toProtoFullStatus(fullStatus)
    +		pbFullStatus.Events = s.statusRecorder.GetEventHistory()
     		statusResponse.FullStatus = pbFullStatus
     	}
     
    @@ -736,17 +742,18 @@ func (s *Server) GetConfig(_ context.Context, _ *proto.GetConfigRequest) (*proto
     	}
     
     	return &proto.GetConfigResponse{
    -		ManagementUrl:       managementURL,
    -		ConfigFile:          s.latestConfigInput.ConfigPath,
    -		LogFile:             s.logFile,
    -		PreSharedKey:        preSharedKey,
    -		AdminURL:            adminURL,
    -		InterfaceName:       s.config.WgIface,
    -		WireguardPort:       int64(s.config.WgPort),
    -		DisableAutoConnect:  s.config.DisableAutoConnect,
    -		ServerSSHAllowed:    *s.config.ServerSSHAllowed,
    -		RosenpassEnabled:    s.config.RosenpassEnabled,
    -		RosenpassPermissive: s.config.RosenpassPermissive,
    +		ManagementUrl:        managementURL,
    +		ConfigFile:           s.latestConfigInput.ConfigPath,
    +		LogFile:              s.logFile,
    +		PreSharedKey:         preSharedKey,
    +		AdminURL:             adminURL,
    +		InterfaceName:        s.config.WgIface,
    +		WireguardPort:        int64(s.config.WgPort),
    +		DisableAutoConnect:   s.config.DisableAutoConnect,
    +		ServerSSHAllowed:     *s.config.ServerSSHAllowed,
    +		RosenpassEnabled:     s.config.RosenpassEnabled,
    +		RosenpassPermissive:  s.config.RosenpassPermissive,
    +		DisableNotifications: s.config.DisableNotifications,
     	}, nil
     }
     func (s *Server) onSessionExpire() {
    diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go
    index 618160128..9ed40b0be 100644
    --- a/client/ui/client_ui.go
    +++ b/client/ui/client_ui.go
    @@ -34,6 +34,7 @@ import (
     	"github.com/netbirdio/netbird/client/internal"
     	"github.com/netbirdio/netbird/client/proto"
     	"github.com/netbirdio/netbird/client/system"
    +	"github.com/netbirdio/netbird/client/ui/event"
     	"github.com/netbirdio/netbird/util"
     	"github.com/netbirdio/netbird/version"
     )
    @@ -161,6 +162,7 @@ type serviceClient struct {
     	mAllowSSH         *systray.MenuItem
     	mAutoConnect      *systray.MenuItem
     	mEnableRosenpass  *systray.MenuItem
    +	mNotifications    *systray.MenuItem
     	mAdvancedSettings *systray.MenuItem
     
     	// application with main windows.
    @@ -196,6 +198,8 @@ type serviceClient struct {
     	isUpdateIconActive   bool
     	showRoutes           bool
     	wRoutes              fyne.Window
    +
    +	eventManager *event.Manager
     }
     
     // newServiceClient instance constructor
    @@ -429,6 +433,7 @@ func (s *serviceClient) menuUpClick() error {
     		log.Errorf("up service: %v", err)
     		return err
     	}
    +
     	return nil
     }
     
    @@ -570,6 +575,7 @@ func (s *serviceClient) onTrayReady() {
     	s.mAllowSSH = s.mSettings.AddSubMenuItemCheckbox("Allow SSH", "Allow SSH connections", false)
     	s.mAutoConnect = s.mSettings.AddSubMenuItemCheckbox("Connect on Startup", "Connect automatically when the service starts", false)
     	s.mEnableRosenpass = s.mSettings.AddSubMenuItemCheckbox("Enable Quantum-Resistance", "Enable post-quantum security via Rosenpass", false)
    +	s.mNotifications = s.mSettings.AddSubMenuItemCheckbox("Notifications", "Enable notifications", true)
     	s.mAdvancedSettings = s.mSettings.AddSubMenuItem("Advanced Settings", "Advanced settings of the application")
     	s.loadSettings()
     
    @@ -606,6 +612,10 @@ func (s *serviceClient) onTrayReady() {
     		}
     	}()
     
    +	s.eventManager = event.NewManager(s.app, s.addr)
    +	s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
    +	go s.eventManager.Start(s.ctx)
    +
     	go func() {
     		var err error
     		for {
    @@ -680,7 +690,20 @@ func (s *serviceClient) onTrayReady() {
     					defer s.mRoutes.Enable()
     					s.runSelfCommand("networks", "true")
     				}()
    +			case <-s.mNotifications.ClickedCh:
    +				if s.mNotifications.Checked() {
    +					s.mNotifications.Uncheck()
    +				} else {
    +					s.mNotifications.Check()
    +				}
    +				if s.eventManager != nil {
    +					s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
    +				}
    +				if err := s.updateConfig(); err != nil {
    +					log.Errorf("failed to update config: %v", err)
    +				}
     			}
    +
     			if err != nil {
     				log.Errorf("process connection: %v", err)
     			}
    @@ -780,8 +803,20 @@ func (s *serviceClient) getSrvConfig() {
     		if !cfg.RosenpassEnabled {
     			s.sRosenpassPermissive.Disable()
     		}
    -
     	}
    +
    +	if s.mNotifications == nil {
    +		return
    +	}
    +	if cfg.DisableNotifications {
    +		s.mNotifications.Uncheck()
    +	} else {
    +		s.mNotifications.Check()
    +	}
    +	if s.eventManager != nil {
    +		s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
    +	}
    +
     }
     
     func (s *serviceClient) onUpdateAvailable() {
    @@ -846,6 +881,15 @@ func (s *serviceClient) loadSettings() {
     	} else {
     		s.mEnableRosenpass.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
    @@ -854,12 +898,14 @@ func (s *serviceClient) updateConfig() error {
     	disableAutoStart := !s.mAutoConnect.Checked()
     	sshAllowed := s.mAllowSSH.Checked()
     	rosenpassEnabled := s.mEnableRosenpass.Checked()
    +	notificationsDisabled := !s.mNotifications.Checked()
     
     	loginRequest := proto.LoginRequest{
     		IsLinuxDesktopClient: runtime.GOOS == "linux",
     		ServerSSHAllowed:     &sshAllowed,
     		RosenpassEnabled:     &rosenpassEnabled,
     		DisableAutoConnect:   &disableAutoStart,
    +		DisableNotifications: ¬ificationsDisabled,
     	}
     
     	if err := s.restartClient(&loginRequest); err != nil {
    diff --git a/client/ui/event/event.go b/client/ui/event/event.go
    new file mode 100644
    index 000000000..7925ee4d3
    --- /dev/null
    +++ b/client/ui/event/event.go
    @@ -0,0 +1,151 @@
    +package event
    +
    +import (
    +	"context"
    +	"fmt"
    +	"strings"
    +	"sync"
    +	"time"
    +
    +	"fyne.io/fyne/v2"
    +	"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/system"
    +)
    +
    +type Manager struct {
    +	app  fyne.App
    +	addr string
    +
    +	mu      sync.Mutex
    +	ctx     context.Context
    +	cancel  context.CancelFunc
    +	enabled bool
    +}
    +
    +func NewManager(app fyne.App, addr string) *Manager {
    +	return &Manager{
    +		app:  app,
    +		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
    +	e.mu.Unlock()
    +
    +	if !enabled {
    +		return
    +	}
    +
    +	title := e.getEventTitle(event)
    +	e.app.SendNotification(fyne.NewNotification(title, event.UserMessage))
    +}
    +
    +func (e *Manager) getEventTitle(event *proto.SystemEvent) string {
    +	var prefix string
    +	switch event.Severity {
    +	case proto.SystemEvent_ERROR, proto.SystemEvent_CRITICAL:
    +		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(system.GetDesktopUIUserAgent()),
    +	)
    +	if err != nil {
    +		return nil, err
    +	}
    +	return proto.NewDaemonServiceClient(conn), nil
    +}