From 724c6a06e6ed25eb0c3f6f347fb1f5b1723533a5 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 20 Jul 2026 18:15:03 +0200 Subject: [PATCH 01/47] [relay] only trust X-Real-Ip headers from configured trusted proxies (#6833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WS listener unconditionally trusted X-Real-Ip/X-Real-Port headers, letting any client forge the source address the relay logs. Gate header trust behind a trusted-proxy allowlist; ignore the headers unless the immediate peer matches a configured prefix. Defaults to never trusting the headers when the allowlist is empty. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added `--trusted-proxies` to configure a comma-separated allowlist of trusted upstream proxy IPs/CIDRs. * **Behavior Changes** * Relay WebSocket now uses `X-Real-Ip` / `X-Real-Port` only when the immediate peer is from the configured trusted set; otherwise it falls back to the direct remote address. * Proxy client IP resolution is now consistent and honors `X-Forwarded-For` only through trusted hops. * **Operational** * Invalid `--trusted-proxies` values fail fast on startup. --- proxy/cmd/proxy/cmd/root.go | 3 +- proxy/internal/accesslog/logger.go | 5 +- proxy/internal/accesslog/requestip.go | 6 +- proxy/internal/proxy/reverseproxy.go | 19 +- proxy/internal/proxy/reverseproxy_test.go | 5 +- proxy/internal/proxy/trustedproxy.go | 81 -------- proxy/internal/proxy/trustedproxy_test.go | 129 ------------- proxy/lifecycle.go | 8 +- proxy/proxyprotocol_test.go | 10 +- proxy/server.go | 61 +++--- proxy/trustedproxy.go | 43 ----- proxy/trustedproxy_test.go | 90 --------- relay/cmd/root.go | 14 +- relay/server/listener/ws/listener.go | 20 +- relay/server/server.go | 12 +- trustedproxy/trustedproxy.go | 132 +++++++++++++ trustedproxy/trustedproxy_test.go | 216 ++++++++++++++++++++++ 17 files changed, 446 insertions(+), 408 deletions(-) delete mode 100644 proxy/internal/proxy/trustedproxy.go delete mode 100644 proxy/internal/proxy/trustedproxy_test.go delete mode 100644 proxy/trustedproxy.go delete mode 100644 proxy/trustedproxy_test.go create mode 100644 trustedproxy/trustedproxy.go create mode 100644 trustedproxy/trustedproxy_test.go diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go index ad8e1b7c0..9b180a5c4 100644 --- a/proxy/cmd/proxy/cmd/root.go +++ b/proxy/cmd/proxy/cmd/root.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/client/embed" "github.com/netbirdio/netbird/proxy" nbacme "github.com/netbirdio/netbird/proxy/internal/acme" + "github.com/netbirdio/netbird/trustedproxy" "github.com/netbirdio/netbird/util" ) @@ -209,7 +210,7 @@ func runServer(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err) } - parsedTrustedProxies, err := proxy.ParseTrustedProxies(trustedProxies) + parsedTrustedProxies, err := trustedproxy.Parse(trustedProxies) if err != nil { return fmt.Errorf("invalid --trusted-proxies: %w", err) } diff --git a/proxy/internal/accesslog/logger.go b/proxy/internal/accesslog/logger.go index db868b4e0..d47c71ca4 100644 --- a/proxy/internal/accesslog/logger.go +++ b/proxy/internal/accesslog/logger.go @@ -16,6 +16,7 @@ import ( "github.com/netbirdio/netbird/proxy/auth" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/trustedproxy" ) const ( @@ -66,7 +67,7 @@ type denyBucket struct { type Logger struct { client gRPCClient logger *log.Logger - trustedProxies []netip.Prefix + trustedProxies *trustedproxy.List usageMux sync.Mutex domainUsage map[string]*domainUsage @@ -82,7 +83,7 @@ type Logger struct { // NewLogger creates a new access log Logger. The trustedProxies parameter // configures which upstream proxy IP ranges are trusted for extracting // the real client IP from X-Forwarded-For headers. -func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies []netip.Prefix) *Logger { +func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies *trustedproxy.List) *Logger { if logger == nil { logger = log.StandardLogger() } diff --git a/proxy/internal/accesslog/requestip.go b/proxy/internal/accesslog/requestip.go index 30c483fd9..71cea85d0 100644 --- a/proxy/internal/accesslog/requestip.go +++ b/proxy/internal/accesslog/requestip.go @@ -4,13 +4,13 @@ import ( "net/http" "net/netip" - "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/trustedproxy" ) // extractSourceIP resolves the real client IP from the request using trusted // proxy configuration. When trustedProxies is non-empty and the direct // connection is from a trusted source, it walks X-Forwarded-For right-to-left // skipping trusted IPs. Otherwise it returns RemoteAddr directly. -func extractSourceIP(r *http.Request, trustedProxies []netip.Prefix) netip.Addr { - return proxy.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), trustedProxies) +func extractSourceIP(r *http.Request, trustedProxies *trustedproxy.List) netip.Addr { + return trustedProxies.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For")) } diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 835a1c0b2..9150c0329 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -22,6 +22,7 @@ import ( "github.com/netbirdio/netbird/proxy/internal/roundtrip" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/trustedproxy" ) type ReverseProxy struct { @@ -29,10 +30,10 @@ type ReverseProxy struct { // forwardedProto overrides the X-Forwarded-Proto header value. // Valid values: "auto" (detect from TLS), "http", "https". forwardedProto string - // trustedProxies is a list of IP prefixes for trusted upstream proxies. - // When the direct connection comes from a trusted proxy, forwarding - // headers are preserved and appended to instead of being stripped. - trustedProxies []netip.Prefix + // trustedProxies is the set of trusted upstream proxies. When the direct + // connection comes from a trusted proxy, forwarding headers are preserved + // and appended to instead of being stripped. + trustedProxies *trustedproxy.List mappingsMux sync.RWMutex mappings map[string]Mapping logger *log.Logger @@ -63,7 +64,7 @@ func WithMiddlewareManager(m *middleware.Manager) Option { // between requested URLs and targets. // The internal mappings can be modified using the AddMapping // and RemoveMapping functions. -func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies []netip.Prefix, logger *log.Logger, opts ...Option) *ReverseProxy { +func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies *trustedproxy.List, logger *log.Logger, opts ...Option) *ReverseProxy { if logger == nil { logger = log.StandardLogger() } @@ -527,7 +528,7 @@ func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool { if !types.IsOverlayOrigin(r.Context()) { return false } - srcIP := extractHostIP(r.RemoteAddr) + srcIP := trustedproxy.ExtractHostIP(r.RemoteAddr) if !srcIP.IsValid() { return false } @@ -578,9 +579,9 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost stampNetBirdIdentity(r) - clientIP := extractHostIP(r.In.RemoteAddr) + clientIP := trustedproxy.ExtractHostIP(r.In.RemoteAddr) - if isTrustedAddr(clientIP, p.trustedProxies) { + if p.trustedProxies.Contains(clientIP) { p.setTrustedForwardingHeaders(r, clientIP) } else { p.setUntrustedForwardingHeaders(r, clientIP) @@ -664,7 +665,7 @@ func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, cli if realIP := r.In.Header.Get("X-Real-IP"); realIP != "" { r.Out.Header.Set("X-Real-IP", realIP) } else { - resolved := ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"), p.trustedProxies) + resolved := p.trustedProxies.ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For")) r.Out.Header.Set("X-Real-IP", resolved.String()) } diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go index 9bd427056..83afee387 100644 --- a/proxy/internal/proxy/reverseproxy_test.go +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/proxy/internal/roundtrip" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/trustedproxy" ) func TestRewriteFunc_HostRewriting(t *testing.T) { @@ -302,7 +303,7 @@ func TestExtractHostIP(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, extractHostIP(tt.remoteAddr)) + assert.Equal(t, tt.expected, trustedproxy.ExtractHostIP(tt.remoteAddr)) }) } } @@ -330,7 +331,7 @@ func TestExtractForwardedPort(t *testing.T) { func TestRewriteFunc_TrustedProxy(t *testing.T) { target, _ := url.Parse("http://backend.internal:8080") - trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} + trusted := trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}) t.Run("appends to X-Forwarded-For", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} diff --git a/proxy/internal/proxy/trustedproxy.go b/proxy/internal/proxy/trustedproxy.go deleted file mode 100644 index 0fe693f90..000000000 --- a/proxy/internal/proxy/trustedproxy.go +++ /dev/null @@ -1,81 +0,0 @@ -package proxy - -import ( - "net/netip" - "strings" -) - -// IsTrustedProxy checks if the given IP string falls within any of the trusted prefixes. -func IsTrustedProxy(ipStr string, trusted []netip.Prefix) bool { - addr, err := netip.ParseAddr(ipStr) - if err != nil || len(trusted) == 0 { - return false - } - return isTrustedAddr(addr.Unmap(), trusted) -} - -// ResolveClientIP extracts the real client IP from X-Forwarded-For using the trusted proxy list. -// It walks the XFF chain right-to-left, skipping IPs that match trusted prefixes. -// The first untrusted IP is the real client. -// -// If the trusted list is empty or remoteAddr is not trusted, it returns the -// remoteAddr IP directly (ignoring any forwarding headers). -func ResolveClientIP(remoteAddr, xff string, trusted []netip.Prefix) netip.Addr { - remoteIP := extractHostIP(remoteAddr) - - if len(trusted) == 0 || !isTrustedAddr(remoteIP, trusted) { - return remoteIP - } - - if xff == "" { - return remoteIP - } - - parts := strings.Split(xff, ",") - for i := len(parts) - 1; i >= 0; i-- { - ip := strings.TrimSpace(parts[i]) - if ip == "" { - continue - } - addr, err := netip.ParseAddr(ip) - if err != nil { - continue - } - addr = addr.Unmap() - if !isTrustedAddr(addr, trusted) { - return addr - } - } - - // All IPs in XFF are trusted; return the leftmost as best guess. - if first := strings.TrimSpace(parts[0]); first != "" { - if addr, err := netip.ParseAddr(first); err == nil { - return addr.Unmap() - } - } - return remoteIP -} - -// extractHostIP parses the IP from a host:port string and returns it unmapped. -func extractHostIP(hostPort string) netip.Addr { - if ap, err := netip.ParseAddrPort(hostPort); err == nil { - return ap.Addr().Unmap() - } - if addr, err := netip.ParseAddr(hostPort); err == nil { - return addr.Unmap() - } - return netip.Addr{} -} - -// isTrustedAddr checks if the given address falls within any of the trusted prefixes. -func isTrustedAddr(addr netip.Addr, trusted []netip.Prefix) bool { - if !addr.IsValid() { - return false - } - for _, prefix := range trusted { - if prefix.Contains(addr) { - return true - } - } - return false -} diff --git a/proxy/internal/proxy/trustedproxy_test.go b/proxy/internal/proxy/trustedproxy_test.go deleted file mode 100644 index 35ed1f5c2..000000000 --- a/proxy/internal/proxy/trustedproxy_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package proxy - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestIsTrustedProxy(t *testing.T) { - trusted := []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), - netip.MustParsePrefix("192.168.1.0/24"), - netip.MustParsePrefix("fd00::/8"), - } - - tests := []struct { - name string - ip string - trusted []netip.Prefix - want bool - }{ - {"empty trusted list", "10.0.0.1", nil, false}, - {"IP within /8 prefix", "10.1.2.3", trusted, true}, - {"IP within /24 prefix", "192.168.1.100", trusted, true}, - {"IP outside all prefixes", "203.0.113.50", trusted, false}, - {"boundary IP just outside prefix", "192.168.2.1", trusted, false}, - {"unparsable IP", "not-an-ip", trusted, false}, - {"IPv6 in trusted range", "fd00::1", trusted, true}, - {"IPv6 outside range", "2001:db8::1", trusted, false}, - {"empty string", "", trusted, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, IsTrustedProxy(tt.ip, tt.trusted)) - }) - } -} - -func TestResolveClientIP(t *testing.T) { - trusted := []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), - netip.MustParsePrefix("172.16.0.0/12"), - } - - tests := []struct { - name string - remoteAddr string - xff string - trusted []netip.Prefix - want netip.Addr - }{ - { - name: "empty trusted list returns RemoteAddr", - remoteAddr: "203.0.113.50:9999", - xff: "1.2.3.4", - trusted: nil, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "untrusted RemoteAddr ignores XFF", - remoteAddr: "203.0.113.50:9999", - xff: "1.2.3.4, 10.0.0.1", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "trusted RemoteAddr with single client in XFF", - remoteAddr: "10.0.0.1:5000", - xff: "203.0.113.50", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "trusted RemoteAddr walks past trusted entries in XFF", - remoteAddr: "10.0.0.1:5000", - xff: "203.0.113.50, 10.0.0.2, 172.16.0.5", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr", - remoteAddr: "10.0.0.1:5000", - xff: "", - trusted: trusted, - want: netip.MustParseAddr("10.0.0.1"), - }, - { - name: "all XFF IPs trusted returns leftmost", - remoteAddr: "10.0.0.1:5000", - xff: "10.0.0.2, 172.16.0.1, 10.0.0.3", - trusted: trusted, - want: netip.MustParseAddr("10.0.0.2"), - }, - { - name: "XFF with whitespace", - remoteAddr: "10.0.0.1:5000", - xff: " 203.0.113.50 , 10.0.0.2 ", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "XFF with empty segments", - remoteAddr: "10.0.0.1:5000", - xff: "203.0.113.50,,10.0.0.2", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "multi-hop with mixed trust", - remoteAddr: "10.0.0.1:5000", - xff: "8.8.8.8, 203.0.113.50, 172.16.0.1", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "RemoteAddr without port", - remoteAddr: "10.0.0.1", - xff: "203.0.113.50", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, ResolveClientIP(tt.remoteAddr, tt.xff, tt.trusted)) - }) - } -} diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go index 0d4aded9c..f8c74d8b5 100644 --- a/proxy/lifecycle.go +++ b/proxy/lifecycle.go @@ -2,13 +2,13 @@ package proxy import ( "context" - "net/netip" "time" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/embed" "github.com/netbirdio/netbird/proxy/internal/acme" + "github.com/netbirdio/netbird/trustedproxy" ) // Config bundles every knob the proxy reads at construction time. It mirrors @@ -83,9 +83,9 @@ type Config struct { // ForwardedProto overrides the X-Forwarded-Proto value sent to // backends. Valid values: "auto", "http", "https". ForwardedProto string - // TrustedProxies is a list of IP prefixes for trusted upstream - // proxies that may set forwarding headers. - TrustedProxies []netip.Prefix + // TrustedProxies is the set of trusted upstream proxies that may set + // forwarding headers. + TrustedProxies *trustedproxy.List // WireguardPort is the UDP port for the embedded NetBird tunnel. // Zero asks the OS for a random port. WireguardPort uint16 diff --git a/proxy/proxyprotocol_test.go b/proxy/proxyprotocol_test.go index fe2fe7e2d..9e19314ed 100644 --- a/proxy/proxyprotocol_test.go +++ b/proxy/proxyprotocol_test.go @@ -10,12 +10,14 @@ import ( log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/trustedproxy" ) func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}), ProxyProtocol: true, } @@ -66,7 +68,7 @@ func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) { func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), } opts := proxyproto.ConnPolicyOptions{ @@ -80,7 +82,7 @@ func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) { func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), } opts := proxyproto.ConnPolicyOptions{ @@ -94,7 +96,7 @@ func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) { func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), } opts := proxyproto.ConnPolicyOptions{ diff --git a/proxy/server.go b/proxy/server.go index f28d580bd..4f448e4b8 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -67,6 +67,7 @@ import ( "github.com/netbirdio/netbird/proxy/web" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/trustedproxy" "github.com/netbirdio/netbird/util/embeddedroots" ) @@ -79,19 +80,19 @@ type portRouter struct { type Server struct { ctx context.Context - mgmtClient proto.ProxyServiceClient - proxy *proxy.ReverseProxy - netbird *roundtrip.NetBird - acme *acme.Manager + mgmtClient proto.ProxyServiceClient + proxy *proxy.ReverseProxy + netbird *roundtrip.NetBird + acme *acme.Manager staticCertWatcher *certwatch.Watcher - auth *auth.Middleware - http *http.Server - https *http.Server - debug *http.Server - healthServer *health.Server - healthChecker *health.Checker - meter *proxymetrics.Metrics - accessLog *accesslog.Logger + auth *auth.Middleware + http *http.Server + https *http.Server + debug *http.Server + healthServer *health.Server + healthChecker *health.Checker + meter *proxymetrics.Metrics + accessLog *accesslog.Logger // middlewareManager drives per-target middleware dispatch. Always // constructed during boot; an empty registry produces empty chains and // the reverse-proxy stays on the no-capture fast path. @@ -99,16 +100,16 @@ type Server struct { // middlewareRegistry is the source of registered middleware factories. // Concrete middlewares register themselves through init(). middlewareRegistry *middleware.Registry - mainRouter *nbtcp.Router - mainPort uint16 - udpMu sync.Mutex - udpRelays map[types.ServiceID]*udprelay.Relay - udpRelayWg sync.WaitGroup - portMu sync.RWMutex - portRouters map[uint16]*portRouter - svcPorts map[types.ServiceID][]uint16 - lastMappings map[types.ServiceID]*proto.ProxyMapping - portRouterWg sync.WaitGroup + mainRouter *nbtcp.Router + mainPort uint16 + udpMu sync.Mutex + udpRelays map[types.ServiceID]*udprelay.Relay + udpRelayWg sync.WaitGroup + portMu sync.RWMutex + portRouters map[uint16]*portRouter + svcPorts map[types.ServiceID][]uint16 + lastMappings map[types.ServiceID]*proto.ProxyMapping + portRouterWg sync.WaitGroup // hijackTracker tracks hijacked connections (e.g. WebSocket upgrades) // so they can be closed during graceful shutdown, since http.Server.Shutdown @@ -192,10 +193,10 @@ type Server struct { // ForwardedProto overrides the X-Forwarded-Proto value sent to backends. // Valid values: "auto" (detect from TLS), "http", "https". ForwardedProto string - // TrustedProxies is a list of IP prefixes for trusted upstream proxies. - // When set, forwarding headers from these sources are preserved and - // appended to instead of being stripped. - TrustedProxies []netip.Prefix + // TrustedProxies is the set of trusted upstream proxies. When set, + // forwarding headers from these sources are preserved and appended to + // instead of being stripped. + TrustedProxies *trustedproxy.List // WireguardPort is the port for the NetBird tunnel interface. Use 0 // for a random OS-assigned port. A fixed port only works with // single-account deployments; multiple accounts will fail to bind @@ -718,7 +719,7 @@ func (s *Server) wrapProxyProtocol(ln net.Listener) net.Listener { Listener: ln, ReadHeaderTimeout: proxyProtoHeaderTimeout, } - if len(s.TrustedProxies) > 0 { + if !s.TrustedProxies.Empty() { ppListener.ConnPolicy = s.proxyProtocolPolicy } else { s.Logger.Warn("PROXY protocol enabled without trusted proxies; any source may send PROXY headers") @@ -742,10 +743,8 @@ func (s *Server) proxyProtocolPolicy(opts proxyproto.ConnPolicyOptions) (proxypr addr = addr.Unmap() // called per accept - for _, prefix := range s.TrustedProxies { - if prefix.Contains(addr) { - return proxyproto.REQUIRE, nil - } + if s.TrustedProxies.Contains(addr) { + return proxyproto.REQUIRE, nil } return proxyproto.IGNORE, nil } diff --git a/proxy/trustedproxy.go b/proxy/trustedproxy.go deleted file mode 100644 index 3a1f0ad37..000000000 --- a/proxy/trustedproxy.go +++ /dev/null @@ -1,43 +0,0 @@ -package proxy - -import ( - "fmt" - "net/netip" - "strings" -) - -// ParseTrustedProxies parses a comma-separated list of CIDR prefixes or bare IPs -// into a slice of netip.Prefix values suitable for trusted proxy configuration. -// Bare IPs are converted to single-host prefixes (/32 or /128). -func ParseTrustedProxies(raw string) ([]netip.Prefix, error) { - if raw == "" { - return nil, nil - } - - parts := strings.Split(raw, ",") - prefixes := make([]netip.Prefix, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - - prefix, err := netip.ParsePrefix(part) - if err == nil { - prefixes = append(prefixes, prefix) - continue - } - - addr, addrErr := netip.ParseAddr(part) - if addrErr != nil { - return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr) - } - - bits := 32 - if addr.Is6() { - bits = 128 - } - prefixes = append(prefixes, netip.PrefixFrom(addr, bits)) - } - return prefixes, nil -} diff --git a/proxy/trustedproxy_test.go b/proxy/trustedproxy_test.go deleted file mode 100644 index 974e56863..000000000 --- a/proxy/trustedproxy_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package proxy - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseTrustedProxies(t *testing.T) { - tests := []struct { - name string - raw string - want []netip.Prefix - wantErr bool - }{ - { - name: "empty string returns nil", - raw: "", - want: nil, - }, - { - name: "single CIDR", - raw: "10.0.0.0/8", - want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, - }, - { - name: "single bare IPv4", - raw: "1.2.3.4", - want: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")}, - }, - { - name: "single bare IPv6", - raw: "::1", - want: []netip.Prefix{netip.MustParsePrefix("::1/128")}, - }, - { - name: "comma-separated CIDRs", - raw: "10.0.0.0/8, 192.168.1.0/24", - want: []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), - netip.MustParsePrefix("192.168.1.0/24"), - }, - }, - { - name: "mixed CIDRs and bare IPs", - raw: "10.0.0.0/8, 1.2.3.4, fd00::/8", - want: []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), - netip.MustParsePrefix("1.2.3.4/32"), - netip.MustParsePrefix("fd00::/8"), - }, - }, - { - name: "whitespace around entries", - raw: " 10.0.0.0/8 , 192.168.0.0/16 ", - want: []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), - netip.MustParsePrefix("192.168.0.0/16"), - }, - }, - { - name: "trailing comma produces no extra entry", - raw: "10.0.0.0/8,", - want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, - }, - { - name: "invalid entry", - raw: "not-an-ip", - wantErr: true, - }, - { - name: "partially invalid", - raw: "10.0.0.0/8, garbage", - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseTrustedProxies(tt.raw) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/relay/cmd/root.go b/relay/cmd/root.go index 4dd1e6236..a64812d4d 100644 --- a/relay/cmd/root.go +++ b/relay/cmd/root.go @@ -24,6 +24,7 @@ import ( "github.com/netbirdio/netbird/shared/metrics" "github.com/netbirdio/netbird/shared/relay/auth" "github.com/netbirdio/netbird/stun" + "github.com/netbirdio/netbird/trustedproxy" "github.com/netbirdio/netbird/util" ) @@ -45,6 +46,9 @@ type Config struct { LogLevel string LogFile string HealthcheckListenAddress string + // TrustedProxies is a comma-separated list of upstream proxy CIDRs/IPs whose + // X-Real-Ip/X-Real-Port headers are trusted. Empty means never trust these headers. + TrustedProxies string // STUN server configuration EnableSTUN bool STUNPorts []int @@ -116,6 +120,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level") rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", "console", "log file") rootCmd.PersistentFlags().StringVarP(&cobraConfig.HealthcheckListenAddress, "health-listen-address", "H", ":9000", "listen address of healthcheck server") + rootCmd.PersistentFlags().StringVar(&cobraConfig.TrustedProxies, "trusted-proxies", "", "comma-separated list of upstream proxy CIDRs or IPs whose X-Real-Ip/X-Real-Port headers are trusted; leave empty to always use the direct connection address") rootCmd.PersistentFlags().BoolVar(&cobraConfig.EnableSTUN, "enable-stun", false, "enable embedded STUN server") rootCmd.PersistentFlags().IntSliceVar(&cobraConfig.STUNPorts, "stun-ports", []int{3478}, "ports for the embedded STUN server (can be specified multiple times or comma-separated)") rootCmd.PersistentFlags().StringVar(&cobraConfig.STUNLogLevel, "stun-log-level", "info", "log level for STUN server (panic, fatal, error, warn, info, debug, trace)") @@ -155,8 +160,15 @@ func execute(cmd *cobra.Command, args []string) error { return fmt.Errorf("setup metrics: %v", err) } + trustedProxies, err := trustedproxy.Parse(cobraConfig.TrustedProxies) + if err != nil { + log.Debugf("failed to parse trusted proxies: %s", err) + return fmt.Errorf("failed to parse trusted proxies: %s", err) + } + srvListenerCfg := server.ListenerConfig{ - Address: cobraConfig.ListenAddress, + Address: cobraConfig.ListenAddress, + TrustedProxies: trustedProxies, } tlsConfig, tlsSupport, err := handleTLSConfig(cobraConfig) diff --git a/relay/server/listener/ws/listener.go b/relay/server/listener/ws/listener.go index ba175f901..208b9186e 100644 --- a/relay/server/listener/ws/listener.go +++ b/relay/server/listener/ws/listener.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/relay/protocol" relaylistener "github.com/netbirdio/netbird/relay/server/listener" "github.com/netbirdio/netbird/shared/relay" + "github.com/netbirdio/netbird/trustedproxy" ) const ( @@ -27,6 +28,9 @@ type Listener struct { Address string // TLSConfig is the TLS configuration for the server. TLSConfig *tls.Config + // TrustedProxies is the set of upstream proxies whose X-Real-Ip/X-Real-Port + // headers are trusted. Headers from any other immediate peer are ignored. + TrustedProxies *trustedproxy.List server *http.Server acceptFn func(conn relaylistener.Conn) @@ -75,7 +79,7 @@ func (l *Listener) Shutdown(ctx context.Context) error { } func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) { - connRemoteAddr := remoteAddr(r) + connRemoteAddr := remoteAddr(r, l.TrustedProxies) acceptOptions := &websocket.AcceptOptions{ OriginPatterns: []string{"*"}, @@ -102,9 +106,17 @@ func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) { l.acceptFn(conn) } -func remoteAddr(r *http.Request) string { - if r.Header.Get("X-Real-Ip") == "" || r.Header.Get("X-Real-Port") == "" { +func remoteAddr(r *http.Request, trustedProxies *trustedproxy.List) string { + realIP := r.Header.Get("X-Real-Ip") + realPort := r.Header.Get("X-Real-Port") + if realIP == "" || realPort == "" { return r.RemoteAddr } - return net.JoinHostPort(r.Header.Get("X-Real-Ip"), r.Header.Get("X-Real-Port")) + + if !trustedProxies.IsTrusted(r.RemoteAddr) { + log.Debugf("ignoring X-Real-Ip header from untrusted peer %s", r.RemoteAddr) + return r.RemoteAddr + } + + return net.JoinHostPort(realIP, realPort) } diff --git a/relay/server/server.go b/relay/server/server.go index 340da55b8..8d303e9e4 100644 --- a/relay/server/server.go +++ b/relay/server/server.go @@ -15,14 +15,17 @@ import ( "github.com/netbirdio/netbird/relay/server/listener/quic" "github.com/netbirdio/netbird/relay/server/listener/ws" quictls "github.com/netbirdio/netbird/shared/relay/tls" + "github.com/netbirdio/netbird/trustedproxy" ) // ListenerConfig is the configuration for the listener. // Address: the address to bind the listener to. It could be an address behind a reverse proxy. // TLSConfig: the TLS configuration for the listener. +// TrustedProxies: upstream proxy prefixes whose forwarding headers (X-Real-Ip/X-Real-Port) are trusted. type ListenerConfig struct { - Address string - TLSConfig *tls.Config + Address string + TLSConfig *tls.Config + TrustedProxies *trustedproxy.List } // Server is the main entry point for the relay server. @@ -62,8 +65,9 @@ func NewServer(config Config) (*Server, error) { // Listen starts the relay server. func (r *Server) Listen(cfg ListenerConfig) error { wSListener := &ws.Listener{ - Address: cfg.Address, - TLSConfig: cfg.TLSConfig, + Address: cfg.Address, + TLSConfig: cfg.TLSConfig, + TrustedProxies: cfg.TrustedProxies, } r.listenerMux.Lock() diff --git a/trustedproxy/trustedproxy.go b/trustedproxy/trustedproxy.go new file mode 100644 index 000000000..70df01d92 --- /dev/null +++ b/trustedproxy/trustedproxy.go @@ -0,0 +1,132 @@ +package trustedproxy + +import ( + "fmt" + "net/netip" + "strings" +) + +// List holds a parsed set of trusted upstream proxy prefixes and answers trust +// questions against it. The zero value (and a nil *List) is a valid, empty list +// that never trusts any address, so callers can use it without a nil check. +type List struct { + prefixes []netip.Prefix +} + +// Parse parses a comma-separated list of CIDR prefixes or bare IPs into a List. +// Bare IPs are converted to single-host prefixes (/32 or /128). An empty input +// yields an empty List that trusts nothing. +func Parse(raw string) (*List, error) { + if raw == "" { + return &List{}, nil + } + + parts := strings.Split(raw, ",") + prefixes := make([]netip.Prefix, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + prefix, err := netip.ParsePrefix(part) + if err == nil { + prefixes = append(prefixes, prefix) + continue + } + + addr, addrErr := netip.ParseAddr(part) + if addrErr != nil { + return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr) + } + + bits := 32 + if addr.Is6() { + bits = 128 + } + prefixes = append(prefixes, netip.PrefixFrom(addr, bits)) + } + return &List{prefixes: prefixes}, nil +} + +// FromPrefixes wraps an already-parsed set of prefixes in a List. +func FromPrefixes(prefixes []netip.Prefix) *List { + return &List{prefixes: prefixes} +} + +// Empty reports whether the list contains no prefixes. +func (l *List) Empty() bool { + return l == nil || len(l.prefixes) == 0 +} + +// IsTrusted reports whether the given host:port or bare IP falls within the list. +func (l *List) IsTrusted(remoteAddr string) bool { + if l.Empty() { + return false + } + return l.Contains(ExtractHostIP(remoteAddr)) +} + +// Contains reports whether the given address falls within any trusted prefix. +func (l *List) Contains(addr netip.Addr) bool { + if l.Empty() || !addr.IsValid() { + return false + } + for _, prefix := range l.prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + +// ResolveClientIP extracts the real client IP from X-Forwarded-For using the +// list. It walks the XFF chain right-to-left, skipping IPs that match trusted +// prefixes; the first untrusted IP is the real client. If the list is empty or +// remoteAddr is not trusted, it returns the remoteAddr IP directly, ignoring any +// forwarding headers. +func (l *List) ResolveClientIP(remoteAddr, xff string) netip.Addr { + remoteIP := ExtractHostIP(remoteAddr) + + if l.Empty() || !l.Contains(remoteIP) { + return remoteIP + } + + if xff == "" { + return remoteIP + } + + parts := strings.Split(xff, ",") + for i := len(parts) - 1; i >= 0; i-- { + ip := strings.TrimSpace(parts[i]) + if ip == "" { + continue + } + addr, err := netip.ParseAddr(ip) + if err != nil { + continue + } + addr = addr.Unmap() + if !l.Contains(addr) { + return addr + } + } + + if first := strings.TrimSpace(parts[0]); first != "" { + if addr, err := netip.ParseAddr(first); err == nil { + return addr.Unmap() + } + } + return remoteIP +} + +// ExtractHostIP parses the IP from a host:port string and returns it unmapped. +func ExtractHostIP(hostPort string) netip.Addr { + if ap, err := netip.ParseAddrPort(hostPort); err == nil { + return ap.Addr().Unmap() + } + if addr, err := netip.ParseAddr(hostPort); err == nil { + return addr.Unmap() + } + return netip.Addr{} +} diff --git a/trustedproxy/trustedproxy_test.go b/trustedproxy/trustedproxy_test.go new file mode 100644 index 000000000..2e702a49c --- /dev/null +++ b/trustedproxy/trustedproxy_test.go @@ -0,0 +1,216 @@ +package trustedproxy + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + raw string + want []netip.Prefix + wantErr bool + }{ + { + name: "empty string returns empty list", + raw: "", + want: nil, + }, + { + name: "single CIDR", + raw: "10.0.0.0/8", + want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + }, + { + name: "single bare IPv4", + raw: "1.2.3.4", + want: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")}, + }, + { + name: "single bare IPv6", + raw: "::1", + want: []netip.Prefix{netip.MustParsePrefix("::1/128")}, + }, + { + name: "comma-separated CIDRs", + raw: "10.0.0.0/8, 192.168.1.0/24", + want: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.1.0/24"), + }, + }, + { + name: "mixed CIDRs and bare IPs", + raw: "10.0.0.0/8, 1.2.3.4, fd00::/8", + want: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("1.2.3.4/32"), + netip.MustParsePrefix("fd00::/8"), + }, + }, + { + name: "whitespace around entries", + raw: " 10.0.0.0/8 , 192.168.0.0/16 ", + want: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.0.0/16"), + }, + }, + { + name: "trailing comma produces no extra entry", + raw: "10.0.0.0/8,", + want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + }, + { + name: "invalid entry", + raw: "not-an-ip", + wantErr: true, + }, + { + name: "partially invalid", + raw: "10.0.0.0/8, garbage", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(tt.raw) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got.prefixes) + }) + } +} + +func TestListIsTrusted(t *testing.T) { + list, err := Parse("10.0.0.0/8, 192.168.1.0/24, fd00::/8") + require.NoError(t, err) + + tests := []struct { + name string + addr string + list *List + want bool + }{ + {"nil list", "10.0.0.1", nil, false}, + {"empty list", "10.0.0.1", &List{}, false}, + {"IP within /8 prefix", "10.1.2.3", list, true}, + {"IP within /24 prefix", "192.168.1.100", list, true}, + {"IP outside all prefixes", "203.0.113.50", list, false}, + {"boundary IP just outside prefix", "192.168.2.1", list, false}, + {"unparsable IP", "not-an-ip", list, false}, + {"IPv6 in trusted range", "fd00::1", list, true}, + {"IPv6 outside range", "2001:db8::1", list, false}, + {"empty string", "", list, false}, + {"host:port within prefix", "10.1.2.3:9999", list, true}, + {"host:port outside prefix", "203.0.113.50:9999", list, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.list.IsTrusted(tt.addr)) + }) + } +} + +func TestListResolveClientIP(t *testing.T) { + trusted, err := Parse("10.0.0.0/8, 172.16.0.0/12") + require.NoError(t, err) + + tests := []struct { + name string + remoteAddr string + xff string + list *List + want netip.Addr + }{ + { + name: "empty list returns RemoteAddr", + remoteAddr: "203.0.113.50:9999", + xff: "1.2.3.4", + list: &List{}, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "nil list returns RemoteAddr", + remoteAddr: "203.0.113.50:9999", + xff: "1.2.3.4", + list: nil, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "untrusted RemoteAddr ignores XFF", + remoteAddr: "203.0.113.50:9999", + xff: "1.2.3.4, 10.0.0.1", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "trusted RemoteAddr with single client in XFF", + remoteAddr: "10.0.0.1:5000", + xff: "203.0.113.50", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "trusted RemoteAddr walks past trusted entries in XFF", + remoteAddr: "10.0.0.1:5000", + xff: "203.0.113.50, 10.0.0.2, 172.16.0.5", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr", + remoteAddr: "10.0.0.1:5000", + xff: "", + list: trusted, + want: netip.MustParseAddr("10.0.0.1"), + }, + { + name: "all XFF IPs trusted returns leftmost", + remoteAddr: "10.0.0.1:5000", + xff: "10.0.0.2, 172.16.0.1, 10.0.0.3", + list: trusted, + want: netip.MustParseAddr("10.0.0.2"), + }, + { + name: "XFF with whitespace", + remoteAddr: "10.0.0.1:5000", + xff: " 203.0.113.50 , 10.0.0.2 ", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "XFF with empty segments", + remoteAddr: "10.0.0.1:5000", + xff: "203.0.113.50,,10.0.0.2", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "multi-hop with mixed trust", + remoteAddr: "10.0.0.1:5000", + xff: "8.8.8.8, 203.0.113.50, 172.16.0.1", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "RemoteAddr without port", + remoteAddr: "10.0.0.1", + xff: "203.0.113.50", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.list.ResolveClientIP(tt.remoteAddr, tt.xff)) + }) + } +} From 51f17bf9197d1abcc88218bc052614a6e98f18d5 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 20 Jul 2026 21:12:22 +0200 Subject: [PATCH 02/47] [client] Update wails to v3.0.0-alpha2.117 (#6837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Update wails to v3.0.0-alpha2.117 ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Chores** * Updated the application framework dependency to a newer release. * Removed an obsolete supporting dependency requirement. --- go.mod | 3 +-- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 3129c0ce6..ca798decc 100644 --- a/go.mod +++ b/go.mod @@ -113,7 +113,7 @@ require ( github.com/ti-mo/conntrack v0.5.1 github.com/ti-mo/netfilter v0.5.2 github.com/vmihailenco/msgpack/v5 v5.4.1 - github.com/wailsapp/wails/v3 v3.0.0-alpha2.111 + github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 github.com/yusufpapurcu/wmi v1.2.4 github.com/zcalusic/sysinfo v1.1.3 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 @@ -303,7 +303,6 @@ require ( github.com/tklauser/numcpus v0.10.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/wailsapp/wails/webview2 v1.0.27 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.3 // indirect diff --git a/go.sum b/go.sum index a69667355..58e30a580 100644 --- a/go.sum +++ b/go.sum @@ -660,10 +660,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/wailsapp/wails/v3 v3.0.0-alpha2.111 h1:MKx1nOnhnDuEGrRBmtxLOJq1NERwailu2cI4BvzWhi4= -github.com/wailsapp/wails/v3 v3.0.0-alpha2.111/go.mod h1:wrdvmyeCsB/K3YqJDoH8E3MwcN8NXAMnEFaDTW46w60= -github.com/wailsapp/wails/webview2 v1.0.27 h1:wjgAi/I8BBZ7kUGU8um3XF3ILEfzr96Q2Q1G4GPjMns= -github.com/wailsapp/wails/webview2 v1.0.27/go.mod h1:zdM4jcO1IaC61RiJL5F1BzgoqBHFIdacz8gPr5exr0o= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= From ca80e49aa071d714c8cd82935b2ea195d1e3478e Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 21 Jul 2026 09:25:33 +0200 Subject: [PATCH 03/47] [client] Refresh WireGuard stats in mobile debug bundles (#6814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS and Android DebugBundle paths built GeneratorDependencies without setting RefreshStatus, so the bundle's status.txt read the cached peer state instead of live WireGuard interface stats. When the periodic health probe had not run yet, connected relayed peers showed "handshake: -" and "0 B/0 B" even though the interface was passing traffic. Wire RefreshStatus to RunHealthProbes on both platforms, matching the desktop daemon path in client/server/debug.go. The engine reference is already available in the cc.Engine() block used for client metrics. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved debug bundle generation on Android and iOS by refreshing connection health status before collecting diagnostic information. * Ensured debug bundles include more current health-related data for troubleshooting. --- client/android/client.go | 3 +++ client/ios/NetBirdSDK/client.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/client/android/client.go b/client/android/client.go index 99ccdf393..2266ff53d 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -247,6 +247,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin deps.SyncResponse = resp if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } if cm := e.GetClientMetrics(); cm != nil { deps.ClientMetrics = cm } diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 359a83556..a2f123900 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -233,6 +233,9 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) { deps.SyncResponse = resp if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } if cm := e.GetClientMetrics(); cm != nil { deps.ClientMetrics = cm } From 82fdfa84b8bfb563ab93fc0cdfdd35f9a920f711 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 21 Jul 2026 10:10:12 +0200 Subject: [PATCH 04/47] [proxy] match Bedrock provider models against the normalized request model (#6773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Native AWS Bedrock requests carry the model in the URL path as a cross-region inference-profile id (e.g. `us.anthropic.claude-haiku-4-5`). The request parser normalizes that to the catalog key (`anthropic.claude-haiku-4-5`) before the router runs, but the router matched it against the operator's registered provider models with exact string equality. So a Bedrock provider registered with the id Bedrock actually uses (`us.anthropic…`) never matched a normalized request → the request denied with `llm_policy.model_not_routable` ("no provider configured for model …"). Only a provider registered with the already-stripped catalog id worked, which is not how Bedrock ids appear. Fix: introduce a single shared `llm.NormalizeBedrockModel` (the same ARN/region-prefix/version-suffix stripping the parser already does) and, in the router's `routeClaimsModel`, normalize a **Bedrock** route's candidate models before comparing. Now a Bedrock provider registered with either the raw inference-profile id or the normalized catalog id matches the request. Non-Bedrock routes keep exact matching. Surfaced by the new native-Bedrock e2e (`WireBedrock`, `/model/{id}/invoke`); the old e2e used the Anthropic body shape, which never normalized either side and so hid this. The request parser keeps its own identical normalizer for now; de-duplicating it onto `llm.NormalizeBedrockModel` is a trivial follow-up. ## Issue ticket number and link N/A — follow-up to the Agent Network Bedrock support / model-allowlist work. ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal routing correctness fix; no user-facing surface change. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ ## Tests - `proxy/internal/llm`: `NormalizeBedrockModel` unit cases (region prefixes, version suffixes, ARN). - `proxy/internal/middleware/builtin/llm_router`: `routeClaimsModel` matches a Bedrock route registered with the raw `us.anthropic…` id against a normalized request model; non-Bedrock routes still match exactly. Note: full through-tunnel e2e verification of this (the native-Bedrock `TestProvidersMatrix/bedrock`) also needs the DNS lazy-connection warm-up (separate PR) to get the client past the proxy-peer gate; they converge once both land. ## Summary by CodeRabbit * **Bug Fixes** * Improved Amazon Bedrock model matching across ARN formats, regional prefixes, and version or throughput suffixes. * Bedrock routes now correctly match equivalent model identifiers even when requests and route configurations use different formats. * Non-Bedrock model matching remains exact. --- proxy/internal/llm/bedrock_model.go | 38 +++++++++++++++++++ proxy/internal/llm/bedrock_model_test.go | 23 +++++++++++ .../builtin/llm_router/bedrock_route_test.go | 30 +++++++++++++++ .../builtin/llm_router/middleware.go | 9 +++++ 4 files changed, 100 insertions(+) create mode 100644 proxy/internal/llm/bedrock_model.go create mode 100644 proxy/internal/llm/bedrock_model_test.go create mode 100644 proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go diff --git a/proxy/internal/llm/bedrock_model.go b/proxy/internal/llm/bedrock_model.go new file mode 100644 index 000000000..a4c4704f7 --- /dev/null +++ b/proxy/internal/llm/bedrock_model.go @@ -0,0 +1,38 @@ +package llm + +import ( + "regexp" + "strings" +) + +// bedrockRegionPrefixes are the cross-region inference-profile prefixes that +// front a Bedrock model id (e.g. "eu.anthropic.claude-..."). +var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} + +// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]" +// version/throughput suffix of a Bedrock model id. +var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) + +// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile +// prefix, and the version/throughput suffix from a Bedrock model id so it +// matches the catalog/pricing key, e.g. +// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5" +// and the inference-profile ARN's last segment likewise. It is the single +// source of truth shared by the request parser (which normalizes the request +// model from the URL path) and the router (which normalizes the operator's +// registered Bedrock model ids so both sides compare equal). +func NormalizeBedrockModel(modelID string) string { + m := modelID + if strings.HasPrefix(m, "arn:") { + if i := strings.LastIndex(m, "/"); i >= 0 { + m = m[i+1:] + } + } + for _, p := range bedrockRegionPrefixes { + if strings.HasPrefix(m, p) { + m = m[len(p):] + break + } + } + return bedrockVersionSuffix.ReplaceAllString(m, "") +} diff --git a/proxy/internal/llm/bedrock_model_test.go b/proxy/internal/llm/bedrock_model_test.go new file mode 100644 index 000000000..3bd9662b7 --- /dev/null +++ b/proxy/internal/llm/bedrock_model_test.go @@ -0,0 +1,23 @@ +package llm + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeBedrockModel(t *testing.T) { + cases := map[string]string{ + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + "us.anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5", + "us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8", + "anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + "meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct", + "amazon.nova-pro-v1:0": "amazon.nova-pro", + // Inference-profile ARN — model id lives in the last path segment. + "arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + } + for in, want := range cases { + require.Equal(t, want, NormalizeBedrockModel(in), "normalize %q", in) + } +} diff --git a/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go new file mode 100644 index 000000000..40cbcb6bd --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go @@ -0,0 +1,30 @@ +package llm_router + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestRouteClaimsModel_BedrockNormalizesCandidate guards the fix for the native +// Bedrock routing gap: the request model reaches the router already normalized +// (the parser strips the region/inference-profile prefix and version suffix), +// so a provider registered with the raw inference-profile id must still match. +func TestRouteClaimsModel_BedrockNormalizesCandidate(t *testing.T) { + route := ProviderRoute{Bedrock: true, Models: []string{"us.anthropic.claude-haiku-4-5"}} + assert.True(t, routeClaimsModel(route, "anthropic.claude-haiku-4-5"), + "raw region-prefixed Bedrock model must match the normalized request model") + assert.False(t, routeClaimsModel(route, "anthropic.claude-opus-4-8"), + "a model outside the provider's list must not match") + + // A provider registered with the already-normalized id also matches. + normalized := ProviderRoute{Bedrock: true, Models: []string{"anthropic.claude-haiku-4-5"}} + assert.True(t, routeClaimsModel(normalized, "anthropic.claude-haiku-4-5"), + "normalized Bedrock model must match") + + // Non-Bedrock routes keep exact matching (no prefix stripping). + openai := ProviderRoute{Models: []string{"gpt-4o"}} + assert.True(t, routeClaimsModel(openai, "gpt-4o"), "exact model must match") + assert.False(t, routeClaimsModel(openai, "us.gpt-4o"), + "non-Bedrock routes must not strip a us. prefix") +} diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index 2aaeb1089..2d987eef6 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -23,6 +23,7 @@ import ( "golang.org/x/oauth2" "golang.org/x/oauth2/google" + "github.com/netbirdio/netbird/proxy/internal/llm" "github.com/netbirdio/netbird/proxy/internal/middleware" ) @@ -555,6 +556,14 @@ func routeClaimsModel(route ProviderRoute, model string) bool { if candidate == model { return true } + // Bedrock request models reach the router already normalized (the parser + // strips the region / inference-profile prefix and version suffix), but + // the operator may register the raw inference-profile id (e.g. + // "us.anthropic.claude-haiku-4-5"). Normalize the candidate so both sides + // compare equal; otherwise a native Bedrock request denies as not-routable. + if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model { + return true + } } return false } From d9392fdbb8690d5afd47b0eab0d2d24eafae4e42 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 21 Jul 2026 11:26:16 +0200 Subject: [PATCH 05/47] [client] Clarify outdated NetBird client overlay (#6718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Add more information about the current client and GUI versions if the user is running an older client. Update the URL to download the latest RC if the user is running any RC build. CleanShot 2026-07-10 at 15 11 54 ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- .../empty-state/DaemonOutdatedOverlay.tsx | 55 ++++++++++++++++++- client/ui/i18n/locales/de/common.json | 7 ++- client/ui/i18n/locales/en/common.json | 12 ++-- client/ui/i18n/locales/es/common.json | 7 ++- client/ui/i18n/locales/fr/common.json | 7 ++- client/ui/i18n/locales/hu/common.json | 7 ++- client/ui/i18n/locales/it/common.json | 7 ++- client/ui/i18n/locales/pt/common.json | 7 ++- client/ui/i18n/locales/ru/common.json | 7 ++- client/ui/i18n/locales/zh-CN/common.json | 7 ++- 10 files changed, 100 insertions(+), 23 deletions(-) diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx index 4ee8c2740..e8e7108eb 100644 --- a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx @@ -1,10 +1,13 @@ +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangleIcon, DownloadIcon } from "lucide-react"; import { Browser } from "@wailsio/runtime"; +import { Version } from "@bindings/services"; import { Button } from "@/components/buttons/Button"; import { useStatus } from "@/contexts/StatusContext.tsx"; const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest"; +const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc"; function openUrl(url: string) { Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank")); @@ -12,7 +15,26 @@ function openUrl(url: string) { export const DaemonOutdatedOverlay = () => { const { t } = useTranslation(); - const { isDaemonOutdated } = useStatus(); + const { status, isDaemonOutdated } = useStatus(); + + const [guiVersion, setGuiVersion] = useState("-"); + const clientVersion = status?.daemonVersion ?? "—"; + + const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion); + const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL; + + useEffect(() => { + if (!isDaemonOutdated) return; + let cancelled = false; + Version.GUI() + .then((v) => { + if (!cancelled) setGuiVersion(v); + }) + .catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err)); + return () => { + cancelled = true; + }; + }, [isDaemonOutdated]); if (!isDaemonOutdated) return null; @@ -38,10 +60,37 @@ export const DaemonOutdatedOverlay = () => {

{t("daemon.outdated.description")}

+
+

+ {clientVersion === "development" ? ( + + {t("settings.about.clientName")}{" "} + + {t("settings.about.development")} + + + ) : ( + t("settings.about.client", { version: clientVersion }) + )} +

+

+ {guiVersion === "development" ? ( + + {t("settings.about.guiName")}{" "} + + {t("settings.about.development")} + + + ) : ( + t("settings.about.gui", { version: guiVersion }) + )} +

+
+
-
diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 5e0d8096d..19e1cffd8 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1293,10 +1293,13 @@ "message": "Dokumentation" }, "daemon.outdated.title": { - "message": "NetBird-Dienst ist veraltet" + "message": "NetBird Client ist veraltet" }, "daemon.outdated.description": { - "message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden." + "message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden." + }, + "daemon.outdated.download": { + "message": "Neueste Version herunterladen" }, "error.jwt_clock_skew": { "message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut." diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 42d40ec30..a83d76be6 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1724,12 +1724,16 @@ "description": "Documentation link on the daemon-unavailable overlay." }, "daemon.outdated.title": { - "message": "NetBird Service Is Outdated", - "description": "Title of the overlay shown when the NetBird background service is too old to drive this UI." + "message": "NetBird Client Is Outdated", + "description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI." }, "daemon.outdated.description": { - "message": "Update the NetBird service to use this app.", - "description": "Body of the daemon-outdated overlay telling the user to upgrade the service." + "message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.", + "description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated." + }, + "daemon.outdated.download": { + "message": "Download Latest", + "description": "Button on the daemon-outdated overlay that opens the download page for the latest release." }, "error.jwt_clock_skew": { "message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.", diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 47faee61f..24127a9b8 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1293,10 +1293,13 @@ "message": "Documentación" }, "daemon.outdated.title": { - "message": "El servicio de NetBird está desactualizado" + "message": "NetBird Client está desactualizado" }, "daemon.outdated.description": { - "message": "Actualice el servicio de NetBird para usar esta aplicación." + "message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación." + }, + "daemon.outdated.download": { + "message": "Descargar la última versión" }, "error.jwt_clock_skew": { "message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo." diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index be0836e93..de2ab0200 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1293,10 +1293,13 @@ "message": "Documentation" }, "daemon.outdated.title": { - "message": "Le service NetBird est obsolète" + "message": "Le Client NetBird est obsolète" }, "daemon.outdated.description": { - "message": "Mettez à jour le service NetBird pour utiliser cette application." + "message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application." + }, + "daemon.outdated.download": { + "message": "Télécharger la dernière version" }, "error.jwt_clock_skew": { "message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer." diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index b54918364..5f3d32187 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1293,10 +1293,13 @@ "message": "Dokumentáció" }, "daemon.outdated.title": { - "message": "A NetBird szolgáltatás elavult" + "message": "A NetBird Kliens elavult" }, "daemon.outdated.description": { - "message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához." + "message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához." + }, + "daemon.outdated.download": { + "message": "Legújabb letöltése" }, "error.jwt_clock_skew": { "message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra." diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 603364fa2..dbcdbd3b9 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1293,10 +1293,13 @@ "message": "Documentazione" }, "daemon.outdated.title": { - "message": "Il servizio NetBird è obsoleto" + "message": "NetBird Client è obsoleto" }, "daemon.outdated.description": { - "message": "Aggiorna il servizio NetBird per usare questa app." + "message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione." + }, + "daemon.outdated.download": { + "message": "Scarica l'ultima versione" }, "error.jwt_clock_skew": { "message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi." diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index 2ed0a94c5..1a7ba0fa5 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1293,10 +1293,13 @@ "message": "Documentação" }, "daemon.outdated.title": { - "message": "O serviço NetBird está desatualizado" + "message": "O NetBird Client está desatualizado" }, "daemon.outdated.description": { - "message": "Atualize o serviço NetBird para usar este aplicativo." + "message": "A nova GUI não é compatível com o seu cliente mais antigo. Atualize o seu cliente para usar o novo aplicativo." + }, + "daemon.outdated.download": { + "message": "Baixar a versão mais recente" }, "error.jwt_clock_skew": { "message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente." diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 6ba7de8cc..c926c8e22 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1293,10 +1293,13 @@ "message": "Документация" }, "daemon.outdated.title": { - "message": "Служба NetBird устарела" + "message": "Клиент NetBird устарел" }, "daemon.outdated.description": { - "message": "Обновите службу NetBird, чтобы использовать это приложение." + "message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение." + }, + "daemon.outdated.download": { + "message": "Скачать последнюю версию" }, "error.jwt_clock_skew": { "message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку." diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 609344fc0..725599df2 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1293,10 +1293,13 @@ "message": "文档" }, "daemon.outdated.title": { - "message": "NetBird 服务版本过旧" + "message": "NetBird 客户端版本过旧" }, "daemon.outdated.description": { - "message": "请更新 NetBird 服务以使用此应用。" + "message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用新应用。" + }, + "daemon.outdated.download": { + "message": "下载最新版本" }, "error.jwt_clock_skew": { "message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。" From 3cda14d7f2efed31799da988a6b602d6cf73dcd1 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 21 Jul 2026 11:26:27 +0200 Subject: [PATCH 06/47] [client] Use menu bar wording on macOS welcome screen (#6810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes The post-install welcome step said "Look for NetBird in your tray" on every OS, but macOS has no system tray — the icon sits in the menu bar. The tray wording stays correct on Windows and Linux. - Add `welcome.titleMac` and `welcome.descriptionMac` (following the existing `settings.advanced.interfaceName.errorMac` key convention) to `en` and all nine translated bundles (`de`, `es`, `fr`, `hu`, `it`, `ja`, `pt`, `ru`, `zh-CN`), each using that language's Apple term for the menu bar (Menüleiste, barra de menús, barre des menus, menüsor, barra dei menu, メニューバー, barra de menus, строка меню, 菜单栏). The `ja` bundle landed on main (#6790) after the initial commit and was covered after merging main back in. - `WelcomeStepTray.tsx` picks the key via `isMacOS()`, which it already uses to choose the per-OS screenshot. Verified: `go test ./client/ui/i18n/...`, `tsc --noEmit`, `eslint`, and `prettier --check` all pass; key set and placement verified identical across all ten bundles. Not visually verified in the running app (headless session) — the welcome dialog only shows on first launch. ## Issue ticket number and link Fixes NET-1411 ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (UI copy fix only; no behavior, API, or configuration change) ### Docs PR URL (required if "docs added" is checked) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added macOS-specific onboarding text that directs users to find NetBird in the menu bar. * Updated localized welcome content across supported languages, while retaining platform-specific tray guidance where applicable. --------- Co-authored-by: Claude Fable 5 --- .../frontend/src/modules/welcome/WelcomeStepTray.tsx | 7 +++++-- client/ui/i18n/locales/de/common.json | 6 ++++++ client/ui/i18n/locales/en/common.json | 12 ++++++++++-- client/ui/i18n/locales/es/common.json | 6 ++++++ client/ui/i18n/locales/fr/common.json | 6 ++++++ client/ui/i18n/locales/hu/common.json | 6 ++++++ client/ui/i18n/locales/it/common.json | 6 ++++++ client/ui/i18n/locales/ja/common.json | 6 ++++++ client/ui/i18n/locales/pt/common.json | 6 ++++++ client/ui/i18n/locales/ru/common.json | 6 ++++++ client/ui/i18n/locales/zh-CN/common.json | 6 ++++++ 11 files changed, 69 insertions(+), 4 deletions(-) diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx index fe06abc20..5a8b0d015 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -22,6 +22,9 @@ type WelcomeStepTrayProps = { export function WelcomeStepTray({ onContinue }: Readonly) { const { t } = useTranslation(); const trayScreenshot = trayScreenshotForOS(); + // macOS has no tray — the icon sits in the menu bar, so the copy says so. + const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title"; + const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description"; return ( <> @@ -36,9 +39,9 @@ export function WelcomeStepTray({ onContinue }: Readonly)
- {t("welcome.title")} + {t(titleKey)} - {t("welcome.description")} + {t(descriptionKey)}
diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 19e1cffd8..5e91e8d88 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Suchen Sie NetBird in der Taskleiste" }, + "welcome.titleMac": { + "message": "Suchen Sie NetBird in der Menüleiste" + }, "welcome.description": { "message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." }, + "welcome.descriptionMac": { + "message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." + }, "welcome.continue": { "message": "Weiter" }, diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index a83d76be6..24bbc67ce 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1377,11 +1377,19 @@ }, "welcome.title": { "message": "Look for NetBird in your tray", - "description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar." + "description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac." + }, + "welcome.titleMac": { + "message": "Look for NetBird in your menu bar", + "description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar." }, "welcome.description": { "message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.", - "description": "Body of the first onboarding step explaining the tray icon." + "description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac." + }, + "welcome.descriptionMac": { + "message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.", + "description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar." }, "welcome.continue": { "message": "Continue", diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 24127a9b8..c036e4f75 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Busque NetBird en su bandeja del sistema" }, + "welcome.titleMac": { + "message": "Busque NetBird en su barra de menús" + }, "welcome.description": { "message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." }, + "welcome.descriptionMac": { + "message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." + }, "welcome.continue": { "message": "Continuar" }, diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index de2ab0200..c6b91fb25 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Cherchez NetBird dans votre barre d’état système" }, + "welcome.titleMac": { + "message": "Cherchez NetBird dans votre barre des menus" + }, "welcome.description": { "message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." }, + "welcome.descriptionMac": { + "message": "NetBird se trouve dans votre barre des menus. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." + }, "welcome.continue": { "message": "Continuer" }, diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 5f3d32187..dd5a1af6c 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Keresse a NetBirdöt a tálcán" }, + "welcome.titleMac": { + "message": "Keresse a NetBirdöt a menüsorban" + }, "welcome.description": { "message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." }, + "welcome.descriptionMac": { + "message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." + }, "welcome.continue": { "message": "Folytatás" }, diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index dbcdbd3b9..7a2eb610c 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Cerchi NetBird nella tray" }, + "welcome.titleMac": { + "message": "Cerchi NetBird nella barra dei menu" + }, "welcome.description": { "message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." }, + "welcome.descriptionMac": { + "message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." + }, "welcome.continue": { "message": "Continua" }, diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index cd54bce17..326c825bf 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "トレイの NetBird を確認してください" }, + "welcome.titleMac": { + "message": "メニューバーの NetBird を確認してください" + }, "welcome.description": { "message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" }, + "welcome.descriptionMac": { + "message": "NetBird はメニューバーに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" + }, "welcome.continue": { "message": "続ける" }, diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index 1a7ba0fa5..37b02d5a8 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Procure o NetBird na sua bandeja" }, + "welcome.titleMac": { + "message": "Procure o NetBird na sua barra de menus" + }, "welcome.description": { "message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações." }, + "welcome.descriptionMac": { + "message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações." + }, "welcome.continue": { "message": "Continuar" }, diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index c926c8e22..b9ae59df2 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Найдите NetBird в системном трее" }, + "welcome.titleMac": { + "message": "Найдите NetBird в строке меню" + }, "welcome.description": { "message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." }, + "welcome.descriptionMac": { + "message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." + }, "welcome.continue": { "message": "Продолжить" }, diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 725599df2..2141a770d 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "在托盘中查找 NetBird" }, + "welcome.titleMac": { + "message": "在菜单栏中查找 NetBird" + }, "welcome.description": { "message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。" }, + "welcome.descriptionMac": { + "message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。" + }, "welcome.continue": { "message": "继续" }, From 6fc05efa6c5e6672c9b733114d20028fcd34711b Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 21 Jul 2026 13:34:30 +0200 Subject: [PATCH 07/47] [client] Disconnect daemon on GUI quit via async Down (#6796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tray Quit menu now disconnects the daemon before exiting instead of only tearing down the GUI. A new DownAsync RPC lets the daemon start the teardown and return immediately: beginDown cancels the connection under the mutex (so it cannot reconnect), then finishDown (the retry-goroutine wait and status reset) runs on a background goroutine. handleQuit aborts any in-flight profile switch first (so a queued Up cannot reconnect during teardown) and calls DownAsync so quitting never blocks on the engine shutdown. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved shutdown reliability by continuing teardown even when stopping the service fails (stop errors are logged but not returned). * Refined connection shutdown to return “service not up” errors directly for clearer, more immediate RPC behavior. * Prevented shutdown hangs by making the tray Quit disconnect time-bounded (5 seconds). * Ensured any in-flight profile switch is cancelled before exiting, with quit serialized to avoid races. --- client/server/server.go | 7 +++++-- client/ui/tray.go | 21 ++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/client/server/server.go b/client/server/server.go index 2b919d58d..8047006fe 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1081,7 +1081,10 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes if err := s.cleanupConnection(); err != nil { s.mutex.Unlock() - // todo review to update the status in case any type of error + if errors.Is(err, ErrServiceNotUp) { + log.Debugf("Down called while service not up: %v", err) + return nil, err + } log.Errorf("failed to shut down properly: %v", err) return nil, err } @@ -1154,7 +1157,7 @@ func (s *Server) cleanupConnection() error { // making the run loop the sole owner of engine shutdown. if engine != nil { if err := engine.Stop(); err != nil { - return err + log.Errorf("failed to stop engine during cleanup: %v", err) } } diff --git a/client/ui/tray.go b/client/ui/tray.go index 700d94098..c4918825f 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -30,6 +30,8 @@ const ( statusError = "Error" + quitDownTimeout = 5 * time.Second + urlGitHubRepo = "https://github.com/netbirdio/netbird" urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest" urlDocs = "https://docs.netbird.io" @@ -446,11 +448,28 @@ func (t *Tray) buildMenu() *application.Menu { menu.AddSeparator() menu.Add(t.loc.T("tray.menu.quit")). SetAccelerator("CmdOrCtrl+Q"). - OnClick(func(*application.Context) { t.app.Quit() }) + OnClick(func(*application.Context) { t.handleQuit() }) return menu } +func (t *Tray) handleQuit() { + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + t.switchCancel = nil + } + t.profileMu.Unlock() + t.svc.DaemonFeed.CancelProfileSwitch() + + ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout) + defer cancel() + if err := t.svc.Connection.Down(ctx); err != nil { + log.Errorf("disconnect on quit: %v", err) + } + t.app.Quit() +} + // handleConnect receives the clicked item from the buildMenu closure — // t.upItem is menuMu-guarded and must not be read here. func (t *Tray) handleConnect(upItem *application.MenuItem) { From b6cd8944b1b675b524cc8e0ffaf5e1d6d861cdd1 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 21 Jul 2026 13:37:29 +0200 Subject: [PATCH 08/47] [client] Fix nil context panic in iOS dynamic route resolver (#6848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes getIPsFromResolver passed a nil context to ExchangeWithFallback, which net.Dialer.DialContext rejects with panic("nil context"). On iOS this crashed the whole network extension (SIGABRT) ~2 seconds after connect whenever the network map contained a domain-based (dynamic) route, as the resolver goroutine panicked on its first DNS query. Passing nil used to be a documented input of ExchangeWithFallback ("If the passed context is nil, this will use Exchange instead of ExchangeContext") since #3632. 9ed2e2a5b (#5971) removed the nil-context branch, but this iOS-only caller was not updated — it never fails CI since route_ios.go only builds with GOOS=ios. Broken since v0.71.1. Pass a context bounded by the existing dialTimeout instead, matching the dnsinterceptor pattern (context.Background() + timeout). Captured panic (netbird.err): panic: nil context net.(*Dialer).DialContext -> miekg/dns ExchangeContext -> nbdns.ExchangeWithFallback(nil, ...) -> dynamic.(*Route).getIPsFromResolver route_ios.go:35 ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Fixed iOS dynamic DNS resolution by ensuring DNS queries use a proper resolver context instead of a nil one. * Improved DNS resolution reliability by propagating cancellation/timeouts through all domain IP lookups, including fallback system resolver queries. --- client/internal/routemanager/dynamic/route.go | 26 ++++++++++++++----- .../routemanager/dynamic/route_generic.go | 5 ++-- .../routemanager/dynamic/route_ios.go | 5 ++-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go index f0efd7b22..3fe8a4bb3 100644 --- a/client/internal/routemanager/dynamic/route.go +++ b/client/internal/routemanager/dynamic/route.go @@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) { } func (r *Route) update(ctx context.Context) error { - resolved, err := r.resolveDomains() + resolved, err := r.resolveDomains(ctx) if err != nil { if len(resolved) == 0 { return fmt.Errorf("resolve domains: %w", err) @@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error { return nil } -func (r *Route) resolveDomains() (domainMap, error) { +func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) { results := make(chan resolveResult) - go r.resolve(results) + go r.resolve(ctx, results) resolved := domainMap{} var merr *multierror.Error @@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) { return resolved, nberrors.FormatErrorOrNil(merr) } -func (r *Route) resolve(results chan resolveResult) { +func (r *Route) resolve(ctx context.Context, results chan resolveResult) { var wg sync.WaitGroup for _, d := range r.route.Domains { @@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) { go func(domain domain.Domain) { defer wg.Done() - ips, err := r.getIPsFromResolver(domain) + ips, err := r.getIPsFromResolver(ctx, domain) if err != nil { log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err) - ips, err = net.LookupIP(domain.PunycodeString()) + ips, err = lookupHostIPs(ctx, domain) if err != nil { results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)} return @@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR return } +// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation. +func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) { + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString()) + if err != nil { + return nil, err + } + + ips := make([]net.IP, 0, len(addrs)) + for _, addr := range addrs { + ips = append(ips, addr.IP) + } + return ips, nil +} + func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix { prefixSet := make(map[netip.Prefix]struct{}) for _, prefix := range oldPrefixes { diff --git a/client/internal/routemanager/dynamic/route_generic.go b/client/internal/routemanager/dynamic/route_generic.go index 56fd63fba..8bc2dd3df 100644 --- a/client/internal/routemanager/dynamic/route_generic.go +++ b/client/internal/routemanager/dynamic/route_generic.go @@ -3,11 +3,12 @@ package dynamic import ( + "context" "net" "github.com/netbirdio/netbird/shared/management/domain" ) -func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { - return net.LookupIP(domain.PunycodeString()) +func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) { + return lookupHostIPs(ctx, domain) } diff --git a/client/internal/routemanager/dynamic/route_ios.go b/client/internal/routemanager/dynamic/route_ios.go index 1ae281d56..6a3d262b8 100644 --- a/client/internal/routemanager/dynamic/route_ios.go +++ b/client/internal/routemanager/dynamic/route_ios.go @@ -3,6 +3,7 @@ package dynamic import ( + "context" "fmt" "net" "time" @@ -16,7 +17,7 @@ import ( const dialTimeout = 10 * time.Second -func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { +func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) { privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout) if err != nil { return nil, fmt.Errorf("error while creating private client: %s", err) @@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { msg := new(dns.Msg) msg.SetQuestion(fqdn, qtype) - response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String()) + response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String()) if err != nil { if queryErr == nil { queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err) From 69c35e31b440396c96f221c4c9caeac6828424a1 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 21 Jul 2026 15:01:31 +0200 Subject: [PATCH 09/47] [client] Fix browser dialog not closing on renew session flow (#6745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ ## Summary by CodeRabbit * **Bug Fixes** * SSO browser-login popups now open centered on the display where the cursor is located, and they recenter correctly on subsequent opens. * Programmatic cleanup no longer triggers “login canceled” behavior; cancel is emitted only when the user closes the active popup. * **New Features** * Added a streamlined “close renewal flow” action that tears down the session-renewal UI by closing both the login and session-expiration popups. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../session/SessionExpirationDialog.tsx | 21 +++--- client/ui/services/windowmanager.go | 67 +++++++++++++++---- 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx index 2ceb958d4..10e71babb 100644 --- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -73,6 +73,13 @@ export default function SessionExpirationDialog() { let offCancel: (() => void) | undefined; + // Return the dialog to its interactive state and dismiss the browser popup + const resetDialog = () => { + offCancel?.(); + WindowManager.CloseBrowserLogin().catch(console.error); + setBusy(false); + }; + try { const start = await Session.RequestExtend({ hint: "" }); const uri = start.verificationUriComplete || start.verificationUri; @@ -105,25 +112,22 @@ export default function SessionExpirationDialog() { if (outcome.kind === "cancel") { waitPromise.cancel?.(); waitPromise.catch(() => {}); + resetDialog(); return; } // Another surface owns this flow; keep the dialog open to retry. if (outcome.result.preempted) { + resetDialog(); return; } - - // Close before the popup so the restore can't flash this window back. - WindowManager.CloseSessionExpiration().catch(console.error); + WindowManager.CloseRenewFlow().catch(console.error); } catch (e) { + resetDialog(); await errorDialog({ Title: t("sessionExpiration.extendFailedTitle"), Message: formatErrorMessage(e), }); - } finally { - offCancel?.(); - WindowManager.CloseBrowserLogin().catch(console.error); - setBusy(false); } }, [busy, t]); @@ -139,12 +143,11 @@ export default function SessionExpirationDialog() { }); WindowManager.CloseSessionExpiration().catch(console.error); } catch (e) { + setBusy(false); await errorDialog({ Title: t("sessionExpiration.logoutFailedTitle"), Message: formatErrorMessage(e), }); - } finally { - setBusy(false); } }, [busy, t]); diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 3316dadaa..1185ec729 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -185,37 +185,38 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) } s.hideOtherWindowsLocked("browser-login") - // Prefer the main window's screen (multi-monitor); falls back to OS-default centering. - var screen *application.Screen - if s.mainWindow != nil { - if sc, err := s.mainWindow.GetScreen(); err == nil { - screen = sc - } - } opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon) // Not always-on-top: it would obscure the browser tab the user logs in through. opts.AlwaysOnTop = false opts.InitialPosition = application.WindowCentered - opts.Screen = screen + // Open on the active (where users cursor is) display, like the session-expiration dialog. + opts.Screen = s.getScreenBasedOnCursorPosition() s.browserLogin = s.app.Window.NewWithOptions(opts) bl := s.browserLogin - // Red-X close means cancel: emit the event so startLogin() tears down the SSO wait. bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { - s.app.Event.Emit(EventBrowserLoginCancel) s.mu.Lock() - s.browserLogin = nil - s.restoreHiddenWindowsLocked() + // Only a live user red-X still has this registered; programmatic closers + // nil s.browserLogin first and clean up themselves. Guarding here stops a + // stale close event from wiping a replacement popup's state. + userClosed := s.browserLogin == bl + if userClosed { + s.browserLogin = nil + s.restoreHiddenWindowsLocked() + } s.mu.Unlock() + if userClosed { + s.app.Event.Emit(EventBrowserLoginCancel) + } }) - s.centerWhenReady(s.browserLogin) + s.centerOnCursorScreen(s.browserLogin) return } if uri != "" { s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) } + s.centerOnCursorScreen(s.browserLogin) s.browserLogin.Show() s.browserLogin.Focus() - s.centerWhenReady(s.browserLogin) } // BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the @@ -238,6 +239,15 @@ func (s *WindowManager) CloseBrowserLogin() { s.mu.Lock() w := s.browserLogin s.browserLogin = nil + // The WindowClosing hook no-ops on a programmatic close, so restore here — + // but only if a popup was actually open. The frontend calls this even when no + // popup was ever shown (e.g. resetDialog() after an early RequestExtend failure, + // or connection.ts's catch path), and hiddenForLogin is shared with + // OpenInstallProgress, so an unconditional restore could re-show windows a + // still-running install-progress is hiding. + if w != nil { + s.restoreHiddenWindowsLocked() + } s.mu.Unlock() if w != nil { w.Close() @@ -279,6 +289,35 @@ func (s *WindowManager) CloseSessionExpiration() { } } +// CloseRenewFlow tears down the SSO session-renewal UI in a single call: it +// closes the browser-login popup and the session-expiration window together. +func (s *WindowManager) CloseRenewFlow() { + s.mu.Lock() + bl := s.browserLogin + se := s.sessionExpiration + s.browserLogin = nil + s.sessionExpiration = nil + if se != nil { + kept := s.hiddenForLogin[:0] + for _, w := range s.hiddenForLogin { + if w != se { + kept = append(kept, w) + } + } + s.hiddenForLogin = kept + } + s.restoreHiddenWindowsLocked() + s.mu.Unlock() + + // Close after unlock so the re-entrant handlers can take s.mu. + if bl != nil { + bl.Close() + } + if se != nil { + se.Close() + } +} + // OpenInstallProgress shows the install-progress window and hides the rest for the duration // (restored on close). It owns its own result polling since the daemon restarts mid-install. func (s *WindowManager) OpenInstallProgress(version string) { From 9620890b6517c0090ca5987e2a2af560db92739a Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 21 Jul 2026 15:28:11 +0200 Subject: [PATCH 10/47] [client] Always connect on profile selection except in manage profiles (#6838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a profile from the header dropdown or the tray submenu now always brings the connection up after the switch, regardless of the previous daemon state. Switching from the manage-profiles screen (including profile creation) never connects, leaving a chance to adjust the management URL first. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added the ability to switch profiles without automatically establishing a connection. * Existing profile switching continues to connect when appropriate, while safely handling active or pending connections during the switch. --- .../frontend/src/contexts/ProfileContext.tsx | 13 +++++ .../src/modules/profiles/ProfilesTab.tsx | 9 ++- client/ui/services/profileswitcher.go | 57 ++++++++++++------- 3 files changed, 52 insertions(+), 27 deletions(-) diff --git a/client/ui/frontend/src/contexts/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx index 4dd3eaa7a..62377f1bc 100644 --- a/client/ui/frontend/src/contexts/ProfileContext.tsx +++ b/client/ui/frontend/src/contexts/ProfileContext.tsx @@ -28,6 +28,7 @@ type ProfileContextValue = { loaded: boolean; refresh: () => Promise; switchProfile: (id: string) => Promise; + switchProfileNoConnect: (id: string) => Promise; addProfile: (name: string) => Promise; removeProfile: (id: string) => Promise; renameProfile: (id: string, newName: string) => Promise; @@ -112,6 +113,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { [username, refresh], ); + // Manage-profiles variant: switches without connecting, so the user can + // still adjust the management URL before bringing the connection up. + const switchProfileNoConnect = useCallback( + async (id: string) => { + await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + // addProfile creates a profile by display name and returns the // daemon-generated ID, so the caller can immediately address it by ID. const addProfile = useCallback( @@ -158,6 +169,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { loaded, refresh, switchProfile, + switchProfileNoConnect, addProfile, removeProfile, renameProfile, @@ -171,6 +183,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { loaded, refresh, switchProfile, + switchProfileNoConnect, addProfile, removeProfile, renameProfile, diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index c1ce2e449..97261ccc9 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -45,7 +45,7 @@ export function ProfilesTab() { activeProfileId, loaded, username, - switchProfile, + switchProfileNoConnect, addProfile, removeProfile, renameProfile, @@ -100,7 +100,7 @@ export function ProfilesTab() { confirmLabel: t("profile.switch.confirm"), }); if (!ok) return; - await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id)); + await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id)); }; const handleDeregister = async (id: string, name: string) => { @@ -129,14 +129,13 @@ export function ProfilesTab() { await guarded(i18next.t("profile.error.createTitle"), async () => { const id = await addProfile(name); // SetConfig is keyed by the new profile's ID, so it writes the - // not-yet-active profile. Write before switching so any reconnect - // targets the right deployment. + // not-yet-active profile before the switch makes it current. if (!isNetbirdCloud(managementUrl)) { await SettingsSvc.SetConfig( new SetConfigParams({ profileName: id, username, managementUrl }), ); } - await switchProfile(id); + await switchProfileNoConnect(id); }); }; diff --git a/client/ui/services/profileswitcher.go b/client/ui/services/profileswitcher.go index c27b62d92..727b2473f 100644 --- a/client/ui/services/profileswitcher.go +++ b/client/ui/services/profileswitcher.go @@ -12,13 +12,15 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" ) -// ProfileSwitcher holds the reconnect policy shared by the tray and React -// frontend so both flip profiles identically. The policy keys off prevStatus -// from DaemonFeed.Get at SwitchActive entry: +// ProfileSwitcher holds the switch policy shared by the tray and React +// frontend so both flip profiles identically. SwitchActive (plain selection: +// header dropdown, tray submenu) always connects after the switch; +// SwitchActiveNoConnect (manage-profiles screen) never does, so the user can +// still adjust the management URL before connecting. prevStatus from +// DaemonFeed.Get at entry only decides the teardown: // -// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint. -// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login. -// Idle → Switch only. +// Connected/Connecting/NeedsLogin/LoginFailed/SessionExpired → Down first. +// Idle → no Down. type ProfileSwitcher struct { profiles *Profiles connection *Connection @@ -29,29 +31,40 @@ func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *Daemon return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed} } -// SwitchActive switches to the named profile applying the reconnect policy. +// SwitchActive switches to the named profile and always connects afterwards. func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error { + return s.switchActive(ctx, p, true) +} + +// SwitchActiveNoConnect switches to the named profile without connecting, +// tearing down any existing connection first. +func (s *ProfileSwitcher) SwitchActiveNoConnect(ctx context.Context, p ProfileRef) error { + return s.switchActive(ctx, p, false) +} + +func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connect bool) error { prevStatus := "" - if st, err := s.feed.Get(ctx); err == nil { - prevStatus = st.Status - } else { - log.Warnf("profileswitcher: get status: %v", err) + if s.feed != nil { + if st, err := s.feed.Get(ctx); err == nil { + prevStatus = st.Status + } else { + log.Warnf("profileswitcher: get status: %v", err) + } } - wasActive := strings.EqualFold(prevStatus, StatusConnected) || - strings.EqualFold(prevStatus, StatusConnecting) - needsDown := wasActive || + needsDown := strings.EqualFold(prevStatus, StatusConnected) || + strings.EqualFold(prevStatus, StatusConnecting) || strings.EqualFold(prevStatus, StatusNeedsLogin) || strings.EqualFold(prevStatus, StatusLoginFailed) || strings.EqualFold(prevStatus, StatusSessionExpired) - log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v", - p.ProfileName, prevStatus, wasActive, needsDown) + log.Infof("profileswitcher: switch profile=%q prevStatus=%q connect=%v needsDown=%v", + p.ProfileName, prevStatus, connect, needsDown) - // Optimistic Connecting paint only when wasActive: those prevStatuses emit - // stale Connected + transient Idle pushes during Down that must be - // suppressed until Up resumes the stream (see DaemonFeed suppression table). - if wasActive { + // Optimistic Connecting paint plus stale-push suppression during Down (see + // DaemonFeed suppression table); also arms the login-watch that pops + // browser-login when the new profile turns out to need SSO. + if connect && s.feed != nil { s.feed.BeginProfileSwitch() } @@ -76,9 +89,9 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error } } - if wasActive { + if connect { if err := s.connection.Up(ctx, UpParams(p)); err != nil { - return fmt.Errorf("reconnect %q: %w", p.ProfileName, err) + return fmt.Errorf("connect %q: %w", p.ProfileName, err) } } From 0e520ee9f50b8e7c844c98ea16348c81615c80ee Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 21 Jul 2026 16:21:49 +0200 Subject: [PATCH 11/47] [client] Copy trustedproxy package into Docker build context (#6851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Updated the build process to include trusted proxy configuration in the application image. --- proxy/Dockerfile.multistage | 1 + 1 file changed, 1 insertion(+) diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage index 01e342c0e..976984256 100644 --- a/proxy/Dockerfile.multistage +++ b/proxy/Dockerfile.multistage @@ -14,6 +14,7 @@ COPY proxy ./proxy COPY route ./route COPY shared ./shared COPY sharedsock ./sharedsock +COPY trustedproxy ./trustedproxy COPY upload-server ./upload-server COPY util ./util COPY version ./version From dc89b471faf49eb41960fc9ab93874fe1799d1d2 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:34:13 +0200 Subject: [PATCH 12/47] [client] checks/enforce MDM disableAutostart on every GUI launch, not just fresh installs (#6782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes `applyAutostartDefault` gated all MDM enforcement behind the one-time `AutostartInitialized` marker, so MDM `disableAutostart` only affected fresh installs — a policy pushed after autostart had been enabled could not revoke the OS login-item. The PR adds follow-up MDM enforcements at the top of the function: if at any time MDM sets `disableAutostart=true` and the OS registration is present, force `SetEnabled(false)` to align it. Trade-off: once the admin lifts the policy, autostart stays off until the user re-toggles in Settings — consistent with "MDM always wins" behavior of the other managed keys. ## Issue ticket number and link Follow-up to PR https://github.com/netbirdio/netbird/pull/6738 (introduced the `disableAutostart` MDM key with fresh-install-only semantics). ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [x] I added/updated documentation for this change - [ ] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: ## Summary by CodeRabbit - **New Features** - Added an administrative policy to disable client autostart (“Disable Autostart”). - Added support for configuring this policy via macOS MDM, Windows Group Policy (ADMX/ADML), and registry settings. - When enforced, the client prevents new autostart registration on fresh installs and removes existing autostart on the next GUI launch, keeping it disabled until the policy is lifted. - **Bug Fixes** - Improved enforcement logic for managed autostart defaults so policy state is applied consistently during startup and first-run setup. --- client/ui/autostart_default.go | 20 +++++++++++++++++--- client/ui/services/autostart.go | 2 +- docs/io.netbird.client.plist | 3 +++ docs/netbird-macos.mobileconfig | 2 ++ docs/netbird-macos.sh | 2 ++ docs/netbird-policy.reg | Bin 1490 -> 1558 bytes docs/netbird.adml | 3 +++ docs/netbird.admx | 12 ++++++++++++ 8 files changed, 40 insertions(+), 4 deletions(-) diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go index bf1b16a97..162922579 100644 --- a/client/ui/autostart_default.go +++ b/client/ui/autostart_default.go @@ -51,7 +51,7 @@ func autostartDisabledByMDM(policy *mdm.Policy) bool { // netbirdFootprintExists reports whether the machine already carries NetBird // daemon config or state, meaning this is not a genuinely fresh install. It is // the update-safety gate for the autostart default: upgrading users always -// have a footprint, so an update can never trigger a login-item write. +// have a footprint, so an update can never trigger a autostart entry write. func netbirdFootprintExists() bool { candidates := []string{ profilemanager.DefaultConfigPath, @@ -69,9 +69,23 @@ func netbirdFootprintExists() bool { // applyAutostartDefault runs the one-time launch-on-login default for genuinely // fresh installs. The autostartInitialized marker is persisted before any // enable attempt so a crash mid-flow degrades to "never enabled" instead of -// retrying login-item writes on every launch. A user's later disable in +// retrying autostart entry writes on every launch. A user's later disable in // Settings is never overridden: the marker guarantees at-most-once, ever. func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) { + mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy()) + + if mdmDisabled { + if enabled, err := autostart.IsEnabled(ctx); err != nil { + log.Warnf("MDM disableAutostart: read autostart state: %v", err) + } else if enabled { + if err := autostart.SetEnabled(ctx, false); err != nil { + log.Warnf("MDM disableAutostart: force off failed: %v", err) + } else { + log.Info("MDM disableAutostart enforced: autostart turned off") + } + } + } + priorFootprint := netbirdFootprintExists() || prefsFileExisted if prefs.Get().AutostartInitialized { @@ -84,7 +98,7 @@ func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, p state := autostartDefaultState{ supported: autostart.Supported(ctx), - mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()), + mdmDisabled: mdmDisabled, priorInstall: priorFootprint, } enable, reason := shouldEnableAutostartDefault(state) diff --git a/client/ui/services/autostart.go b/client/ui/services/autostart.go index f7e3aeea0..98e893f04 100644 --- a/client/ui/services/autostart.go +++ b/client/ui/services/autostart.go @@ -10,7 +10,7 @@ import ( "github.com/wailsapp/wails/v3/pkg/application" ) -// Autostart facade over Wails' AutostartManager. The OS login-item registration +// Autostart facade over Wails' AutostartManager. The OS autostart entry registration // is the single source of truth; nothing is mirrored to preferences. type Autostart struct { mgr *application.AutostartManager diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist index 800ecead1..fe10b5b63 100644 --- a/docs/io.netbird.client.plist +++ b/docs/io.netbird.client.plist @@ -66,6 +66,9 @@ disableAutoConnect + disableAutostart + + disableClientRoutes diff --git a/docs/netbird-macos.mobileconfig b/docs/netbird-macos.mobileconfig index 9bf616094..8216dd55d 100644 --- a/docs/netbird-macos.mobileconfig +++ b/docs/netbird-macos.mobileconfig @@ -103,6 +103,8 @@ ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Tray “session expires” and countdown now accurately reflect expired sessions and remaining time near boundary moments (including improved rounding). * **Bug Fixes** * Recently expired deadlines are retained for correct status reporting, while very old past deadlines are rejected and cleared. * Session expiry “expires at” is preserved when the session watcher closes during reconnect scenarios. * Clicking an already-expired session in the tray now routes the user to the login screen. * **Tests** * Updated and expanded coverage for recent/ancient past handling and watcher close behavior. --- client/internal/auth/sessionwatch/watcher.go | 59 +++++++------- .../auth/sessionwatch/watcher_test.go | 70 +++++++++------- client/internal/connect.go | 5 +- .../internal/engine_session_deadline_test.go | 10 +++ client/internal/peer/status.go | 15 ++-- client/ui/tray.go | 3 +- client/ui/tray_session.go | 79 +++++++++++++++---- 7 files changed, 152 insertions(+), 89 deletions(-) diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go index e75a7022e..e685c28d0 100644 --- a/client/internal/auth/sessionwatch/watcher.go +++ b/client/internal/auth/sessionwatch/watcher.go @@ -24,11 +24,7 @@ import ( ) const ( - // Skew tolerates a small clock difference between the management - // server and this peer before treating a deadline as "in the past". - // Slightly above typical NTP drift; tight enough that the UI doesn't - // paint a stale expiry as if it were valid. - Skew = 30 * time.Second + maxPastHorizon = 30 * 24 * time.Hour // maxDeadlineHorizon caps how far in the future an accepted deadline // can sit. A timestamp beyond this is almost certainly a protocol @@ -57,7 +53,7 @@ var ( ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future") // ErrDeadlineInPast is returned by Update when the supplied deadline - // is more than Skew in the past. + // is more than maxPastHorizon in the past. ErrDeadlineInPast = errors.New("session deadline in the past") ) @@ -66,15 +62,14 @@ var ( // for deadline change/clear, PublishEvent for the two warnings); tests pass // a fake recorder so the same surface is observable without an engine. // -// The watcher is the single owner of the deadline propagated to the -// recorder: every set, clear, sanity-check rejection and Close routes the -// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI -// reads can never drift from the watcher's timer state. (SetSessionExpiresAt -// fans out its own state-change notification, so no separate notify is -// needed.) The recorder is server-scoped and outlives this engine-scoped -// watcher — without the Close-time clear a teardown (Down, or the Down+Up of -// a profile switch) would leave the next session showing the previous one's -// stale "expires in" value. +// While the watcher runs, it owns the deadline propagated to the recorder: +// every set, clear and sanity-check rejection routes the value through +// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can +// never drift from the watcher's timer state. (SetSessionExpiresAt fans +// out its own state-change notification, so no separate notify is needed.) +// The recorder is server-scoped and outlives this engine-scoped watcher; +// Close deliberately leaves the recorder value in place so transient engine +// restarts don't blank it — the client run loop clears it on real teardown. // // PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher // composes the metadata internally so the wire format (MetaSession*) is @@ -135,10 +130,13 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher { // was disabled). // // Same-value updates are no-ops. A different non-zero value cancels any -// pending timer, resets the "already fired" guard, and arms a new one. +// pending timer, resets the "already fired" guards, and — when the +// deadline lies in the future — arms fresh warning timers. A deadline +// already in the past (within maxPastHorizon) is recorded as-is with no +// timers: the session has expired and consumers render it that way. // // Returns one of the sentinel Err* values when the deadline fails the -// sanity checks (pre-epoch, far future, or in the past beyond Skew). +// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon). // In every error case the watcher first clears its state so it stays // consistent with what the caller will push into its other sinks (e.g. // applySessionDeadline forces a zero deadline into the status recorder @@ -163,7 +161,7 @@ func (w *Watcher) Update(deadline time.Time) error { case deadline.After(now.Add(maxDeadlineHorizon)): w.clearLocked() return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline) - case deadline.Before(now.Add(-Skew)): + case deadline.Before(now.Add(-maxPastHorizon)): w.clearLocked() return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now) } @@ -183,7 +181,9 @@ func (w *Watcher) Update(deadline time.Time) error { w.finalFiredAt = time.Time{} w.dismissedAt = time.Time{} - w.armTimerLocked(deadline) + if deadline.After(now) { + w.armTimerLocked(deadline) + } recorder := w.recorder w.mu.Unlock() if recorder != nil { @@ -227,30 +227,25 @@ func (w *Watcher) Dismiss() { log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339)) } -// Close stops any pending timer and drops the deadline on the status -// recorder. Update calls after Close are ignored. Clearing the recorder -// here is what keeps a teardown (Down, or the Down+Up of a profile switch) -// from leaving the next session showing this one's stale "expires in" -// value — the recorder is server-scoped and outlives this engine-scoped -// watcher, so nothing else drops the anchor on teardown. +// Close stops any pending timer. Update calls after Close are ignored. +// The recorder keeps its deadline: the watcher is engine-scoped and closes +// on every engine restart (network change, sleep/wake, stream errors) +// while the SSO deadline stays valid across those, so clearing here would +// blank the UI's "expires in" row on every transient reconnect. The +// client run loop clears the server-scoped recorder when it exits for +// real (Down, profile switch, permanent login failure). func (w *Watcher) Close() { w.mu.Lock() + defer w.mu.Unlock() if w.closed { - w.mu.Unlock() return } w.closed = true w.stopTimerLocked() - hadDeadline := !w.current.IsZero() w.current = time.Time{} w.firedAt = time.Time{} w.finalFiredAt = time.Time{} w.dismissedAt = time.Time{} - recorder := w.recorder - w.mu.Unlock() - if recorder != nil && hadDeadline { - recorder.SetSessionExpiresAt(time.Time{}) - } } // clearLocked drops the tracked deadline and notifies the recorder so diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go index da2b6add6..4b49a94b6 100644 --- a/client/internal/auth/sessionwatch/watcher_test.go +++ b/client/internal/auth/sessionwatch/watcher_test.go @@ -224,11 +224,13 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) { func TestRefreshAfterFireArmsNewWarning(t *testing.T) { r := &fakeRecorder{} - lead := 30 * time.Millisecond + lead := 150 * time.Millisecond w := newWatcher(lead, r) defer w.Close() - first := time.Now().Add(50 * time.Millisecond) + // Warning fires ~20ms in; the deadline itself stays 150ms away so the + // replacement below lands well before it. + first := time.Now().Add(170 * time.Millisecond) _ = w.Update(first) // Wait for stateChange + warning of the first cycle. @@ -306,7 +308,29 @@ func TestUpdateRejectsTooFarFuture(t *testing.T) { } } -func TestUpdateInPastClearsDeadline(t *testing.T) { +func TestUpdateRecentPastRecordedAsExpired(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(-1 * time.Hour) + if err := w.Update(d); err != nil { + t.Fatalf("recent-past Update should succeed, got %v", err) + } + if !w.Deadline().Equal(d) { + t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d) + } + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline = %v, want %v", got, d) + } + + time.Sleep(80 * time.Millisecond) + if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 { + t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot()) + } +} + +func TestUpdateAncientPastRejected(t *testing.T) { r := &fakeRecorder{} w := newWatcher(50*time.Millisecond, r) defer w.Close() @@ -318,12 +342,12 @@ func TestUpdateInPastClearsDeadline(t *testing.T) { // Drain the stateChange from the seed. waitForEvents(t, r, 1) - err := w.Update(time.Now().Add(-1 * time.Hour)) + err := w.Update(time.Now().Add(-31 * 24 * time.Hour)) if !errors.Is(err, ErrDeadlineInPast) { t.Fatalf("want ErrDeadlineInPast, got %v", err) } if !w.Deadline().IsZero() { - t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline()) + t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline()) } events := waitForEvents(t, r, 2) if events[1].kind != stateChange { @@ -331,39 +355,25 @@ func TestUpdateInPastClearsDeadline(t *testing.T) { } } -func TestUpdateWithinSkewAccepted(t *testing.T) { - r := &fakeRecorder{} - w := newWatcher(50*time.Millisecond, r) - defer w.Close() - - // 5 seconds in the past is within the 30s Skew tolerance — accept it. - d := time.Now().Add(-5 * time.Second) - if err := w.Update(d); err != nil { - t.Fatalf("within-skew Update should succeed, got %v", err) - } - if !w.Deadline().Equal(d) { - t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d) - } -} - func TestCloseSilencesUpdates(t *testing.T) { r := &fakeRecorder{} w := newWatcher(50*time.Millisecond, r) w.Close() - _ = w.Update(time.Now().Add(time.Hour)) - - time.Sleep(20 * time.Millisecond) + if err := w.Update(time.Now().Add(time.Hour)); err != nil { + t.Fatalf("Update after Close: want nil, got %v", err) + } if got := r.snapshot(); len(got) != 0 { t.Fatalf("expected no events after Close, got %+v", got) } } -// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher -// holding a live deadline must zero the recorder on Close so the next -// engine's watcher (and the UI reading the shared server-scoped recorder) -// doesn't start out showing the previous session's stale "expires in". -func TestCloseClearsRecorderDeadline(t *testing.T) { +// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher +// closes on every engine restart (network change, sleep/wake) while the +// SSO deadline stays valid across those, so Close must leave the +// server-scoped recorder's value in place. The client run loop clears the +// recorder when it exits for real. +func TestCloseKeepsRecorderDeadline(t *testing.T) { r := &fakeRecorder{} w := newWatcher(time.Hour, r) @@ -377,8 +387,8 @@ func TestCloseClearsRecorderDeadline(t *testing.T) { w.Close() - if got := r.deadline(); !got.IsZero() { - t.Fatalf("recorder deadline after Close = %v, want zero", got) + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline after Close = %v, want %v", got, d) } } diff --git a/client/internal/connect.go b/client/internal/connect.go index c2fc2fd73..ae5971a85 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -257,7 +257,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan log.Errorf("failed to clean up temporary installer file: %v", err) } - defer c.statusRecorder.ClientStop() + defer func() { + c.statusRecorder.SetSessionExpiresAt(time.Time{}) + c.statusRecorder.ClientStop() + }() operation := func() error { // if context cancelled we not start new backoff cycle if c.ctx.Err() != nil { diff --git a/client/internal/engine_session_deadline_test.go b/client/internal/engine_session_deadline_test.go index 6127e5bb0..5a67f103a 100644 --- a/client/internal/engine_session_deadline_test.go +++ b/client/internal/engine_session_deadline_test.go @@ -75,4 +75,14 @@ func TestApplySessionDeadline_ThreeState(t *testing.T) { require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(), "invalid timestamp must clear the deadline") }) + + t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) { + e := newEngine() + expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second) + + e.ApplySessionDeadline(timestamppb.New(expired)) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired), + "recently-expired deadline must stay on the recorder so consumers render it as expired") + }) } diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index a987482fe..423ce9b23 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -813,19 +813,14 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) { } // GetSessionExpiresAt returns the most recently recorded SSO session deadline, -// or the zero value when no deadline is tracked. A deadline that has already -// slipped into the past reports as "none": once the session has expired it is -// no longer a meaningful countdown, and the sessionwatch.Watcher does not -// arm a timer at the deadline itself to clear it (only the two pre-expiry -// warnings). Without this guard the UI would keep painting a stale -// "expires in …" against a moment that has passed until the next login, -// extend, or teardown rewrote the value. +// or the zero value when no deadline is tracked. A deadline in the past is +// returned as-is: it means the session has expired, and consumers (tray row, +// CLI status) render it as "expired" rather than hiding it — masking it as +// "none" would blank the UI at the exact moment it should say the session +// ended. func (d *Status) GetSessionExpiresAt() time.Time { d.mux.Lock() defer d.mux.Unlock() - if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) { - return time.Time{} - } return d.sessionExpiresAt } diff --git a/client/ui/tray.go b/client/ui/tray.go index c4918825f..63b6a46ec 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -317,8 +317,7 @@ func (t *Tray) relayoutMenu() { if sessionDeadline.IsZero() { t.sessionExpiresItem.SetHidden(true) } else { - remaining := t.formatSessionRemaining(time.Until(sessionDeadline)) - t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) + t.sessionExpiresItem.SetLabel(t.sessionRowLabel(sessionDeadline)) t.sessionExpiresItem.SetHidden(false) } } diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go index 6b73ddb49..885fdb348 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -64,11 +64,42 @@ func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool { return changed } -// runSessionExpiryTicker recomputes the "Expires in …" row label every 30s. Runs until process exit. +// runSessionExpiryTicker recomputes the "Expires in …" row label until process exit. +// The interval scales with the remaining time: coarse when the deadline is far off, +// down to 10s in the final two minutes so the label doesn't lag the ceiling-rounded +// countdown near expiry. The cached deadline is re-read every iteration, so an extend +// or reconnect that moves it is picked up on the next tick. func (t *Tray) runSessionExpiryTicker() { - tk := time.NewTicker(30 * time.Second) - for range tk.C { + tm := time.NewTimer(sessionRefreshInterval(t.sessionRemaining())) + defer tm.Stop() + for range tm.C { t.refreshSessionExpiresLabel() + tm.Reset(sessionRefreshInterval(t.sessionRemaining())) + } +} + +// sessionRemaining returns the time left on the cached SSO deadline, or 0 when unknown. +func (t *Tray) sessionRemaining() time.Duration { + t.sessionMu.Lock() + deadline := t.sessionExpiresAt + t.sessionMu.Unlock() + if deadline.IsZero() { + return 0 + } + return time.Until(deadline) +} + +// sessionRefreshInterval picks how long to wait before the next label recompute. +func sessionRefreshInterval(remaining time.Duration) time.Duration { + switch { + case remaining <= 0: + return 30 * time.Second + case remaining <= 2*time.Minute: + return 10 * time.Second + case remaining <= time.Hour: + return 30 * time.Second + default: + return time.Minute } } @@ -87,30 +118,39 @@ func (t *Tray) refreshSessionExpiresLabel() { if deadline.IsZero() { return } - remaining := t.formatSessionRemaining(time.Until(deadline)) - item.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) + item.SetLabel(t.sessionRowLabel(deadline)) +} + +func (t *Tray) sessionRowLabel(deadline time.Time) string { + remaining := time.Until(deadline) + if remaining <= 0 { + return t.loc.T("tray.status.sessionExpired") + } + return t.loc.T("tray.session.expiresIn", "remaining", t.formatSessionRemaining(remaining)) } // formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit. +// Each unit is rounded up so the label never claims less time than actually remains, matching the +// upper-bound sense of the sub-minute "less than a minute" fragment. // Singular/plural keys are split per language for proper translation. func (t *Tray) formatSessionRemaining(d time.Duration) string { switch { case d < time.Minute: return t.loc.T("tray.session.unit.lessThanMinute") - case d < time.Hour: - m := int(d / time.Minute) + case d <= 59*time.Minute: + m := ceilDiv(d, time.Minute) if m == 1 { return t.loc.T("tray.session.unit.minute") } return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m)) - case d < 24*time.Hour: - h := int((d + 30*time.Minute) / time.Hour) + case d <= 23*time.Hour: + h := ceilDiv(d, time.Hour) if h == 1 { return t.loc.T("tray.session.unit.hour") } return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h)) default: - days := int((d + 12*time.Hour) / (24 * time.Hour)) + days := ceilDiv(d, 24*time.Hour) if days == 1 { return t.loc.T("tray.session.unit.day") } @@ -118,6 +158,11 @@ func (t *Tray) formatSessionRemaining(d time.Duration) string { } } +// ceilDiv divides d by unit rounding up, assuming d > 0. +func ceilDiv(d, unit time.Duration) int { + return int((d + unit - time.Nanosecond) / unit) +} + // registerSessionWarningCategory wires the OS notification category and response handler for the expiry warning. // Errors are swallowed since the worst case is a plain notification without buttons. func (t *Tray) registerSessionWarningCategory() { @@ -252,11 +297,9 @@ func (t *Tray) openSessionExpiration() { } // openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time, -// for the "Expires in …" tray row. No-ops when the deadline is unknown or elapsed. +// for the "Expires in …" tray row. Once the deadline has elapsed the row reads "Session expired" and the +// click routes to the login flow instead. No-op when the deadline is unknown. func (t *Tray) openSessionExtendFlow() { - if t.svc.WindowManager == nil { - return - } t.sessionMu.Lock() deadline := t.sessionExpiresAt t.sessionMu.Unlock() @@ -265,6 +308,14 @@ func (t *Tray) openSessionExtendFlow() { } seconds := int(time.Until(deadline).Seconds()) if seconds <= 0 { + if t.window != nil { + t.window.SetURL("/#/login") + t.window.Show() + t.window.Focus() + } + return + } + if t.svc.WindowManager == nil { return } t.svc.WindowManager.OpenSessionExpiration(seconds) From ed682fad87342b2fe05a455e03ddd7722a84b0fe Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 22 Jul 2026 16:01:33 +0200 Subject: [PATCH 14/47] [client] Run pnpm install with --ignore-scripts in frontend CI (#6859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent dependency lifecycle scripts (preinstall/postinstall/prepare) from executing during install in the UI frontend CI job, closing the most common npm supply-chain vector at build time. The frontend build does not rely on any dependency install scripts. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Chores** * Updated the UI frontend installation workflow to skip package installation scripts while preserving the locked dependency versions. --- .github/workflows/frontend-ui.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/frontend-ui.yml b/.github/workflows/frontend-ui.yml index bb5bb4528..552ccef29 100644 --- a/.github/workflows/frontend-ui.yml +++ b/.github/workflows/frontend-ui.yml @@ -86,7 +86,7 @@ jobs: ${{ runner.os }}-pnpm- - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts - name: Generate Wails bindings run: pnpm run bindings From 8435682ac8931371bb460fd5e5c44b4dfadd74cb Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Wed, 22 Jul 2026 18:20:27 +0200 Subject: [PATCH 15/47] [client, management] offload client config generation to the client (#6711) Signed-off-by: Dmitri Dolguikh Co-authored-by: crn4 Co-authored-by: pascal --- .github/workflows/golangci-lint.yml | 4 +- client/internal/auth/auth.go | 1 + client/internal/connect.go | 2 + client/internal/debug/debug.go | 1 + client/internal/debug/debug_test.go | 2 + client/internal/engine.go | 79 +- client/internal/profilemanager/config.go | 8 + client/system/info.go | 6 +- combined/cmd/config.go | 25 +- combined/cmd/root.go | 11 + dns/nameserver.go | 1 + idp/dex/config.go | 2 +- idp/dex/provider.go | 3 +- idp/dex/sqlite_cgo.go | 15 + idp/dex/sqlite_nocgo.go | 15 + management/cmd/management.go | 15 +- management/cmd/management_test.go | 50 +- .../network_map/controller/controller.go | 219 +- .../controllers/network_map/interface.go | 1 + .../controllers/network_map/interface_mock.go | 31 +- management/internals/server/config/config.go | 4 + .../shared/grpc/components_encoder.go | 769 +++ .../shared/grpc/components_encoder_test.go | 785 +++ .../grpc/components_envelope_response.go | 200 + .../grpc/components_envelope_response_test.go | 184 + .../internals/shared/grpc/conversion.go | 289 +- .../internals/shared/grpc/conversion_test.go | 12 +- management/internals/shared/grpc/server.go | 52 +- management/server/account.go | 4 + management/server/account_test.go | 10 + management/server/group.go | 12 +- management/server/migration/migration.go | 48 + management/server/nameserver.go | 4 + management/server/networks/manager.go | 28 +- management/server/networks/manager_test.go | 70 + .../server/networks/resources/manager.go | 4 + .../networks/resources/types/resource.go | 2 + management/server/networks/routers/manager.go | 7 + .../server/networks/routers/types/router.go | 2 + management/server/networks/types/network.go | 10 +- management/server/peer/peer.go | 17 +- management/server/policy.go | 3 + management/server/posture/checks.go | 3 + management/server/posture_checks.go | 8 + management/server/posture_checks_test.go | 58 + management/server/route.go | 3 + management/server/store/sql_store.go | 64 +- management/server/store/sql_store_test.go | 86 +- management/server/store/store.go | 24 + management/server/store/store_mock.go | 835 +++- .../server/store/store_mock_agentnetwork.go | 495 -- .../server/telemetry/updatechannel_metrics.go | 66 +- management/server/types/account.go | 152 +- management/server/types/account_components.go | 200 +- management/server/types/account_test.go | 2 +- management/server/types/aliases.go | 145 + .../networkmap_components_correctness_test.go | 22 +- .../types/networkmap_wire_benchmark_test.go | 163 + .../types/networkmap_wire_breakdown_test.go | 149 + .../server/types/peer_networkmap_result.go | 25 + .../types/peer_networkmap_result_test.go | 104 + route/route.go | 2 + shared/management/client/client_test.go | 72 +- shared/management/client/grpc.go | 10 + .../management/grpc/sync_message_versions.go | 67 + .../grpc/sync_message_versions_test.go | 39 + shared/management/networkmap/decode.go | 550 +++ shared/management/networkmap/encode.go | 323 ++ shared/management/networkmap/envelope.go | 189 + shared/management/networkmap/envelope_test.go | 295 ++ shared/management/proto/management.pb.go | 4234 ++++++++++++++--- shared/management/proto/management.proto | 450 ++ .../management}/types/dns_settings.go | 0 shared/management/types/firewall_helpers.go | 131 + .../management}/types/firewall_rule.go | 4 +- .../management}/types/firewall_rule_test.go | 12 +- .../management}/types/group.go | 3 + .../management}/types/network.go | 0 .../management}/types/network_test.go | 0 .../types/networkmap_components.go | 68 +- .../types/networkmap_components_compact.go | 0 .../management}/types/policy.go | 3 + .../management}/types/policyrule.go | 0 .../management}/types/resource.go | 0 .../management}/types/route_firewall_rule.go | 0 85 files changed, 9932 insertions(+), 2131 deletions(-) create mode 100644 idp/dex/sqlite_cgo.go create mode 100644 idp/dex/sqlite_nocgo.go create mode 100644 management/internals/shared/grpc/components_encoder.go create mode 100644 management/internals/shared/grpc/components_encoder_test.go create mode 100644 management/internals/shared/grpc/components_envelope_response.go create mode 100644 management/internals/shared/grpc/components_envelope_response_test.go delete mode 100644 management/server/store/store_mock_agentnetwork.go create mode 100644 management/server/types/aliases.go create mode 100644 management/server/types/networkmap_wire_benchmark_test.go create mode 100644 management/server/types/networkmap_wire_breakdown_test.go create mode 100644 management/server/types/peer_networkmap_result.go create mode 100644 management/server/types/peer_networkmap_result_test.go create mode 100644 shared/management/grpc/sync_message_versions.go create mode 100644 shared/management/grpc/sync_message_versions_test.go create mode 100644 shared/management/networkmap/decode.go create mode 100644 shared/management/networkmap/encode.go create mode 100644 shared/management/networkmap/envelope.go create mode 100644 shared/management/networkmap/envelope_test.go rename {management/server => shared/management}/types/dns_settings.go (100%) create mode 100644 shared/management/types/firewall_helpers.go rename {management/server => shared/management}/types/firewall_rule.go (97%) rename {management/server => shared/management}/types/firewall_rule_test.go (92%) rename {management/server => shared/management}/types/group.go (98%) rename {management/server => shared/management}/types/network.go (100%) rename {management/server => shared/management}/types/network_test.go (100%) rename {management/server => shared/management}/types/networkmap_components.go (93%) rename {management/server => shared/management}/types/networkmap_components_compact.go (100%) rename {management/server => shared/management}/types/policy.go (99%) rename {management/server => shared/management}/types/policyrule.go (100%) rename {management/server => shared/management}/types/resource.go (100%) rename {management/server => shared/management}/types/route_firewall_rule.go (100%) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index b444a9900..586e1235b 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -45,7 +45,7 @@ jobs: display_name: Linux name: ${{ matrix.display_name }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 25 steps: - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -79,4 +79,4 @@ jobs: skip-cache: true skip-save-cache: true cache-invalidation-interval: 0 - args: --timeout=12m + args: --timeout=20m diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index 51f56b644..153727a6c 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -351,6 +351,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) { a.config.BlockLANAccess, a.config.BlockInbound, a.config.DisableIPv6, + a.config.SyncMessageVersion, a.config.EnableSSHRoot, a.config.EnableSSHSFTP, a.config.EnableSSHLocalPortForwarding, diff --git a/client/internal/connect.go b/client/internal/connect.go index ae5971a85..f4d14aab2 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -621,6 +621,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf BlockLANAccess: config.BlockLANAccess, BlockInbound: config.BlockInbound, DisableIPv6: config.DisableIPv6, + SyncMessageVersion: config.SyncMessageVersion, LazyConnection: lazyconn.ParseState(config.LazyConnection), @@ -696,6 +697,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config.BlockLANAccess, config.BlockInbound, config.DisableIPv6, + config.SyncMessageVersion, config.EnableSSHRoot, config.EnableSSHSFTP, config.EnableSSHLocalPortForwarding, diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 0e506ccd7..2de1023e9 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -676,6 +676,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess)) configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound)) configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6)) + configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion)) if g.internalConfig.DisableNotifications != nil { configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications)) diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 8286f6852..7fe93a5c1 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -887,6 +887,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { ClientCertKeyPath: "/tmp/key", LazyConnection: "on", MTU: 1280, + DisableIPv6: true, + SyncMessageVersion: func(v int) *int { return &v }(1), } for _, anonymize := range []bool{false, true} { diff --git a/client/internal/engine.go b/client/internal/engine.go index 1d00ed0d2..79f916a12 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -64,7 +64,10 @@ import ( "github.com/netbirdio/netbird/route" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" + sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" + nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + types "github.com/netbirdio/netbird/shared/management/types" "github.com/netbirdio/netbird/shared/netiputil" auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" relayClient "github.com/netbirdio/netbird/shared/relay/client" @@ -147,6 +150,7 @@ type EngineConfig struct { BlockLANAccess bool BlockInbound bool DisableIPv6 bool + SyncMessageVersion *int // LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to // the env var and management feature flag. @@ -220,6 +224,13 @@ type Engine struct { // networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service networkSerial uint64 + // latestComponents is the most-recent NetworkMapComponents decoded from + // a NetworkMapEnvelope (capability=3 peers only). Held alongside the + // NetworkMap that Calculate() produced from it so future incremental + // updates have a base to apply changes against. nil for legacy-format + // peers. Guarded by syncMsgMux. + latestComponents *types.NetworkMapComponents + networkMonitor *networkmonitor.NetworkMonitor sshServer sshServer @@ -963,8 +974,12 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { e.ApplySessionDeadline(update.GetSessionExpiresAt()) - if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil { - e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate) + // Envelope sync responses carry PeerConfig at the top level; legacy + // NetworkMap syncs carry it under NetworkMap.PeerConfig. + if pc := update.GetPeerConfig(); pc != nil { + e.handleAutoUpdateVersion(pc.GetAutoUpdate()) + } else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil { + e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate()) } done := e.phase("netbird_config") @@ -974,12 +989,47 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return err } + // Decode the network map from either the components envelope or the + // legacy proto.NetworkMap before the posture-check gating below, so the + // "is there a network map" decision covers both wire shapes. + var ( + nm *mgmProto.NetworkMap + components *types.NetworkMapComponents + ) + if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) { + // Components-format peer: decode the envelope back to typed + // components, run Calculate() locally, and convert to the wire + // NetworkMap shape the rest of the engine consumes. Components are + // retained so future incremental updates can apply deltas instead + // of doing a full reconstruction. + envelope := update.GetNetworkMapEnvelope() + if envelope == nil { + return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing") + } + + localKey := e.config.WgPrivateKey.PublicKey().String() + dnsName := "" + if pc := update.GetPeerConfig(); pc != nil { + // PeerConfig.Fqdn = "." — extract the + // shared domain by stripping the peer's own label prefix. Falls + // back to empty if the FQDN doesn't have the expected shape. + dnsName = extractDNSDomainFromFQDN(pc.GetFqdn()) + } + result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName) + if err != nil { + return fmt.Errorf("decode network map envelope: %w", err) + } + nm = result.NetworkMap + components = result.Components + } else { + nm = update.GetNetworkMap() + } + // Posture checks are bound to the network map presence: // NetworkMap != nil, checks present -> apply the received checks // NetworkMap != nil, checks nil -> posture checks were removed, clear them // NetworkMap == nil -> config-only update (e.g. relay token rotation), // leave the previously applied checks untouched - nm := update.GetNetworkMap() if nm == nil { return nil } @@ -992,6 +1042,14 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { } done = e.phase("persist") + // Only retain the components view when the server sent the envelope + // path. A legacy proto.NetworkMap means components == nil; writing it + // here would clobber a previously-cached snapshot, breaking the + // incremental-delta base on a future envelope sync. + if components != nil { + e.latestComponents = components + } + e.persistSyncResponse(update) done() @@ -1005,6 +1063,19 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return nil } +// extractDNSDomainFromFQDN returns the trailing dotted domain part of the +// receiving peer's FQDN — the same value the management server fills as +// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" → +// "netbird.cloud". An empty string is returned for unrecognized formats. +func extractDNSDomainFromFQDN(fqdn string) string { + for i := 0; i < len(fqdn); i++ { + if fqdn[i] == '.' && i+1 < len(fqdn) { + return fqdn[i+1:] + } + } + return "" +} + // updateNetbirdConfig applies the management-provided NetBird configuration: // STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op, // which is the case for sync updates carrying only a network map. @@ -1164,6 +1235,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) { e.config.BlockLANAccess, e.config.BlockInbound, e.config.DisableIPv6, + e.config.SyncMessageVersion, e.config.EnableSSHRoot, e.config.EnableSSHSFTP, e.config.EnableSSHLocalPortForwarding, @@ -2032,6 +2104,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err e.config.BlockLANAccess, e.config.BlockInbound, e.config.DisableIPv6, + e.config.SyncMessageVersion, e.config.EnableSSHRoot, e.config.EnableSSHSFTP, e.config.EnableSSHLocalPortForwarding, diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index ed2f21999..a110e4102 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -96,6 +96,7 @@ type ConfigInput struct { BlockLANAccess *bool BlockInbound *bool DisableIPv6 *bool + SyncMessageVersion *int DisableNotifications *bool @@ -137,6 +138,7 @@ type Config struct { BlockLANAccess bool BlockInbound bool DisableIPv6 bool + SyncMessageVersion *int DisableNotifications *bool @@ -587,6 +589,12 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion { + log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion) + *config.SyncMessageVersion = *input.SyncMessageVersion + updated = true + } + if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) { if *input.DisableNotifications { log.Infof("disabling notifications") diff --git a/client/system/info.go b/client/system/info.go index 1838204b8..daeabca13 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -79,13 +79,15 @@ type Info struct { EnableSSHLocalPortForwarding bool EnableSSHRemotePortForwarding bool DisableSSHAuth bool + + SyncMessageVersion *int } func (i *Info) SetFlags( rosenpassEnabled, rosenpassPermissive bool, serverSSHAllowed *bool, disableClientRoutes, disableServerRoutes, - disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, + disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int, enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool, disableSSHAuth *bool, ) { @@ -103,6 +105,8 @@ func (i *Info) SetFlags( i.BlockInbound = blockInbound i.DisableIPv6 = disableIPv6 + i.SyncMessageVersion = syncMessageVersion + if enableSSHRoot != nil { i.EnableSSHRoot = *enableSSHRoot } diff --git a/combined/cmd/config.go b/combined/cmd/config.go index fcbc60dc9..d022c2197 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -74,6 +74,9 @@ type ServerConfig struct { ActivityStore StoreConfig `yaml:"activityStore"` AuthStore StoreConfig `yaml:"authStore"` ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"` + + SupportedSyncMessageVersions *int `yaml:"supportedSyncMessageVersions,omitempty"` + PerAccountSupportedSyncMessageVersions map[string]int `yaml:"perAccountSupportedSyncMessageVersions,omitempty"` } // TLSConfig contains TLS/HTTPS settings @@ -696,16 +699,18 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) { httpConfig.AuthCallbackURL = callbackURL + types.ProxyCallbackEndpointFull return &nbconfig.Config{ - Stuns: stuns, - Relay: relayConfig, - Signal: signalConfig, - Datadir: mgmt.DataDir, - DataStoreEncryptionKey: mgmt.Store.EncryptionKey, - HttpConfig: httpConfig, - StoreConfig: storeConfig, - ReverseProxy: reverseProxy, - DisableDefaultPolicy: mgmt.DisableDefaultPolicy, - EmbeddedIdP: embeddedIdP, + Stuns: stuns, + Relay: relayConfig, + Signal: signalConfig, + Datadir: mgmt.DataDir, + DataStoreEncryptionKey: mgmt.Store.EncryptionKey, + HttpConfig: httpConfig, + StoreConfig: storeConfig, + ReverseProxy: reverseProxy, + DisableDefaultPolicy: mgmt.DisableDefaultPolicy, + EmbeddedIdP: embeddedIdP, + HighestSupportedSyncMessageVersion: c.Server.SupportedSyncMessageVersions, + PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions, }, nil } diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 2b7956f11..1a0127ff3 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -31,6 +31,7 @@ import ( relayServer "github.com/netbirdio/netbird/relay/server" "github.com/netbirdio/netbird/relay/server/listener" "github.com/netbirdio/netbird/relay/server/listener/ws" + syncgrpc "github.com/netbirdio/netbird/shared/management/grpc" sharedMetrics "github.com/netbirdio/netbird/shared/metrics" "github.com/netbirdio/netbird/shared/relay/auth" "github.com/netbirdio/netbird/shared/signal/proto" @@ -505,6 +506,16 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m } mgmtPort, _ := strconv.Atoi(portStr) + if err := syncgrpc.ValidateSyncMessageVersion(mgmtConfig.HighestSupportedSyncMessageVersion); err != nil { + return nil, err + } + + for accountId, version := range mgmtConfig.PerAccountHighestSupportedSyncMessageVersion { + if err := syncgrpc.ValidateSyncMessageVersion(&version); err != nil { + return nil, fmt.Errorf("unrecognized sync message version in perAccountSupportedSyncMessageVersions for account %s %w", accountId, err) + } + } + mgmtSrv := newServer( &mgmtServer.Config{ NbConfig: mgmtConfig, diff --git a/dns/nameserver.go b/dns/nameserver.go index 81c616c50..84e83e2b4 100644 --- a/dns/nameserver.go +++ b/dns/nameserver.go @@ -53,6 +53,7 @@ type NameServerGroup struct { ID string `gorm:"primaryKey"` // AccountID is a reference to Account that this object belongs AccountID string `gorm:"index"` + PublicID string `json:"-"` // Name group name Name string // Description group description diff --git a/idp/dex/config.go b/idp/dex/config.go index 9e56eb6c0..00b5ce745 100644 --- a/idp/dex/config.go +++ b/idp/dex/config.go @@ -308,7 +308,7 @@ func (s *Storage) OpenStorage(logger *slog.Logger) (storage.Storage, error) { if file == "" { return nil, fmt.Errorf("sqlite3 storage requires 'file' config") } - return (&sql.SQLite3{File: file}).Open(logger) + return newSQLite3(file).Open(logger) case "postgres": dsn, _ := s.Config["dsn"].(string) if dsn == "" { diff --git a/idp/dex/provider.go b/idp/dex/provider.go index c0b705f13..5582af528 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -20,7 +20,6 @@ import ( "github.com/dexidp/dex/server" "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/sql" "github.com/go-jose/go-jose/v4" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" @@ -79,7 +78,7 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) { // Initialize SQLite storage dbPath := filepath.Join(config.DataDir, "oidc.db") - sqliteConfig := &sql.SQLite3{File: dbPath} + sqliteConfig := newSQLite3(dbPath) stor, err := sqliteConfig.Open(logger) if err != nil { return nil, fmt.Errorf("failed to open storage: %w", err) diff --git a/idp/dex/sqlite_cgo.go b/idp/dex/sqlite_cgo.go new file mode 100644 index 000000000..5de66f647 --- /dev/null +++ b/idp/dex/sqlite_cgo.go @@ -0,0 +1,15 @@ +//go:build cgo + +package dex + +import ( + sql "github.com/dexidp/dex/storage/sql" +) + +// newSQLite3 builds the dex SQLite3 config. CGO builds use the upstream +// struct that takes a File path. Non-CGO builds get an empty stub whose +// Open() returns the dex "SQLite not available" error — correct behaviour +// for binaries that can't link sqlite3 (e.g. cross-compiled ARM targets). +func newSQLite3(file string) *sql.SQLite3 { + return &sql.SQLite3{File: file} +} diff --git a/idp/dex/sqlite_nocgo.go b/idp/dex/sqlite_nocgo.go new file mode 100644 index 000000000..4def12143 --- /dev/null +++ b/idp/dex/sqlite_nocgo.go @@ -0,0 +1,15 @@ +//go:build !cgo + +package dex + +import ( + sql "github.com/dexidp/dex/storage/sql" +) + +// newSQLite3 for non-CGO builds. The dex SQLite3 stub has no fields and its +// Open() returns an error documenting the missing CGO support — correct +// behaviour for cross-compiled artefacts that never actually run the +// embedded IdP. The `file` argument is ignored. +func newSQLite3(_ string) *sql.SQLite3 { + return &sql.SQLite3{} +} diff --git a/management/cmd/management.go b/management/cmd/management.go index 27d8055e7..19e93c762 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -25,6 +25,7 @@ import ( "github.com/netbirdio/netbird/management/internals/server" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" nbdomain "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util/crypt" ) @@ -153,8 +154,20 @@ func LoadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Confi ApplyCommandLineOverrides(loadedConfig) + err := grpc.ValidateSyncMessageVersion(loadedConfig.HighestSupportedSyncMessageVersion) + if err != nil { + return nil, err + } + + for account, version := range loadedConfig.PerAccountHighestSupportedSyncMessageVersion { + err := grpc.ValidateSyncMessageVersion(&version) + if err != nil { + return nil, fmt.Errorf("unrecognized sync message version for account %s, %w", account, err) + } + } + // Apply EmbeddedIdP config to HttpConfig if embedded IdP is enabled - err := ApplyEmbeddedIdPConfig(ctx, loadedConfig) + err = ApplyEmbeddedIdPConfig(ctx, loadedConfig) if err != nil { return nil, err } diff --git a/management/cmd/management_test.go b/management/cmd/management_test.go index f0c89dd3f..2c3481213 100644 --- a/management/cmd/management_test.go +++ b/management/cmd/management_test.go @@ -4,6 +4,9 @@ import ( "context" "os" "testing" + + "github.com/netbirdio/netbird/shared/management/grpc" + "github.com/stretchr/testify/assert" ) const ( @@ -20,34 +23,49 @@ const ( "AuthAudience": "https://stageapp/", "AuthIssuer": "https://something.eu.auth0.com/", "OIDCConfigEndpoint": "https://something.eu.auth0.com/.well-known/openid-configuration" + }, + "HighestSupportedSyncMessageVersion": 1, + "PerAccountHighestSupportedSyncMessageVersion": { + "1": 0, + "2": 1 } }` ) -func Test_loadMgmtConfig(t *testing.T) { - tmpFile, err := createConfig() - if err != nil { - t.Fatalf("failed to create config: %s", err) - } +func Test_LoadMgmtConfig(t *testing.T) { + tmpFile, err := createConfig(exampleConfig) + assert.NoError(t, err) cfg, err := LoadMgmtConfig(context.Background(), tmpFile) - if err != nil { - t.Fatalf("failed to load management config: %s", err) - } - if cfg.Relay == nil { - t.Fatalf("config is nil") - } - if len(cfg.Relay.Addresses) == 0 { - t.Fatalf("relay address is empty") - } + assert.NoError(t, err) + assert.NotEmpty(t, cfg.Relay) + assert.NotEmpty(t, cfg.Relay.Addresses) + assert.Equal(t, int(grpc.ComponentNetworkMap), *cfg.HighestSupportedSyncMessageVersion) + assert.Equal(t, map[string]int{"1": int(grpc.Base), "2": int(grpc.ComponentNetworkMap)}, cfg.PerAccountHighestSupportedSyncMessageVersion) } -func createConfig() (string, error) { +func Test_LoadMgmtConfig_Empty(t *testing.T) { + tmpFile, err := createConfig(`{ + "HttpConfig": { + "AuthAudience": "https://stageapp/", + "AuthIssuer": "https://something.eu.auth0.com/", + "OIDCConfigEndpoint": "https://something.eu.auth0.com/.well-known/openid-configuration" + } + }`) + assert.NoError(t, err) + + cfg, err := LoadMgmtConfig(context.Background(), tmpFile) + assert.NoError(t, err) + assert.Nil(t, cfg.HighestSupportedSyncMessageVersion) + assert.Nil(t, cfg.PerAccountHighestSupportedSyncMessageVersion) +} + +func createConfig(config string) (string, error) { tmpfile, err := os.CreateTemp("", "config.json") if err != nil { return "", err } - _, err = tmpfile.Write([]byte(exampleConfig)) + _, err = tmpfile.Write([]byte(config)) if err != nil { return "", err } diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index f1b1832d2..5785004db 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -29,6 +29,7 @@ import ( "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/types" + sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/util" @@ -56,6 +57,10 @@ type Controller struct { proxyController port_forwarding.Controller integratedPeerValidator integrated_validator.IntegratedValidator + + serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion + + perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion } type bufferUpdate struct { @@ -90,8 +95,10 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App dnsDomain: dnsDomain, config: config, - proxyController: proxyController, - EphemeralPeersManager: ephemeralPeersManager, + proxyController: proxyController, + EphemeralPeersManager: ephemeralPeersManager, + serverSupportedSyncMessageVersion: sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion), + perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion), } } @@ -222,18 +229,53 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin c.metrics.CountCalcPostureChecksDuration(time.Since(start)) start = time.Now() - remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + peerGroups := account.GetPeerGroups(p.ID) + proxyNetworkMap := proxyNetworkMaps[p.ID] + var update *proto.SyncResponse + + commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( + c.perAccountOrGlobalSupportedSyncMessageVersions(accountID), + sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap { + components := account.GetPeerNetworkMapComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) + + c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) + + start = time.Now() + // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; + // the client merges it into Calculate()'s output the same + // way the legacy server did via NetworkMap.Merge. + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) + + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + + return + } + + nmap := account.GetPeerNetworkMapFromComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) - proxyNetworkMap, ok := proxyNetworkMaps[p.ID] - if ok { - remotePeerNetworkMap.Merge(proxyNetworkMap) + if proxyNetworkMap != nil { + nmap.Merge(proxyNetworkMap) } - peerGroups := account.GetPeerGroups(p.ID) start = time.Now() - update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -251,6 +293,13 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin return nil } +func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion { + if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok { + return perAccount + } + return c.serverSupportedSyncMessageVersion +} + // UpdatePeers updates all peers that belong to an account. // Should be called when changes have to be synced to peers. func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error { @@ -352,18 +401,53 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s c.metrics.CountCalcPostureChecksDuration(time.Since(start)) start = time.Now() - remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + peerGroups := account.GetPeerGroups(p.ID) + proxyNetworkMap := proxyNetworkMaps[p.ID] + var update *proto.SyncResponse + + commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( + c.perAccountOrGlobalSupportedSyncMessageVersions(accountID), + sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap { + components := account.GetPeerNetworkMapComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) + + c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) + + start = time.Now() + // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; + // the client merges it into Calculate()'s output the same + // way the legacy server did via NetworkMap.Merge. + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) + + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + + return + } + + nmap := account.GetPeerNetworkMapFromComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) - proxyNetworkMap, ok := proxyNetworkMaps[p.ID] - if ok { - remotePeerNetworkMap.Merge(proxyNetworkMap) + if proxyNetworkMap != nil { + nmap.Merge(proxyNetworkMap) } - peerGroups := account.GetPeerGroups(p.ID) start = time.Now() - update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -451,13 +535,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return err } - remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, peerId, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) - - proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] - if ok { - remotePeerNetworkMap.Merge(proxyNetworkMap) - } - + proxyNetworkMap := proxyNetworkMaps[peer.ID] extraSettings, err := c.settingsManager.GetExtraSettings(ctx, peer.AccountID) if err != nil { return fmt.Errorf("failed to get extra settings: %v", err) @@ -466,7 +544,45 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe peerGroups := account.GetPeerGroups(peerId) dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) - update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + var update *proto.SyncResponse + + commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( + c.perAccountOrGlobalSupportedSyncMessageVersions(accountId), + sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap { + components := account.GetPeerNetworkMapComponents( + ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) + + // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; + // the client merges it into Calculate()'s output the same + // way the legacy server did via NetworkMap.Merge. + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + + c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + + return nil + } + + nmap := account.GetPeerNetworkMapFromComponents( + ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + + if proxyNetworkMap != nil { + nmap.Merge(proxyNetworkMap) + } + + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ Update: update, MessageType: network_map.MessageTypeNetworkMap, @@ -513,6 +629,65 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str return nil } +// GetValidatedPeerWithComponents is the components-format counterpart of +// GetValidatedPeerWithMap. It returns raw NetworkMapComponents for capable +// peers along with the proxy NetworkMap fragment (BYOP / port-forwarding +// data the legacy server folds in via NetworkMap.Merge). The gRPC layer +// encodes both into the wire envelope. Callers must gate on capability +// themselves before dispatching here — this method does NOT branch on it. +func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) { + if isRequiresApproval { + network, err := c.repo.GetAccountNetwork(ctx, accountID) + if err != nil { + return nil, nil, nil, nil, 0, err + } + return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil + } + + account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + c.injectAllProxyPolicies(ctx, account) + + approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + postureChecks, err := c.getPeerPostureChecks(account, peer.ID) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + accountZones, err := c.repo.GetAccountZones(ctx, account.Id) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + // Fetch the proxy network map fragment for this peer alongside the + // components — same single-account-load path the streaming controller + // uses, so initial-sync delivers BYOP/forwarding patches synchronously + // instead of waiting for the next streaming push. + proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers) + if err != nil { + log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) + return nil, nil, nil, nil, 0, err + } + + dnsDomain := c.GetDNSDomain(account.Settings) + peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + components := account.GetPeerNetworkMapComponents(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) + dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) + + return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil +} + // BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval. func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error { if len(peerIDs) == 0 { diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go index 14b12aba6..e6e464566 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -24,6 +24,7 @@ type Controller interface { UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) + GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) GetDNSDomain(settings *types.Settings) string StartWarmup(context.Context) GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go index bfff32e6f..42051f172 100644 --- a/management/internals/controllers/network_map/interface_mock.go +++ b/management/internals/controllers/network_map/interface_mock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: management/internals/controllers/network_map/interface.go +// Source: ./interface.go // // Generated by this command: // -// mockgen -package network_map -destination=management/internals/controllers/network_map/interface_mock.go -source=management/internals/controllers/network_map/interface.go -build_flags=-mod=mod +// mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod // // Package network_map is a generated GoMock package. @@ -126,8 +126,27 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkMap", reflect.TypeOf((*MockController)(nil).GetNetworkMap), ctx, peerID) } +// GetValidatedPeerWithComponents mocks base method. +func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetValidatedPeerWithComponents", ctx, isRequiresApproval, accountID, p) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(*types.NetworkMapComponents) + ret2, _ := ret[2].(*types.NetworkMap) + ret3, _ := ret[3].([]*posture.Checks) + ret4, _ := ret[4].(int64) + ret5, _ := ret[5].(error) + return ret0, ret1, ret2, ret3, ret4, ret5 +} + +// GetValidatedPeerWithComponents indicates an expected call of GetValidatedPeerWithComponents. +func (mr *MockControllerMockRecorder) GetValidatedPeerWithComponents(ctx, isRequiresApproval, accountID, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithComponents", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithComponents), ctx, isRequiresApproval, accountID, p) +} + // GetValidatedPeerWithMap mocks base method. -func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) { +func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, peerID) ret0, _ := ret[0].(*types.NetworkMap) @@ -171,7 +190,7 @@ func (mr *MockControllerMockRecorder) OnPeerDisconnected(ctx, accountID, peerID } // OnPeersAdded mocks base method. -func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error { +func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs, affectedPeerIDs []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OnPeersAdded", ctx, accountID, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) @@ -185,7 +204,7 @@ func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs, affe } // OnPeersDeleted mocks base method. -func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error { +func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs, affectedPeerIDs []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) @@ -199,7 +218,7 @@ func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs, af } // OnPeersUpdated mocks base method. -func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string, affectedPeerIDs []string) error { +func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs, affectedPeerIDs []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) diff --git a/management/internals/server/config/config.go b/management/internals/server/config/config.go index fb9c842b7..a77d5c19b 100644 --- a/management/internals/server/config/config.go +++ b/management/internals/server/config/config.go @@ -61,6 +61,10 @@ type Config struct { // EmbeddedIdP contains configuration for the embedded Dex OIDC provider. // When set, Dex will be embedded in the management server and serve requests at /oauth2/ EmbeddedIdP *idp.EmbeddedIdPConfig + + HighestSupportedSyncMessageVersion *int + + PerAccountHighestSupportedSyncMessageVersion map[string]int } // GetAuthAudiences returns the audience from the http config and device authorization flow config diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go new file mode 100644 index 000000000..d7b787464 --- /dev/null +++ b/management/internals/shared/grpc/components_encoder.go @@ -0,0 +1,769 @@ +package grpc + +import ( + "encoding/base64" + "strconv" + + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// wgKeyRawLen is the raw byte length of a WireGuard public key. +const wgKeyRawLen = 32 + +// ComponentsEnvelopeInput bundles the data the component-format encoder needs. +// The envelope is fully self-contained — every field needed by the client's +// local Calculate() comes from the components struct itself. The only +// externally-supplied data is the receiving peer's PeerConfig (which is +// computed alongside the components in the network_map controller and reused +// from the legacy proto path) and the dns_domain string. +type ComponentsEnvelopeInput struct { + Components *types.NetworkMapComponents + PeerConfig *proto.PeerConfig + DNSDomain string + DNSForwarderPort int64 + // UserIDClaim is the OIDC claim name the client should embed in + // SshAuth.UserIDClaim when reconstructing the NetworkMap. Empty value + // is OK — client treats empty as "no SshAuth to build". + UserIDClaim string + // ProxyPatch carries pre-expanded NetworkMap fragments injected by + // external controllers (BYOP/port-forwarding). Nil when no proxy data + // is present; encoder skips the field in that case. + ProxyPatch *proto.ProxyPatch +} + +// EncodeNetworkMapEnvelope converts NetworkMapComponents into the component +// wire envelope. The encoder is intentionally non-deterministic: it iterates +// Go maps in their native (random) order. Indexes inside the envelope +// (peer_indexes, source_group_ids, agent_version_idx, router_peer_indexes) +// are self-consistent within a single encode, so the decoder reconstructs +// the same typed objects regardless of emit order. Tests that need to +// compare envelopes do so semantically via proto round-trip + canonicalize, +// not byte-equal. +// +// Callers must NOT concatenate or merge envelopes from different encodes — +// index spaces are local to a single envelope. +func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvelope { + c := in.Components + + // Graceful degrade when components is nil — matches the legacy path's + // behaviour for missing/unvalidated peers (return a NetworkMap with only + // Network populated). The receiver gets an envelope it can decode + // without crashing; AccountSettings stays non-nil so client-side + // dereferences are safe. + if c.IsEmpty() { + // Match legacy missing-peer minimum: a NetworkMap with only Network + // populated. The receiver gets enough to bootstrap (Network + // identifier, dns_domain, account_settings) and the peer itself. + return &proto.NetworkMapEnvelope{ + Payload: &proto.NetworkMapEnvelope_Full{ + Full: &proto.NetworkMapComponentsFull{ + PeerConfig: in.PeerConfig, + // components.Peers always contains the target peer + Peers: []*proto.PeerCompact{toPeerCompact(c.Peers[c.PeerID])}, + DnsDomain: in.DNSDomain, + DnsForwarderPort: in.DNSForwarderPort, + UserIdClaim: in.UserIDClaim, + AccountSettings: &proto.AccountSettingsCompact{}, + ProxyPatch: in.ProxyPatch, + }, + }, + } + } + + // Phase 1: build dedup tables. Every routing peer (in c.RouterPeers) and + // every regular peer (in c.Peers) must be indexed before any encoder + // looks up indexes via e.peerOrder — otherwise routes / routers_map for + // peers that exist only in c.RouterPeers would silently lose their + // peer_index reference. + enc := newComponentEncoder(c) + enc.indexAllPeers() + routerIdxs := enc.indexRouterPeers(c.RouterPeers) + + // Phase 2: gather every policy that any consumer references (peer-pair + // policies + resource-only policies) so encodeResourcePoliciesMap can + // translate every *Policy pointer to a wire index. + allPolicies := unionPolicies(c.Policies, c.ResourcePoliciesMap) + policies := enc.encodePolicies(allPolicies) + + // Phase 3: emit. Order of struct field expressions no longer matters: + // every encoder either reads from the dedup tables or works on + // independent input. + full := &proto.NetworkMapComponentsFull{ + Serial: networkSerial(c.Network), + PeerConfig: in.PeerConfig, + Network: toAccountNetwork(c.Network), + AccountSettings: toAccountSettingsCompact(c.AccountSettings), + DnsForwarderPort: in.DNSForwarderPort, + UserIdClaim: in.UserIDClaim, + ProxyPatch: in.ProxyPatch, + DnsSettings: enc.encodeDNSSettings(c.DNSSettings), + DnsDomain: in.DNSDomain, + CustomZoneDomain: c.CustomZoneDomain, + AgentVersions: enc.agentVersions, + Peers: enc.peers, + RouterPeerIndexes: routerIdxs, + Policies: policies, + Groups: enc.encodeGroups(), + Routes: enc.encodeRoutes(c.Routes), + NameserverGroups: enc.encodeNameServerGroups(c.NameServerGroups), + AllDnsRecords: encodeSimpleRecords(c.AllDNSRecords), + AccountZones: encodeCustomZones(c.AccountZones), + NetworkResources: enc.encodeNetworkResources(c.NetworkResources), + RoutersMap: enc.encodeRoutersMap(c.RoutersMap), + ResourcePoliciesMap: enc.encodeResourcePoliciesMap(c.ResourcePoliciesMap), + GroupIdToUserIds: enc.encodeGroupIDToUserIDs(c.GroupIDToUserIDs), + AllowedUserIds: stringSetToSlice(c.AllowedUserIDs), + PostureFailedPeers: enc.encodePostureFailedPeers(c.PostureFailedPeers), + } + + return &proto.NetworkMapEnvelope{ + Payload: &proto.NetworkMapEnvelope_Full{Full: full}, + } +} + +// networkSerial returns c.Network.CurrentSerial() with a nil guard. The +// production path always populates c.Network, but the encoder is exported +// and a hand-built components struct may omit it. +func networkSerial(n *types.Network) uint64 { + if n == nil { + return 0 + } + return n.CurrentSerial() +} + +type componentEncoder struct { + components *types.NetworkMapComponents + + peerOrder map[string]uint32 + peers []*proto.PeerCompact + + agentVersionOrder map[string]uint32 + agentVersions []string +} + +func newComponentEncoder(c *types.NetworkMapComponents) *componentEncoder { + return &componentEncoder{ + components: c, + peerOrder: make(map[string]uint32, len(c.Peers)), + peers: make([]*proto.PeerCompact, 0, len(c.Peers)), + agentVersionOrder: make(map[string]uint32), + } +} + +func (e *componentEncoder) indexAllPeers() { + for _, p := range e.components.Peers { + if p == nil { + continue + } + e.appendPeer(p) + } +} + +func (e *componentEncoder) appendPeer(p *nbpeer.Peer) uint32 { + if idx, ok := e.peerOrder[p.ID]; ok { + return idx + } + idx := uint32(len(e.peers)) + e.peerOrder[p.ID] = idx + e.peers = append(e.peers, toPeerCompact(p)) + return idx +} + +// indexRouterPeers ensures every router peer is in the peer dedup table +// (c.RouterPeers may contain peers not in c.Peers when validation rules drop +// them) and returns their wire indexes for the RouterPeerIndexes field. Must +// run before any encoder that resolves peer ids via e.peerOrder. +func (e *componentEncoder) indexRouterPeers(routers map[string]*nbpeer.Peer) []uint32 { + if len(routers) == 0 { + return nil + } + out := make([]uint32, 0, len(routers)) + for _, p := range routers { + if p == nil { + continue + } + out = append(out, e.appendPeer(p)) + } + return out +} + +func (e *componentEncoder) encodeGroups() []*proto.GroupCompact { + if len(e.components.Groups) == 0 { + return nil + } + + out := make([]*proto.GroupCompact, 0, len(e.components.Groups)) + for _, g := range e.components.Groups { + peerIdxs := make([]uint32, 0, len(g.Peers)) + for _, peerID := range g.Peers { + if idx, ok := e.peerOrder[peerID]; ok { + peerIdxs = append(peerIdxs, idx) + } + } + out = append(out, &proto.GroupCompact{ + Id: g.PublicID, + PeerIndexes: peerIdxs, + IsAll: g.IsGroupAll(), + }) + } + return out +} + +// encodePolicies flattens Policy{Rules} → []PolicyCompact. Returns the wire +// list and a map from policy pointer to the indexes of its emitted rules in +// that list — used by encodeResourcePoliciesMap to translate +// ResourcePoliciesMap[resourceID][]*Policy into wire-side indexes. +func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.PolicyCompact { + if len(policies) == 0 { + return nil + } + + out := make([]*proto.PolicyCompact, 0, len(policies)) + + for _, pol := range policies { + if !pol.Enabled { + continue + } + for _, r := range pol.Rules { + if r == nil || !r.Enabled { + continue + } + out = append(out, e.encodePolicyRule(pol, r)) + } + } + return out +} + +// encodePolicyRule maps a single PolicyRule under pol to a PolicyCompact entry. +func (e *componentEncoder) encodePolicyRule(pol *types.Policy, r *types.PolicyRule) *proto.PolicyCompact { + return &proto.PolicyCompact{ + Id: pol.PublicID, + Action: networkmap.GetProtoAction(string(r.Action)), + Protocol: networkmap.GetProtoProtocol(string(r.Protocol)), + Bidirectional: r.Bidirectional, + Ports: portsToUint32(r.Ports), + PortRanges: portRangesToProto(r.PortRanges), + SourceGroupIds: e.groupPublicXids(r.Sources), + DestinationGroupIds: e.groupPublicXids(r.Destinations), + AuthorizedUser: r.AuthorizedUser, + AuthorizedGroups: e.encodeAuthorizedGroups(r.AuthorizedGroups), + SourceResource: e.resourceToProto(r.SourceResource), + DestinationResource: e.resourceToProto(r.DestinationResource), + SourcePostureCheckIds: e.postureCheckSeqs(pol.SourcePostureChecks), + } +} + +// groupPublicXids maps the xid group IDs in src to their public xids, +// dropping any group with invalid public xid. +func (e *componentEncoder) groupPublicXids(src []string) []string { + if len(src) == 0 { + return nil + } + out := make([]string, 0, len(src)) + for _, gid := range src { + if id, ok := e.groupPublicXid(gid); ok { + out = append(out, id) + } + } + return out +} + +// unionPolicies merges c.Policies with every policy referenced by +// c.ResourcePoliciesMap, deduplicating by pointer identity. Resource-only +// policies (relevant to a NetworkResource but not to peer-pair traffic) +// only live in ResourcePoliciesMap; without this union step they'd be lost +// from the wire and the client's resource-policy lookup would come back +// empty. +func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*types.Policy) []*types.Policy { + // Fast path: non-router peers have no resource-only policies, so the + // "union" is identical to `policies`. Skip the dedup map allocation. + if len(resourcePolicies) == 0 { + return policies + } + seen := make(map[string]struct{}, len(policies)) + out := make([]*types.Policy, 0, len(policies)) + for _, p := range policies { + if p == nil { + continue + } + if _, ok := seen[p.ID]; ok { + continue + } + seen[p.ID] = struct{}{} + out = append(out, p) + } + for _, list := range resourcePolicies { + for _, p := range list { + if p == nil { + continue + } + if _, ok := seen[p.ID]; ok { + continue + } + seen[p.ID] = struct{}{} + out = append(out, p) + } + } + return out +} + +// encodeAuthorizedGroups translates rule.AuthorizedGroups (map keyed by +// group xid → local-user names) to the wire form (map keyed by group +// account_seq_id → UserNameList). Groups without a seq id are dropped — +// matches how source/destination group references handle the same case. +func (e *componentEncoder) encodeAuthorizedGroups(m map[string][]string) map[string]*proto.UserNameList { + if len(m) == 0 { + return nil + } + out := make(map[string]*proto.UserNameList, len(m)) + for groupID, names := range m { + id, ok := e.groupPublicXid(groupID) + if !ok { + continue + } + out[id] = &proto.UserNameList{Names: names} + } + return out +} + +func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) { + g, ok := e.components.Groups[groupID] + if !ok { + return "", false + } + return g.PublicID, true +} + +// resourceToProto translates types.Resource for the wire. For peer-typed +// resources the peer id is converted to a peer index into the envelope's +// peers array. For other resource types only the type string is shipped +// today (Calculate's resource-typed rule path consults SourceResource only +// for "peer" — other types fall through to group-based lookup). +func (e *componentEncoder) resourceToProto(r types.Resource) *proto.ResourceCompact { + if r.ID == "" && r.Type == "" { + return nil + } + out := &proto.ResourceCompact{Type: string(r.Type)} + if r.Type == types.ResourceTypePeer && r.ID != "" { + if idx, ok := e.peerOrder[r.ID]; ok { + out.PeerIndexSet = true + out.PeerIndex = idx + } + } + return out +} + +// postureCheckSeqs translates a slice of posture-check xids to their +// public xids. Unresolvable xids are silently dropped — matches how group/peer +// references handle the same case. +func (e *componentEncoder) postureCheckSeqs(xids []string) []string { + if len(xids) == 0 || len(e.components.PostureCheckXIDToPublicID) == 0 { + return nil + } + out := make([]string, 0, len(xids)) + for _, xid := range xids { + if seq, ok := e.components.PostureCheckXIDToPublicID[xid]; ok { + out = append(out, seq) + } + } + return out +} + +// networkSeq translates a Network xid to its public id using +// the NetworkMapComponents.NetworkXIDToPublicID lookup. Returns (0,false) when +// the xid isn't known — callers decide whether to skip the parent record. +func (e *componentEncoder) networkPublicId(xid string) (string, bool) { + if xid == "" { + return "", false + } + id, ok := e.components.NetworkXIDToPublicID[xid] + if !ok { + return "", false + } + return id, true +} + +func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSettingsCompact { + if s == nil || len(s.DisabledManagementGroups) == 0 { + return nil + } + out := &proto.DNSSettingsCompact{ + DisabledManagementGroupIds: make([]string, 0, len(s.DisabledManagementGroups)), + } + for _, gid := range s.DisabledManagementGroups { + if id, ok := e.groupPublicXid(gid); ok { + out.DisabledManagementGroupIds = append(out.DisabledManagementGroupIds, id) + } + } + return out +} + +func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteRaw { + if len(routes) == 0 { + return nil + } + out := make([]*proto.RouteRaw, 0, len(routes)) + for _, r := range routes { + if r == nil { + continue + } + rr := &proto.RouteRaw{ + Id: r.PublicID, + NetId: string(r.NetID), + Description: r.Description, + KeepRoute: r.KeepRoute, + NetworkType: int32(r.NetworkType), + Masquerade: r.Masquerade, + Metric: int32(r.Metric), + Enabled: r.Enabled, + SkipAutoApply: r.SkipAutoApply, + Domains: r.Domains.ToPunycodeList(), + GroupIds: e.groupPublicXids(r.Groups), + AccessControlGroupIds: e.groupPublicXids(r.AccessControlGroups), + PeerGroupIds: e.groupPublicXids(r.PeerGroups), + } + if r.Network.IsValid() { + rr.NetworkCidr = r.Network.String() + } + if r.Peer != "" { + if idx, ok := e.peerOrder[r.Peer]; ok { + rr.PeerIndexSet = true + rr.PeerIndex = idx + } + } + out = append(out, rr) + } + return out +} + +func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup) []*proto.NameServerGroupRaw { + if len(nsgs) == 0 { + return nil + } + out := make([]*proto.NameServerGroupRaw, 0, len(nsgs)) + for _, nsg := range nsgs { + if nsg == nil { + continue + } + entry := &proto.NameServerGroupRaw{ + Id: nsg.PublicID, + Nameservers: encodeNameServers(nsg.NameServers), + GroupIds: e.groupPublicXids(nsg.Groups), + Primary: nsg.Primary, + Domains: nsg.Domains, + Enabled: nsg.Enabled, + SearchDomainsEnabled: nsg.SearchDomainsEnabled, + } + out = append(out, entry) + } + return out +} + +func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer { + if len(servers) == 0 { + return nil + } + out := make([]*proto.NameServer, 0, len(servers)) + for _, s := range servers { + out = append(out, &proto.NameServer{ + IP: s.IP.String(), + NSType: int64(s.NSType), + Port: int64(s.Port), + }) + } + return out +} + +func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord { + if len(records) == 0 { + return nil + } + out := make([]*proto.SimpleRecord, 0, len(records)) + for _, r := range records { + out = append(out, &proto.SimpleRecord{ + Name: r.Name, + Type: int64(r.Type), + Class: r.Class, + TTL: int64(r.TTL), + RData: r.RData, + }) + } + return out +} + +func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone { + if len(zones) == 0 { + return nil + } + out := make([]*proto.CustomZone, 0, len(zones)) + for _, z := range zones { + out = append(out, &proto.CustomZone{ + Domain: z.Domain, + Records: encodeSimpleRecords(z.Records), + SearchDomainDisabled: z.SearchDomainDisabled, + NonAuthoritative: z.NonAuthoritative, + }) + } + return out +} + +func (e *componentEncoder) encodeNetworkResources(resources []*resourceTypes.NetworkResource) []*proto.NetworkResourceRaw { + if len(resources) == 0 { + return nil + } + out := make([]*proto.NetworkResourceRaw, 0, len(resources)) + for _, r := range resources { + if r == nil { + continue + } + entry := &proto.NetworkResourceRaw{ + Id: r.PublicID, + Name: r.Name, + Description: r.Description, + Type: string(r.Type), + Address: r.Address, + DomainValue: r.Domain, + Enabled: r.Enabled, + } + if id, ok := e.networkPublicId(r.NetworkID); ok { + entry.NetworkSeq = id + } + if r.Prefix.IsValid() { + entry.PrefixCidr = r.Prefix.String() + } + out = append(out, entry) + } + return out +} + +func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*routerTypes.NetworkRouter) map[string]*proto.NetworkRouterList { + if len(routersMap) == 0 { + return nil + } + out := make(map[string]*proto.NetworkRouterList, len(routersMap)) + for networkXID, routers := range routersMap { + if len(routers) == 0 { + continue + } + id, ok := e.networkPublicId(networkXID) + if !ok { + continue + } + entries := make([]*proto.NetworkRouterEntry, 0, len(routers)) + for peerID, r := range routers { + if r == nil { + continue + } + entry := &proto.NetworkRouterEntry{ + Id: r.PublicID, + PeerGroupIds: e.groupPublicXids(r.PeerGroups), + Masquerade: r.Masquerade, + Metric: int32(r.Metric), + Enabled: r.Enabled, + } + if idx, ok := e.peerOrder[peerID]; ok { + entry.PeerIndexSet = true + entry.PeerIndex = idx + } + entries = append(entries, entry) + } + out[id] = &proto.NetworkRouterList{Entries: entries} + } + return out +} + +func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Policy) map[string]*proto.PolicyIds { + if len(rpm) == 0 { + return nil + } + // resourceXIDToPublicID is local to one encode — built from components.NetworkResources + // (small slice). Network resources without seq id are dropped, matching how + // other components-without-seq are silently filtered. + resourceXIDToPublicID := make(map[string]string, len(e.components.NetworkResources)) + for _, r := range e.components.NetworkResources { + if r != nil { + resourceXIDToPublicID[r.ID] = r.PublicID + } + } + out := make(map[string]*proto.PolicyIds, len(rpm)) + for resourceXID, policies := range rpm { + resId, ok := resourceXIDToPublicID[resourceXID] + if !ok { + continue + } + ids := make([]string, 0, len(policies)) + for _, pol := range policies { + ids = append(ids, pol.PublicID) + } + if len(ids) == 0 { + continue + } + out[resId] = &proto.PolicyIds{Ids: ids} + } + return out +} + +func (e *componentEncoder) encodeGroupIDToUserIDs(m map[string][]string) map[string]*proto.UserIDList { + if len(m) == 0 { + return nil + } + out := make(map[string]*proto.UserIDList, len(m)) + for groupID, userIDs := range m { + id, ok := e.groupPublicXid(groupID) + if !ok || len(userIDs) == 0 { + continue + } + out[id] = &proto.UserIDList{UserIds: userIDs} + } + return out +} + +func stringSetToSlice(s map[string]struct{}) []string { + if len(s) == 0 { + return nil + } + out := make([]string, 0, len(s)) + for k := range s { + out = append(out, k) + } + return out +} + +func (e *componentEncoder) encodePostureFailedPeers(m map[string]map[string]struct{}) map[string]*proto.PeerIndexSet { + if len(m) == 0 { + return nil + } + out := make(map[string]*proto.PeerIndexSet, len(m)) + for checkXID, failedPeerIDs := range m { + id, ok := e.components.PostureCheckXIDToPublicID[checkXID] + if !ok { + continue + } + idxs := make([]uint32, 0, len(failedPeerIDs)) + for peerID := range failedPeerIDs { + if idx, ok := e.peerOrder[peerID]; ok { + idxs = append(idxs, idx) + } + } + if len(idxs) == 0 { + continue + } + out[id] = &proto.PeerIndexSet{PeerIndexes: idxs} + } + return out +} + +// toAccountSettingsCompact always returns a non-nil message — the client +// dereferences it unconditionally during Calculate(), so a nil here would +// crash the receiver. A missing types.AccountSettingsInfo on the server +// (which shouldn't happen in production but the encoder is exported) +// degrades to login_expiration_enabled = false, which makes +// LoginExpired() return false for every peer. +func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettingsCompact { + if s == nil { + return &proto.AccountSettingsCompact{} + } + return &proto.AccountSettingsCompact{ + PeerLoginExpirationEnabled: s.PeerLoginExpirationEnabled, + PeerLoginExpirationNs: int64(s.PeerLoginExpiration), + } +} + +func toAccountNetwork(n *types.Network) *proto.AccountNetwork { + if n == nil { + return nil + } + out := &proto.AccountNetwork{ + Identifier: n.Identifier, + NetCidr: n.Net.String(), + Dns: n.Dns, + Serial: n.CurrentSerial(), + } + if len(n.NetV6.IP) > 0 { + out.NetV6Cidr = n.NetV6.String() + } + return out +} + +func toPeerCompact(p *nbpeer.Peer) *proto.PeerCompact { + pc := &proto.PeerCompact{ + WgPubKey: decodeWgKey(p.Key), + SshPubKey: []byte(p.SSHKey), + DnsLabel: p.DNSLabel, + AgentVersion: p.Meta.WtVersion, + AddedWithSsoLogin: p.UserID != "", + LoginExpirationEnabled: p.LoginExpirationEnabled, + SshEnabled: p.SSHEnabled, + SupportsIpv6: p.SupportsIPv6(), + SupportsSourcePrefixes: p.SupportsSourcePrefixes(), + ServerSshAllowed: p.Meta.Flags.ServerSSHAllowed, + } + if p.LastLogin != nil { + pc.LastLoginUnixNano = p.LastLogin.UnixNano() + } + switch { + case !p.IP.IsValid(): + // leave Ip nil + case p.IP.Is4() || p.IP.Is4In6(): + ip := p.IP.Unmap().As4() + pc.Ip = ip[:] + default: + ip := p.IP.As16() + pc.Ip = ip[:] + } + if p.IPv6.IsValid() { + ip := p.IPv6.As16() + pc.Ipv6 = ip[:] + } + return pc +} + +// decodeWgKey returns the raw 32 bytes of a base64-encoded WireGuard public +// key, or nil for an empty / malformed key. +func decodeWgKey(s string) []byte { + if s == "" { + return nil + } + out := make([]byte, wgKeyRawLen) + n, err := base64.StdEncoding.Decode(out, []byte(s)) + if err != nil || n != wgKeyRawLen { + return nil + } + return out +} + +func portsToUint32(ports []string) []uint32 { + if len(ports) == 0 { + return nil + } + out := make([]uint32, 0, len(ports)) + for _, p := range ports { + v, err := strconv.ParseUint(p, 10, 16) + if err != nil { + continue + } + out = append(out, uint32(v)) + } + return out +} + +func portRangesToProto(ranges []types.RulePortRange) []*proto.PortInfo_Range { + if len(ranges) == 0 { + return nil + } + out := make([]*proto.PortInfo_Range, 0, len(ranges)) + for _, r := range ranges { + out = append(out, &proto.PortInfo_Range{ + Start: uint32(r.Start), + End: uint32(r.End), + }) + } + return out +} diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go new file mode 100644 index 000000000..d82bba362 --- /dev/null +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -0,0 +1,785 @@ +package grpc + +import ( + "bytes" + "cmp" + "net" + "net/netip" + "slices" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + goproto "google.golang.org/protobuf/proto" + + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const testWgKeyA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq=" +const testWgKeyB = "BBCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq=" +const testWgKeyC = "CBCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq=" + +// canonicalize rewrites a NetworkMapComponentsFull in place into a canonical +// form: peers reordered by wg_pub_key, with the rest of the message rewritten +// to reference the new peer indexes. Groups, policies, and router indexes are +// also sorted. After canonicalize, two envelopes built from the same logical +// input compare byte-equal via proto.Equal. +// +// This lives on the test side — the encoder itself emits in map-iteration +// order. Test-side normalization is the contract for "two encodes are +// equivalent". +func canonicalize(full *proto.NetworkMapComponentsFull) { + if full == nil { + return + } + + type peerEntry struct { + peer *proto.PeerCompact + oldIdx uint32 + } + entries := make([]peerEntry, len(full.Peers)) + for i, p := range full.Peers { + entries[i] = peerEntry{peer: p, oldIdx: uint32(i)} + } + // DnsLabel is unique per peer; it tiebreaks on equal WgPubKey (e.g. both + // nil from malformed keys, or both empty for placeholders). + slices.SortFunc(entries, func(a, b peerEntry) int { + if c := bytes.Compare(a.peer.WgPubKey, b.peer.WgPubKey); c != 0 { + return c + } + return cmp.Compare(a.peer.DnsLabel, b.peer.DnsLabel) + }) + + remap := make(map[uint32]uint32, len(entries)) + newPeers := make([]*proto.PeerCompact, len(entries)) + for newIdx, e := range entries { + remap[e.oldIdx] = uint32(newIdx) + newPeers[newIdx] = e.peer + } + full.Peers = newPeers + + full.RouterPeerIndexes = remapAndSort(full.RouterPeerIndexes, remap) + for _, g := range full.Groups { + g.PeerIndexes = remapAndSort(g.PeerIndexes, remap) + } + slices.SortFunc(full.Groups, func(a, b *proto.GroupCompact) int { return cmp.Compare(a.Id, b.Id) }) + + for _, r := range full.Routes { + if r.PeerIndexSet { + if newIdx, ok := remap[r.PeerIndex]; ok { + r.PeerIndex = newIdx + } + } + slices.Sort(r.GroupIds) + slices.Sort(r.AccessControlGroupIds) + slices.Sort(r.PeerGroupIds) + } + slices.SortFunc(full.Routes, func(a, b *proto.RouteRaw) int { return cmp.Compare(a.Id, b.Id) }) + + for _, list := range full.RoutersMap { + for _, entry := range list.Entries { + if entry.PeerIndexSet { + if newIdx, ok := remap[entry.PeerIndex]; ok { + entry.PeerIndex = newIdx + } + } + slices.Sort(entry.PeerGroupIds) + } + slices.SortFunc(list.Entries, func(a, b *proto.NetworkRouterEntry) int { return cmp.Compare(a.Id, b.Id) }) + } + + for _, set := range full.PostureFailedPeers { + set.PeerIndexes = remapAndSort(set.PeerIndexes, remap) + } + + for _, p := range full.Policies { + slices.Sort(p.SourceGroupIds) + slices.Sort(p.DestinationGroupIds) + } + // Sort policies by (Id, source_group_ids, destination_group_ids) so that + // multiple PolicyCompact entries sharing the same Id (one per rule, when + // a Policy has multiple rules) still get a deterministic order. After + // sorting we remap indexes in ResourcePoliciesMap. + policyOldOrder := make(map[*proto.PolicyCompact]uint32, len(full.Policies)) + for i, p := range full.Policies { + policyOldOrder[p] = uint32(i) + } + slices.SortFunc(full.Policies, func(a, b *proto.PolicyCompact) int { + if c := cmp.Compare(a.Id, b.Id); c != 0 { + return c + } + if c := slices.Compare(a.SourceGroupIds, b.SourceGroupIds); c != 0 { + return c + } + return slices.Compare(a.DestinationGroupIds, b.DestinationGroupIds) + }) + policyRemap := make(map[uint32]uint32, len(full.Policies)) + for newIdx, p := range full.Policies { + policyRemap[policyOldOrder[p]] = uint32(newIdx) + } + for _, idxs := range full.ResourcePoliciesMap { + slices.Sort(idxs.Ids) + } + for _, list := range full.GroupIdToUserIds { + slices.Sort(list.UserIds) + } + slices.Sort(full.AllowedUserIds) +} + +func remapAndSort(idxs []uint32, remap map[uint32]uint32) []uint32 { + out := make([]uint32, 0, len(idxs)) + for _, i := range idxs { + if newIdx, ok := remap[i]; ok { + out = append(out, newIdx) + } + } + slices.Sort(out) + return out +} + +// envelopesEquivalent decodes both envelopes, canonicalizes them, and reports +// whether they're proto.Equal. Use instead of byte-comparing marshaled output: +// the encoder is intentionally non-deterministic. +func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool { + canonicalize(a.GetFull()) + canonicalize(b.GetFull()) + return goproto.Equal(a, b) +} + +func newTestComponents() *types.NetworkMapComponents { + peerA := &nbpeer.Peer{ + ID: "peer-a", + Key: testWgKeyA, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peera", + SSHKey: "ssh-a", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + peerB := &nbpeer.Peer{ + ID: "peer-b", + Key: testWgKeyB, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), + DNSLabel: "peerb", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.25.0"}, + } + peerC := &nbpeer.Peer{ + ID: "peer-c", + Key: testWgKeyC, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), + DNSLabel: "peerc", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + + return &types.NetworkMapComponents{ + PeerID: "peer-a", + Network: &types.Network{ + Identifier: "net-test", + Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, + Serial: 7, + }, + AccountSettings: &types.AccountSettingsInfo{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 2 * time.Hour, + }, + Peers: map[string]*nbpeer.Peer{ + "peer-a": peerA, + "peer-b": peerB, + "peer-c": peerC, + }, + Groups: map[string]*types.Group{ + "group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}}, + "group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}}, + }, + Policies: []*types.Policy{ + { + ID: "pol-1", + PublicID: "10", + Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-1", Enabled: true, Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true, + Ports: []string{"22", "80"}, + PortRanges: []types.RulePortRange{{Start: 8000, End: 8100}}, + Sources: []string{"group-src"}, + Destinations: []string{"group-dst"}, + }}, + }, + }, + RouterPeers: map[string]*nbpeer.Peer{"peer-c": peerC}, + } +} + +func TestEncodeNetworkMapEnvelope_Basic(t *testing.T) { + c := newTestComponents() + env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + + require.NotNil(t, env) + full := env.GetFull() + require.NotNil(t, full, "envelope must contain Full payload") + + assert.EqualValues(t, 7, full.Serial) + assert.Equal(t, "netbird.cloud", full.DnsDomain) + + require.NotNil(t, full.Network) + assert.Equal(t, "net-test", full.Network.Identifier) + assert.Equal(t, "100.64.0.0/10", full.Network.NetCidr) + + require.NotNil(t, full.AccountSettings) + assert.True(t, full.AccountSettings.PeerLoginExpirationEnabled) + assert.EqualValues(t, (2 * time.Hour).Nanoseconds(), full.AccountSettings.PeerLoginExpirationNs) + + require.Len(t, full.Peers, 3) + byLabel := map[string]*proto.PeerCompact{} + for _, p := range full.Peers { + assert.Len(t, p.WgPubKey, 32, "wg key must be raw 32 bytes") + assert.Len(t, p.Ip, 4, "ipv4 must be raw 4 bytes") + byLabel[p.DnsLabel] = p + } + assert.Len(t, byLabel["peerb"].Ipv6, 16, "peer-b has ipv6 → 16 bytes") +} + +func TestEncodeNetworkMapEnvelope_RepeatEncodesEquivalent(t *testing.T) { + c := newTestComponents() + + expected := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + + // Hammer it 100 times — Go map iteration is randomized per call, so each + // run produces different wire bytes, but the canonicalized form must + // match. + for i := 0; i < 100; i++ { + got := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + require.True(t, envelopesEquivalent(expected, got), + "encode #%d must be semantically equivalent to first encode", i) + } +} + +func TestEncodeNetworkMapEnvelope_ConcurrentEncodesEquivalent(t *testing.T) { + c := newTestComponents() + + expected := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + + const goroutines = 50 + var wg sync.WaitGroup + wg.Add(goroutines) + results := make([]*proto.NetworkMapEnvelope, goroutines) + for i := 0; i < goroutines; i++ { + i := i + go func() { + defer wg.Done() + results[i] = EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + }() + } + wg.Wait() + + for i, got := range results { + require.NotNil(t, got, "goroutine %d returned nil", i) + require.True(t, envelopesEquivalent(expected, got), + "goroutine %d produced inequivalent envelope", i) + } +} + +func TestEncodeNetworkMapEnvelope_GroupsByAccountPublicId(t *testing.T) { + c := newTestComponents() + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Groups, 2) + + groupByID := map[string]*proto.GroupCompact{} + for _, g := range full.Groups { + groupByID[g.Id] = g + } + require.Contains(t, groupByID, "1") + require.Contains(t, groupByID, "2") + assert.Len(t, groupByID["1"].PeerIndexes, 1) + assert.Len(t, groupByID["2"].PeerIndexes, 2) +} + +func TestEncodeNetworkMapEnvelope_PolicyExpansion(t *testing.T) { + c := newTestComponents() + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Policies, 1) + pc := full.Policies[0] + assert.EqualValues(t, "10", pc.Id) + assert.Equal(t, proto.RuleAction_ACCEPT, pc.Action) + assert.Equal(t, proto.RuleProtocol_TCP, pc.Protocol) + assert.True(t, pc.Bidirectional) + assert.Equal(t, []uint32{22, 80}, pc.Ports) + require.Len(t, pc.PortRanges, 1) + assert.EqualValues(t, 8000, pc.PortRanges[0].Start) + assert.EqualValues(t, 8100, pc.PortRanges[0].End) + assert.Equal(t, []string{"1"}, pc.SourceGroupIds) + assert.Equal(t, []string{"2"}, pc.DestinationGroupIds) +} + +func TestEncodeNetworkMapEnvelope_RouterIndexes(t *testing.T) { + c := newTestComponents() + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.RouterPeerIndexes, 1) + idx := full.RouterPeerIndexes[0] + require.Less(t, int(idx), len(full.Peers)) + assert.Equal(t, "peerc", full.Peers[idx].DnsLabel) +} + +func TestEncodeNetworkMapEnvelope_DisabledPolicySkipped(t *testing.T) { + c := newTestComponents() + c.Policies[0].Enabled = false + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + assert.Empty(t, full.Policies) +} + +func TestEncodeNetworkMapEnvelope_TwoPeersSameMalformedKey(t *testing.T) { + // Both peers have nil WgPubKey after decode; canonicalize must still + // produce a stable order using DnsLabel as a tiebreaker, so 100 encodes + // canonicalize identically. + c := newTestComponents() + c.Peers["peer-a"].Key = "garbage-a-!!!" + c.Peers["peer-b"].Key = "garbage-b-!!!" + + expected := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + for i := 0; i < 100; i++ { + got := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + require.True(t, envelopesEquivalent(expected, got), + "encode #%d with two same-key peers must canonicalize equivalently", i) + } +} + +func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) { + c := newTestComponents() + c.Peers["peer-a"].Key = "not-base64-!!!" + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Peers, 3) + + var byLabel = map[string]*proto.PeerCompact{} + for _, p := range full.Peers { + byLabel[p.DnsLabel] = p + } + assert.Nil(t, byLabel["peera"].WgPubKey, "peer with malformed key encodes nil WgPubKey") + assert.Len(t, byLabel["peerb"].WgPubKey, 32, "other peers retain their key") +} + +func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { + c := newTestComponents() + v6Only := &nbpeer.Peer{ + ID: "peer-v6", + Key: testWgKeyA, + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), + DNSLabel: "peerv6", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + c.Peers["peer-v6"] = v6Only + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + var found *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peerv6" { + found = p + } + } + require.NotNil(t, found, "ipv6-only peer must be present") + assert.Empty(t, found.Ip, "no IPv4 address → empty Ip") + assert.Len(t, found.Ipv6, 16) +} + +func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) { + c := newTestComponents() + c.Peers["peer-noip"] = &nbpeer.Peer{ + ID: "peer-noip", + Key: testWgKeyA, + DNSLabel: "peernoip", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + var found *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peernoip" { + found = p + } + } + require.NotNil(t, found) + assert.Empty(t, found.Ip) + assert.Empty(t, found.Ipv6) +} + +func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) { + c := &types.NetworkMapComponents{ + Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}}, + } + + env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + + full := env.GetFull() + require.NotNil(t, full) + assert.Empty(t, full.Peers) + assert.Empty(t, full.Groups) + assert.Empty(t, full.Policies) + assert.Empty(t, full.RouterPeerIndexes) + require.NotNil(t, full.AccountSettings, "AccountSettingsCompact must always be emitted (client dereferences it unconditionally)") +} + +func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) { + c := newTestComponents() + now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + c.Peers["peer-a"].UserID = "user-1" + c.Peers["peer-a"].LoginExpirationEnabled = true + c.Peers["peer-a"].LastLogin = &now + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + var pa *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peera" { + pa = p + } + } + require.NotNil(t, pa) + assert.True(t, pa.AddedWithSsoLogin) + assert.True(t, pa.LoginExpirationEnabled) + assert.Equal(t, now.UnixNano(), pa.LastLoginUnixNano) + + // peer-b has no UserID and no LastLogin → all fields zero-value. + var pb *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peerb" { + pb = p + } + } + require.NotNil(t, pb) + assert.False(t, pb.AddedWithSsoLogin) + assert.False(t, pb.LoginExpirationEnabled) + assert.Zero(t, pb.LastLoginUnixNano) +} + +func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) { + c := newTestComponents() + c.Routes = []*nbroute.Route{ + { + ID: "route-peer", + PublicID: "100", + NetID: "net-A", + Description: "via peer-c", + Network: netip.MustParsePrefix("10.0.0.0/16"), + Peer: "peer-c", // peer ID, not WG key + Groups: []string{"group-src"}, + AccessControlGroups: []string{"group-dst"}, + Enabled: true, + }, + { + ID: "route-peergroup", + PublicID: "101", + NetID: "net-B", + Network: netip.MustParsePrefix("10.1.0.0/16"), + PeerGroups: []string{"group-src", "group-dst"}, + Enabled: true, + }, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Routes, 2) + byNetID := map[string]*proto.RouteRaw{} + for _, r := range full.Routes { + byNetID[r.NetId] = r + } + + r1 := byNetID["net-A"] + require.NotNil(t, r1) + assert.True(t, r1.PeerIndexSet, "route with peer must set peer_index_set") + require.Less(t, int(r1.PeerIndex), len(full.Peers)) + assert.Equal(t, "peerc", full.Peers[r1.PeerIndex].DnsLabel) + assert.Equal(t, []string{"1"}, r1.GroupIds, "group-src has AccountSeqID 1") + assert.Equal(t, []string{"2"}, r1.AccessControlGroupIds, "group-dst has AccountSeqID 2") + assert.Empty(t, r1.PeerGroupIds) + + r2 := byNetID["net-B"] + require.NotNil(t, r2) + assert.False(t, r2.PeerIndexSet, "route with peer_groups must NOT set peer_index_set") + assert.ElementsMatch(t, []string{"1", "2"}, r2.PeerGroupIds) +} + +func TestEncodeNetworkMapEnvelope_RouteWithMissingPeerLeavesIndexUnset(t *testing.T) { + c := newTestComponents() + c.Routes = []*nbroute.Route{{ + ID: "route-x", + PublicID: "100", + Peer: "peer-not-in-components", + Network: netip.MustParsePrefix("10.0.0.0/16"), + Enabled: true, + }} + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Routes, 1) + assert.False(t, full.Routes[0].PeerIndexSet, + "missing peer reference must not pretend to point at peer index 0") +} + +func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing.T) { + c := newTestComponents() + // Policy that exists ONLY in ResourcePoliciesMap, not in c.Policies. This + // is the I1 case — without unionPolicies the encoder would silently + // drop it from the wire. + resourceOnlyPolicy := &types.Policy{ + ID: "pol-resource", PublicID: "99", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-r", Enabled: true, Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolTCP, + Sources: []string{"group-src"}, + Destinations: []string{"group-dst"}, + }}, + } + c.ResourcePoliciesMap = map[string][]*types.Policy{ + "resource-x": {c.Policies[0], resourceOnlyPolicy}, // shared + resource-only + } + // Resource must appear in components.NetworkResources with a seq id — + // encoder uses that to translate the xid map key to uint32. + c.NetworkResources = []*resourceTypes.NetworkResource{ + {ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true}, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Policies, 2, "encoded policies must include both peer-traffic and resource-only") + + policyByID := map[string]*proto.PolicyCompact{} + policyIds := make([]string, 0) + for _, p := range full.Policies { + policyByID[p.Id] = p + policyIds = append(policyIds, p.Id) + } + require.Contains(t, policyByID, "10", "original peer-traffic policy id 10") + require.Contains(t, policyByID, "99", "resource-only policy id 99") + + require.Contains(t, full.ResourcePoliciesMap, "77") + ids := full.ResourcePoliciesMap["77"].Ids + require.Len(t, ids, 2) + assert.ElementsMatch(t, policyIds, ids, + "resource policies map must reference both wire policy indexes") +} + +func TestEncodeNetworkMapEnvelope_NameServerGroups(t *testing.T) { + c := newTestComponents() + c.NameServerGroups = []*nbdns.NameServerGroup{{ + ID: "nsg-1", PublicID: "50", Name: "Main", Description: "primary", + NameServers: []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53, + }}, + Groups: []string{"group-src", "group-not-persisted"}, + Primary: true, Enabled: true, + Domains: []string{"corp.example"}, + }} + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.NameserverGroups, 1) + nsg := full.NameserverGroups[0] + assert.EqualValues(t, "50", nsg.Id) + assert.True(t, nsg.Primary) + require.Len(t, nsg.Nameservers, 1) + assert.Equal(t, "8.8.8.8", nsg.Nameservers[0].IP) + assert.Equal(t, []string{"1"}, nsg.GroupIds) +} + +func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) { + c := newTestComponents() + c.PostureCheckXIDToPublicID = map[string]string{"check-1": "33"} + c.PostureFailedPeers = map[string]map[string]struct{}{ + "check-1": { + "peer-a": {}, + "peer-b": {}, + "peer-not-in-account": {}, + }, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Contains(t, full.PostureFailedPeers, "33") + idxs := full.PostureFailedPeers["33"].PeerIndexes + assert.Len(t, idxs, 2, "missing peer is silently dropped (filterPostureFailedPeers guarantees presence in real data)") +} + +func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) { + c := newTestComponents() + c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} + c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ + "net-1": { + "peer-c": { + ID: "router-1", PublicID: "200", + Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, + }, + }, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Contains(t, full.RoutersMap, "5") + entries := full.RoutersMap["5"].Entries + require.Len(t, entries, 1) + e := entries[0] + assert.EqualValues(t, "200", e.Id) + assert.True(t, e.PeerIndexSet) + require.Less(t, int(e.PeerIndex), len(full.Peers)) + assert.Equal(t, "peerc", full.Peers[e.PeerIndex].DnsLabel) + assert.True(t, e.Masquerade) + assert.EqualValues(t, 10, e.Metric) + assert.True(t, e.Enabled) +} + +func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) { + // Router peer in c.RouterPeers but NOT in c.Peers (validation may have + // filtered it). indexRouterPeers runs before encodeRoutersMap, so the + // peer_index reference must still resolve. + c := newTestComponents() + delete(c.Peers, "peer-c") + routerPeer := &nbpeer.Peer{ + ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), + DNSLabel: "peerc", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + c.RouterPeers = map[string]*nbpeer.Peer{"peer-c": routerPeer} + c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} + c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ + "net-1": {"peer-c": {ID: "r-1", PublicID: "1", Peer: "peer-c", Enabled: true}}, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Contains(t, full.RoutersMap, "5") + require.Len(t, full.RoutersMap["5"].Entries, 1) + e := full.RoutersMap["5"].Entries[0] + assert.True(t, e.PeerIndexSet, "router peer must be indexed even when not in c.Peers") +} + +func TestEncodeNetworkMapEnvelope_GroupIDToUserIDs(t *testing.T) { + c := newTestComponents() + c.GroupIDToUserIDs = map[string][]string{ + "group-src": {"user-1", "user-2"}, + "group-missing": {"user-4"}, // group not in components → drop + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.GroupIdToUserIds, 1, "only present groups survive") + require.Contains(t, full.GroupIdToUserIds, "1") + assert.ElementsMatch(t, []string{"user-1", "user-2"}, full.GroupIdToUserIds["1"].UserIds) +} + +func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) { + assert.Nil(t, toProxyPatch(nil, "netbird.cloud", false, false)) + assert.Nil(t, toProxyPatch(&types.NetworkMap{}, "netbird.cloud", false, false), + "empty NetworkMap (no peers, rules, routes etc) → nil patch so proto3 omits the field") +} + +func TestToProxyPatch_PopulatesAllFields(t *testing.T) { + nm := &types.NetworkMap{ + Peers: []*nbpeer.Peer{{ + ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}), + DNSLabel: "extpeer", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + }}, + FirewallRules: []*types.FirewallRule{{ + PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp", + }}, + } + + patch := toProxyPatch(nm, "netbird.cloud", false, false) + + require.NotNil(t, patch) + assert.Len(t, patch.Peers, 1) + assert.Len(t, patch.FirewallRules, 1) +} + +// TestEncodeNetworkMapEnvelope_ProxyPatchPropagated covers the ProxyPatch +// pass-through in both encoder branches (normal path + nil-Components +// graceful-degrade). Guards against a regression that drops `ProxyPatch:` +// from one of the envelope struct literals. +func TestEncodeNetworkMapEnvelope_ProxyPatchPropagated(t *testing.T) { + patch := &proto.ProxyPatch{ + ForwardingRules: []*proto.ForwardingRule{{ + Protocol: proto.RuleProtocol_TCP, + DestinationPort: &proto.PortInfo{PortSelection: &proto.PortInfo_Port{Port: 80}}, + TranslatedAddress: net.IPv4(10, 0, 0, 1).To4(), + TranslatedPort: &proto.PortInfo{PortSelection: &proto.PortInfo_Port{Port: 8080}}, + }}, + } + + t.Run("normal_path", func(t *testing.T) { + c := newTestComponents() + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: c, + ProxyPatch: patch, + }).GetFull() + + require.NotNil(t, full.ProxyPatch, "ProxyPatch must propagate through the normal encode path") + assert.Len(t, full.ProxyPatch.ForwardingRules, 1) + }) + + t.Run("empty_components_graceful_degrade", func(t *testing.T) { + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: emptyNetworkMapComponents(), + ProxyPatch: patch, + }).GetFull() + + require.NotNil(t, full.ProxyPatch, "ProxyPatch must propagate through the nil-Components branch too") + assert.Len(t, full.ProxyPatch.ForwardingRules, 1) + }) +} + +func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) { + // nil Components → minimal envelope, no crash. Matches the legacy + // behaviour for missing/unvalidated peers. + env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: emptyNetworkMapComponents(), + DNSDomain: "netbird.cloud", + }) + + require.NotNil(t, env) + full := env.GetFull() + require.NotNil(t, full) + require.NotNil(t, full.AccountSettings, "AccountSettings must always be non-nil") + assert.Equal(t, "netbird.cloud", full.DnsDomain) + assert.Len(t, full.Peers, 1) + assert.Empty(t, full.Policies) +} + +func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) { + c := &types.NetworkMapComponents{ + Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}}, + // AccountSettings deliberately nil + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.NotNil(t, full.AccountSettings, "client dereferences AccountSettings unconditionally during Calculate(); a nil here would crash the receiver") + assert.False(t, full.AccountSettings.PeerLoginExpirationEnabled) + assert.Zero(t, full.AccountSettings.PeerLoginExpirationNs) +} + +func emptyNetworkMapComponents() *types.NetworkMapComponents { + return types.EmptyNetworkMapComponents( + &types.NetworkMapComponents{ + PeerID: "peer-id", Peers: map[string]*nbpeer.Peer{"peer-id": {}}}, + ) +} diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go new file mode 100644 index 000000000..cedd1b889 --- /dev/null +++ b/management/internals/shared/grpc/components_envelope_response.go @@ -0,0 +1,200 @@ +package grpc + +import ( + "context" + + integrationsConfig "github.com/netbirdio/management-integrations/integrations/config" + + "github.com/netbirdio/netbird/client/ssh/auth" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/types" + sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// ToComponentSyncResponse builds a SyncResponse carrying the compact +// NetworkMapEnvelope for capability-aware peers. The legacy proto.NetworkMap +// field is intentionally left empty — capable peers ignore it and the +// envelope alone is the authoritative wire shape. +// +// PeerConfig is computed once server-side using the receiving peer's own +// account-level network metadata. EnableSSH inside PeerConfig is left at +// peer.SSHEnabled (the peer's local setting); account-policy-driven SSH is +// computed by the client from the envelope's GroupIDToUserIDs / AllowedUserIDs +// inside Calculate(), so the SshConfig.SshEnabled bit may flip true on the +// client even though the server-side PeerConfig reports false. +func ToComponentSyncResponse( + ctx context.Context, + config *nbconfig.Config, + httpConfig *nbconfig.HttpServerConfig, + deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, + peer *nbpeer.Peer, + turnCredentials *Token, + relayCredentials *Token, + components *types.NetworkMapComponents, + proxyPatch *types.NetworkMap, + dnsName string, + checks []*posture.Checks, + settings *types.Settings, + extraSettings *types.ExtraSettings, + peerGroups []string, + dnsFwdPort int64, +) *proto.SyncResponse { + // + // 'component' parameter is expected to never be nil + // 'peer' parameter is expected to never be nil + // + // TODO (dmitri) consider using invariants? + // + enableSSH := computeSSHEnabledForPeer(components, peer) + peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH) + + includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() + useSourcePrefixes := peer.SupportsSourcePrefixes() + + userIDClaim := auth.DefaultUserIDClaim + if httpConfig != nil && httpConfig.AuthUserIDClaim != "" { + userIDClaim = httpConfig.AuthUserIDClaim + } + + envelope := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: components, + PeerConfig: peerConfig, + DNSDomain: dnsName, + DNSForwarderPort: dnsFwdPort, + UserIDClaim: userIDClaim, + ProxyPatch: toProxyPatch(proxyPatch, dnsName, includeIPv6, useSourcePrefixes), + }) + + resp := &proto.SyncResponse{ + PeerConfig: peerConfig, + NetworkMapEnvelope: envelope, + Checks: toProtocolChecks(ctx, checks), + Version: int32(sharedgrpc.ComponentNetworkMap), + } + + nbConfig := toNetbirdConfig(config, turnCredentials, relayCredentials, extraSettings, settings) + resp.NetbirdConfig = integrationsConfig.ExtendNetBirdConfig(peer.ID, peerGroups, nbConfig, extraSettings) + + // settings == nil → field stays nil → "no info in this snapshot", client + // preserves the deadline it already had. settings non-nil → emit either a + // valid deadline or the explicit-zero "disabled" sentinel via + // encodeSessionExpiresAt. + if settings != nil { + resp.SessionExpiresAt = encodeSessionExpiresAt( + peer.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), + ) + } + + return resp +} + +// toProxyPatch converts a proxy-injected *types.NetworkMap into the wire +// patch the components envelope ships alongside. Returns nil when there are +// no fragments to merge — proto3 omits a nil message field, so the receiver +// sees no patch and skips the merge step entirely. +// +// We reuse the legacy proto-conversion helpers (toProtocolRoutes, +// toProtocolFirewallRules, toProtocolRoutesFirewallRules, +// appendRemotePeerConfig, ForwardingRule.ToProto) because the proxy +// delivers fragments pre-expanded — there's no raw component shape to +// derive them from. Components purity isn't violated: proxy data isn't +// policy-graph-derived, it's externally injected post-Calculate, so the +// client merges it on top of its locally-computed NetworkMap. +func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePrefixes bool) *proto.ProxyPatch { + if nm == nil { + return nil + } + if len(nm.Peers) == 0 && len(nm.OfflinePeers) == 0 && len(nm.FirewallRules) == 0 && + len(nm.Routes) == 0 && len(nm.RoutesFirewallRules) == 0 && len(nm.ForwardingRules) == 0 { + return nil + } + + patch := &proto.ProxyPatch{ + Peers: networkmap.AppendRemotePeerConfig(nil, nm.Peers, dnsName, includeIPv6), + OfflinePeers: networkmap.AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6), + FirewallRules: networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes), + Routes: networkmap.ToProtocolRoutes(nm.Routes), + RouteFirewallRules: networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules), + } + if len(nm.ForwardingRules) > 0 { + patch.ForwardingRules = make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules)) + for _, r := range nm.ForwardingRules { + patch.ForwardingRules = append(patch.ForwardingRules, r.ToProto()) + } + } + return patch +} + +// computeSSHEnabledForPeer mirrors the SSH-server-activation bit that +// Calculate() folds into NetworkMap.EnableSSH. Components-format peers +// receive a freshly-computed PeerConfig.SshConfig.SshEnabled at sync time; +// without this helper the field would be incorrectly false for any peer +// that's the destination of an SSH-enabling policy without having +// peer.SSHEnabled set locally. +// +// Mirrors the two activation paths Calculate() uses: +// 1. Explicit: rule.Protocol == NetbirdSSH and peer is in the rule's +// destinations. +// 2. Legacy implicit: rule covers TCP/22 or TCP/22022 (or ALL), peer is in +// destinations, AND the peer has SSHEnabled set locally — this is the +// "allow-all/TCP-22 implies SSH activation for SSH-capable peers" path. +// +// The full SSH AuthorizedUsers map is still produced by the client when it +// runs Calculate() over the envelope. +func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer) bool { + if c == nil || peer == nil { + return false + } + // Mirror Calculate's `getAllPeersFromGroups` invariant: target peer must + // exist in c.Peers, otherwise no rule applies to it. + if _, ok := c.Peers[peer.ID]; !ok { + return false + } + for _, policy := range c.Policies { + if policy == nil || !policy.Enabled { + continue + } + for _, rule := range policy.Rules { + if ruleEnablesSSHForPeer(c, rule, peer) { + return true + } + } + } + return false +} + +// ruleEnablesSSHForPeer returns true when rule is active, targets peer, and +// either explicitly authorises SSH or covers the legacy TCP/22 path while the +// peer itself has SSH enabled locally. +func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *types.PolicyRule, peer *nbpeer.Peer) bool { + if rule == nil || !rule.Enabled { + return false + } + if !peerInDestinations(c, rule, peer.ID) { + return false + } + if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH { + return true + } + return peer.SSHEnabled && types.PolicyRuleImpliesLegacySSH(rule) +} + +// peerInDestinations reports whether peerID is in any of rule.Destinations' +// groups (or matches DestinationResource if it's a peer-typed resource — +// for non-peer types Calculate falls through to group lookup, so we mirror +// that exactly to avoid silent divergence). +func peerInDestinations(c *types.NetworkMapComponents, rule *types.PolicyRule, peerID string) bool { + if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" { + return rule.DestinationResource.ID == peerID + } + for _, groupID := range rule.Destinations { + if c.IsPeerInGroup(peerID, groupID) { + return true + } + } + return false +} diff --git a/management/internals/shared/grpc/components_envelope_response_test.go b/management/internals/shared/grpc/components_envelope_response_test.go new file mode 100644 index 000000000..bf35bb7b9 --- /dev/null +++ b/management/internals/shared/grpc/components_envelope_response_test.go @@ -0,0 +1,184 @@ +package grpc + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" +) + +// TestComputeSSHEnabledForPeer covers both Calculate-mirroring branches: +// explicit NetbirdSSH protocol, and the legacy implicit case where a +// TCP/22 (or 22022 / ALL / port-range-covering-22) rule activates SSH when +// the destination peer has SSHEnabled=true locally. +func TestComputeSSHEnabledForPeer(t *testing.T) { + const targetPeerID = "target" + const targetGroupID = "g_dst" + + mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) { + peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled} + group := &types.Group{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} + return &types.NetworkMapComponents{ + Peers: map[string]*nbpeer.Peer{targetPeerID: peer}, + Groups: map[string]*types.Group{targetGroupID: group}, + Policies: []*types.Policy{{ + ID: "p", + Enabled: true, + Rules: []*types.PolicyRule{rule}, + }}, + }, peer + } + + cases := []struct { + name string + peerSSH bool + rule types.PolicyRule + wantEnabled bool + }{ + { + name: "explicit-netbird-ssh-activates-regardless-of-peer-ssh", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-tcp-22-with-peer-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-tcp-22-without-peer-ssh-disabled", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: false, + }, + { + name: "implicit-tcp-22022-with-peer-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22022"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-all-protocol-with-peer-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolALL, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-port-range-covers-22", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, + Protocol: types.PolicyRuleProtocolTCP, + PortRanges: []types.RulePortRange{{Start: 20, End: 30}}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "tcp-80-no-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"80"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: false, + }, + { + name: "disabled-rule-skipped", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: false, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{targetGroupID}, + }, + wantEnabled: false, + }, + { + name: "peer-not-in-destinations", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{"g_other"}, // target not in this group + }, + wantEnabled: false, + }, + { + name: "peer-typed-destination-resource-matches", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, + Protocol: types.PolicyRuleProtocolNetbirdSSH, + DestinationResource: types.Resource{ID: targetPeerID, Type: types.ResourceTypePeer}, + }, + wantEnabled: true, + }, + { + name: "non-peer-destination-resource-falls-through-to-groups", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, + Protocol: types.PolicyRuleProtocolNetbirdSSH, + DestinationResource: types.Resource{ID: targetPeerID, Type: "host"}, // wrong type + Destinations: []string{targetGroupID}, // saved by group fallback + }, + wantEnabled: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, peer := mkComponents(&tc.rule, tc.peerSSH) + got := computeSSHEnabledForPeer(c, peer) + assert.Equal(t, tc.wantEnabled, got) + }) + } +} + +// TestComputeSSHEnabledForPeer_TargetMissingFromComponents covers the +// belt-and-suspenders presence guard mirroring Calculate's +// getAllPeersFromGroups invariant. +func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) { + peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true} + c := &types.NetworkMapComponents{ + Peers: map[string]*nbpeer.Peer{}, // target peer NOT present + Groups: map[string]*types.Group{ + "g": {ID: "g", Peers: []string{"missing"}}, + }, + Policies: []*types.Policy{{ + ID: "p", Enabled: true, + Rules: []*types.PolicyRule{{ + Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{"g"}, + }}, + }}, + } + assert.False(t, computeSSHEnabledForPeer(c, peer), + "missing target peer must short-circuit to false, not consult policies") +} + +// TestComputeSSHEnabledForPeer_NilInputs guards the cheap nil-checks at +// function entry — Calculate doesn't accept nil either, but the helper is +// exported indirectly via ToComponentSyncResponse and may receive nil +// components on graceful-degrade paths. +func TestComputeSSHEnabledForPeer_NilInputs(t *testing.T) { + assert.False(t, computeSSHEnabledForPeer(nil, &nbpeer.Peer{ID: "x"})) + assert.False(t, computeSSHEnabledForPeer(&types.NetworkMapComponents{}, nil)) +} diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index bdb4c8cf4..696d28f5c 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -10,24 +10,20 @@ import ( "github.com/hashicorp/go-version" nbversion "github.com/netbirdio/netbird/version" - log "github.com/sirupsen/logrus" - goproto "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" integrationsConfig "github.com/netbirdio/management-integrations/integrations/config" "github.com/netbirdio/netbird/client/ssh/auth" - nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/types" - nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/netiputil" - "github.com/netbirdio/netbird/shared/sshauth" ) const ( @@ -169,8 +165,8 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), NetworkMap: &proto.NetworkMap{ Serial: networkMap.Network.CurrentSerial(), - Routes: toProtocolRoutes(networkMap.Routes), - DNSConfig: toProtocolDNSConfig(networkMap.DNSConfig, dnsCache, dnsFwdPort), + Routes: networkmap.ToProtocolRoutes(networkMap.Routes), + DNSConfig: networkmap.ToProtocolDNSConfig(networkMap.DNSConfig, dnsCache, dnsFwdPort), PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), }, Checks: toProtocolChecks(ctx, checks), @@ -183,7 +179,7 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb response.NetworkMap.PeerConfig = response.PeerConfig remotePeers := make([]*proto.RemotePeerConfig, 0, len(networkMap.Peers)+len(networkMap.OfflinePeers)) - remotePeers = appendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6) + remotePeers = networkmap.AppendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6) if !shouldSkipSendingDeprecatedRemotePeers(peer.Meta.WtVersion) { response.RemotePeers = remotePeers @@ -193,13 +189,13 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb response.RemotePeersIsEmpty = len(remotePeers) == 0 response.NetworkMap.RemotePeersIsEmpty = response.RemotePeersIsEmpty - response.NetworkMap.OfflinePeers = appendRemotePeerConfig(nil, networkMap.OfflinePeers, dnsName, includeIPv6) + response.NetworkMap.OfflinePeers = networkmap.AppendRemotePeerConfig(nil, networkMap.OfflinePeers, dnsName, includeIPv6) - firewallRules := toProtocolFirewallRules(networkMap.FirewallRules, includeIPv6, useSourcePrefixes) + firewallRules := networkmap.ToProtocolFirewallRules(networkMap.FirewallRules, includeIPv6, useSourcePrefixes) response.NetworkMap.FirewallRules = firewallRules response.NetworkMap.FirewallRulesIsEmpty = len(firewallRules) == 0 - routesFirewallRules := toProtocolRoutesFirewallRules(networkMap.RoutesFirewallRules) + routesFirewallRules := networkmap.ToProtocolRoutesFirewallRules(networkMap.RoutesFirewallRules) response.NetworkMap.RoutesFirewallRules = routesFirewallRules response.NetworkMap.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0 @@ -212,7 +208,7 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb } if networkMap.AuthorizedUsers != nil { - hashedUsers, machineUsers := buildAuthorizedUsersProto(ctx, networkMap.AuthorizedUsers) + hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, networkMap.AuthorizedUsers) userIDClaim := auth.DefaultUserIDClaim if httpConfig != nil && httpConfig.AuthUserIDClaim != "" { userIDClaim = httpConfig.AuthUserIDClaim @@ -252,33 +248,6 @@ func encodeSessionExpiresAt(deadline time.Time) *timestamppb.Timestamp { return timestamppb.New(deadline) } -func buildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]map[string]struct{}) ([][]byte, map[string]*proto.MachineUserIndexes) { - userIDToIndex := make(map[string]uint32) - var hashedUsers [][]byte - machineUsers := make(map[string]*proto.MachineUserIndexes, len(authorizedUsers)) - - for machineUser, users := range authorizedUsers { - indexes := make([]uint32, 0, len(users)) - for userID := range users { - idx, exists := userIDToIndex[userID] - if !exists { - hash, err := sshauth.HashUserID(userID) - if err != nil { - log.WithContext(ctx).Errorf("failed to hash user id %s: %v", userID, err) - continue - } - idx = uint32(len(hashedUsers)) - userIDToIndex[userID] = idx - hashedUsers = append(hashedUsers, hash[:]) - } - indexes = append(indexes, idx) - } - machineUsers[machineUser] = &proto.MachineUserIndexes{Indexes: indexes} - } - - return hashedUsers, machineUsers -} - func shouldSkipSendingDeprecatedRemotePeers(peerVersion string) bool { if nbversion.IsDevelopmentVersion(peerVersion) { return true @@ -292,51 +261,6 @@ func shouldSkipSendingDeprecatedRemotePeers(peerVersion string) bool { return precomputedDeprecatedRemotePeersConstraint.Check(peerNBVersion) } -func appendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { - for _, rPeer := range peers { - allowedIPs := []string{rPeer.IP.String() + "/32"} - if includeIPv6 && rPeer.IPv6.IsValid() { - allowedIPs = append(allowedIPs, rPeer.IPv6.String()+"/128") - } - dst = append(dst, &proto.RemotePeerConfig{ - WgPubKey: rPeer.Key, - AllowedIps: allowedIPs, - SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, - Fqdn: rPeer.FQDN(dnsName), - AgentVersion: rPeer.Meta.WtVersion, - }) - } - return dst -} - -// toProtocolDNSConfig converts nbdns.Config to proto.DNSConfig using the cache -func toProtocolDNSConfig(update nbdns.Config, cache *cache.DNSConfigCache, forwardPort int64) *proto.DNSConfig { - protoUpdate := &proto.DNSConfig{ - ServiceEnable: update.ServiceEnable, - CustomZones: make([]*proto.CustomZone, 0, len(update.CustomZones)), - NameServerGroups: make([]*proto.NameServerGroup, 0, len(update.NameServerGroups)), - ForwarderPort: forwardPort, - } - - for _, zone := range update.CustomZones { - protoZone := convertToProtoCustomZone(zone) - protoUpdate.CustomZones = append(protoUpdate.CustomZones, protoZone) - } - - for _, nsGroup := range update.NameServerGroups { - cacheKey := nsGroup.ID - if cachedGroup, exists := cache.GetNameServerGroup(cacheKey); exists { - protoUpdate.NameServerGroups = append(protoUpdate.NameServerGroups, cachedGroup) - } else { - protoGroup := convertToProtoNameServerGroup(nsGroup) - cache.SetNameServerGroup(cacheKey, protoGroup) - protoUpdate.NameServerGroups = append(protoUpdate.NameServerGroups, protoGroup) - } - } - - return protoUpdate -} - func ToResponseProto(configProto nbconfig.Protocol) proto.HostConfig_Protocol { switch configProto { case nbconfig.UDP: @@ -354,203 +278,6 @@ func ToResponseProto(configProto nbconfig.Protocol) proto.HostConfig_Protocol { } } -func toProtocolRoutes(routes []*nbroute.Route) []*proto.Route { - protoRoutes := make([]*proto.Route, 0, len(routes)) - for _, r := range routes { - protoRoutes = append(protoRoutes, toProtocolRoute(r)) - } - return protoRoutes -} - -func toProtocolRoute(route *nbroute.Route) *proto.Route { - return &proto.Route{ - ID: string(route.ID), - NetID: string(route.NetID), - Network: route.Network.String(), - Domains: route.Domains.ToPunycodeList(), - NetworkType: int64(route.NetworkType), - Peer: route.Peer, - Metric: int64(route.Metric), - Masquerade: route.Masquerade, - KeepRoute: route.KeepRoute, - SkipAutoApply: route.SkipAutoApply, - } -} - -// toProtocolFirewallRules converts the firewall rules to the protocol firewall rules. -// When useSourcePrefixes is true, the compact SourcePrefixes field is populated -// alongside the deprecated PeerIP for forward compatibility. -// Wildcard rules ("0.0.0.0") are expanded into separate v4 and v6 SourcePrefixes -// when includeIPv6 is true. -func toProtocolFirewallRules(rules []*types.FirewallRule, includeIPv6, useSourcePrefixes bool) []*proto.FirewallRule { - result := make([]*proto.FirewallRule, 0, len(rules)) - for i := range rules { - rule := rules[i] - - fwRule := &proto.FirewallRule{ - PolicyID: []byte(rule.PolicyID), - PeerIP: rule.PeerIP, //nolint:staticcheck // populated for backward compatibility - Direction: getProtoDirection(rule.Direction), - Action: getProtoAction(rule.Action), - Protocol: getProtoProtocol(rule.Protocol), - Port: rule.Port, - } - - if useSourcePrefixes && rule.PeerIP != "" { - result = append(result, populateSourcePrefixes(fwRule, rule, includeIPv6)...) - } - - if shouldUsePortRange(fwRule) { - fwRule.PortInfo = rule.PortRange.ToProto() - } - - result = append(result, fwRule) - } - return result -} - -// populateSourcePrefixes sets SourcePrefixes on fwRule and returns any -// additional rules needed (e.g. a v6 wildcard clone when the peer IP is unspecified). -func populateSourcePrefixes(fwRule *proto.FirewallRule, rule *types.FirewallRule, includeIPv6 bool) []*proto.FirewallRule { - addr, err := netip.ParseAddr(rule.PeerIP) - if err != nil { - return nil - } - - if !addr.IsUnspecified() { - fwRule.SourcePrefixes = [][]byte{netiputil.EncodeAddr(addr.Unmap())} - return nil - } - - // IPv4Unspecified/0 is always valid, error is impossible. - v4Wildcard, _ := netiputil.EncodePrefix(netip.PrefixFrom(netip.IPv4Unspecified(), 0)) - fwRule.SourcePrefixes = [][]byte{v4Wildcard} - - if !includeIPv6 { - return nil - } - - v6Rule := goproto.Clone(fwRule).(*proto.FirewallRule) - v6Rule.PeerIP = "::" //nolint:staticcheck // populated for backward compatibility - // IPv6Unspecified/0 is always valid, error is impossible. - v6Wildcard, _ := netiputil.EncodePrefix(netip.PrefixFrom(netip.IPv6Unspecified(), 0)) - v6Rule.SourcePrefixes = [][]byte{v6Wildcard} - if shouldUsePortRange(v6Rule) { - v6Rule.PortInfo = rule.PortRange.ToProto() - } - return []*proto.FirewallRule{v6Rule} -} - -// getProtoDirection converts the direction to proto.RuleDirection. -func getProtoDirection(direction int) proto.RuleDirection { - if direction == types.FirewallRuleDirectionOUT { - return proto.RuleDirection_OUT - } - return proto.RuleDirection_IN -} - -func toProtocolRoutesFirewallRules(rules []*types.RouteFirewallRule) []*proto.RouteFirewallRule { - result := make([]*proto.RouteFirewallRule, len(rules)) - for i := range rules { - rule := rules[i] - result[i] = &proto.RouteFirewallRule{ - SourceRanges: rule.SourceRanges, - Action: getProtoAction(rule.Action), - Destination: rule.Destination, - Protocol: getProtoProtocol(rule.Protocol), - PortInfo: getProtoPortInfo(rule), - IsDynamic: rule.IsDynamic, - Domains: rule.Domains.ToPunycodeList(), - PolicyID: []byte(rule.PolicyID), - RouteID: string(rule.RouteID), - } - } - - return result -} - -// getProtoAction converts the action to proto.RuleAction. -func getProtoAction(action string) proto.RuleAction { - if action == string(types.PolicyTrafficActionDrop) { - return proto.RuleAction_DROP - } - return proto.RuleAction_ACCEPT -} - -// getProtoProtocol converts the protocol to proto.RuleProtocol. -func getProtoProtocol(protocol string) proto.RuleProtocol { - switch types.PolicyRuleProtocolType(protocol) { - case types.PolicyRuleProtocolALL: - return proto.RuleProtocol_ALL - case types.PolicyRuleProtocolTCP: - return proto.RuleProtocol_TCP - case types.PolicyRuleProtocolUDP: - return proto.RuleProtocol_UDP - case types.PolicyRuleProtocolICMP: - return proto.RuleProtocol_ICMP - default: - return proto.RuleProtocol_UNKNOWN - } -} - -// getProtoPortInfo converts the port info to proto.PortInfo. -func getProtoPortInfo(rule *types.RouteFirewallRule) *proto.PortInfo { - var portInfo proto.PortInfo - if rule.Port != 0 { - portInfo.PortSelection = &proto.PortInfo_Port{Port: uint32(rule.Port)} - } else if portRange := rule.PortRange; portRange.Start != 0 && portRange.End != 0 { - portInfo.PortSelection = &proto.PortInfo_Range_{ - Range: &proto.PortInfo_Range{ - Start: uint32(portRange.Start), - End: uint32(portRange.End), - }, - } - } - return &portInfo -} - -func shouldUsePortRange(rule *proto.FirewallRule) bool { - return rule.Port == "" && (rule.Protocol == proto.RuleProtocol_UDP || rule.Protocol == proto.RuleProtocol_TCP) -} - -// Helper function to convert nbdns.CustomZone to proto.CustomZone -func convertToProtoCustomZone(zone nbdns.CustomZone) *proto.CustomZone { - protoZone := &proto.CustomZone{ - Domain: zone.Domain, - Records: make([]*proto.SimpleRecord, 0, len(zone.Records)), - SearchDomainDisabled: zone.SearchDomainDisabled, - NonAuthoritative: zone.NonAuthoritative, - } - for _, record := range zone.Records { - protoZone.Records = append(protoZone.Records, &proto.SimpleRecord{ - Name: record.Name, - Type: int64(record.Type), - Class: record.Class, - TTL: int64(record.TTL), - RData: record.RData, - }) - } - return protoZone -} - -// Helper function to convert nbdns.NameServerGroup to proto.NameServerGroup -func convertToProtoNameServerGroup(nsGroup *nbdns.NameServerGroup) *proto.NameServerGroup { - protoGroup := &proto.NameServerGroup{ - Primary: nsGroup.Primary, - Domains: nsGroup.Domains, - SearchDomainsEnabled: nsGroup.SearchDomainsEnabled, - NameServers: make([]*proto.NameServer, 0, len(nsGroup.NameServers)), - } - for _, ns := range nsGroup.NameServers { - protoGroup.NameServers = append(protoGroup.NameServers, &proto.NameServer{ - IP: ns.IP.String(), - Port: int64(ns.Port), - NSType: int64(ns.NSType), - }) - } - return protoGroup -} - // buildJWTConfig constructs JWT configuration for SSH servers from management server config func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow) *proto.JWTConfig { if config == nil || config.AuthAudience == "" { diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go index c81bef25c..402b4fd07 100644 --- a/management/internals/shared/grpc/conversion_test.go +++ b/management/internals/shared/grpc/conversion_test.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/networkmap" ) func TestToProtocolDNSConfigWithCache(t *testing.T) { @@ -64,13 +65,13 @@ func TestToProtocolDNSConfigWithCache(t *testing.T) { } // First run with config1 - result1 := toProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) + result1 := networkmap.ToProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) // Second run with config2 - result2 := toProtocolDNSConfig(config2, &cache, int64(network_map.DnsForwarderPort)) + result2 := networkmap.ToProtocolDNSConfig(config2, &cache, int64(network_map.DnsForwarderPort)) // Third run with config1 again - result3 := toProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) + result3 := networkmap.ToProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) // Verify that result1 and result3 are identical if !reflect.DeepEqual(result1, result3) { @@ -102,15 +103,14 @@ func BenchmarkToProtocolDNSConfig(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - toProtocolDNSConfig(testData, cache, int64(network_map.DnsForwarderPort)) + networkmap.ToProtocolDNSConfig(testData, cache, int64(network_map.DnsForwarderPort)) } }) b.Run(fmt.Sprintf("WithoutCache-Size%d", size), func(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - cache := &cache.DNSConfigCache{} - toProtocolDNSConfig(testData, cache, int64(network_map.DnsForwarderPort)) + networkmap.ToProtocolDNSConfig(testData, nil, int64(network_map.DnsForwarderPort)) } }) } diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index fa06687d0..3b7d62ac7 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -25,6 +25,7 @@ import ( "google.golang.org/grpc/status" "github.com/netbirdio/netbird/shared/management/client/common" + "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/management/internals/controllers/network_map" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" @@ -245,6 +246,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S realIP := getRealIP(ctx) sRealIP := realIP.String() peerMeta := extractPeerMeta(ctx, syncReq.GetMeta()) + userID, err := s.accountManager.GetUserIDByPeerKey(ctx, peerKey.String()) if err != nil { s.syncSem.Add(-1) @@ -683,8 +685,9 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee LazyConnectionEnabled: meta.GetFlags().GetLazyConnectionEnabled(), DisableIPv6: meta.GetFlags().GetDisableIPv6(), }, - Files: files, - Capabilities: capabilitiesToInt32(meta.GetCapabilities()), + Files: files, + Capabilities: capabilitiesToInt32(meta.GetCapabilities()), + SyncMessageVersion: int(meta.GetSyncMessageVersion()), } } @@ -1016,7 +1019,43 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer return status.Errorf(codes.Internal, "failed to get peer groups %s", err) } - plainResp := ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, peer, turnToken, relayToken, networkMap, s.networkMapController.GetDNSDomain(settings), postureChecks, nil, settings, settings.Extra, peerGroups, dnsFwdPort) + dnsName := s.networkMapController.GetDNSDomain(settings) + + var plainResp *proto.SyncResponse + + commonSyncMessageVersion := grpc.HighestCommonSyncMessageVersion( + s.perAccountOrGlobalSyncMessageVersions(peer.AccountID), + grpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": s.perAccountOrGlobalSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": grpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == grpc.ComponentNetworkMap { + // Capable peer: discard the legacy NetworkMap that SyncAndMarkPeer + // computed and recompute the raw components instead. This wastes one + // Calculate() call per initial-sync — the component-based wire + // format is what the peer actually consumes. The streaming path + // (network_map.Controller.UpdateAccountPeers) skips this duplication + // because it dispatches by capability before computing. + // + // TODO: refactor SyncPeer / SyncAndMarkPeer / their mocks + manager + // interfaces to return PeerNetworkMapResult so the initial-sync path + // stops doing duplicate work. Deferred until the client-side + // decoder lands and there's a real deployment of capability=3 peers + // worth optimizing for. + freshPeer, components, proxyPatch, freshPostureChecks, freshDnsFwdPort, err := s.networkMapController.GetValidatedPeerWithComponents(ctx, false, peer.AccountID, peer) + if err != nil { + log.WithContext(ctx).Errorf("failed to build components for peer %s on initial sync: %v", peer.ID, err) + return status.Errorf(codes.Internal, "failed to build initial sync envelope") + } + plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, freshPeer, turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, settings, settings.Extra, peerGroups, freshDnsFwdPort) + } else { + plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, peer, turnToken, relayToken, networkMap, dnsName, postureChecks, nil, settings, settings.Extra, peerGroups, dnsFwdPort) + } key, err := s.secretsManager.GetWGKey() if err != nil { @@ -1041,6 +1080,13 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer return nil } +func (s *Server) perAccountOrGlobalSyncMessageVersions(accountId string) grpc.SyncMessageVersion { + if version, ok := s.config.PerAccountHighestSupportedSyncMessageVersion[accountId]; ok { + return grpc.SyncMessageVersionFromConfig(&version) + } + return grpc.SyncMessageVersionFromConfig(s.config.HighestSupportedSyncMessageVersion) +} + // GetDeviceAuthorizationFlow returns a device authorization flow information // This is used for initiating an Oauth 2 device authorization grant flow // which will be used by our clients to Login diff --git a/management/server/account.go b/management/server/account.go index 9d2759cb7..619036d0c 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -1648,6 +1648,10 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth return nil } + for _, g := range newGroupsToCreate { + g.PublicID = xid.New().String() + } + if err = transaction.CreateGroups(ctx, userAuth.AccountId, newGroupsToCreate); err != nil { return fmt.Errorf("error saving groups: %w", err) } diff --git a/management/server/account_test.go b/management/server/account_test.go index ee910630a..3c0bb25da 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -3170,6 +3170,16 @@ func TestAccount_SetJWTGroups(t *testing.T) { user, err := manager.Store.GetUserByUserID(context.Background(), store.LockingStrengthNone, "user2") assert.NoError(t, err, "unable to get user") assert.Len(t, user.AutoGroups, 1, "new group should be added") + + var newJWTGroup *types.Group + for _, g := range groups { + if g.Name == "group3" { + newJWTGroup = g + break + } + } + require.NotNil(t, newJWTGroup, "JIT-created JWT group not found") + assert.NotEqual(t, "", newJWTGroup.PublicID, "JIT-created JWT group must have a non-empty PublicID") }) t.Run("remove all JWT groups when list is empty", func(t *testing.T) { diff --git a/management/server/group.go b/management/server/group.go index 460b51274..dab891f2a 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -93,6 +93,8 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) eventsToStore = append(eventsToStore, events...) + newGroup.PublicID = xid.New().String() + if err := transaction.CreateGroup(ctx, newGroup); err != nil { return status.Errorf(status.Internal, "failed to create group: %v", err) } @@ -158,6 +160,8 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use return err } + newGroup.PublicID = oldGroup.PublicID + if err = transaction.UpdateGroup(ctx, newGroup); err != nil { return err } @@ -235,6 +239,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us } newGroup.AccountID = accountID + newGroup.PublicID = xid.New().String() if err = transaction.CreateGroup(ctx, newGroup); err != nil { return err @@ -327,6 +332,12 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI newGroup.AccountID = accountID + oldGroup, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, newGroup.ID) + if err != nil { + return err + } + newGroup.PublicID = oldGroup.PublicID + if err := transaction.UpdateGroup(ctx, newGroup); err != nil { return err } @@ -341,7 +352,6 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) - var err error snap, err = affectedpeers.Load(ctx, transaction, accountID, change) return err }) diff --git a/management/server/migration/migration.go b/management/server/migration/migration.go index 7a51cc200..ae26a254e 100644 --- a/management/server/migration/migration.go +++ b/management/server/migration/migration.go @@ -13,6 +13,7 @@ import ( "strings" "unicode/utf8" + "github.com/rs/xid" log "github.com/sirupsen/logrus" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -635,3 +636,50 @@ func RemoveDuplicatePeerKeys(ctx context.Context, db *gorm.DB) error { return nil } + +func BackfillPublicIDs[T any](ctx context.Context, db *gorm.DB) error { + var model T + + if !db.Migrator().HasTable(&model) { + log.WithContext(ctx).Debugf("Table for %T does not exist, no backfill needed", model) + return nil + } + + stmt := &gorm.Statement{DB: db} + err := stmt.Parse(&model) + if err != nil { + return fmt.Errorf("parse model: %w", err) + } + tableName := stmt.Schema.Table + + if err := db.Transaction(func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&model, "public_id") { + log.WithContext(ctx).Infof("Column public_id does not exist in table %s, adding it", tableName) + if err := tx.Migrator().AddColumn(&model, "public_id"); err != nil { + return fmt.Errorf("add column public_id: %w", err) + } + } + + var rows []map[string]any + if err := tx.Table(tableName).Select("id", "public_id").Where("public_id IS NULL").Or("public_id = ''").Find(&rows).Error; err != nil { + return fmt.Errorf("failed to find rows with empty public_id: %w", err) + } + + if len(rows) == 0 { + log.WithContext(ctx).Infof("No rows with empty public_id found in table %s, no migration needed", tableName) + return nil + } + + for _, row := range rows { + if err := tx.Table(tableName).Where("id = ?", row["id"]).Update("public_id", xid.New().String()).Error; err != nil { + return fmt.Errorf("failed to update row with id %v: %w", row["id"], err) + } + } + return nil + }); err != nil { + return err + } + + log.WithContext(ctx).Infof("Backfill of empty public_id in table %s completed", tableName) + return nil +} diff --git a/management/server/nameserver.go b/management/server/nameserver.go index b9cebf726..0a4c2291e 100644 --- a/management/server/nameserver.go +++ b/management/server/nameserver.go @@ -67,6 +67,8 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco return err } + newNSGroup.PublicID = xid.New().String() + if err = transaction.SaveNameServerGroup(ctx, newNSGroup); err != nil { return err } @@ -116,6 +118,8 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun return err } + nsGroupToSave.PublicID = oldNSGroup.PublicID + if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil { return err } diff --git a/management/server/networks/manager.go b/management/server/networks/manager.go index d572502fd..fc03fff9f 100644 --- a/management/server/networks/manager.go +++ b/management/server/networks/manager.go @@ -71,9 +71,16 @@ func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network network.ID = xid.New().String() - err = m.store.SaveNetwork(ctx, network) + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + network.PublicID = xid.New().String() + + if err := transaction.SaveNetwork(ctx, network); err != nil { + return fmt.Errorf("failed to save network: %w", err) + } + return nil + }) if err != nil { - return nil, fmt.Errorf("failed to save network: %w", err) + return nil, err } m.accountManager.StoreEvent(ctx, userID, network.ID, network.AccountID, activity.NetworkCreated, network.EventMeta()) @@ -102,14 +109,25 @@ func (m *managerImpl) UpdateNetwork(ctx context.Context, userID string, network return nil, status.NewPermissionDeniedError() } - _, err = m.store.GetNetworkByID(ctx, store.LockingStrengthUpdate, network.AccountID, network.ID) + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + existing, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, network.AccountID, network.ID) + if err != nil { + return fmt.Errorf("failed to get network: %w", err) + } + network.PublicID = existing.PublicID + + if err := transaction.SaveNetwork(ctx, network); err != nil { + return fmt.Errorf("failed to save network: %w", err) + } + return nil + }) if err != nil { - return nil, fmt.Errorf("failed to get network: %w", err) + return nil, err } m.accountManager.StoreEvent(ctx, userID, network.ID, network.AccountID, activity.NetworkUpdated, network.EventMeta()) - return network, m.store.SaveNetwork(ctx, network) + return network, nil } func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, networkID string) error { diff --git a/management/server/networks/manager_test.go b/management/server/networks/manager_test.go index 24d5f49b7..b5fb1c72d 100644 --- a/management/server/networks/manager_test.go +++ b/management/server/networks/manager_test.go @@ -255,3 +255,73 @@ func Test_UpdateNetworkFailsWithPermissionDenied(t *testing.T) { require.Error(t, err) require.Nil(t, updatedNetwork) } + +// Test_CreateNetworkAllocatesSeqID verifies that CreateNetwork sets a +// non-zero AccountSeqID on the persisted network (allocated through the +// account_seq_counters table). +func Test_CreateNetworkSetsPublicId(t *testing.T) { + ctx := context.Background() + const accountID = "testAccountId" + const userID = "testAdminId" + + s, cleanUp, err := store.NewTestStoreFromSQL(ctx, "../testdata/networks.sql", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanUp) + + am := mock_server.MockAccountManager{} + permissionsManager := permissions.NewManager(s) + groupsManager := groups.NewManagerMock() + routerManager := routers.NewManagerMock() + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) + manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) + + created, err := manager.CreateNetwork(ctx, userID, &types.Network{ + AccountID: accountID, + Name: "seq-allocation-test", + }) + require.NoError(t, err) + require.NotEqual(t, "", created.PublicID, "CreateNetwork must allocate a non-zero AccountSeqID") +} + +// Test_UpdateNetworkPreservesSeqID verifies UpdateNetwork does not reset +// AccountSeqID even when the caller passes a zero value (the shape REST +// handlers produce because the field is `json:"-"`). +func Test_UpdateNetworkPreservesPublicId(t *testing.T) { + ctx := context.Background() + const accountID = "testAccountId" + const userID = "testAdminId" + + s, cleanUp, err := store.NewTestStoreFromSQL(ctx, "../testdata/networks.sql", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanUp) + + am := mock_server.MockAccountManager{} + permissionsManager := permissions.NewManager(s) + groupsManager := groups.NewManagerMock() + routerManager := routers.NewManagerMock() + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) + manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) + + created, err := manager.CreateNetwork(ctx, userID, &types.Network{ + AccountID: accountID, + Name: "seq-preserve-original", + }) + require.NoError(t, err) + originalPublicId := created.PublicID + require.NotZero(t, originalPublicId) + + update := &types.Network{ + AccountID: accountID, + ID: created.ID, + Name: "seq-preserve-renamed", + } + require.Equal(t, "", update.PublicID, "incoming struct must mirror an HTTP handler shape") + + _, err = manager.UpdateNetwork(ctx, userID, update) + require.NoError(t, err) + + got, err := manager.GetNetwork(ctx, accountID, userID, created.ID) + require.NoError(t, err) + require.Equal(t, originalPublicId, got.PublicID, "PublicID must survive UpdateNetwork") + require.Equal(t, "seq-preserve-renamed", got.Name) +} diff --git a/management/server/networks/resources/manager.go b/management/server/networks/resources/manager.go index 6c427ce62..001af4d83 100644 --- a/management/server/networks/resources/manager.go +++ b/management/server/networks/resources/manager.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/rs/xid" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" @@ -146,6 +147,8 @@ func (m *managerImpl) createResourceInTransaction(ctx context.Context, transacti return nil, nil, fmt.Errorf("failed to get network: %w", err) } + resource.PublicID = xid.New().String() + if err = transaction.SaveNetworkResource(ctx, resource); err != nil { return nil, nil, fmt.Errorf("failed to save network resource: %w", err) } @@ -245,6 +248,7 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc if err != nil { return fmt.Errorf("failed to get network resource: %w", err) } + resource.PublicID = oldResource.PublicID oldGroups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthNone, resource.AccountID, resource.ID) if err != nil { diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go index 1fa908393..4cf7f7ea3 100644 --- a/management/server/networks/resources/types/resource.go +++ b/management/server/networks/resources/types/resource.go @@ -32,6 +32,7 @@ type NetworkResource struct { ID string `gorm:"primaryKey"` NetworkID string `gorm:"index"` AccountID string `gorm:"index"` + PublicID string `json:"-"` Name string Description string Type NetworkResourceType @@ -96,6 +97,7 @@ func (n *NetworkResource) Copy() *NetworkResource { ID: n.ID, AccountID: n.AccountID, NetworkID: n.NetworkID, + PublicID: n.PublicID, Name: n.Name, Description: n.Description, Type: n.Type, diff --git a/management/server/networks/routers/manager.go b/management/server/networks/routers/manager.go index cff387a7c..f72716579 100644 --- a/management/server/networks/routers/manager.go +++ b/management/server/networks/routers/manager.go @@ -104,6 +104,8 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t router.ID = xid.New().String() + router.PublicID = xid.New().String() + err = transaction.CreateNetworkRouter(ctx, router) if err != nil { return fmt.Errorf("failed to create network router: %w", err) @@ -199,6 +201,11 @@ func (m *managerImpl) updateRouterInTransaction(ctx context.Context, transaction return nil, nil, affectedpeers.Change{}, status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID) } + // Preserve PublicID from the existing router so the upstream + // UpdateNetworkRouter (which does Updates(router) with Select("*")) + // doesn't clobber it with the request's zero value. + router.PublicID = existing.PublicID + if err = transaction.UpdateNetworkRouter(ctx, router); err != nil { return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to update network router: %w", err) } diff --git a/management/server/networks/routers/types/router.go b/management/server/networks/routers/types/router.go index 1293a9934..189d7f792 100644 --- a/management/server/networks/routers/types/router.go +++ b/management/server/networks/routers/types/router.go @@ -13,6 +13,7 @@ type NetworkRouter struct { ID string `gorm:"primaryKey"` NetworkID string `gorm:"index"` AccountID string `gorm:"index"` + PublicID string `json:"-"` Peer string PeerGroups []string `gorm:"serializer:json"` Masquerade bool @@ -81,6 +82,7 @@ func (n *NetworkRouter) Copy() *NetworkRouter { ID: n.ID, NetworkID: n.NetworkID, AccountID: n.AccountID, + PublicID: n.PublicID, Peer: n.Peer, PeerGroups: n.PeerGroups, Masquerade: n.Masquerade, diff --git a/management/server/networks/types/network.go b/management/server/networks/types/network.go index 69d596f8b..6f7381bff 100644 --- a/management/server/networks/types/network.go +++ b/management/server/networks/types/network.go @@ -7,8 +7,11 @@ import ( ) type Network struct { - ID string `gorm:"primaryKey"` - AccountID string `gorm:"index"` + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + + PublicID string `json:"-"` + Name string Description string } @@ -41,11 +44,12 @@ func (n *Network) FromAPIRequest(req *api.NetworkRequest) { } } -// Copy returns a copy of a posture checks. +// Copy returns a copy of a network. func (n *Network) Copy() *Network { return &Network{ ID: n.ID, AccountID: n.AccountID, + PublicID: n.PublicID, Name: n.Name, Description: n.Description, } diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 3110cd9c1..39022d095 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -17,8 +17,9 @@ import ( // Peer capability constants mirror the proto enum values. const ( - PeerCapabilitySourcePrefixes int32 = 1 - PeerCapabilityIPv6Overlay int32 = 2 + PeerCapabilitySourcePrefixes int32 = 1 + PeerCapabilityIPv6Overlay int32 = 2 + PeerCapabilityComponentNetworkMap int32 = 3 ) // Peer represents a machine connected to the network. @@ -172,6 +173,7 @@ type PeerSystemMeta struct { //nolint:revive Flags Flags `gorm:"serializer:json"` Files []File `gorm:"serializer:json"` Capabilities []int32 `gorm:"serializer:json"` + SyncMessageVersion int } func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool { @@ -218,6 +220,14 @@ func (p *Peer) SupportsSourcePrefixes() bool { return p.HasCapability(PeerCapabilitySourcePrefixes) } +// SupportsComponentNetworkMap reports whether the peer assembles its +// NetworkMap from server-shipped components instead of consuming a fully +// expanded NetworkMap. Determines whether the network_map controller skips +// Calculate() server-side and emits the components envelope. +func (p *Peer) SupportsComponentNetworkMap() bool { + return p.HasCapability(PeerCapabilityComponentNetworkMap) +} + func capabilitiesEqual(a, b []int32) bool { if len(a) != len(b) { return false @@ -406,6 +416,9 @@ func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location if !sameMultiset(oldMeta.Files, newMeta.Files) { add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files)) } + if oldMeta.SyncMessageVersion != newMeta.SyncMessageVersion { + add("sync_meta_version", fmt.Sprintf("%d", oldMeta.SyncMessageVersion), fmt.Sprintf("%d", newMeta.SyncMessageVersion)) + } if !oldLocation.equal(newLocation) { add("connection_ip", oldLocation.ConnectionIP, newLocation.ConnectionIP) diff --git a/management/server/policy.go b/management/server/policy.go index 187c879cb..30b101aae 100644 --- a/management/server/policy.go +++ b/management/server/policy.go @@ -67,10 +67,13 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user action = activity.PolicyUpdated + policy.PublicID = existingPolicy.PublicID + if err = transaction.SavePolicy(ctx, policy); err != nil { return err } } else { + policy.PublicID = xid.New().String() if err = transaction.CreatePolicy(ctx, policy); err != nil { return err } diff --git a/management/server/posture/checks.go b/management/server/posture/checks.go index 23ae4efa9..72b719252 100644 --- a/management/server/posture/checks.go +++ b/management/server/posture/checks.go @@ -49,6 +49,8 @@ type Checks struct { // AccountID is a reference to the Account that this object belongs AccountID string `json:"-" gorm:"index"` + PublicID string `json:"-"` + // Checks is a set of objects that perform the actual checks Checks ChecksDefinition `gorm:"serializer:json"` } @@ -167,6 +169,7 @@ func (pc *Checks) Copy() *Checks { Name: pc.Name, Description: pc.Description, AccountID: pc.AccountID, + PublicID: pc.PublicID, Checks: pc.Checks.Copy(), } return checks diff --git a/management/server/posture_checks.go b/management/server/posture_checks.go index 1d962438c..081226866 100644 --- a/management/server/posture_checks.go +++ b/management/server/posture_checks.go @@ -52,7 +52,15 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI } if isUpdate { + existing, err := transaction.GetPostureChecksByID(ctx, store.LockingStrengthNone, accountID, postureChecks.ID) + if err != nil { + return err + } + postureChecks.PublicID = existing.PublicID + action = activity.PostureCheckUpdated + } else { + postureChecks.PublicID = xid.New().String() } postureChecks.AccountID = accountID diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go index abf0b3237..74738e72d 100644 --- a/management/server/posture_checks_test.go +++ b/management/server/posture_checks_test.go @@ -563,3 +563,61 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { assert.Empty(t, directPeerIDs) }) } + +// TestSavePostureChecks_AllocatesSeqIDOnCreate verifies that the create path +// (no incoming ID) allocates a non-zero AccountSeqID via the +// account_seq_counters table. +func TestSavePostureChecks_AllocatesSeqIDOnCreate(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err) + + account, err := initTestPostureChecksAccount(am) + require.NoError(t, err) + + created, err := am.SavePostureChecks(context.Background(), account.Id, adminUserID, &posture.Checks{ + Name: "seq-allocation-test", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.26.0"}, + }, + }, true) + require.NoError(t, err) + require.NotEqual(t, "", created.PublicID, "SavePostureChecks on create must create PublicID") +} + +// TestSavePostureChecks_PreservesSeqIDOnUpdate verifies the update path does +// not reset AccountSeqID even when the caller passes a zero value (REST +// handler shape, because the field is `json:"-"`). +func TestSavePostureChecks_PreservesSeqIDOnUpdate(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err) + + account, err := initTestPostureChecksAccount(am) + require.NoError(t, err) + + created, err := am.SavePostureChecks(context.Background(), account.Id, adminUserID, &posture.Checks{ + Name: "seq-preserve-original", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.26.0"}, + }, + }, true) + require.NoError(t, err) + originalPublicID := created.PublicID + require.NotEqual(t, "", originalPublicID) + + update := &posture.Checks{ + ID: created.ID, + Name: "seq-preserve-renamed", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.27.0"}, + }, + } + require.Equal(t, "", update.PublicID, "incoming struct must mirror an HTTP handler shape") + + _, err = am.SavePostureChecks(context.Background(), account.Id, adminUserID, update, false) + require.NoError(t, err) + + got, err := am.GetPostureChecks(context.Background(), account.Id, created.ID, adminUserID) + require.NoError(t, err) + require.Equal(t, originalPublicID, got.PublicID, "PublicID must survive SavePostureChecks update") + require.Equal(t, "seq-preserve-renamed", got.Name) +} diff --git a/management/server/route.go b/management/server/route.go index 08e1489b2..5a55bf2b3 100644 --- a/management/server/route.go +++ b/management/server/route.go @@ -175,6 +175,8 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri return err } + newRoute.PublicID = xid.New().String() + if err = transaction.SaveRoute(ctx, newRoute); err != nil { return err } @@ -222,6 +224,7 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI } routeToSave.AccountID = accountID + routeToSave.PublicID = oldRoute.PublicID if err = transaction.SaveRoute(ctx, routeToSave); err != nil { return err diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index bb1650d54..7bf6110d8 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -642,6 +642,22 @@ func (s *SqlStore) SaveUser(ctx context.Context, user *types.User) error { } // CreateGroups creates the given list of groups to the database. +// groupUpsertColumns is the explicit allowlist of columns that get updated when +// CreateGroups / UpdateGroups hit a PK conflict. public_id is intentionally +// omitted so a caller passing an entity with the zero value (e.g. an HTTP +// handler-built struct) cannot reset the persisted public_id during an upsert. +// Keep this in sync with the Group schema in management/server/types/group.go. +func groupUpsertColumns() clause.Set { + return clause.AssignmentColumns([]string{ + "account_id", + "name", + "issued", + "integration_ref_id", + "integration_ref_integration_type", + "resources", + }) +} + func (s *SqlStore) CreateGroups(ctx context.Context, accountID string, groups []*types.Group) error { if len(groups) == 0 { return nil @@ -651,8 +667,9 @@ func (s *SqlStore) CreateGroups(ctx context.Context, accountID string, groups [] result := tx. Clauses( clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}}, - UpdateAll: true, + DoUpdates: groupUpsertColumns(), }, ). Omit(clause.Associations). @@ -676,8 +693,9 @@ func (s *SqlStore) UpdateGroups(ctx context.Context, accountID string, groups [] result := tx. Clauses( clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}}, - UpdateAll: true, + DoUpdates: groupUpsertColumns(), }, ). Omit(clause.Associations). @@ -1851,7 +1869,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer, meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_session_started_at, peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip, - location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6 + location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6, meta_sync_message_version FROM peers WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { @@ -1873,6 +1891,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString locationCountryCode, locationCityName, proxyCluster sql.NullString locationGeoNameID sql.NullInt64 + metaSyncMessageVersion sql.NullInt32 ) err := row.Scan(&p.ID, &p.AccountID, &p.Key, &ip, &p.Name, &p.DNSLabel, &p.UserID, &p.SSHKey, &sshEnabled, @@ -1882,7 +1901,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities, &peerStatusLastSeen, &peerStatusSessionStartedAt, &peerStatusConnected, &peerStatusLoginExpired, &peerStatusRequiresApproval, &connIP, &locationCountryCode, &locationCityName, &locationGeoNameID, - &proxyEmbedded, &proxyCluster, &ipv6) + &proxyEmbedded, &proxyCluster, &ipv6, &metaSyncMessageVersion) if err == nil { if lastLogin.Valid { @@ -2002,6 +2021,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee if connIP != nil { _ = json.Unmarshal(connIP, &p.Location.ConnectionIP) } + if metaSyncMessageVersion.Valid { + p.Meta.SyncMessageVersion = int(metaSyncMessageVersion.Int32) + } } return p, err }) @@ -2057,7 +2079,7 @@ func (s *SqlStore) getUsers(ctx context.Context, accountID string) ([]types.User } func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Group, error) { - const query = `SELECT id, account_id, name, issued, resources, integration_ref_id, integration_ref_integration_type FROM groups WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, name, issued, resources, integration_ref_id, integration_ref_integration_type FROM groups WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -2067,7 +2089,7 @@ func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Gr var resources []byte var refID sql.NullInt64 var refType sql.NullString - err := row.Scan(&g.ID, &g.AccountID, &g.Name, &g.Issued, &resources, &refID, &refType) + err := row.Scan(&g.ID, &g.AccountID, &g.PublicID, &g.Name, &g.Issued, &resources, &refID, &refType) if err == nil { if refID.Valid { g.IntegrationReference.ID = int(refID.Int64) @@ -2092,7 +2114,7 @@ func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Gr } func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types.Policy, error) { - const query = `SELECT id, account_id, name, description, enabled, source_posture_checks FROM policies WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, name, description, enabled, source_posture_checks FROM policies WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -2101,7 +2123,7 @@ func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types. var p types.Policy var checks []byte var enabled sql.NullBool - err := row.Scan(&p.ID, &p.AccountID, &p.Name, &p.Description, &enabled, &checks) + err := row.Scan(&p.ID, &p.AccountID, &p.PublicID, &p.Name, &p.Description, &enabled, &checks) if err == nil { if enabled.Valid { p.Enabled = enabled.Bool @@ -2119,7 +2141,7 @@ func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types. } func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Route, error) { - const query = `SELECT id, account_id, network, domains, keep_route, net_id, description, peer, peer_groups, network_type, masquerade, metric, enabled, groups, access_control_groups, skip_auto_apply FROM routes WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, network, domains, keep_route, net_id, description, peer, peer_groups, network_type, masquerade, metric, enabled, groups, access_control_groups, skip_auto_apply FROM routes WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -2129,7 +2151,7 @@ func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Rou var network, domains, peerGroups, groups, accessGroups []byte var keepRoute, masquerade, enabled, skipAutoApply sql.NullBool var metric sql.NullInt64 - err := row.Scan(&r.ID, &r.AccountID, &network, &domains, &keepRoute, &r.NetID, &r.Description, &r.Peer, &peerGroups, &r.NetworkType, &masquerade, &metric, &enabled, &groups, &accessGroups, &skipAutoApply) + err := row.Scan(&r.ID, &r.AccountID, &r.PublicID, &network, &domains, &keepRoute, &r.NetID, &r.Description, &r.Peer, &peerGroups, &r.NetworkType, &masquerade, &metric, &enabled, &groups, &accessGroups, &skipAutoApply) if err == nil { if keepRoute.Valid { r.KeepRoute = keepRoute.Bool @@ -2171,7 +2193,7 @@ func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Rou } func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([]nbdns.NameServerGroup, error) { - const query = `SELECT id, account_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled FROM name_server_groups WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled FROM name_server_groups WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -2180,7 +2202,7 @@ func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([ var n nbdns.NameServerGroup var ns, groups, domains []byte var primary, enabled, searchDomainsEnabled sql.NullBool - err := row.Scan(&n.ID, &n.AccountID, &n.Name, &n.Description, &ns, &groups, &primary, &domains, &enabled, &searchDomainsEnabled) + err := row.Scan(&n.ID, &n.AccountID, &n.PublicID, &n.Name, &n.Description, &ns, &groups, &primary, &domains, &enabled, &searchDomainsEnabled) if err == nil { if primary.Valid { n.Primary = primary.Bool @@ -2216,7 +2238,7 @@ func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([ } func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*posture.Checks, error) { - const query = `SELECT id, account_id, name, description, checks FROM posture_checks WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, name, description, checks FROM posture_checks WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -2224,7 +2246,7 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p checks, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*posture.Checks, error) { var c posture.Checks var checksDef []byte - err := row.Scan(&c.ID, &c.AccountID, &c.Name, &c.Description, &checksDef) + err := row.Scan(&c.ID, &c.AccountID, &c.PublicID, &c.Name, &c.Description, &checksDef) if err == nil && checksDef != nil { _ = json.Unmarshal(checksDef, &c.Checks) } @@ -2404,7 +2426,7 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv } func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networkTypes.Network, error) { - const query = `SELECT id, account_id, name, description FROM networks WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, name, description FROM networks WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -2421,7 +2443,7 @@ func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networ } func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]*routerTypes.NetworkRouter, error) { - const query = `SELECT id, network_id, account_id, peer, peer_groups, masquerade, metric, enabled FROM network_routers WHERE account_id = $1` + const query = `SELECT id, network_id, account_id, public_id, peer, peer_groups, masquerade, metric, enabled FROM network_routers WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -2431,7 +2453,7 @@ func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]* var peerGroups []byte var masquerade, enabled sql.NullBool var metric sql.NullInt64 - err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.Peer, &peerGroups, &masquerade, &metric, &enabled) + err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.PublicID, &r.Peer, &peerGroups, &masquerade, &metric, &enabled) if err == nil { if masquerade.Valid { r.Masquerade = masquerade.Bool @@ -2459,7 +2481,7 @@ func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]* } func (s *SqlStore) getNetworkResources(ctx context.Context, accountID string) ([]*resourceTypes.NetworkResource, error) { - const query = `SELECT id, network_id, account_id, name, description, type, domain, prefix, enabled FROM network_resources WHERE account_id = $1` + const query = `SELECT id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled FROM network_resources WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -2468,7 +2490,7 @@ func (s *SqlStore) getNetworkResources(ctx context.Context, accountID string) ([ var r resourceTypes.NetworkResource var prefix []byte var enabled sql.NullBool - err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.Name, &r.Description, &r.Type, &r.Domain, &prefix, &enabled) + err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.PublicID, &r.Name, &r.Description, &r.Type, &r.Domain, &prefix, &enabled) if err == nil { if enabled.Valid { r.Enabled = enabled.Bool @@ -3830,7 +3852,7 @@ func (s *SqlStore) UpdateGroup(ctx context.Context, group *types.Group) error { return status.Errorf(status.InvalidArgument, "group is nil") } - if err := s.db.Omit(clause.Associations).Save(group).Error; err != nil { + if err := s.db.Omit(clause.Associations, "public_id").Save(group).Error; err != nil { log.WithContext(ctx).Errorf("failed to save group to store: %v", err) return status.Errorf(status.Internal, "failed to save group to store") } @@ -3918,7 +3940,7 @@ func (s *SqlStore) CreatePolicy(ctx context.Context, policy *types.Policy) error // SavePolicy saves a policy to the database. func (s *SqlStore) SavePolicy(ctx context.Context, policy *types.Policy) error { - result := s.db.Session(&gorm.Session{FullSaveAssociations: true}).Save(policy) + result := s.db.Session(&gorm.Session{FullSaveAssociations: true}).Omit("public_id").Save(policy) if err := result.Error; err != nil { log.WithContext(ctx).Errorf("failed to save policy to the store: %s", err) return status.Errorf(status.Internal, "failed to save policy to store") diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 258e1aaa0..ed3419dd7 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -47,6 +47,7 @@ func runTestForAllEngines(t *testing.T, testDataFile string, f func(t *testing.T } t.Setenv("NETBIRD_STORE_ENGINE", string(engine)) store, cleanUp, err := NewTestStoreFromSQL(context.Background(), testDataFile, t.TempDir()) + assert.NoError(t, err, "engine: ", string(engine)) t.Cleanup(cleanUp) assert.NoError(t, err) t.Run(string(engine), func(t *testing.T) { @@ -561,53 +562,60 @@ func TestSqlStore_GetPeerByIP_NotFound(t *testing.T) { } func TestSqlStore_SavePeer(t *testing.T) { - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) + populateFields := testing_helpers.NewPopulateFields() - account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") - require.NoError(t, err) + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") + require.NoError(t, err) - // save status of non-existing peer - peer := &nbpeer.Peer{ - Key: "peerkey", - ID: "testpeer", - IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), - IPv6: netip.MustParseAddr("fd00::1"), - Meta: nbpeer.PeerSystemMeta{Hostname: "testingpeer"}, - Name: "peer name", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - CreatedAt: time.Now().UTC(), - } - ctx := context.Background() - err = store.SavePeer(ctx, account.Id, peer) - 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") + metadata := nbpeer.PeerSystemMeta{} + reflectedMetadata := reflect.ValueOf(&metadata).Elem() - // save new status of existing peer - account.Peers[peer.ID] = peer + numOfFields, err := populateFields.PopulateAll(reflectedMetadata) + assert.NoError(t, err) + assert.Equal(t, 32, numOfFields) - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) + // save status of non-existing peer + peer := &nbpeer.Peer{ + Key: "peerkey", + ID: "testpeer", + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + Meta: metadata, //nbpeer.PeerSystemMeta{Hostname: "testingpeer"}, + Name: "peer name", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + CreatedAt: time.Now().UTC(), + } + ctx := context.Background() + err = store.SavePeer(ctx, account.Id, peer) + 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") - updatedPeer := peer.Copy() - updatedPeer.Status.Connected = false - updatedPeer.Meta.Hostname = "updatedpeer" + // save new status of existing peer + account.Peers[peer.ID] = peer - err = store.SavePeer(ctx, account.Id, updatedPeer) - require.NoError(t, err) + err = store.SaveAccount(context.Background(), account) + require.NoError(t, err) - account, err = store.GetAccount(context.Background(), account.Id) - require.NoError(t, err) + updatedPeer := peer.Copy() + updatedPeer.Status.Connected = false + updatedPeer.Meta.Hostname = "updatedpeer" - actual := account.Peers[peer.ID] - assert.Equal(t, updatedPeer.Meta, actual.Meta) - assert.Equal(t, updatedPeer.Status.Connected, actual.Status.Connected) - assert.Equal(t, updatedPeer.Status.LoginExpired, actual.Status.LoginExpired) - assert.Equal(t, updatedPeer.Status.RequiresApproval, actual.Status.RequiresApproval) - assert.WithinDurationf(t, updatedPeer.Status.LastSeen, actual.Status.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") + err = store.SavePeer(ctx, account.Id, updatedPeer) + require.NoError(t, err) + + account, err = store.GetAccount(context.Background(), account.Id) + require.NoError(t, err) + + actual := account.Peers[peer.ID] + assert.Equal(t, updatedPeer.Meta, actual.Meta) + assert.Equal(t, updatedPeer.Status.Connected, actual.Status.Connected) + assert.Equal(t, updatedPeer.Status.LoginExpired, actual.Status.LoginExpired) + assert.Equal(t, updatedPeer.Status.RequiresApproval, actual.Status.RequiresApproval) + assert.WithinDurationf(t, updatedPeer.Status.LastSeen, actual.Status.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") + }) } func TestSqlStore_SavePeerStatus(t *testing.T) { diff --git a/management/server/store/store.go b/management/server/store/store.go index 908c199f5..0bc385d83 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -582,6 +582,30 @@ func getMigrationsPreAuto(ctx context.Context) []migrationFunc { func(db *gorm.DB) error { return migration.CleanupOrphanedResources[domain.Domain, types.Account](ctx, db, "account_id") }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[types.Policy](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[types.Group](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[route.Route](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[resourceTypes.NetworkResource](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[routerTypes.NetworkRouter](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[dns.NameServerGroup](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[networkTypes.Network](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[posture.Checks](ctx, db) + }, } } diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index fdd2d0900..2da9881de 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -13,18 +13,19 @@ import ( gomock "github.com/golang/mock/gomock" dns "github.com/netbirdio/netbird/dns" + types "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" accesslogs "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" domain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" proxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" zones "github.com/netbirdio/netbird/management/internals/modules/zones" records "github.com/netbirdio/netbird/management/internals/modules/zones/records" - types "github.com/netbirdio/netbird/management/server/networks/resources/types" - types0 "github.com/netbirdio/netbird/management/server/networks/routers/types" - types1 "github.com/netbirdio/netbird/management/server/networks/types" + types0 "github.com/netbirdio/netbird/management/server/networks/resources/types" + types1 "github.com/netbirdio/netbird/management/server/networks/routers/types" + types2 "github.com/netbirdio/netbird/management/server/networks/types" peer "github.com/netbirdio/netbird/management/server/peer" posture "github.com/netbirdio/netbird/management/server/posture" - types2 "github.com/netbirdio/netbird/management/server/types" + types3 "github.com/netbirdio/netbird/management/server/types" route "github.com/netbirdio/netbird/route" crypt "github.com/netbirdio/netbird/util/crypt" ) @@ -124,7 +125,7 @@ func (mr *MockStoreMockRecorder) AddPeerToGroup(ctx, accountID, peerId, groupID } // AddResourceToGroup mocks base method. -func (m *MockStore) AddResourceToGroup(ctx context.Context, accountId, groupID string, resource *types2.Resource) error { +func (m *MockStore) AddResourceToGroup(ctx context.Context, accountId, groupID string, resource *types3.Resource) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "AddResourceToGroup", ctx, accountId, groupID, resource) ret0, _ := ret[0].(error) @@ -181,7 +182,7 @@ func (mr *MockStoreMockRecorder) Close(ctx interface{}) *gomock.Call { } // CompletePeerJob mocks base method. -func (m *MockStore) CompletePeerJob(ctx context.Context, job *types2.Job) error { +func (m *MockStore) CompletePeerJob(ctx context.Context, job *types3.Job) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CompletePeerJob", ctx, job) ret0, _ := ret[0].(error) @@ -253,6 +254,34 @@ func (mr *MockStoreMockRecorder) CreateAccessLog(ctx, log interface{}) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAccessLog), ctx, log) } +// CreateAgentNetworkAccessLog mocks base method. +func (m *MockStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAgentNetworkAccessLog", ctx, entry, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateAgentNetworkAccessLog indicates an expected call of CreateAgentNetworkAccessLog. +func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups) +} + +// CreateAgentNetworkUsage mocks base method. +func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *types.AgentNetworkUsage, groups []types.AgentNetworkUsageGroup) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAgentNetworkUsage", ctx, usage, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateAgentNetworkUsage indicates an expected call of CreateAgentNetworkUsage. +func (mr *MockStoreMockRecorder) CreateAgentNetworkUsage(ctx, usage, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkUsage", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkUsage), ctx, usage, groups) +} + // CreateCustomDomain mocks base method. func (m *MockStore) CreateCustomDomain(ctx context.Context, accountID, domainName, targetCluster string, validated bool) (*domain.Domain, error) { m.ctrl.T.Helper() @@ -283,7 +312,7 @@ func (mr *MockStoreMockRecorder) CreateDNSRecord(ctx, record interface{}) *gomoc } // CreateGroup mocks base method. -func (m *MockStore) CreateGroup(ctx context.Context, group *types2.Group) error { +func (m *MockStore) CreateGroup(ctx context.Context, group *types3.Group) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateGroup", ctx, group) ret0, _ := ret[0].(error) @@ -297,7 +326,7 @@ func (mr *MockStoreMockRecorder) CreateGroup(ctx, group interface{}) *gomock.Cal } // CreateGroups mocks base method. -func (m *MockStore) CreateGroups(ctx context.Context, accountID string, groups []*types2.Group) error { +func (m *MockStore) CreateGroups(ctx context.Context, accountID string, groups []*types3.Group) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateGroups", ctx, accountID, groups) ret0, _ := ret[0].(error) @@ -311,7 +340,7 @@ func (mr *MockStoreMockRecorder) CreateGroups(ctx, accountID, groups interface{} } // CreateNetworkRouter mocks base method. -func (m *MockStore) CreateNetworkRouter(ctx context.Context, router *types0.NetworkRouter) error { +func (m *MockStore) CreateNetworkRouter(ctx context.Context, router *types1.NetworkRouter) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateNetworkRouter", ctx, router) ret0, _ := ret[0].(error) @@ -325,7 +354,7 @@ func (mr *MockStoreMockRecorder) CreateNetworkRouter(ctx, router interface{}) *g } // CreatePeerJob mocks base method. -func (m *MockStore) CreatePeerJob(ctx context.Context, job *types2.Job) error { +func (m *MockStore) CreatePeerJob(ctx context.Context, job *types3.Job) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreatePeerJob", ctx, job) ret0, _ := ret[0].(error) @@ -339,7 +368,7 @@ func (mr *MockStoreMockRecorder) CreatePeerJob(ctx, job interface{}) *gomock.Cal } // CreatePolicy mocks base method. -func (m *MockStore) CreatePolicy(ctx context.Context, policy *types2.Policy) error { +func (m *MockStore) CreatePolicy(ctx context.Context, policy *types3.Policy) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreatePolicy", ctx, policy) ret0, _ := ret[0].(error) @@ -381,7 +410,7 @@ func (mr *MockStoreMockRecorder) CreateZone(ctx, zone interface{}) *gomock.Call } // DeleteAccount mocks base method. -func (m *MockStore) DeleteAccount(ctx context.Context, account *types2.Account) error { +func (m *MockStore) DeleteAccount(ctx context.Context, account *types3.Account) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteAccount", ctx, account) ret0, _ := ret[0].(error) @@ -408,6 +437,62 @@ func (mr *MockStoreMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accou return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockStore)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID) } +// DeleteAgentNetworkBudgetRule mocks base method. +func (m *MockStore) DeleteAgentNetworkBudgetRule(ctx context.Context, accountID, ruleID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkBudgetRule", ctx, accountID, ruleID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkBudgetRule indicates an expected call of DeleteAgentNetworkBudgetRule. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkBudgetRule(ctx, accountID, ruleID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkBudgetRule), ctx, accountID, ruleID) +} + +// DeleteAgentNetworkGuardrail mocks base method. +func (m *MockStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkGuardrail", ctx, accountID, guardrailID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkGuardrail indicates an expected call of DeleteAgentNetworkGuardrail. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkGuardrail(ctx, accountID, guardrailID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkGuardrail), ctx, accountID, guardrailID) +} + +// DeleteAgentNetworkPolicy mocks base method. +func (m *MockStore) DeleteAgentNetworkPolicy(ctx context.Context, accountID, policyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkPolicy", ctx, accountID, policyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkPolicy indicates an expected call of DeleteAgentNetworkPolicy. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkPolicy(ctx, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkPolicy), ctx, accountID, policyID) +} + +// DeleteAgentNetworkProvider mocks base method. +func (m *MockStore) DeleteAgentNetworkProvider(ctx context.Context, accountID, providerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkProvider", ctx, accountID, providerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkProvider indicates an expected call of DeleteAgentNetworkProvider. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkProvider(ctx, accountID, providerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkProvider), ctx, accountID, providerID) +} + // DeleteCustomDomain mocks base method. func (m *MockStore) DeleteCustomDomain(ctx context.Context, accountID, domainID string) error { m.ctrl.T.Helper() @@ -549,6 +634,21 @@ func (mr *MockStoreMockRecorder) DeleteOldAccessLogs(ctx, olderThan interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAccessLogs), ctx, olderThan) } +// DeleteOldAgentNetworkAccessLogs mocks base method. +func (m *MockStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOldAgentNetworkAccessLogs", ctx, accountID, olderThan) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteOldAgentNetworkAccessLogs indicates an expected call of DeleteOldAgentNetworkAccessLogs. +func (mr *MockStoreMockRecorder) DeleteOldAgentNetworkAccessLogs(ctx, accountID, olderThan interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAgentNetworkAccessLogs), ctx, accountID, olderThan) +} + // DeletePAT mocks base method. func (m *MockStore) DeletePAT(ctx context.Context, userID, patID string) error { m.ctrl.T.Helper() @@ -789,10 +889,10 @@ func (mr *MockStoreMockRecorder) ExecuteInTransaction(ctx, f interface{}) *gomoc } // GetAccount mocks base method. -func (m *MockStore) GetAccount(ctx context.Context, accountID string) (*types2.Account, error) { +func (m *MockStore) GetAccount(ctx context.Context, accountID string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccount", ctx, accountID) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -819,11 +919,71 @@ func (mr *MockStoreMockRecorder) GetAccountAccessLogs(ctx, lockStrength, account return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAccountAccessLogs), ctx, lockStrength, accountID, filter) } +// GetAccountAgentNetworkBudgetRules mocks base method. +func (m *MockStore) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.AccountBudgetRule, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkBudgetRules", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.AccountBudgetRule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkBudgetRules indicates an expected call of GetAccountAgentNetworkBudgetRules. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkBudgetRules(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkBudgetRules", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkBudgetRules), ctx, lockStrength, accountID) +} + +// GetAccountAgentNetworkGuardrails mocks base method. +func (m *MockStore) GetAccountAgentNetworkGuardrails(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Guardrail, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkGuardrails", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Guardrail) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkGuardrails indicates an expected call of GetAccountAgentNetworkGuardrails. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkGuardrails(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkGuardrails", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkGuardrails), ctx, lockStrength, accountID) +} + +// GetAccountAgentNetworkPolicies mocks base method. +func (m *MockStore) GetAccountAgentNetworkPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkPolicies", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkPolicies indicates an expected call of GetAccountAgentNetworkPolicies. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkPolicies(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkPolicies", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkPolicies), ctx, lockStrength, accountID) +} + +// GetAccountAgentNetworkProviders mocks base method. +func (m *MockStore) GetAccountAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkProviders", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkProviders indicates an expected call of GetAccountAgentNetworkProviders. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkProviders(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkProviders), ctx, lockStrength, accountID) +} + // GetAccountByPeerID mocks base method. -func (m *MockStore) GetAccountByPeerID(ctx context.Context, peerID string) (*types2.Account, error) { +func (m *MockStore) GetAccountByPeerID(ctx context.Context, peerID string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountByPeerID", ctx, peerID) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -835,10 +995,10 @@ func (mr *MockStoreMockRecorder) GetAccountByPeerID(ctx, peerID interface{}) *go } // GetAccountByPeerPubKey mocks base method. -func (m *MockStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types2.Account, error) { +func (m *MockStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountByPeerPubKey", ctx, peerKey) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -850,10 +1010,10 @@ func (mr *MockStoreMockRecorder) GetAccountByPeerPubKey(ctx, peerKey interface{} } // GetAccountByPrivateDomain mocks base method. -func (m *MockStore) GetAccountByPrivateDomain(ctx context.Context, domain string) (*types2.Account, error) { +func (m *MockStore) GetAccountByPrivateDomain(ctx context.Context, domain string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountByPrivateDomain", ctx, domain) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -865,10 +1025,10 @@ func (mr *MockStoreMockRecorder) GetAccountByPrivateDomain(ctx, domain interface } // GetAccountBySetupKey mocks base method. -func (m *MockStore) GetAccountBySetupKey(ctx context.Context, setupKey string) (*types2.Account, error) { +func (m *MockStore) GetAccountBySetupKey(ctx context.Context, setupKey string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountBySetupKey", ctx, setupKey) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -880,10 +1040,10 @@ func (mr *MockStoreMockRecorder) GetAccountBySetupKey(ctx, setupKey interface{}) } // GetAccountByUser mocks base method. -func (m *MockStore) GetAccountByUser(ctx context.Context, userID string) (*types2.Account, error) { +func (m *MockStore) GetAccountByUser(ctx context.Context, userID string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountByUser", ctx, userID) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -910,10 +1070,10 @@ func (mr *MockStoreMockRecorder) GetAccountCreatedBy(ctx, lockStrength, accountI } // GetAccountDNSSettings mocks base method. -func (m *MockStore) GetAccountDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.DNSSettings, error) { +func (m *MockStore) GetAccountDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.DNSSettings, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountDNSSettings", ctx, lockStrength, accountID) - ret0, _ := ret[0].(*types2.DNSSettings) + ret0, _ := ret[0].(*types3.DNSSettings) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -956,10 +1116,10 @@ func (mr *MockStoreMockRecorder) GetAccountGroupPeers(ctx, lockStrength, account } // GetAccountGroups mocks base method. -func (m *MockStore) GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.Group, error) { +func (m *MockStore) GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountGroups", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.Group) + ret0, _ := ret[0].([]*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1046,10 +1206,10 @@ func (mr *MockStoreMockRecorder) GetAccountIDByUserID(ctx, lockStrength, userID } // GetAccountMeta mocks base method. -func (m *MockStore) GetAccountMeta(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.AccountMeta, error) { +func (m *MockStore) GetAccountMeta(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.AccountMeta, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountMeta", ctx, lockStrength, accountID) - ret0, _ := ret[0].(*types2.AccountMeta) + ret0, _ := ret[0].(*types3.AccountMeta) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1076,10 +1236,10 @@ func (mr *MockStoreMockRecorder) GetAccountNameServerGroups(ctx, lockStrength, a } // GetAccountNetwork mocks base method. -func (m *MockStore) GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountId string) (*types2.Network, error) { +func (m *MockStore) GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountId string) (*types3.Network, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountNetwork", ctx, lockStrength, accountId) - ret0, _ := ret[0].(*types2.Network) + ret0, _ := ret[0].(*types3.Network) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1091,10 +1251,10 @@ func (mr *MockStoreMockRecorder) GetAccountNetwork(ctx, lockStrength, accountId } // GetAccountNetworks mocks base method. -func (m *MockStore) GetAccountNetworks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types1.Network, error) { +func (m *MockStore) GetAccountNetworks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.Network, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountNetworks", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types1.Network) + ret0, _ := ret[0].([]*types2.Network) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1106,10 +1266,10 @@ func (mr *MockStoreMockRecorder) GetAccountNetworks(ctx, lockStrength, accountID } // GetAccountOnboarding mocks base method. -func (m *MockStore) GetAccountOnboarding(ctx context.Context, accountID string) (*types2.AccountOnboarding, error) { +func (m *MockStore) GetAccountOnboarding(ctx context.Context, accountID string) (*types3.AccountOnboarding, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountOnboarding", ctx, accountID) - ret0, _ := ret[0].(*types2.AccountOnboarding) + ret0, _ := ret[0].(*types3.AccountOnboarding) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1121,10 +1281,10 @@ func (mr *MockStoreMockRecorder) GetAccountOnboarding(ctx, accountID interface{} } // GetAccountOwner mocks base method. -func (m *MockStore) GetAccountOwner(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.User, error) { +func (m *MockStore) GetAccountOwner(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountOwner", ctx, lockStrength, accountID) - ret0, _ := ret[0].(*types2.User) + ret0, _ := ret[0].(*types3.User) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1181,10 +1341,10 @@ func (mr *MockStoreMockRecorder) GetAccountPeersWithInactivity(ctx, lockStrength } // GetAccountPolicies mocks base method. -func (m *MockStore) GetAccountPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.Policy, error) { +func (m *MockStore) GetAccountPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.Policy, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountPolicies", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.Policy) + ret0, _ := ret[0].([]*types3.Policy) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1241,10 +1401,10 @@ func (mr *MockStoreMockRecorder) GetAccountServices(ctx, lockStrength, accountID } // GetAccountSettings mocks base method. -func (m *MockStore) GetAccountSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.Settings, error) { +func (m *MockStore) GetAccountSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.Settings, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountSettings", ctx, lockStrength, accountID) - ret0, _ := ret[0].(*types2.Settings) + ret0, _ := ret[0].(*types3.Settings) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1256,10 +1416,10 @@ func (mr *MockStoreMockRecorder) GetAccountSettings(ctx, lockStrength, accountID } // GetAccountSetupKeys mocks base method. -func (m *MockStore) GetAccountSetupKeys(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.SetupKey, error) { +func (m *MockStore) GetAccountSetupKeys(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.SetupKey, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountSetupKeys", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.SetupKey) + ret0, _ := ret[0].([]*types3.SetupKey) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1271,10 +1431,10 @@ func (mr *MockStoreMockRecorder) GetAccountSetupKeys(ctx, lockStrength, accountI } // GetAccountUserInvites mocks base method. -func (m *MockStore) GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.UserInviteRecord, error) { +func (m *MockStore) GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.UserInviteRecord, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountUserInvites", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.UserInviteRecord) + ret0, _ := ret[0].([]*types3.UserInviteRecord) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1286,10 +1446,10 @@ func (mr *MockStoreMockRecorder) GetAccountUserInvites(ctx, lockStrength, accoun } // GetAccountUsers mocks base method. -func (m *MockStore) GetAccountUsers(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.User, error) { +func (m *MockStore) GetAccountUsers(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountUsers", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.User) + ret0, _ := ret[0].([]*types3.User) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1360,11 +1520,193 @@ func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddressesForAccount(ctx, a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusterAddressesForAccount", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusterAddressesForAccount), ctx, accountID) } +// GetAgentNetworkAccessLogSessions mocks base method. +func (m *MockStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogSessions", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*types.AgentNetworkAccessLogSession) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAgentNetworkAccessLogSessions indicates an expected call of GetAgentNetworkAccessLogSessions. +func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogSessions(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogSessions", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogSessions), ctx, lockStrength, accountID, filter) +} + +// GetAgentNetworkAccessLogs mocks base method. +func (m *MockStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogs", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*types.AgentNetworkAccessLog) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAgentNetworkAccessLogs indicates an expected call of GetAgentNetworkAccessLogs. +func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogs(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogs), ctx, lockStrength, accountID, filter) +} + +// GetAgentNetworkBudgetRuleByID mocks base method. +func (m *MockStore) GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*types.AccountBudgetRule, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkBudgetRuleByID", ctx, lockStrength, accountID, ruleID) + ret0, _ := ret[0].(*types.AccountBudgetRule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkBudgetRuleByID indicates an expected call of GetAgentNetworkBudgetRuleByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkBudgetRuleByID(ctx, lockStrength, accountID, ruleID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkBudgetRuleByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkBudgetRuleByID), ctx, lockStrength, accountID, ruleID) +} + +// GetAgentNetworkConsumption mocks base method. +func (m *MockStore) GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*types.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkConsumption", ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) + ret0, _ := ret[0].(*types.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkConsumption indicates an expected call of GetAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) GetAgentNetworkConsumption(ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumption), ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) +} + +// GetAgentNetworkConsumptionBatch mocks base method. +func (m *MockStore) GetAgentNetworkConsumptionBatch(ctx context.Context, lockStrength LockingStrength, accountID string, keys []types.ConsumptionKey) (map[types.ConsumptionKey]*types.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkConsumptionBatch", ctx, lockStrength, accountID, keys) + ret0, _ := ret[0].(map[types.ConsumptionKey]*types.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkConsumptionBatch indicates an expected call of GetAgentNetworkConsumptionBatch. +func (mr *MockStoreMockRecorder) GetAgentNetworkConsumptionBatch(ctx, lockStrength, accountID, keys interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumptionBatch), ctx, lockStrength, accountID, keys) +} + +// GetAgentNetworkGuardrailByID mocks base method. +func (m *MockStore) GetAgentNetworkGuardrailByID(ctx context.Context, lockStrength LockingStrength, accountID, guardrailID string) (*types.Guardrail, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkGuardrailByID", ctx, lockStrength, accountID, guardrailID) + ret0, _ := ret[0].(*types.Guardrail) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkGuardrailByID indicates an expected call of GetAgentNetworkGuardrailByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkGuardrailByID(ctx, lockStrength, accountID, guardrailID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkGuardrailByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkGuardrailByID), ctx, lockStrength, accountID, guardrailID) +} + +// GetAgentNetworkMetrics mocks base method. +func (m *MockStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetrics, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkMetrics", ctx) + ret0, _ := ret[0].(AgentNetworkMetrics) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkMetrics indicates an expected call of GetAgentNetworkMetrics. +func (mr *MockStoreMockRecorder) GetAgentNetworkMetrics(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkMetrics", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkMetrics), ctx) +} + +// GetAgentNetworkPolicyByID mocks base method. +func (m *MockStore) GetAgentNetworkPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkPolicyByID", ctx, lockStrength, accountID, policyID) + ret0, _ := ret[0].(*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkPolicyByID indicates an expected call of GetAgentNetworkPolicyByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkPolicyByID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkPolicyByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkPolicyByID), ctx, lockStrength, accountID, policyID) +} + +// GetAgentNetworkProviderByID mocks base method. +func (m *MockStore) GetAgentNetworkProviderByID(ctx context.Context, lockStrength LockingStrength, accountID, providerID string) (*types.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkProviderByID", ctx, lockStrength, accountID, providerID) + ret0, _ := ret[0].(*types.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkProviderByID indicates an expected call of GetAgentNetworkProviderByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkProviderByID(ctx, lockStrength, accountID, providerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkProviderByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkProviderByID), ctx, lockStrength, accountID, providerID) +} + +// GetAgentNetworkSettings mocks base method. +func (m *MockStore) GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkSettings", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkSettings indicates an expected call of GetAgentNetworkSettings. +func (mr *MockStoreMockRecorder) GetAgentNetworkSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettings), ctx, lockStrength, accountID) +} + +// GetAgentNetworkSettingsByCluster mocks base method. +func (m *MockStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkSettingsByCluster", ctx, lockStrength, cluster) + ret0, _ := ret[0].([]*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkSettingsByCluster indicates an expected call of GetAgentNetworkSettingsByCluster. +func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByCluster(ctx, lockStrength, cluster interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByCluster", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByCluster), ctx, lockStrength, cluster) +} + +// GetAgentNetworkUsageRows mocks base method. +func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkUsage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkUsageRows", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*types.AgentNetworkUsage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkUsageRows indicates an expected call of GetAgentNetworkUsageRows. +func (mr *MockStoreMockRecorder) GetAgentNetworkUsageRows(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkUsageRows", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkUsageRows), ctx, lockStrength, accountID, filter) +} + // GetAllAccounts mocks base method. -func (m *MockStore) GetAllAccounts(ctx context.Context) []*types2.Account { +func (m *MockStore) GetAllAccounts(ctx context.Context) []*types3.Account { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAllAccounts", ctx) - ret0, _ := ret[0].([]*types2.Account) + ret0, _ := ret[0].([]*types3.Account) return ret0 } @@ -1374,6 +1716,36 @@ func (mr *MockStoreMockRecorder) GetAllAccounts(ctx interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAccounts", reflect.TypeOf((*MockStore)(nil).GetAllAccounts), ctx) } +// GetAllAgentNetworkProviders mocks base method. +func (m *MockStore) GetAllAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength) ([]*types.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllAgentNetworkProviders", ctx, lockStrength) + ret0, _ := ret[0].([]*types.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllAgentNetworkProviders indicates an expected call of GetAllAgentNetworkProviders. +func (mr *MockStoreMockRecorder) GetAllAgentNetworkProviders(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkProviders), ctx, lockStrength) +} + +// GetAllAgentNetworkSettings mocks base method. +func (m *MockStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllAgentNetworkSettings", ctx, lockStrength) + ret0, _ := ret[0].([]*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllAgentNetworkSettings indicates an expected call of GetAllAgentNetworkSettings. +func (mr *MockStoreMockRecorder) GetAllAgentNetworkSettings(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkSettings), ctx, lockStrength) +} + // GetAllEphemeralPeers mocks base method. func (m *MockStore) GetAllEphemeralPeers(ctx context.Context, lockStrength LockingStrength) ([]*peer.Peer, error) { m.ctrl.T.Helper() @@ -1390,10 +1762,10 @@ func (mr *MockStoreMockRecorder) GetAllEphemeralPeers(ctx, lockStrength interfac } // GetAllProxyAccessTokens mocks base method. -func (m *MockStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types2.ProxyAccessToken, error) { +func (m *MockStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types3.ProxyAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAllProxyAccessTokens", ctx, lockStrength) - ret0, _ := ret[0].([]*types2.ProxyAccessToken) + ret0, _ := ret[0].([]*types3.ProxyAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1521,6 +1893,21 @@ func (mr *MockStoreMockRecorder) GetDNSRecordByID(ctx, lockStrength, accountID, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDNSRecordByID", reflect.TypeOf((*MockStore)(nil).GetDNSRecordByID), ctx, lockStrength, accountID, zoneID, recordID) } +// GetEmbeddedProxyPeerIDsByCluster mocks base method. +func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEmbeddedProxyPeerIDsByCluster", ctx, accountID) + ret0, _ := ret[0].(map[string][]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEmbeddedProxyPeerIDsByCluster indicates an expected call of GetEmbeddedProxyPeerIDsByCluster. +func (mr *MockStoreMockRecorder) GetEmbeddedProxyPeerIDsByCluster(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedProxyPeerIDsByCluster", reflect.TypeOf((*MockStore)(nil).GetEmbeddedProxyPeerIDsByCluster), ctx, accountID) +} + // GetExpiredEphemeralServices mocks base method. func (m *MockStore) GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*service.Service, error) { m.ctrl.T.Helper() @@ -1537,10 +1924,10 @@ func (mr *MockStoreMockRecorder) GetExpiredEphemeralServices(ctx, ttl, limit int } // GetGroupByID mocks base method. -func (m *MockStore) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types2.Group, error) { +func (m *MockStore) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupByID", ctx, lockStrength, accountID, groupID) - ret0, _ := ret[0].(*types2.Group) + ret0, _ := ret[0].(*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1552,10 +1939,10 @@ func (mr *MockStoreMockRecorder) GetGroupByID(ctx, lockStrength, accountID, grou } // GetGroupByName mocks base method. -func (m *MockStore) GetGroupByName(ctx context.Context, lockStrength LockingStrength, accountID, groupName string) (*types2.Group, error) { +func (m *MockStore) GetGroupByName(ctx context.Context, lockStrength LockingStrength, accountID, groupName string) (*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupByName", ctx, lockStrength, accountID, groupName) - ret0, _ := ret[0].(*types2.Group) + ret0, _ := ret[0].(*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1566,11 +1953,26 @@ func (mr *MockStoreMockRecorder) GetGroupByName(ctx, lockStrength, accountID, gr return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByName", reflect.TypeOf((*MockStore)(nil).GetGroupByName), ctx, lockStrength, accountID, groupName) } +// GetGroupIDsByPeerIDs mocks base method. +func (m *MockStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupIDsByPeerIDs", ctx, accountID, peerIDs) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupIDsByPeerIDs indicates an expected call of GetGroupIDsByPeerIDs. +func (mr *MockStoreMockRecorder) GetGroupIDsByPeerIDs(ctx, accountID, peerIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupIDsByPeerIDs", reflect.TypeOf((*MockStore)(nil).GetGroupIDsByPeerIDs), ctx, accountID, peerIDs) +} + // GetGroupsByIDs mocks base method. -func (m *MockStore) GetGroupsByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, groupIDs []string) (map[string]*types2.Group, error) { +func (m *MockStore) GetGroupsByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, groupIDs []string) (map[string]*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupsByIDs", ctx, lockStrength, accountID, groupIDs) - ret0, _ := ret[0].(map[string]*types2.Group) + ret0, _ := ret[0].(map[string]*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1611,10 +2013,10 @@ func (mr *MockStoreMockRecorder) GetNameServerGroupByID(ctx, lockStrength, nameS } // GetNetworkByID mocks base method. -func (m *MockStore) GetNetworkByID(ctx context.Context, lockStrength LockingStrength, accountID, networkID string) (*types1.Network, error) { +func (m *MockStore) GetNetworkByID(ctx context.Context, lockStrength LockingStrength, accountID, networkID string) (*types2.Network, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkByID", ctx, lockStrength, accountID, networkID) - ret0, _ := ret[0].(*types1.Network) + ret0, _ := ret[0].(*types2.Network) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1626,10 +2028,10 @@ func (mr *MockStoreMockRecorder) GetNetworkByID(ctx, lockStrength, accountID, ne } // GetNetworkResourceByID mocks base method. -func (m *MockStore) GetNetworkResourceByID(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) (*types.NetworkResource, error) { +func (m *MockStore) GetNetworkResourceByID(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) (*types0.NetworkResource, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkResourceByID", ctx, lockStrength, accountID, resourceID) - ret0, _ := ret[0].(*types.NetworkResource) + ret0, _ := ret[0].(*types0.NetworkResource) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1641,10 +2043,10 @@ func (mr *MockStoreMockRecorder) GetNetworkResourceByID(ctx, lockStrength, accou } // GetNetworkResourceByName mocks base method. -func (m *MockStore) GetNetworkResourceByName(ctx context.Context, lockStrength LockingStrength, accountID, resourceName string) (*types.NetworkResource, error) { +func (m *MockStore) GetNetworkResourceByName(ctx context.Context, lockStrength LockingStrength, accountID, resourceName string) (*types0.NetworkResource, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkResourceByName", ctx, lockStrength, accountID, resourceName) - ret0, _ := ret[0].(*types.NetworkResource) + ret0, _ := ret[0].(*types0.NetworkResource) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1656,10 +2058,10 @@ func (mr *MockStoreMockRecorder) GetNetworkResourceByName(ctx, lockStrength, acc } // GetNetworkResourcesByAccountID mocks base method. -func (m *MockStore) GetNetworkResourcesByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.NetworkResource, error) { +func (m *MockStore) GetNetworkResourcesByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types0.NetworkResource, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkResourcesByAccountID", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types.NetworkResource) + ret0, _ := ret[0].([]*types0.NetworkResource) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1671,10 +2073,10 @@ func (mr *MockStoreMockRecorder) GetNetworkResourcesByAccountID(ctx, lockStrengt } // GetNetworkResourcesByNetID mocks base method. -func (m *MockStore) GetNetworkResourcesByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types.NetworkResource, error) { +func (m *MockStore) GetNetworkResourcesByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types0.NetworkResource, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkResourcesByNetID", ctx, lockStrength, accountID, netID) - ret0, _ := ret[0].([]*types.NetworkResource) + ret0, _ := ret[0].([]*types0.NetworkResource) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1686,10 +2088,10 @@ func (mr *MockStoreMockRecorder) GetNetworkResourcesByNetID(ctx, lockStrength, a } // GetNetworkRouterByID mocks base method. -func (m *MockStore) GetNetworkRouterByID(ctx context.Context, lockStrength LockingStrength, accountID, routerID string) (*types0.NetworkRouter, error) { +func (m *MockStore) GetNetworkRouterByID(ctx context.Context, lockStrength LockingStrength, accountID, routerID string) (*types1.NetworkRouter, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkRouterByID", ctx, lockStrength, accountID, routerID) - ret0, _ := ret[0].(*types0.NetworkRouter) + ret0, _ := ret[0].(*types1.NetworkRouter) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1701,10 +2103,10 @@ func (mr *MockStoreMockRecorder) GetNetworkRouterByID(ctx, lockStrength, account } // GetNetworkRoutersByAccountID mocks base method. -func (m *MockStore) GetNetworkRoutersByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types0.NetworkRouter, error) { +func (m *MockStore) GetNetworkRoutersByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types1.NetworkRouter, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkRoutersByAccountID", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types0.NetworkRouter) + ret0, _ := ret[0].([]*types1.NetworkRouter) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1716,10 +2118,10 @@ func (mr *MockStoreMockRecorder) GetNetworkRoutersByAccountID(ctx, lockStrength, } // GetNetworkRoutersByNetID mocks base method. -func (m *MockStore) GetNetworkRoutersByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types0.NetworkRouter, error) { +func (m *MockStore) GetNetworkRoutersByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types1.NetworkRouter, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkRoutersByNetID", ctx, lockStrength, accountID, netID) - ret0, _ := ret[0].([]*types0.NetworkRouter) + ret0, _ := ret[0].([]*types1.NetworkRouter) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1731,10 +2133,10 @@ func (mr *MockStoreMockRecorder) GetNetworkRoutersByNetID(ctx, lockStrength, acc } // GetPATByHashedToken mocks base method. -func (m *MockStore) GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types2.PersonalAccessToken, error) { +func (m *MockStore) GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types3.PersonalAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPATByHashedToken", ctx, lockStrength, hashedToken) - ret0, _ := ret[0].(*types2.PersonalAccessToken) + ret0, _ := ret[0].(*types3.PersonalAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1746,10 +2148,10 @@ func (mr *MockStoreMockRecorder) GetPATByHashedToken(ctx, lockStrength, hashedTo } // GetPATByID mocks base method. -func (m *MockStore) GetPATByID(ctx context.Context, lockStrength LockingStrength, userID, patID string) (*types2.PersonalAccessToken, error) { +func (m *MockStore) GetPATByID(ctx context.Context, lockStrength LockingStrength, userID, patID string) (*types3.PersonalAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPATByID", ctx, lockStrength, userID, patID) - ret0, _ := ret[0].(*types2.PersonalAccessToken) + ret0, _ := ret[0].(*types3.PersonalAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1821,10 +2223,10 @@ func (mr *MockStoreMockRecorder) GetPeerGroupIDs(ctx, lockStrength, accountId, p } // GetPeerGroups mocks base method. -func (m *MockStore) GetPeerGroups(ctx context.Context, lockStrength LockingStrength, accountId, peerId string) ([]*types2.Group, error) { +func (m *MockStore) GetPeerGroups(ctx context.Context, lockStrength LockingStrength, accountId, peerId string) ([]*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPeerGroups", ctx, lockStrength, accountId, peerId) - ret0, _ := ret[0].([]*types2.Group) + ret0, _ := ret[0].([]*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1850,6 +2252,21 @@ func (mr *MockStoreMockRecorder) GetPeerIDByKey(ctx, lockStrength, key interface return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDByKey", reflect.TypeOf((*MockStore)(nil).GetPeerIDByKey), ctx, lockStrength, key) } +// GetPeerIDsByGroups mocks base method. +func (m *MockStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerIDsByGroups", ctx, accountID, groupIDs) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerIDsByGroups indicates an expected call of GetPeerIDsByGroups. +func (mr *MockStoreMockRecorder) GetPeerIDsByGroups(ctx, accountID, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDsByGroups", reflect.TypeOf((*MockStore)(nil).GetPeerIDsByGroups), ctx, accountID, groupIDs) +} + // GetPeerIdByLabel mocks base method. func (m *MockStore) GetPeerIdByLabel(ctx context.Context, lockStrength LockingStrength, accountID, hostname string) (string, error) { m.ctrl.T.Helper() @@ -1866,10 +2283,10 @@ func (mr *MockStoreMockRecorder) GetPeerIdByLabel(ctx, lockStrength, accountID, } // GetPeerJobByID mocks base method. -func (m *MockStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types2.Job, error) { +func (m *MockStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types3.Job, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPeerJobByID", ctx, accountID, jobID) - ret0, _ := ret[0].(*types2.Job) + ret0, _ := ret[0].(*types3.Job) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1881,10 +2298,10 @@ func (mr *MockStoreMockRecorder) GetPeerJobByID(ctx, accountID, jobID interface{ } // GetPeerJobs mocks base method. -func (m *MockStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types2.Job, error) { +func (m *MockStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types3.Job, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPeerJobs", ctx, accountID, peerID) - ret0, _ := ret[0].([]*types2.Job) + ret0, _ := ret[0].([]*types3.Job) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1925,51 +2342,6 @@ func (mr *MockStoreMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupIDs int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockStore)(nil).GetPeersByGroupIDs), ctx, accountID, groupIDs) } -// GetPeerIDsByGroups mocks base method. -func (m *MockStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPeerIDsByGroups", ctx, accountID, groupIDs) - ret0, _ := ret[0].([]string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPeerIDsByGroups indicates an expected call of GetPeerIDsByGroups. -func (mr *MockStoreMockRecorder) GetPeerIDsByGroups(ctx, accountID, groupIDs interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDsByGroups", reflect.TypeOf((*MockStore)(nil).GetPeerIDsByGroups), ctx, accountID, groupIDs) -} - -// GetGroupIDsByPeerIDs mocks base method. -func (m *MockStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetGroupIDsByPeerIDs", ctx, accountID, peerIDs) - ret0, _ := ret[0].([]string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetGroupIDsByPeerIDs indicates an expected call of GetGroupIDsByPeerIDs. -func (mr *MockStoreMockRecorder) GetGroupIDsByPeerIDs(ctx, accountID, peerIDs interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupIDsByPeerIDs", reflect.TypeOf((*MockStore)(nil).GetGroupIDsByPeerIDs), ctx, accountID, peerIDs) -} - -// GetEmbeddedProxyPeerIDsByCluster mocks base method. -func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetEmbeddedProxyPeerIDsByCluster", ctx, accountID) - ret0, _ := ret[0].(map[string][]string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetEmbeddedProxyPeerIDsByCluster indicates an expected call of GetEmbeddedProxyPeerIDsByCluster. -func (mr *MockStoreMockRecorder) GetEmbeddedProxyPeerIDsByCluster(ctx, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedProxyPeerIDsByCluster", reflect.TypeOf((*MockStore)(nil).GetEmbeddedProxyPeerIDsByCluster), ctx, accountID) -} - // GetPeersByIDs mocks base method. func (m *MockStore) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*peer.Peer, error) { m.ctrl.T.Helper() @@ -1986,10 +2358,10 @@ func (mr *MockStoreMockRecorder) GetPeersByIDs(ctx, lockStrength, accountID, pee } // GetPolicyByID mocks base method. -func (m *MockStore) GetPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types2.Policy, error) { +func (m *MockStore) GetPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types3.Policy, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPolicyByID", ctx, lockStrength, accountID, policyID) - ret0, _ := ret[0].(*types2.Policy) + ret0, _ := ret[0].(*types3.Policy) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2001,10 +2373,10 @@ func (mr *MockStoreMockRecorder) GetPolicyByID(ctx, lockStrength, accountID, pol } // GetPolicyRulesByResourceID mocks base method. -func (m *MockStore) GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) ([]*types2.PolicyRule, error) { +func (m *MockStore) GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) ([]*types3.PolicyRule, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPolicyRulesByResourceID", ctx, lockStrength, accountID, peerID) - ret0, _ := ret[0].([]*types2.PolicyRule) + ret0, _ := ret[0].([]*types3.PolicyRule) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2061,10 +2433,10 @@ func (mr *MockStoreMockRecorder) GetPostureChecksByIDs(ctx, lockStrength, accoun } // GetProxyAccessTokenByHashedToken mocks base method. -func (m *MockStore) GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types2.HashedProxyToken) (*types2.ProxyAccessToken, error) { +func (m *MockStore) GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types3.HashedProxyToken) (*types3.ProxyAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetProxyAccessTokenByHashedToken", ctx, lockStrength, hashedToken) - ret0, _ := ret[0].(*types2.ProxyAccessToken) + ret0, _ := ret[0].(*types3.ProxyAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2076,10 +2448,10 @@ func (mr *MockStoreMockRecorder) GetProxyAccessTokenByHashedToken(ctx, lockStren } // GetProxyAccessTokenByID mocks base method. -func (m *MockStore) GetProxyAccessTokenByID(ctx context.Context, lockStrength LockingStrength, tokenID string) (*types2.ProxyAccessToken, error) { +func (m *MockStore) GetProxyAccessTokenByID(ctx context.Context, lockStrength LockingStrength, tokenID string) (*types3.ProxyAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetProxyAccessTokenByID", ctx, lockStrength, tokenID) - ret0, _ := ret[0].(*types2.ProxyAccessToken) + ret0, _ := ret[0].(*types3.ProxyAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2091,10 +2463,10 @@ func (mr *MockStoreMockRecorder) GetProxyAccessTokenByID(ctx, lockStrength, toke } // GetProxyAccessTokensByAccountID mocks base method. -func (m *MockStore) GetProxyAccessTokensByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.ProxyAccessToken, error) { +func (m *MockStore) GetProxyAccessTokensByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.ProxyAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetProxyAccessTokensByAccountID", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.ProxyAccessToken) + ret0, _ := ret[0].([]*types3.ProxyAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2151,10 +2523,10 @@ func (mr *MockStoreMockRecorder) GetProxyMetrics(ctx interface{}) *gomock.Call { } // GetResourceGroups mocks base method. -func (m *MockStore) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types2.Group, error) { +func (m *MockStore) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetResourceGroups", ctx, lockStrength, accountID, resourceID) - ret0, _ := ret[0].([]*types2.Group) + ret0, _ := ret[0].([]*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2286,10 +2658,10 @@ func (mr *MockStoreMockRecorder) GetServicesByClusterAndPort(ctx, lockStrength, } // GetSetupKeyByID mocks base method. -func (m *MockStore) GetSetupKeyByID(ctx context.Context, lockStrength LockingStrength, accountID, setupKeyID string) (*types2.SetupKey, error) { +func (m *MockStore) GetSetupKeyByID(ctx context.Context, lockStrength LockingStrength, accountID, setupKeyID string) (*types3.SetupKey, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSetupKeyByID", ctx, lockStrength, accountID, setupKeyID) - ret0, _ := ret[0].(*types2.SetupKey) + ret0, _ := ret[0].(*types3.SetupKey) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2301,10 +2673,10 @@ func (mr *MockStoreMockRecorder) GetSetupKeyByID(ctx, lockStrength, accountID, s } // GetSetupKeyBySecret mocks base method. -func (m *MockStore) GetSetupKeyBySecret(ctx context.Context, lockStrength LockingStrength, key string) (*types2.SetupKey, error) { +func (m *MockStore) GetSetupKeyBySecret(ctx context.Context, lockStrength LockingStrength, key string) (*types3.SetupKey, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSetupKeyBySecret", ctx, lockStrength, key) - ret0, _ := ret[0].(*types2.SetupKey) + ret0, _ := ret[0].(*types3.SetupKey) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2316,10 +2688,10 @@ func (mr *MockStoreMockRecorder) GetSetupKeyBySecret(ctx, lockStrength, key inte } // GetStoreEngine mocks base method. -func (m *MockStore) GetStoreEngine() types2.Engine { +func (m *MockStore) GetStoreEngine() types3.Engine { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetStoreEngine") - ret0, _ := ret[0].(types2.Engine) + ret0, _ := ret[0].(types3.Engine) return ret0 } @@ -2375,10 +2747,10 @@ func (mr *MockStoreMockRecorder) GetTokenIDByHashedToken(ctx, secret interface{} } // GetUserByPATID mocks base method. -func (m *MockStore) GetUserByPATID(ctx context.Context, lockStrength LockingStrength, patID string) (*types2.User, error) { +func (m *MockStore) GetUserByPATID(ctx context.Context, lockStrength LockingStrength, patID string) (*types3.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByPATID", ctx, lockStrength, patID) - ret0, _ := ret[0].(*types2.User) + ret0, _ := ret[0].(*types3.User) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2390,10 +2762,10 @@ func (mr *MockStoreMockRecorder) GetUserByPATID(ctx, lockStrength, patID interfa } // GetUserByUserID mocks base method. -func (m *MockStore) GetUserByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (*types2.User, error) { +func (m *MockStore) GetUserByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (*types3.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByUserID", ctx, lockStrength, userID) - ret0, _ := ret[0].(*types2.User) + ret0, _ := ret[0].(*types3.User) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2420,10 +2792,10 @@ func (mr *MockStoreMockRecorder) GetUserIDByPeerKey(ctx, lockStrength, peerKey i } // GetUserInviteByEmail mocks base method. -func (m *MockStore) GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types2.UserInviteRecord, error) { +func (m *MockStore) GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types3.UserInviteRecord, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserInviteByEmail", ctx, lockStrength, accountID, email) - ret0, _ := ret[0].(*types2.UserInviteRecord) + ret0, _ := ret[0].(*types3.UserInviteRecord) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2435,10 +2807,10 @@ func (mr *MockStoreMockRecorder) GetUserInviteByEmail(ctx, lockStrength, account } // GetUserInviteByHashedToken mocks base method. -func (m *MockStore) GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types2.UserInviteRecord, error) { +func (m *MockStore) GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types3.UserInviteRecord, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserInviteByHashedToken", ctx, lockStrength, hashedToken) - ret0, _ := ret[0].(*types2.UserInviteRecord) + ret0, _ := ret[0].(*types3.UserInviteRecord) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2450,10 +2822,10 @@ func (mr *MockStoreMockRecorder) GetUserInviteByHashedToken(ctx, lockStrength, h } // GetUserInviteByID mocks base method. -func (m *MockStore) GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types2.UserInviteRecord, error) { +func (m *MockStore) GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types3.UserInviteRecord, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserInviteByID", ctx, lockStrength, accountID, inviteID) - ret0, _ := ret[0].(*types2.UserInviteRecord) + ret0, _ := ret[0].(*types3.UserInviteRecord) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2465,10 +2837,10 @@ func (mr *MockStoreMockRecorder) GetUserInviteByID(ctx, lockStrength, accountID, } // GetUserPATs mocks base method. -func (m *MockStore) GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types2.PersonalAccessToken, error) { +func (m *MockStore) GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types3.PersonalAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserPATs", ctx, lockStrength, userID) - ret0, _ := ret[0].([]*types2.PersonalAccessToken) + ret0, _ := ret[0].([]*types3.PersonalAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2554,6 +2926,34 @@ func (mr *MockStoreMockRecorder) GetZoneDNSRecordsByName(ctx, lockStrength, acco return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneDNSRecordsByName", reflect.TypeOf((*MockStore)(nil).GetZoneDNSRecordsByName), ctx, lockStrength, accountID, zoneID, name) } +// IncrementAgentNetworkConsumption mocks base method. +func (m *MockStore) IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumption", ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementAgentNetworkConsumption indicates an expected call of IncrementAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumption), ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) +} + +// IncrementAgentNetworkConsumptionBatch mocks base method. +func (m *MockStore) IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []types.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumptionBatch", ctx, accountID, keys, tokensIn, tokensOut, costUSD) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementAgentNetworkConsumptionBatch indicates an expected call of IncrementAgentNetworkConsumptionBatch. +func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumptionBatch(ctx, accountID, keys, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumptionBatch), ctx, accountID, keys, tokensIn, tokensOut, costUSD) +} + // IncrementNetworkSerial mocks base method. func (m *MockStore) IncrementNetworkSerial(ctx context.Context, accountId string) error { m.ctrl.T.Helper() @@ -2628,6 +3028,21 @@ func (mr *MockStoreMockRecorder) IsProxyAccessTokenValid(ctx, tokenID interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsProxyAccessTokenValid", reflect.TypeOf((*MockStore)(nil).IsProxyAccessTokenValid), ctx, tokenID) } +// ListAgentNetworkConsumption mocks base method. +func (m *MockStore) ListAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAgentNetworkConsumption", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAgentNetworkConsumption indicates an expected call of ListAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) ListAgentNetworkConsumption(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).ListAgentNetworkConsumption), ctx, lockStrength, accountID) +} + // ListCustomDomains mocks base method. func (m *MockStore) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) { m.ctrl.T.Helper() @@ -2829,7 +3244,7 @@ func (mr *MockStoreMockRecorder) RevokeProxyAccessToken(ctx, tokenID interface{} } // SaveAccount mocks base method. -func (m *MockStore) SaveAccount(ctx context.Context, account *types2.Account) error { +func (m *MockStore) SaveAccount(ctx context.Context, account *types3.Account) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveAccount", ctx, account) ret0, _ := ret[0].(error) @@ -2843,7 +3258,7 @@ func (mr *MockStoreMockRecorder) SaveAccount(ctx, account interface{}) *gomock.C } // SaveAccountOnboarding mocks base method. -func (m *MockStore) SaveAccountOnboarding(ctx context.Context, onboarding *types2.AccountOnboarding) error { +func (m *MockStore) SaveAccountOnboarding(ctx context.Context, onboarding *types3.AccountOnboarding) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveAccountOnboarding", ctx, onboarding) ret0, _ := ret[0].(error) @@ -2857,7 +3272,7 @@ func (mr *MockStoreMockRecorder) SaveAccountOnboarding(ctx, onboarding interface } // SaveAccountSettings mocks base method. -func (m *MockStore) SaveAccountSettings(ctx context.Context, accountID string, settings *types2.Settings) error { +func (m *MockStore) SaveAccountSettings(ctx context.Context, accountID string, settings *types3.Settings) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveAccountSettings", ctx, accountID, settings) ret0, _ := ret[0].(error) @@ -2870,8 +3285,78 @@ func (mr *MockStoreMockRecorder) SaveAccountSettings(ctx, accountID, settings in return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAccountSettings", reflect.TypeOf((*MockStore)(nil).SaveAccountSettings), ctx, accountID, settings) } +// SaveAgentNetworkBudgetRule mocks base method. +func (m *MockStore) SaveAgentNetworkBudgetRule(ctx context.Context, rule *types.AccountBudgetRule) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkBudgetRule", ctx, rule) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkBudgetRule indicates an expected call of SaveAgentNetworkBudgetRule. +func (mr *MockStoreMockRecorder) SaveAgentNetworkBudgetRule(ctx, rule interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkBudgetRule), ctx, rule) +} + +// SaveAgentNetworkGuardrail mocks base method. +func (m *MockStore) SaveAgentNetworkGuardrail(ctx context.Context, guardrail *types.Guardrail) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkGuardrail", ctx, guardrail) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkGuardrail indicates an expected call of SaveAgentNetworkGuardrail. +func (mr *MockStoreMockRecorder) SaveAgentNetworkGuardrail(ctx, guardrail interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkGuardrail), ctx, guardrail) +} + +// SaveAgentNetworkPolicy mocks base method. +func (m *MockStore) SaveAgentNetworkPolicy(ctx context.Context, policy *types.Policy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkPolicy", ctx, policy) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkPolicy indicates an expected call of SaveAgentNetworkPolicy. +func (mr *MockStoreMockRecorder) SaveAgentNetworkPolicy(ctx, policy interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkPolicy), ctx, policy) +} + +// SaveAgentNetworkProvider mocks base method. +func (m *MockStore) SaveAgentNetworkProvider(ctx context.Context, provider *types.Provider) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkProvider", ctx, provider) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkProvider indicates an expected call of SaveAgentNetworkProvider. +func (mr *MockStoreMockRecorder) SaveAgentNetworkProvider(ctx, provider interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkProvider), ctx, provider) +} + +// SaveAgentNetworkSettings mocks base method. +func (m *MockStore) SaveAgentNetworkSettings(ctx context.Context, settings *types.Settings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkSettings", ctx, settings) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkSettings indicates an expected call of SaveAgentNetworkSettings. +func (mr *MockStoreMockRecorder) SaveAgentNetworkSettings(ctx, settings interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkSettings), ctx, settings) +} + // SaveDNSSettings mocks base method. -func (m *MockStore) SaveDNSSettings(ctx context.Context, accountID string, settings *types2.DNSSettings) error { +func (m *MockStore) SaveDNSSettings(ctx context.Context, accountID string, settings *types3.DNSSettings) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveDNSSettings", ctx, accountID, settings) ret0, _ := ret[0].(error) @@ -2913,7 +3398,7 @@ func (mr *MockStoreMockRecorder) SaveNameServerGroup(ctx, nameServerGroup interf } // SaveNetwork mocks base method. -func (m *MockStore) SaveNetwork(ctx context.Context, network *types1.Network) error { +func (m *MockStore) SaveNetwork(ctx context.Context, network *types2.Network) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveNetwork", ctx, network) ret0, _ := ret[0].(error) @@ -2927,7 +3412,7 @@ func (mr *MockStoreMockRecorder) SaveNetwork(ctx, network interface{}) *gomock.C } // SaveNetworkResource mocks base method. -func (m *MockStore) SaveNetworkResource(ctx context.Context, resource *types.NetworkResource) error { +func (m *MockStore) SaveNetworkResource(ctx context.Context, resource *types0.NetworkResource) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveNetworkResource", ctx, resource) ret0, _ := ret[0].(error) @@ -2941,7 +3426,7 @@ func (mr *MockStoreMockRecorder) SaveNetworkResource(ctx, resource interface{}) } // SavePAT mocks base method. -func (m *MockStore) SavePAT(ctx context.Context, pat *types2.PersonalAccessToken) error { +func (m *MockStore) SavePAT(ctx context.Context, pat *types3.PersonalAccessToken) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SavePAT", ctx, pat) ret0, _ := ret[0].(error) @@ -2983,7 +3468,7 @@ func (mr *MockStoreMockRecorder) SavePeerStatus(ctx, accountID, peerID, status i } // SavePolicy mocks base method. -func (m *MockStore) SavePolicy(ctx context.Context, policy *types2.Policy) error { +func (m *MockStore) SavePolicy(ctx context.Context, policy *types3.Policy) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SavePolicy", ctx, policy) ret0, _ := ret[0].(error) @@ -3025,7 +3510,7 @@ func (mr *MockStoreMockRecorder) SaveProxy(ctx, proxy interface{}) *gomock.Call } // SaveProxyAccessToken mocks base method. -func (m *MockStore) SaveProxyAccessToken(ctx context.Context, token *types2.ProxyAccessToken) error { +func (m *MockStore) SaveProxyAccessToken(ctx context.Context, token *types3.ProxyAccessToken) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveProxyAccessToken", ctx, token) ret0, _ := ret[0].(error) @@ -3053,7 +3538,7 @@ func (mr *MockStoreMockRecorder) SaveRoute(ctx, route interface{}) *gomock.Call } // SaveSetupKey mocks base method. -func (m *MockStore) SaveSetupKey(ctx context.Context, setupKey *types2.SetupKey) error { +func (m *MockStore) SaveSetupKey(ctx context.Context, setupKey *types3.SetupKey) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveSetupKey", ctx, setupKey) ret0, _ := ret[0].(error) @@ -3067,7 +3552,7 @@ func (mr *MockStoreMockRecorder) SaveSetupKey(ctx, setupKey interface{}) *gomock } // SaveUser mocks base method. -func (m *MockStore) SaveUser(ctx context.Context, user *types2.User) error { +func (m *MockStore) SaveUser(ctx context.Context, user *types3.User) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveUser", ctx, user) ret0, _ := ret[0].(error) @@ -3081,7 +3566,7 @@ func (mr *MockStoreMockRecorder) SaveUser(ctx, user interface{}) *gomock.Call { } // SaveUserInvite mocks base method. -func (m *MockStore) SaveUserInvite(ctx context.Context, invite *types2.UserInviteRecord) error { +func (m *MockStore) SaveUserInvite(ctx context.Context, invite *types3.UserInviteRecord) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveUserInvite", ctx, invite) ret0, _ := ret[0].(error) @@ -3109,7 +3594,7 @@ func (mr *MockStoreMockRecorder) SaveUserLastLogin(ctx, accountID, userID, lastL } // SaveUsers mocks base method. -func (m *MockStore) SaveUsers(ctx context.Context, users []*types2.User) error { +func (m *MockStore) SaveUsers(ctx context.Context, users []*types3.User) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveUsers", ctx, users) ret0, _ := ret[0].(error) @@ -3206,7 +3691,7 @@ func (mr *MockStoreMockRecorder) UpdateDNSRecord(ctx, record interface{}) *gomoc } // UpdateGroup mocks base method. -func (m *MockStore) UpdateGroup(ctx context.Context, group *types2.Group) error { +func (m *MockStore) UpdateGroup(ctx context.Context, group *types3.Group) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateGroup", ctx, group) ret0, _ := ret[0].(error) @@ -3220,7 +3705,7 @@ func (mr *MockStoreMockRecorder) UpdateGroup(ctx, group interface{}) *gomock.Cal } // UpdateGroups mocks base method. -func (m *MockStore) UpdateGroups(ctx context.Context, accountID string, groups []*types2.Group) error { +func (m *MockStore) UpdateGroups(ctx context.Context, accountID string, groups []*types3.Group) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateGroups", ctx, accountID, groups) ret0, _ := ret[0].(error) @@ -3234,7 +3719,7 @@ func (mr *MockStoreMockRecorder) UpdateGroups(ctx, accountID, groups interface{} } // UpdateNetworkRouter mocks base method. -func (m *MockStore) UpdateNetworkRouter(ctx context.Context, router *types0.NetworkRouter) error { +func (m *MockStore) UpdateNetworkRouter(ctx context.Context, router *types1.NetworkRouter) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateNetworkRouter", ctx, router) ret0, _ := ret[0].(error) diff --git a/management/server/store/store_mock_agentnetwork.go b/management/server/store/store_mock_agentnetwork.go deleted file mode 100644 index 18adf20f0..000000000 --- a/management/server/store/store_mock_agentnetwork.go +++ /dev/null @@ -1,495 +0,0 @@ -package store - -import ( - context "context" - reflect "reflect" - time "time" - - gomock "github.com/golang/mock/gomock" - - agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" -) - -// GetAllAgentNetworkProviders mocks base method. -func (m *MockStore) GetAllAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Provider, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAllAgentNetworkProviders", ctx, lockStrength) - ret0, _ := ret[0].([]*agentNetworkTypes.Provider) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAllAgentNetworkProviders indicates an expected call of GetAllAgentNetworkProviders. -func (mr *MockStoreMockRecorder) GetAllAgentNetworkProviders(ctx, lockStrength interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkProviders), ctx, lockStrength) -} - -// GetAgentNetworkMetrics mocks base method. -func (m *MockStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetrics, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkMetrics", ctx) - ret0, _ := ret[0].(AgentNetworkMetrics) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkMetrics indicates an expected call of GetAgentNetworkMetrics. -func (mr *MockStoreMockRecorder) GetAgentNetworkMetrics(ctx interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkMetrics", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkMetrics), ctx) -} - -// GetAccountAgentNetworkProviders mocks base method. -func (m *MockStore) GetAccountAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Provider, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountAgentNetworkProviders", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*agentNetworkTypes.Provider) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAccountAgentNetworkProviders indicates an expected call of GetAccountAgentNetworkProviders. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkProviders(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkProviders), ctx, lockStrength, accountID) -} - -// GetAgentNetworkProviderByID mocks base method. -func (m *MockStore) GetAgentNetworkProviderByID(ctx context.Context, lockStrength LockingStrength, accountID, providerID string) (*agentNetworkTypes.Provider, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkProviderByID", ctx, lockStrength, accountID, providerID) - ret0, _ := ret[0].(*agentNetworkTypes.Provider) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkProviderByID indicates an expected call of GetAgentNetworkProviderByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkProviderByID(ctx, lockStrength, accountID, providerID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkProviderByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkProviderByID), ctx, lockStrength, accountID, providerID) -} - -// SaveAgentNetworkProvider mocks base method. -func (m *MockStore) SaveAgentNetworkProvider(ctx context.Context, provider *agentNetworkTypes.Provider) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveAgentNetworkProvider", ctx, provider) - ret0, _ := ret[0].(error) - return ret0 -} - -// SaveAgentNetworkProvider indicates an expected call of SaveAgentNetworkProvider. -func (mr *MockStoreMockRecorder) SaveAgentNetworkProvider(ctx, provider interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkProvider), ctx, provider) -} - -// DeleteAgentNetworkProvider mocks base method. -func (m *MockStore) DeleteAgentNetworkProvider(ctx context.Context, accountID, providerID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteAgentNetworkProvider", ctx, accountID, providerID) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteAgentNetworkProvider indicates an expected call of DeleteAgentNetworkProvider. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkProvider(ctx, accountID, providerID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkProvider), ctx, accountID, providerID) -} - -// GetAccountAgentNetworkPolicies mocks base method. -func (m *MockStore) GetAccountAgentNetworkPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Policy, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountAgentNetworkPolicies", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*agentNetworkTypes.Policy) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAccountAgentNetworkPolicies indicates an expected call of GetAccountAgentNetworkPolicies. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkPolicies(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkPolicies", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkPolicies), ctx, lockStrength, accountID) -} - -// GetAgentNetworkPolicyByID mocks base method. -func (m *MockStore) GetAgentNetworkPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*agentNetworkTypes.Policy, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkPolicyByID", ctx, lockStrength, accountID, policyID) - ret0, _ := ret[0].(*agentNetworkTypes.Policy) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkPolicyByID indicates an expected call of GetAgentNetworkPolicyByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkPolicyByID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkPolicyByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkPolicyByID), ctx, lockStrength, accountID, policyID) -} - -// SaveAgentNetworkPolicy mocks base method. -func (m *MockStore) SaveAgentNetworkPolicy(ctx context.Context, policy *agentNetworkTypes.Policy) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveAgentNetworkPolicy", ctx, policy) - ret0, _ := ret[0].(error) - return ret0 -} - -// SaveAgentNetworkPolicy indicates an expected call of SaveAgentNetworkPolicy. -func (mr *MockStoreMockRecorder) SaveAgentNetworkPolicy(ctx, policy interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkPolicy), ctx, policy) -} - -// DeleteAgentNetworkPolicy mocks base method. -func (m *MockStore) DeleteAgentNetworkPolicy(ctx context.Context, accountID, policyID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteAgentNetworkPolicy", ctx, accountID, policyID) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteAgentNetworkPolicy indicates an expected call of DeleteAgentNetworkPolicy. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkPolicy(ctx, accountID, policyID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkPolicy), ctx, accountID, policyID) -} - -// GetAccountAgentNetworkGuardrails mocks base method. -func (m *MockStore) GetAccountAgentNetworkGuardrails(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Guardrail, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountAgentNetworkGuardrails", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*agentNetworkTypes.Guardrail) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAccountAgentNetworkGuardrails indicates an expected call of GetAccountAgentNetworkGuardrails. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkGuardrails(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkGuardrails", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkGuardrails), ctx, lockStrength, accountID) -} - -// GetAgentNetworkGuardrailByID mocks base method. -func (m *MockStore) GetAgentNetworkGuardrailByID(ctx context.Context, lockStrength LockingStrength, accountID, guardrailID string) (*agentNetworkTypes.Guardrail, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkGuardrailByID", ctx, lockStrength, accountID, guardrailID) - ret0, _ := ret[0].(*agentNetworkTypes.Guardrail) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkGuardrailByID indicates an expected call of GetAgentNetworkGuardrailByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkGuardrailByID(ctx, lockStrength, accountID, guardrailID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkGuardrailByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkGuardrailByID), ctx, lockStrength, accountID, guardrailID) -} - -// SaveAgentNetworkGuardrail mocks base method. -func (m *MockStore) SaveAgentNetworkGuardrail(ctx context.Context, guardrail *agentNetworkTypes.Guardrail) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveAgentNetworkGuardrail", ctx, guardrail) - ret0, _ := ret[0].(error) - return ret0 -} - -// SaveAgentNetworkGuardrail indicates an expected call of SaveAgentNetworkGuardrail. -func (mr *MockStoreMockRecorder) SaveAgentNetworkGuardrail(ctx, guardrail interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkGuardrail), ctx, guardrail) -} - -// DeleteAgentNetworkGuardrail mocks base method. -func (m *MockStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteAgentNetworkGuardrail", ctx, accountID, guardrailID) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteAgentNetworkGuardrail indicates an expected call of DeleteAgentNetworkGuardrail. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkGuardrail(ctx, accountID, guardrailID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkGuardrail), ctx, accountID, guardrailID) -} - -// GetAgentNetworkSettings mocks base method. -func (m *MockStore) GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkSettings", ctx, lockStrength, accountID) - ret0, _ := ret[0].(*agentNetworkTypes.Settings) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkSettings indicates an expected call of GetAgentNetworkSettings. -func (mr *MockStoreMockRecorder) GetAgentNetworkSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettings), ctx, lockStrength, accountID) -} - -// GetAgentNetworkSettingsByCluster mocks base method. -func (m *MockStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkSettingsByCluster", ctx, lockStrength, cluster) - ret0, _ := ret[0].([]*agentNetworkTypes.Settings) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkSettingsByCluster indicates an expected call of GetAgentNetworkSettingsByCluster. -func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByCluster(ctx, lockStrength, cluster interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByCluster", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByCluster), ctx, lockStrength, cluster) -} - -// SaveAgentNetworkSettings mocks base method. -func (m *MockStore) SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveAgentNetworkSettings", ctx, settings) - ret0, _ := ret[0].(error) - return ret0 -} - -// SaveAgentNetworkSettings indicates an expected call of SaveAgentNetworkSettings. -func (mr *MockStoreMockRecorder) SaveAgentNetworkSettings(ctx, settings interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkSettings), ctx, settings) -} - -// IncrementAgentNetworkConsumption mocks base method. -func (m *MockStore) IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumption", ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) - ret0, _ := ret[0].(error) - return ret0 -} - -// IncrementAgentNetworkConsumption indicates an expected call of IncrementAgentNetworkConsumption. -func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumption), ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) -} - -// GetAgentNetworkConsumption mocks base method. -func (m *MockStore) GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkConsumption", ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) - ret0, _ := ret[0].(*agentNetworkTypes.Consumption) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkConsumption indicates an expected call of GetAgentNetworkConsumption. -func (mr *MockStoreMockRecorder) GetAgentNetworkConsumption(ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumption), ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) -} - -// GetAgentNetworkConsumptionBatch mocks base method. -func (m *MockStore) GetAgentNetworkConsumptionBatch(ctx context.Context, lockStrength LockingStrength, accountID string, keys []agentNetworkTypes.ConsumptionKey) (map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkConsumptionBatch", ctx, lockStrength, accountID, keys) - ret0, _ := ret[0].(map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkConsumptionBatch indicates an expected call of GetAgentNetworkConsumptionBatch. -func (mr *MockStoreMockRecorder) GetAgentNetworkConsumptionBatch(ctx, lockStrength, accountID, keys interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumptionBatch), ctx, lockStrength, accountID, keys) -} - -// IncrementAgentNetworkConsumptionBatch mocks base method. -func (m *MockStore) IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []agentNetworkTypes.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumptionBatch", ctx, accountID, keys, tokensIn, tokensOut, costUSD) - ret0, _ := ret[0].(error) - return ret0 -} - -// IncrementAgentNetworkConsumptionBatch indicates an expected call of IncrementAgentNetworkConsumptionBatch. -func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumptionBatch(ctx, accountID, keys, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumptionBatch), ctx, accountID, keys, tokensIn, tokensOut, costUSD) -} - -// ListAgentNetworkConsumption mocks base method. -func (m *MockStore) ListAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Consumption, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListAgentNetworkConsumption", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*agentNetworkTypes.Consumption) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListAgentNetworkConsumption indicates an expected call of ListAgentNetworkConsumption. -func (mr *MockStoreMockRecorder) ListAgentNetworkConsumption(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).ListAgentNetworkConsumption), ctx, lockStrength, accountID) -} - -// GetAccountAgentNetworkBudgetRules mocks base method. -func (m *MockStore) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.AccountBudgetRule, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountAgentNetworkBudgetRules", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*agentNetworkTypes.AccountBudgetRule) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAccountAgentNetworkBudgetRules indicates an expected call of GetAccountAgentNetworkBudgetRules. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkBudgetRules(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkBudgetRules", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkBudgetRules), ctx, lockStrength, accountID) -} - -// GetAgentNetworkBudgetRuleByID mocks base method. -func (m *MockStore) GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*agentNetworkTypes.AccountBudgetRule, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkBudgetRuleByID", ctx, lockStrength, accountID, ruleID) - ret0, _ := ret[0].(*agentNetworkTypes.AccountBudgetRule) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkBudgetRuleByID indicates an expected call of GetAgentNetworkBudgetRuleByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkBudgetRuleByID(ctx, lockStrength, accountID, ruleID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkBudgetRuleByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkBudgetRuleByID), ctx, lockStrength, accountID, ruleID) -} - -// SaveAgentNetworkBudgetRule mocks base method. -func (m *MockStore) SaveAgentNetworkBudgetRule(ctx context.Context, rule *agentNetworkTypes.AccountBudgetRule) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveAgentNetworkBudgetRule", ctx, rule) - ret0, _ := ret[0].(error) - return ret0 -} - -// SaveAgentNetworkBudgetRule indicates an expected call of SaveAgentNetworkBudgetRule. -func (mr *MockStoreMockRecorder) SaveAgentNetworkBudgetRule(ctx, rule interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkBudgetRule), ctx, rule) -} - -// DeleteAgentNetworkBudgetRule mocks base method. -func (m *MockStore) DeleteAgentNetworkBudgetRule(ctx context.Context, accountID, ruleID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteAgentNetworkBudgetRule", ctx, accountID, ruleID) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteAgentNetworkBudgetRule indicates an expected call of DeleteAgentNetworkBudgetRule. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkBudgetRule(ctx, accountID, ruleID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkBudgetRule), ctx, accountID, ruleID) -} - -// CreateAgentNetworkAccessLog mocks base method. -func (m *MockStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAgentNetworkAccessLog", ctx, entry, groups) - ret0, _ := ret[0].(error) - return ret0 -} - -// CreateAgentNetworkAccessLog indicates an expected call of CreateAgentNetworkAccessLog. -func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups) -} - -// CreateAgentNetworkUsage mocks base method. -func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAgentNetworkUsage", ctx, usage, groups) - ret0, _ := ret[0].(error) - return ret0 -} - -// CreateAgentNetworkUsage indicates an expected call of CreateAgentNetworkUsage. -func (mr *MockStoreMockRecorder) CreateAgentNetworkUsage(ctx, usage, groups interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkUsage", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkUsage), ctx, usage, groups) -} - -// GetAgentNetworkAccessLogs mocks base method. -func (m *MockStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogs", ctx, lockStrength, accountID, filter) - ret0, _ := ret[0].([]*agentNetworkTypes.AgentNetworkAccessLog) - ret1, _ := ret[1].(int64) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetAgentNetworkAccessLogs indicates an expected call of GetAgentNetworkAccessLogs. -func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogs(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogs), ctx, lockStrength, accountID, filter) -} - -// GetAgentNetworkAccessLogSessions mocks base method. -func (m *MockStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogSessions", ctx, lockStrength, accountID, filter) - ret0, _ := ret[0].([]*agentNetworkTypes.AgentNetworkAccessLogSession) - ret1, _ := ret[1].(int64) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetAgentNetworkAccessLogSessions indicates an expected call of GetAgentNetworkAccessLogSessions. -func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogSessions(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogSessions", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogSessions), ctx, lockStrength, accountID, filter) -} - -// GetAgentNetworkUsageRows mocks base method. -func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkUsageRows", ctx, lockStrength, accountID, filter) - ret0, _ := ret[0].([]*agentNetworkTypes.AgentNetworkUsage) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkUsageRows indicates an expected call of GetAgentNetworkUsageRows. -func (mr *MockStoreMockRecorder) GetAgentNetworkUsageRows(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkUsageRows", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkUsageRows), ctx, lockStrength, accountID, filter) -} - -// DeleteOldAgentNetworkAccessLogs mocks base method. -func (m *MockStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteOldAgentNetworkAccessLogs", ctx, accountID, olderThan) - ret0, _ := ret[0].(int64) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// DeleteOldAgentNetworkAccessLogs indicates an expected call of DeleteOldAgentNetworkAccessLogs. -func (mr *MockStoreMockRecorder) DeleteOldAgentNetworkAccessLogs(ctx, accountID, olderThan interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAgentNetworkAccessLogs), ctx, accountID, olderThan) -} - -// GetAllAgentNetworkSettings mocks base method. -func (m *MockStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAllAgentNetworkSettings", ctx, lockStrength) - ret0, _ := ret[0].([]*agentNetworkTypes.Settings) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAllAgentNetworkSettings indicates an expected call of GetAllAgentNetworkSettings. -func (mr *MockStoreMockRecorder) GetAllAgentNetworkSettings(ctx, lockStrength interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkSettings), ctx, lockStrength) -} diff --git a/management/server/telemetry/updatechannel_metrics.go b/management/server/telemetry/updatechannel_metrics.go index 2b280b352..ade46be59 100644 --- a/management/server/telemetry/updatechannel_metrics.go +++ b/management/server/telemetry/updatechannel_metrics.go @@ -10,19 +10,20 @@ import ( // UpdateChannelMetrics represents all metrics related to the UpdateChannel type UpdateChannelMetrics struct { - createChannelDurationMicro metric.Int64Histogram - closeChannelDurationMicro metric.Int64Histogram - closeChannelsDurationMicro metric.Int64Histogram - closeChannels metric.Int64Histogram - sendUpdateDurationMicro metric.Int64Histogram - getAllConnectedPeersDurationMicro metric.Int64Histogram - getAllConnectedPeers metric.Int64Histogram - hasChannelDurationMicro metric.Int64Histogram - calcPostureChecksDurationMicro metric.Int64Histogram - calcPeerNetworkMapDurationMs metric.Int64Histogram - mergeNetworkMapDurationMicro metric.Int64Histogram - toSyncResponseDurationMicro metric.Int64Histogram - ctx context.Context + createChannelDurationMicro metric.Int64Histogram + closeChannelDurationMicro metric.Int64Histogram + closeChannelsDurationMicro metric.Int64Histogram + closeChannels metric.Int64Histogram + sendUpdateDurationMicro metric.Int64Histogram + getAllConnectedPeersDurationMicro metric.Int64Histogram + getAllConnectedPeers metric.Int64Histogram + hasChannelDurationMicro metric.Int64Histogram + calcPostureChecksDurationMicro metric.Int64Histogram + calcPeerNetworkMapDurationMs metric.Int64Histogram + mergeNetworkMapDurationMicro metric.Int64Histogram + toSyncResponseDurationMicro metric.Int64Histogram + toComponentSyncResponseDurationMicro metric.Int64Histogram + ctx context.Context } // NewUpdateChannelMetrics creates an instance of UpdateChannel @@ -125,20 +126,29 @@ func NewUpdateChannelMetrics(ctx context.Context, meter metric.Meter) (*UpdateCh return nil, err } + toComponentSyncResponseDurationMicro, err := meter.Int64Histogram("management.updatechannel.tocomponentsyncresponse.duration.micro", + metric.WithUnit("microseconds"), + metric.WithDescription("Duration of how long it takes to convert components to component sync response"), + ) + if err != nil { + return nil, err + } + return &UpdateChannelMetrics{ - createChannelDurationMicro: createChannelDurationMicro, - closeChannelDurationMicro: closeChannelDurationMicro, - closeChannelsDurationMicro: closeChannelsDurationMicro, - closeChannels: closeChannels, - sendUpdateDurationMicro: sendUpdateDurationMicro, - getAllConnectedPeersDurationMicro: getAllConnectedPeersDurationMicro, - getAllConnectedPeers: getAllConnectedPeers, - hasChannelDurationMicro: hasChannelDurationMicro, - calcPostureChecksDurationMicro: calcPostureChecksDurationMicro, - calcPeerNetworkMapDurationMs: calcPeerNetworkMapDurationMs, - mergeNetworkMapDurationMicro: mergeNetworkMapDurationMicro, - toSyncResponseDurationMicro: toSyncResponseDurationMicro, - ctx: ctx, + createChannelDurationMicro: createChannelDurationMicro, + closeChannelDurationMicro: closeChannelDurationMicro, + closeChannelsDurationMicro: closeChannelsDurationMicro, + closeChannels: closeChannels, + sendUpdateDurationMicro: sendUpdateDurationMicro, + getAllConnectedPeersDurationMicro: getAllConnectedPeersDurationMicro, + getAllConnectedPeers: getAllConnectedPeers, + hasChannelDurationMicro: hasChannelDurationMicro, + calcPostureChecksDurationMicro: calcPostureChecksDurationMicro, + calcPeerNetworkMapDurationMs: calcPeerNetworkMapDurationMs, + mergeNetworkMapDurationMicro: mergeNetworkMapDurationMicro, + toSyncResponseDurationMicro: toSyncResponseDurationMicro, + toComponentSyncResponseDurationMicro: toComponentSyncResponseDurationMicro, + ctx: ctx, }, nil } @@ -193,3 +203,7 @@ func (metrics *UpdateChannelMetrics) CountMergeNetworkMapDuration(duration time. func (metrics *UpdateChannelMetrics) CountToSyncResponseDuration(duration time.Duration) { metrics.toSyncResponseDurationMicro.Record(metrics.ctx, duration.Microseconds()) } + +func (metrics *UpdateChannelMetrics) CountToComponentSyncResponseDuration(duration time.Duration) { + metrics.toComponentSyncResponseDurationMicro.Record(metrics.ctx, duration.Microseconds()) +} diff --git a/management/server/types/account.go b/management/server/types/account.go index 6be865a43..05033ae15 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -29,7 +29,6 @@ import ( "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/status" - "github.com/netbirdio/netbird/version" ) const ( @@ -42,27 +41,8 @@ const ( PublicCategory = "public" PrivateCategory = "private" UnknownCategory = "unknown" - - // firewallRuleMinPortRangesVer defines the minimum peer version that supports port range rules. - firewallRuleMinPortRangesVer = "0.48.0" - // firewallRuleMinNativeSSHVer defines the minimum peer version that supports native SSH features in the firewall rules. - firewallRuleMinNativeSSHVer = "0.60.0" - - // nativeSSHPortString defines the default port number as a string used for native SSH connections; this port is used by clients when hijacking ssh connections. - nativeSSHPortString = "22022" - nativeSSHPortNumber = 22022 - // defaultSSHPortString defines the standard SSH port number as a string, commonly used for default SSH connections. - defaultSSHPortString = "22" - defaultSSHPortNumber = 22 ) -type supportedFeatures struct { - nativeSSH bool - portRanges bool -} - -type LookupMap map[string]struct{} - // AccountMeta is a struct that contains a stripped down version of the Account object. // It doesn't carry any peers, groups, policies, or routes, etc. Just some metadata (e.g. ID, created by, created at, etc). type AccountMeta struct { @@ -1071,7 +1051,7 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P default: authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs() } - } else if peerInDestinations && policyRuleImpliesLegacySSH(rule) && peer.SSHEnabled { + } else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && peer.SSHEnabled { sshEnabled = true authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs() } @@ -1137,15 +1117,15 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) } else { - rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...) + rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...) } - rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{ - direction: direction, - dirStr: strconv.Itoa(direction), - protocolStr: string(protocol), - actionStr: string(rule.Action), - portsJoined: strings.Join(rule.Ports, ","), + rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{ + Direction: direction, + DirStr: strconv.Itoa(direction), + ProtocolStr: string(protocol), + ActionStr: string(rule.Action), + PortsJoined: strings.Join(rule.Ports, ","), }) } }, func() ([]*nbpeer.Peer, []*FirewallRule) { @@ -1153,10 +1133,6 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer } } -func policyRuleImpliesLegacySSH(rule *PolicyRule) bool { - return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges))) -} - // PeerSSHEnabledFromPolicies is the network-map-free equivalent of the sshEnabled // determination in GetPeerConnectionResources / CalculateNetworkMapFromComponents. func PeerSSHEnabledFromPolicies(policies []*Policy, peerID string, peerGroupIDs map[string]struct{}, peerSSHEnabled bool) bool { @@ -1171,7 +1147,7 @@ func PeerSSHEnabledFromPolicies(policies []*Policy, peerID string, peerGroupIDs } isSSHRule := rule.Protocol == PolicyRuleProtocolNetbirdSSH || - (policyRuleImpliesLegacySSH(rule) && peerSSHEnabled) + (PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled) if !isSSHRule { continue } @@ -1198,24 +1174,6 @@ func ruleHasDestination(rule *PolicyRule, peerID string, peerGroupIDs map[string return false } -func portRangeIncludesSSH(portRanges []RulePortRange) bool { - for _, pr := range portRanges { - if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) { - return true - } - } - return false -} - -func portsIncludesSSH(ports []string) bool { - for _, port := range ports { - if port == defaultSSHPortString || port == nativeSSHPortString { - return true - } - } - return false -} - // getAllPeersFromGroups for given peer ID and list of groups // // Returns a list of peers from specified groups that pass specified posture checks @@ -1315,7 +1273,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli } rulePeers := a.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers, validatedPeersMap) - rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) + rules := GenerateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) fwRules = append(fwRules, rules...) } } @@ -1808,96 +1766,6 @@ func (a *Account) createProxyPolicy(svc *service.Service, target *service.Target } } -// expandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules -func expandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { - features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) - - var expanded []*FirewallRule - - for _, port := range rule.Ports { - fr := base - fr.Port = port - expanded = append(expanded, &fr) - } - - for _, portRange := range rule.PortRanges { - // prefer PolicyRule.Ports - if len(rule.Ports) > 0 { - break - } - fr := base - - if features.portRanges { - fr.PortRange = portRange - } else { - // Peer doesn't support port ranges, only allow single-port ranges - if portRange.Start != portRange.End { - continue - } - fr.Port = strconv.FormatUint(uint64(portRange.Start), 10) - } - expanded = append(expanded, &fr) - } - - if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == PolicyRuleProtocolNetbirdSSH { - expanded = addNativeSSHRule(base, expanded) - } - - return expanded -} - -// addNativeSSHRule adds a native SSH rule (port 22022) to the expanded rules if the base rule has port 22 configured. -func addNativeSSHRule(base FirewallRule, expanded []*FirewallRule) []*FirewallRule { - shouldAdd := false - for _, fr := range expanded { - if isPortInRule(nativeSSHPortString, 22022, fr) { - return expanded - } - if isPortInRule(defaultSSHPortString, 22, fr) { - shouldAdd = true - } - } - if !shouldAdd { - return expanded - } - - fr := base - fr.Port = nativeSSHPortString - return append(expanded, &fr) -} - -func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool { - return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End) -} - -// shouldCheckRulesForNativeSSH determines whether specific policy rules should be checked for native SSH support. -// While users can add the nativeSSHPortString, we look for cases when they used port 22 and based on SSH enabled -// in both management and client, we indicate to add the native port. -func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *nbpeer.Peer) bool { - return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP -} - -// peerSupportedFirewallFeatures checks if the peer version supports port ranges. -func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { - if version.IsDevelopmentVersion(peerVer) { - return supportedFeatures{true, true} - } - - var features supportedFeatures - - meetMinVer, err := posture.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) - features.nativeSSH = err == nil && meetMinVer - - if features.nativeSSH { - features.portRanges = true - } else { - meetMinVer, err = posture.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) - features.portRanges = err == nil && meetMinVer - } - - return features -} - // filterZoneRecordsForPeers filters DNS records to only include peers to connect. // AAAA records are excluded when the requesting peer lacks IPv6 capability. func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord { diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go index a42028351..0205a1f55 100644 --- a/management/server/types/account_components.go +++ b/management/server/types/account_components.go @@ -16,6 +16,39 @@ import ( "github.com/netbirdio/netbird/route" ) +// GetPeerNetworkMapResult dispatches to either the legacy-NetworkMap path or +// the components path based on the peer's capability and the kill switch. +// Capable peers (PeerCapabilityComponentNetworkMap) get the raw components +// shape — the server skips Calculate() entirely for them, saving CPU +// proportional to the number of capable peers in the account. Legacy peers +// (or any peer when componentsDisabled is true) get the fully-expanded +// NetworkMap as before. +func (a *Account) GetPeerNetworkMapResult( + ctx context.Context, + peerID string, + componentsDisabled bool, + peersCustomZone nbdns.CustomZone, + accountZones []*zones.Zone, + validatedPeersMap map[string]struct{}, + resourcePolicies map[string][]*Policy, + routers map[string]map[string]*routerTypes.NetworkRouter, + metrics *telemetry.AccountManagerMetrics, + groupIDToUserIDs map[string][]string, +) PeerNetworkMapResult { + peer := a.Peers[peerID] + if !componentsDisabled && peer != nil && peer.SupportsComponentNetworkMap() { + components := a.GetPeerNetworkMapComponents( + ctx, peerID, peersCustomZone, accountZones, validatedPeersMap, resourcePolicies, routers, groupIDToUserIDs, + ) + return PeerNetworkMapResult{Components: components} + } + return PeerNetworkMapResult{ + NetworkMap: a.GetPeerNetworkMapFromComponents( + ctx, peerID, peersCustomZone, accountZones, validatedPeersMap, resourcePolicies, routers, metrics, groupIDToUserIDs, + ), + } +} + func (a *Account) GetPeerNetworkMapFromComponents( ctx context.Context, peerID string, @@ -40,8 +73,8 @@ func (a *Account) GetPeerNetworkMapFromComponents( groupIDToUserIDs, ) - if components == nil { - return &NetworkMap{Network: a.Network.Copy()} + if components.IsEmpty() { + return &NetworkMap{Network: components.Network} } nm := CalculateNetworkMapFromComponents(ctx, components) @@ -71,26 +104,54 @@ func (a *Account) GetPeerNetworkMapComponents( routers map[string]map[string]*routerTypes.NetworkRouter, groupIDToUserIDs map[string][]string, ) *NetworkMapComponents { - peer := a.Peers[peerID] + // this can never happen, things are very wrong if it did + // TODO (dmitri) maybe consider using invariants? if peer == nil { - return nil + log.WithField("peer id", peerID).Error("NetworkMapComponents are computed for a peer missing from the account") + return EmptyNetworkMapComponents(&NetworkMapComponents{ + PeerID: peerID, + Network: a.Network.Copy(), + // must include the target peer as it's required on the client + Peers: map[string]*nbpeer.Peer{peerID: peer}, + }) } if _, ok := validatedPeersMap[peerID]; !ok { - return nil + // Mirror legacy graceful-degrade: GetPeerNetworkMapFromComponents + // returns &NetworkMap{Network: a.Network.Copy()} when components is + // nil. Match that floor so the receiving client always sees the + // account Network identifier, not a fully-empty envelope. + return EmptyNetworkMapComponents(&NetworkMapComponents{ + PeerID: peerID, + Network: a.Network.Copy(), + // must include the target peer as it's required on the client + Peers: map[string]*nbpeer.Peer{peerID: peer}, + }) } components := &NetworkMapComponents{ - PeerID: peerID, - Network: a.Network.Copy(), - NameServerGroups: make([]*nbdns.NameServerGroup, 0), - CustomZoneDomain: peersCustomZone.Domain, - ResourcePoliciesMap: make(map[string][]*Policy), - RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), - NetworkResources: make([]*resourceTypes.NetworkResource, 0), - PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), - RouterPeers: make(map[string]*nbpeer.Peer), + PeerID: peerID, + Network: a.Network.Copy(), + NameServerGroups: make([]*nbdns.NameServerGroup, 0), + CustomZoneDomain: peersCustomZone.Domain, + ResourcePoliciesMap: make(map[string][]*Policy), + RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), + NetworkResources: make([]*resourceTypes.NetworkResource, 0), + PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), + RouterPeers: make(map[string]*nbpeer.Peer), + NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), + PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), + } + for _, n := range a.Networks { + if n != nil { + components.NetworkXIDToPublicID[n.ID] = n.PublicID + } + } + for _, pc := range a.PostureChecks { + if pc != nil { + components.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID + } } components.AccountSettings = &AccountSettingsInfo{ @@ -102,6 +163,7 @@ func (a *Account) GetPeerNetworkMapComponents( components.DNSSettings = &a.DNSSettings + // relevantPeers always contains the target peer (peerID) relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := a.getPeersGroupsPoliciesRoutes(ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers) if len(sshReqs.neededGroupIDs) > 0 { @@ -209,21 +271,26 @@ func (a *Account) GetPeerNetworkMapComponents( components.ResourcePoliciesMap[resource.ID] = policies } - components.RoutersMap[resource.NetworkID] = networkRoutingPeers - for peerIDKey := range networkRoutingPeers { - if p := a.Peers[peerIDKey]; p != nil { - if _, exists := components.RouterPeers[peerIDKey]; !exists { - components.RouterPeers[peerIDKey] = p - } - if _, exists := components.Peers[peerIDKey]; !exists { - if _, validated := validatedPeersMap[peerIDKey]; validated { - components.Peers[peerIDKey] = p + // Only expose router peers and the per-network routers_map when this + // target peer actually has access to the resource (either as a router + // itself or via a policy that includes it as a source). Without this + // gate, every peer's envelope was leaking router peers of every + // network in the account — accounts with many tenants/networks + // shipped tens of unrelated peers in `peers[]` and `routers_map`. + if addSourcePeers { + components.RoutersMap[resource.NetworkID] = networkRoutingPeers + for peerIDKey := range networkRoutingPeers { + if p := a.Peers[peerIDKey]; p != nil { + if _, exists := components.RouterPeers[peerIDKey]; !exists { + components.RouterPeers[peerIDKey] = p + } + if _, exists := components.Peers[peerIDKey]; !exists { + if _, validated := validatedPeersMap[peerIDKey]; validated { + components.Peers[peerIDKey] = p + } } } } - } - - if addSourcePeers { components.NetworkResources = append(components.NetworkResources, resource) } } @@ -254,18 +321,44 @@ func (a *Account) getPeersGroupsPoliciesRoutes( relevantPeerIDs[peerID] = a.GetPeer(peerID) + peerGroupSet := make(map[string]struct{}, 8) for groupID, group := range a.Groups { if slices.Contains(group.Peers, peerID) { relevantGroupIDs[groupID] = a.GetGroup(groupID) + peerGroupSet[groupID] = struct{}{} } } routeAccessControlGroups := make(map[string]struct{}) for _, r := range a.Routes { - for _, groupID := range r.Groups { + if r == nil { + continue + } + relevant := r.Peer == peerID + if !relevant { + for _, groupID := range r.PeerGroups { + if _, ok := peerGroupSet[groupID]; ok { + relevant = true + break + } + } + } + if !relevant && r.Enabled { + for _, groupID := range r.Groups { + if _, ok := peerGroupSet[groupID]; ok { + relevant = true + break + } + } + } + if !relevant { + continue + } + + for _, groupID := range r.PeerGroups { relevantGroupIDs[groupID] = a.GetGroup(groupID) } - for _, groupID := range r.PeerGroups { + for _, groupID := range r.Groups { relevantGroupIDs[groupID] = a.GetGroup(groupID) } if r.Enabled { @@ -274,6 +367,44 @@ func (a *Account) getPeersGroupsPoliciesRoutes( routeAccessControlGroups[groupID] = struct{}{} } } + + // Include route advertisers in relevantPeerIDs. The envelope + // encoder writes route.peer_index by looking up r.Peer in the + // shipped peers list; if the advertiser is policy-isolated from + // the target peer (no rule edge between them), it would otherwise + // be omitted and the decoder would fail to resolve r.Peer, leaving + // the client without a WG tunnel target for this route. Legacy + // NetworkMap.Routes shipped the WG public key inline, so the + // equivalence path doesn't surface this — but the dependency is + // real once a client actually tries to use the route. + // Gate by validatedPeersMap so non-validated advertisers stay out + // (matches the network-resource router behaviour at the bottom of + // this loop, and the legacy invariant that only validated peers + // reach a client's view). + if r.Peer != "" { + if _, ok := validatedPeersMap[r.Peer]; ok { + if p := a.GetPeer(r.Peer); p != nil { + relevantPeerIDs[r.Peer] = p + } + } + } + for _, groupID := range r.PeerGroups { + g := a.GetGroup(groupID) + if g == nil { + continue + } + for _, pid := range g.Peers { + if _, exists := relevantPeerIDs[pid]; exists { + continue + } + if _, ok := validatedPeersMap[pid]; !ok { + continue + } + if p := a.GetPeer(pid); p != nil { + relevantPeerIDs[pid] = p + } + } + } relevantRoutes = append(relevantRoutes, r) } @@ -353,7 +484,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes( default: sshReqs.needAllowedUserIDs = true } - } else if policyRuleImpliesLegacySSH(rule) && peerSSHEnabled { + } else if PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled { sshReqs.needAllowedUserIDs = true } } @@ -486,6 +617,13 @@ func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChe return dest } +// filterGroupPeers trims each group's Peers slice to only those peers that +// also appear in `peers`. Groups whose filtered list is empty are NOT +// deleted from the map — they're kept so the components wire encoder can +// still resolve seq references from routes/policies/access-control groups +// that name them. Calculate() tolerates groups with empty Peers (the inner +// loops simply iterate zero times), so retaining them is behaviourally a +// no-op for the legacy path that consumes the same NetworkMapComponents. func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) { for groupID, groupInfo := range *groups { filteredPeers := make([]string, 0, len(groupInfo.Peers)) @@ -495,9 +633,7 @@ func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) } } - if len(filteredPeers) == 0 { - delete(*groups, groupID) - } else if len(filteredPeers) != len(groupInfo.Peers) { + if len(filteredPeers) != len(groupInfo.Peers) { ng := groupInfo.Copy() ng.Peers = filteredPeers (*groups)[groupID] = ng diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index d8e2e1f8c..e5b5708fa 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -666,7 +666,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expandPortsAndRanges(tt.base, tt.rule, tt.peer) + result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer) var ports []string for _, fr := range result { diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go new file mode 100644 index 000000000..f5837a343 --- /dev/null +++ b/management/server/types/aliases.go @@ -0,0 +1,145 @@ +package types + +import ( + "context" + "math/rand" + "net" + "net/netip" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + nbroute "github.com/netbirdio/netbird/route" + sharedtypes "github.com/netbirdio/netbird/shared/management/types" +) + +// Type aliases for types relocated to shared/management/types so that the +// client-side compute path can depend on them + +type DNSSettings = sharedtypes.DNSSettings + +type FirewallRule = sharedtypes.FirewallRule + +type Group = sharedtypes.Group +type GroupPeer = sharedtypes.GroupPeer + +type Network = sharedtypes.Network +type NetworkMap = sharedtypes.NetworkMap +type ForwardingRule = sharedtypes.ForwardingRule + +type Policy = sharedtypes.Policy +type PolicyUpdateOperation = sharedtypes.PolicyUpdateOperation + +type PolicyRule = sharedtypes.PolicyRule +type PolicyUpdateOperationType = sharedtypes.PolicyUpdateOperationType +type PolicyTrafficActionType = sharedtypes.PolicyTrafficActionType +type PolicyRuleProtocolType = sharedtypes.PolicyRuleProtocolType +type PolicyRuleDirection = sharedtypes.PolicyRuleDirection +type RulePortRange = sharedtypes.RulePortRange + +type Resource = sharedtypes.Resource +type ResourceType = sharedtypes.ResourceType + +type RouteFirewallRule = sharedtypes.RouteFirewallRule + +type NetworkMapComponents = sharedtypes.NetworkMapComponents + +var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents + +type AccountSettingsInfo = sharedtypes.AccountSettingsInfo + +type GroupCompact = sharedtypes.GroupCompact +type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact + +type LookupMap = sharedtypes.LookupMap +type FirewallRuleContext = sharedtypes.FirewallRuleContext + +const ( + GroupIssuedAPI = sharedtypes.GroupIssuedAPI + GroupIssuedJWT = sharedtypes.GroupIssuedJWT + GroupIssuedIntegration = sharedtypes.GroupIssuedIntegration + GroupAllName = sharedtypes.GroupAllName +) + +// Function forwarders preserve types.X(...) call sites that previously +// resolved to package-local funcs. Plain forwarders (not var aliases) keep +// the symbol immutable and allow the inliner to flatten the call. + +func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { + return sharedtypes.PolicyRuleImpliesLegacySSH(rule) +} + +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { + return sharedtypes.ExpandPortsAndRanges(base, rule, peer) +} + +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { + return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc) +} + +func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap { + return sharedtypes.CalculateNetworkMapFromComponents(ctx, components) +} + +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { + return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6) +} + +func AllocateIPv6Subnet(r *rand.Rand) net.IPNet { + return sharedtypes.AllocateIPv6Subnet(r) +} + +func NewNetwork() *Network { + return sharedtypes.NewNetwork() +} + +func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) { + return sharedtypes.AllocatePeerIP(prefix, takenIps) +} + +func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) { + return sharedtypes.AllocateRandomPeerIP(prefix) +} + +func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) { + return sharedtypes.AllocateRandomPeerIPv6(prefix) +} + +func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) { + return sharedtypes.ParseRuleString(rule) +} + +const ( + FirewallRuleDirectionIN = sharedtypes.FirewallRuleDirectionIN + FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT +) + +const ( + ResourceTypePeer = sharedtypes.ResourceTypePeer + ResourceTypeDomain = sharedtypes.ResourceTypeDomain + ResourceTypeHost = sharedtypes.ResourceTypeHost + ResourceTypeSubnet = sharedtypes.ResourceTypeSubnet +) + +const ( + PolicyTrafficActionAccept = sharedtypes.PolicyTrafficActionAccept + PolicyTrafficActionDrop = sharedtypes.PolicyTrafficActionDrop +) + +const ( + PolicyRuleProtocolALL = sharedtypes.PolicyRuleProtocolALL + PolicyRuleProtocolTCP = sharedtypes.PolicyRuleProtocolTCP + PolicyRuleProtocolUDP = sharedtypes.PolicyRuleProtocolUDP + PolicyRuleProtocolICMP = sharedtypes.PolicyRuleProtocolICMP + PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH +) + +const ( + PolicyRuleFlowDirect = sharedtypes.PolicyRuleFlowDirect + PolicyRuleFlowBidirect = sharedtypes.PolicyRuleFlowBidirect +) + +const ( + DefaultRuleName = sharedtypes.DefaultRuleName + DefaultRuleDescription = sharedtypes.DefaultRuleDescription + DefaultPolicyName = sharedtypes.DefaultPolicyName + DefaultPolicyDescription = sharedtypes.DefaultPolicyDescription +) diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go index 1e3035300..825d51d4e 100644 --- a/management/server/types/networkmap_components_correctness_test.go +++ b/management/server/types/networkmap_components_correctness_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/rs/xid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -88,13 +89,13 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( for i := start; i < end; i++ { groupPeers = append(groupPeers, fmt.Sprintf("peer-%d", i)) } - groups[groupID] = &types.Group{ID: groupID, Name: fmt.Sprintf("Group %d", g), Peers: groupPeers} + groups[groupID] = &types.Group{ID: groupID, PublicID: xid.New().String(), Name: fmt.Sprintf("Group %d", g), Peers: groupPeers} } policies := make([]*types.Policy, 0, numGroups+2) if withDefaultPolicy { policies = append(policies, &types.Policy{ - ID: "policy-all", Name: "Default-Allow", Enabled: true, + ID: "policy-all", PublicID: xid.New().String(), Name: "Default-Allow", Enabled: true, Rules: []*types.PolicyRule{{ ID: "rule-all", Name: "Allow All", Enabled: true, Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, Bidirectional: true, @@ -107,7 +108,7 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( groupID := fmt.Sprintf("group-%d", g) dstGroup := fmt.Sprintf("group-%d", (g+1)%numGroups) policies = append(policies, &types.Policy{ - ID: fmt.Sprintf("policy-%d", g), Name: fmt.Sprintf("Policy %d", g), Enabled: true, + ID: fmt.Sprintf("policy-%d", g), PublicID: xid.New().String(), Name: fmt.Sprintf("Policy %d", g), Enabled: true, Rules: []*types.PolicyRule{{ ID: fmt.Sprintf("rule-%d", g), Name: fmt.Sprintf("Rule %d", g), Enabled: true, Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, @@ -120,7 +121,7 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( if numGroups >= 2 { policies = append(policies, &types.Policy{ - ID: "policy-drop", Name: "Drop DB traffic", Enabled: true, + ID: "policy-drop", PublicID: xid.New().String(), Name: "Drop DB traffic", Enabled: true, Rules: []*types.PolicyRule{{ ID: "rule-drop", Name: "Drop DB", Enabled: true, Action: types.PolicyTrafficActionDrop, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"5432"}, Bidirectional: true, @@ -144,6 +145,7 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( groupID := fmt.Sprintf("group-%d", r%numGroups) routes[routeID] = &route.Route{ ID: routeID, + PublicID: xid.New().String(), Network: netip.MustParsePrefix(fmt.Sprintf("10.%d.0.0/16", r)), Peer: peers[routePeerID].Key, PeerID: routePeerID, @@ -178,18 +180,18 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( } routerPeerID := fmt.Sprintf("peer-%d", routerPeerIdx) - networksList = append(networksList, &networkTypes.Network{ID: netID, Name: fmt.Sprintf("Network %d", nr), AccountID: "test-account"}) + networksList = append(networksList, &networkTypes.Network{ID: netID, PublicID: xid.New().String(), Name: fmt.Sprintf("Network %d", nr), AccountID: "test-account"}) networkResources = append(networkResources, &resourceTypes.NetworkResource{ - ID: resID, NetworkID: netID, AccountID: "test-account", Enabled: true, + ID: resID, PublicID: xid.New().String(), NetworkID: netID, AccountID: "test-account", Enabled: true, Address: fmt.Sprintf("svc-%d.netbird.cloud", nr), }) networkRouters = append(networkRouters, &routerTypes.NetworkRouter{ - ID: fmt.Sprintf("router-%d", nr), NetworkID: netID, Peer: routerPeerID, + ID: fmt.Sprintf("router-%d", nr), PublicID: xid.New().String(), NetworkID: netID, Peer: routerPeerID, Enabled: true, AccountID: "test-account", }) policies = append(policies, &types.Policy{ - ID: fmt.Sprintf("policy-res-%d", nr), Name: fmt.Sprintf("Resource Policy %d", nr), Enabled: true, + ID: fmt.Sprintf("policy-res-%d", nr), PublicID: xid.New().String(), Name: fmt.Sprintf("Resource Policy %d", nr), Enabled: true, SourcePostureChecks: []string{"posture-check-ver"}, Rules: []*types.PolicyRule{{ ID: fmt.Sprintf("rule-res-%d", nr), Name: fmt.Sprintf("Allow Resource %d", nr), Enabled: true, @@ -215,12 +217,12 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( DNSSettings: types.DNSSettings{DisabledManagementGroups: []string{}}, NameServerGroups: map[string]*nbdns.NameServerGroup{ "ns-group-main": { - ID: "ns-group-main", Name: "Main NS", Enabled: true, Groups: []string{"group-all"}, + ID: "ns-group-main", PublicID: xid.New().String(), Name: "Main NS", Enabled: true, Groups: []string{"group-all"}, NameServers: []nbdns.NameServer{{IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53}}, }, }, PostureChecks: []*posture.Checks{ - {ID: "posture-check-ver", Name: "Check version", Checks: posture.ChecksDefinition{ + {ID: "posture-check-ver", PublicID: xid.New().String(), Name: "Check version", Checks: posture.ChecksDefinition{ NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.26.0"}, }}, }, diff --git a/management/server/types/networkmap_wire_benchmark_test.go b/management/server/types/networkmap_wire_benchmark_test.go new file mode 100644 index 000000000..ee9839a3f --- /dev/null +++ b/management/server/types/networkmap_wire_benchmark_test.go @@ -0,0 +1,163 @@ +package types_test + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "testing" + + goproto "google.golang.org/protobuf/proto" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/types" +) + +// wireBenchScales — trimmed scale set for wire-size measurements. Encoding +// and marshalling are linear, so the largest extremes don't add signal. +var wireBenchScales = []benchmarkScale{ + {"100peers_5groups", 100, 5}, + {"500peers_20groups", 500, 20}, + {"1000peers_50groups", 1000, 50}, + {"5000peers_100groups", 5000, 100}, +} + +// assignValidWgKeys overwrites every peer's Key with a valid base64-encoded +// 32-byte string. The default scalableTestAccount uses unparsable strings +// like "key-peer-0", which makes the components encoder emit a nil WgPubKey +// and the legacy encoder ship 10-char placeholders — both shrink the wire +// size in unrealistic ways. Production peers always have valid 44-char base64 +// keys, so any benchmark/breakdown that wants honest numbers must call this. +func assignValidWgKeys(account *types.Account) { + for _, p := range account.Peers { + var raw [32]byte + _, _ = rand.Read(raw[:]) + p.Key = base64.StdEncoding.EncodeToString(raw[:]) + } +} + +// BenchmarkNetworkMapWireEncode reports per-call ns and the marshaled wire +// size for both encoding paths. Run with: +// +// go test -run=^$ -bench=BenchmarkNetworkMapWireEncode -benchmem ./management/server/types/ +func BenchmarkNetworkMapWireEncode(b *testing.B) { + skipCIBenchmark(b) + + for _, scale := range wireBenchScales { + account, validatedPeers := scalableTestAccount(scale.peers, scale.groups) + // populateAccountSeqIDs(account) + assignValidWgKeys(account) + + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + peerID := "peer-0" + peer := account.Peers[peerID] + + networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + components := account.GetPeerNetworkMapComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, groupIDToUserIDs) + + dnsCache := &cache.DNSConfigCache{} + settings := &types.Settings{} + + // Pre-encode once so the size metric is identical for every run inside + // the same scale; the b.Loop call only re-runs encode + Marshal. + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap) + if err != nil { + b.Fatalf("marshal legacy networkmap: %v", err) + } + + envelopeInput := mgmtgrpc.ComponentsEnvelopeInput{ + Components: components, + PeerConfig: legacyResp.NetworkMap.PeerConfig, + DNSDomain: "netbird.cloud", + } + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(envelopeInput) + envelopeBytes, err := goproto.Marshal(envelope) + if err != nil { + b.Fatalf("marshal envelope: %v", err) + } + + b.Run(fmt.Sprintf("legacy/%s", scale.name), func(b *testing.B) { + b.ReportAllocs() + b.ReportMetric(float64(len(legacyBytes)), "bytes/msg") + b.ResetTimer() + for range b.N { + resp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + if _, err := goproto.Marshal(resp.NetworkMap); err != nil { + b.Fatal(err) + } + } + }) + + b.Run(fmt.Sprintf("components/%s", scale.name), func(b *testing.B) { + b.ReportAllocs() + b.ReportMetric(float64(len(envelopeBytes)), "bytes/msg") + b.ResetTimer() + for range b.N { + env := mgmtgrpc.EncodeNetworkMapEnvelope(envelopeInput) + if _, err := goproto.Marshal(env); err != nil { + b.Fatal(err) + } + } + }) + } +} + +// BenchmarkNetworkMapWireSize is a fast snapshot of the wire size by scale +// without a tight encode loop. Run with -bench to see one ns/op + bytes per +// scale (treat the timing as informational; the sample is one Marshal per +// scale, not the full b.N loop). +func BenchmarkNetworkMapWireSize(b *testing.B) { + skipCIBenchmark(b) + + for _, scale := range wireBenchScales { + account, validatedPeers := scalableTestAccount(scale.peers, scale.groups) + // populateAccountSeqIDs(account) + assignValidWgKeys(account) + + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + peerID := "peer-0" + peer := account.Peers[peerID] + + networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + components := account.GetPeerNetworkMapComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, groupIDToUserIDs) + + dnsCache := &cache.DNSConfigCache{} + settings := &types.Settings{} + + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap) + if err != nil { + b.Fatalf("marshal legacy networkmap: %v", err) + } + + env := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: components, + PeerConfig: legacyResp.NetworkMap.PeerConfig, + DNSDomain: "netbird.cloud", + }) + envBytes, err := goproto.Marshal(env) + if err != nil { + b.Fatalf("marshal envelope: %v", err) + } + + b.Run(fmt.Sprintf("size/%s", scale.name), func(b *testing.B) { + b.ReportMetric(float64(len(legacyBytes)), "legacy_bytes") + b.ReportMetric(float64(len(envBytes)), "components_bytes") + ratio := float64(len(envBytes)) / float64(len(legacyBytes)) + b.ReportMetric(ratio, "components/legacy") + for range b.N { + } + }) + } +} diff --git a/management/server/types/networkmap_wire_breakdown_test.go b/management/server/types/networkmap_wire_breakdown_test.go new file mode 100644 index 000000000..ac2855fa3 --- /dev/null +++ b/management/server/types/networkmap_wire_breakdown_test.go @@ -0,0 +1,149 @@ +package types_test + +import ( + "context" + "fmt" + "os" + "testing" + + goproto "google.golang.org/protobuf/proto" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// TestNetworkMapWireBreakdown is a one-shot diagnostic: it computes the wire +// size attributable to each top-level field of both the legacy NetworkMap and +// the components NetworkMapEnvelope at the 5000-peer scale, so the migration +// docs can attribute the size reduction to each optimization. Runs only on +// demand via -run TestNetworkMapWireBreakdown. +func TestNetworkMapWireBreakdown(t *testing.T) { + if testing.Short() { + t.Skip("size diagnostic, skipped with -short") + } + if os.Getenv("NB_RUN_WIRE_BREAKDOWN") != "1" { + t.Skip("set NB_RUN_WIRE_BREAKDOWN=1 to run wire breakdown diagnostic") + } + + const peerCount, groupCount = 5000, 100 + account, validatedPeers := scalableTestAccount(peerCount, groupCount) + assignValidWgKeys(account) + + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + peerID := "peer-0" + peer := account.Peers[peerID] + networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + components := account.GetPeerNetworkMapComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, groupIDToUserIDs) + + dnsCache := &cache.DNSConfigCache{} + settings := &types.Settings{} + + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyTotal := mustMarshalSize(t, legacyResp.NetworkMap) + + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: components, + PeerConfig: legacyResp.NetworkMap.PeerConfig, + DNSDomain: "netbird.cloud", + }) + componentsTotal := mustMarshalSize(t, envelope) + + t.Logf("\n=== LEGACY NetworkMap (%d peers, %d groups) ===", peerCount, groupCount) + t.Logf(" Total: %d bytes\n", legacyTotal) + + legacyBreakdown := []struct { + name string + nm *proto.NetworkMap + }{ + {"RemotePeers", &proto.NetworkMap{RemotePeers: legacyResp.NetworkMap.RemotePeers}}, + {"OfflinePeers", &proto.NetworkMap{OfflinePeers: legacyResp.NetworkMap.OfflinePeers}}, + {"FirewallRules", &proto.NetworkMap{FirewallRules: legacyResp.NetworkMap.FirewallRules}}, + {"Routes", &proto.NetworkMap{Routes: legacyResp.NetworkMap.Routes}}, + {"RoutesFirewallRules", &proto.NetworkMap{RoutesFirewallRules: legacyResp.NetworkMap.RoutesFirewallRules}}, + {"DNSConfig", &proto.NetworkMap{DNSConfig: legacyResp.NetworkMap.DNSConfig}}, + {"PeerConfig", &proto.NetworkMap{PeerConfig: legacyResp.NetworkMap.PeerConfig}}, + {"SshAuth", &proto.NetworkMap{SshAuth: legacyResp.NetworkMap.SshAuth}}, + } + for _, e := range legacyBreakdown { + size := mustMarshalSize(t, e.nm) + t.Logf(" %-22s %8d bytes %5.1f%%", e.name, size, pct(size, legacyTotal)) + } + + full := envelope.GetFull() + if full == nil { + t.Fatalf("expected full network map envelope payload, got nil") + } + t.Logf("\n=== COMPONENTS NetworkMapEnvelope (%d peers, %d groups) ===", peerCount, groupCount) + t.Logf(" Total: %d bytes (%.1f%% of legacy)\n", componentsTotal, pct(componentsTotal, legacyTotal)) + + componentsBreakdown := []struct { + name string + nm *proto.NetworkMapComponentsFull + }{ + {"Peers", &proto.NetworkMapComponentsFull{Peers: full.Peers}}, + {"Policies", &proto.NetworkMapComponentsFull{Policies: full.Policies}}, + {"Groups", &proto.NetworkMapComponentsFull{Groups: full.Groups}}, + {"Routes (raw)", &proto.NetworkMapComponentsFull{Routes: full.Routes}}, + {"NameServerGroups", &proto.NetworkMapComponentsFull{NameserverGroups: full.NameserverGroups}}, + {"AllDNSRecords", &proto.NetworkMapComponentsFull{AllDnsRecords: full.AllDnsRecords}}, + {"AccountZones", &proto.NetworkMapComponentsFull{AccountZones: full.AccountZones}}, + {"NetworkResources", &proto.NetworkMapComponentsFull{NetworkResources: full.NetworkResources}}, + {"RoutersMap", &proto.NetworkMapComponentsFull{RoutersMap: full.RoutersMap}}, + {"ResourcePoliciesMap", &proto.NetworkMapComponentsFull{ResourcePoliciesMap: full.ResourcePoliciesMap}}, + {"GroupIDToUserIDs", &proto.NetworkMapComponentsFull{GroupIdToUserIds: full.GroupIdToUserIds}}, + {"AllowedUserIDs", &proto.NetworkMapComponentsFull{AllowedUserIds: full.AllowedUserIds}}, + {"PostureFailedPeers", &proto.NetworkMapComponentsFull{PostureFailedPeers: full.PostureFailedPeers}}, + {"DNSSettings", &proto.NetworkMapComponentsFull{DnsSettings: full.DnsSettings}}, + {"PeerConfig", &proto.NetworkMapComponentsFull{PeerConfig: full.PeerConfig}}, + {"AgentVersions", &proto.NetworkMapComponentsFull{AgentVersions: full.AgentVersions}}, + } + for _, e := range componentsBreakdown { + size := mustMarshalSize(t, e.nm) + t.Logf(" %-22s %8d bytes %5.1f%%", e.name, size, pct(size, componentsTotal)) + } + + t.Logf("\n=== Per-PeerCompact average ===") + if len(full.Peers) > 0 { + t.Logf(" PeerCompact avg: %d bytes/peer", mustMarshalSize(t, &proto.NetworkMapComponentsFull{Peers: full.Peers})/len(full.Peers)) + } + if len(legacyResp.NetworkMap.RemotePeers) > 0 { + t.Logf(" RemotePeer avg: %d bytes/peer", + mustMarshalSize(t, &proto.NetworkMap{RemotePeers: legacyResp.NetworkMap.RemotePeers})/len(legacyResp.NetworkMap.RemotePeers)) + } + + t.Logf("\n=== FirewallRule expansion footprint ===") + t.Logf(" legacy FirewallRules count: %d", len(legacyResp.NetworkMap.FirewallRules)) + t.Logf(" components Policies count: %d", len(full.Policies)) + t.Logf(" components Groups count: %d", len(full.Groups)) + + totalGroupPeerIdxs := 0 + for _, g := range full.Groups { + totalGroupPeerIdxs += len(g.PeerIndexes) + } + t.Logf(" components peer-index refs across all groups: %d", totalGroupPeerIdxs) +} + +func mustMarshalSize(t *testing.T, m goproto.Message) int { + b, err := goproto.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return len(b) +} + +func pct(part, total int) float64 { + if total == 0 { + return 0 + } + return 100 * float64(part) / float64(total) +} + +// Stops fmt being unused if the breakdown loop above is later commented out. +var _ = fmt.Sprintf diff --git a/management/server/types/peer_networkmap_result.go b/management/server/types/peer_networkmap_result.go new file mode 100644 index 000000000..fadbeb599 --- /dev/null +++ b/management/server/types/peer_networkmap_result.go @@ -0,0 +1,25 @@ +package types + +// PeerNetworkMapResult is what the network_map controller produces for a +// single peer. Exactly one of NetworkMap or Components is populated depending +// on the peer's capability: +// +// - Components-capable peers (PeerCapabilityComponentNetworkMap) get +// Components: the raw types.NetworkMapComponents the client decodes and +// runs Calculate() on locally. NetworkMap stays nil — the server skips +// the expansion entirely. +// - Legacy peers (or any peer when the kill switch is set) get NetworkMap: +// the fully-expanded view the legacy gRPC path consumes. +// +// The gRPC layer (ToSyncResponseForPeer) dispatches by which field is +// non-nil; callers must not rely on both being set. +type PeerNetworkMapResult struct { + NetworkMap *NetworkMap + Components *NetworkMapComponents +} + +// IsComponents reports whether the result carries the components shape. +// Use this in preference to direct nil checks on the fields. +func (r PeerNetworkMapResult) IsComponents() bool { + return r.Components != nil +} diff --git a/management/server/types/peer_networkmap_result_test.go b/management/server/types/peer_networkmap_result_test.go new file mode 100644 index 000000000..908581a08 --- /dev/null +++ b/management/server/types/peer_networkmap_result_test.go @@ -0,0 +1,104 @@ +package types_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" +) + +// helper: marks the given peer as components-capable. +func markCapable(p *nbpeer.Peer) { + p.Meta.Capabilities = append(p.Meta.Capabilities, nbpeer.PeerCapabilityComponentNetworkMap) +} + +func TestGetPeerNetworkMapResult_CapablePeerGetsComponents(t *testing.T) { + account, validatedPeers := scalableTestAccount(10, 2) + markCapable(account.Peers["peer-0"]) + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + result := account.GetPeerNetworkMapResult( + context.Background(), + "peer-0", + false, // componentsDisabled + nbdns.CustomZone{}, + nil, + validatedPeers, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + + require.True(t, result.IsComponents(), "capable peer must get the components shape") + assert.Nil(t, result.NetworkMap) + require.NotNil(t, result.Components) + assert.Equal(t, "peer-0", result.Components.PeerID) +} + +func TestGetPeerNetworkMapResult_LegacyPeerGetsNetworkMap(t *testing.T) { + account, validatedPeers := scalableTestAccount(10, 2) + // peer-0 left without the component capability + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + result := account.GetPeerNetworkMapResult( + context.Background(), + "peer-0", + false, + nbdns.CustomZone{}, + nil, + validatedPeers, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + + assert.False(t, result.IsComponents()) + assert.Nil(t, result.Components) + require.NotNil(t, result.NetworkMap, "legacy peer must get a NetworkMap") +} + +func TestGetPeerNetworkMapResult_KillSwitchOverridesCapability(t *testing.T) { + // Capable peer + componentsDisabled=true → falls back to legacy. + account, validatedPeers := scalableTestAccount(10, 2) + markCapable(account.Peers["peer-0"]) + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + result := account.GetPeerNetworkMapResult( + context.Background(), + "peer-0", + true, // componentsDisabled = true (kill switch) + nbdns.CustomZone{}, + nil, + validatedPeers, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + + assert.False(t, result.IsComponents(), "kill switch must force legacy NetworkMap path") + assert.Nil(t, result.Components) + require.NotNil(t, result.NetworkMap) +} + +func TestPeerNetworkMapResult_IsComponents(t *testing.T) { + assert.True(t, types.PeerNetworkMapResult{Components: &types.NetworkMapComponents{}}.IsComponents()) + assert.False(t, types.PeerNetworkMapResult{NetworkMap: &types.NetworkMap{}}.IsComponents()) + assert.False(t, types.PeerNetworkMapResult{}.IsComponents()) +} diff --git a/route/route.go b/route/route.go index 97b9721f6..3bdb0a3a1 100644 --- a/route/route.go +++ b/route/route.go @@ -95,6 +95,7 @@ type Route struct { ID ID `gorm:"primaryKey"` // AccountID is a reference to Account that this object belongs AccountID string `gorm:"index"` + PublicID string `json:"-"` // Network and Domains are mutually exclusive Network netip.Prefix `gorm:"serializer:json"` Domains domain.List `gorm:"serializer:json"` @@ -128,6 +129,7 @@ func (r *Route) Copy() *Route { route := &Route{ ID: r.ID, AccountID: r.AccountID, + PublicID: r.PublicID, Description: r.Description, NetID: r.NetID, Network: r.Network, diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go index b62317775..570de7631 100644 --- a/shared/management/client/client_test.go +++ b/shared/management/client/client_test.go @@ -316,33 +316,87 @@ func TestClient_Sync(t *testing.T) { select { case resp := <-ch: - if resp.GetPeerConfig() == nil { + if resp.GetPeerConfig() == nil && resp.GetNetworkMap().GetPeerConfig() == nil { t.Error("expecting non nil PeerConfig got nil") } if resp.GetNetbirdConfig() == nil { t.Error("expecting non nil NetbirdConfig got nil") } - // we test network map peers from 0.29.3 and dev builds + // Top-level RemotePeers is deprecated and must stay empty for + // v0.29.3+ (and dev) clients — the field rides inside NetworkMap + // (legacy) or the NetworkMapEnvelope (components) instead. if len(resp.GetRemotePeers()) != 0 { t.Error("expecting top-level RemotePeers to be empty for v0.29.3+ clients") } - networkMap := resp.GetNetworkMap() - if len(networkMap.GetRemotePeers()) != 1 { - t.Errorf("expecting RemotePeers size %d got %d", 1, len(networkMap.GetRemotePeers())) + // Component-capable clients receive a NetworkMapEnvelope; the + // remote-peers list is encoded inside it. Decode it and check the + // envelope's peers slice. Legacy peers populate NetworkMap.RemotePeers; + // both shapes must surface exactly one remote peer. + remotePeerKeys := remotePeerKeysFromSync(resp, testKey.PublicKey().String()) + if len(remotePeerKeys) != 1 { + t.Errorf("expecting RemotePeers size %d got %d", 1, len(remotePeerKeys)) return } - - if networkMap.GetRemotePeersIsEmpty() { + if resp.GetNetworkMap() != nil && resp.GetNetworkMap().GetRemotePeersIsEmpty() { t.Error("expecting RemotePeers property to be false, got true") } - if networkMap.GetRemotePeers()[0].GetWgPubKey() != remoteKey.PublicKey().String() { - t.Errorf("expecting RemotePeer public key %s got %s", remoteKey.PublicKey().String(), networkMap.GetRemotePeers()[0].GetWgPubKey()) + if remotePeerKeys[0] != remoteKey.PublicKey().String() { + t.Errorf("expecting RemotePeer public key %s got %s", remoteKey.PublicKey().String(), remotePeerKeys[0]) } case <-time.After(3 * time.Second): t.Error("timeout waiting for test to finish") } } +// remotePeerKeysFromSync extracts the remote-peer WG keys from either the +// legacy NetworkMap.RemotePeers list or the components NetworkMapEnvelope's +// inner peers slice (filtering out the local receiving peer identified by +// localKey, since the envelope's peers list is index-addressed and includes +// the local peer alongside remotes). +func remotePeerKeysFromSync(resp *mgmtProto.SyncResponse, localKey string) []string { + if rp := resp.GetRemotePeers(); len(rp) > 0 { + out := make([]string, 0, len(rp)) + for _, p := range rp { + out = append(out, p.GetWgPubKey()) + } + return out + } + if rp := resp.GetNetworkMap().GetRemotePeers(); len(rp) > 0 { + out := make([]string, 0, len(rp)) + for _, p := range rp { + out = append(out, p.GetWgPubKey()) + } + return out + } + env := resp.GetNetworkMapEnvelope().GetFull() + if env == nil { + return nil + } + out := make([]string, 0, len(env.GetPeers())) + for _, p := range env.GetPeers() { + key := wgKeyFromBytes(p.GetWgPubKey()) + if key == "" || key == localKey { + continue + } + out = append(out, key) + } + return out +} + +// wgKeyFromBytes mirrors the client-side decoder: the envelope ships raw 32 +// bytes; reconstruct the standard base64 key the test compares against. +func wgKeyFromBytes(raw []byte) string { + if len(raw) == 0 { + return "" + } + var k wgtypes.Key + if len(raw) != len(k) { + return "" + } + copy(k[:], raw) + return k.String() +} + func Test_SystemMetaDataFromClient(t *testing.T) { s, lis, mgmtMockServer, serverKey := startMockManagement(t) defer s.GracefulStop() diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 0735a15b9..78d28e3a3 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -24,6 +24,7 @@ import ( "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/encryption" "github.com/netbirdio/netbird/shared/management/domain" + nbmgmtgrpc "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/util/wsproxy" ) @@ -1026,6 +1027,8 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta { }, Capabilities: peerCapabilities(*info), + + SyncMessageVersion: syncMessageVersion(*info), } } @@ -1039,3 +1042,10 @@ func peerCapabilities(info system.Info) []proto.PeerCapability { } return caps } + +func syncMessageVersion(info system.Info) int32 { + if info.SyncMessageVersion != nil { + return int32(*info.SyncMessageVersion) + } + return int32(nbmgmtgrpc.HighestSyncMessageVersion) +} diff --git a/shared/management/grpc/sync_message_versions.go b/shared/management/grpc/sync_message_versions.go new file mode 100644 index 000000000..4852408f7 --- /dev/null +++ b/shared/management/grpc/sync_message_versions.go @@ -0,0 +1,67 @@ +package grpc + +import ( + "errors" + "fmt" +) + +type SyncMessageVersion uint16 + +const ( + Base SyncMessageVersion = iota + ComponentNetworkMap +) + +const DefaultSyncMessageVersion = Base +const HighestSyncMessageVersion = ComponentNetworkMap + +var ErrorUnrecognizedSyncMessageVersion = errors.New("unrecognized SyncMessageVersion") + +func ValidateSyncMessageVersion(v *int) error { + // empty list == we support all available versions + if v == nil { + return nil + } + if *v < 0 || *v > int(HighestSyncMessageVersion) { + return fmt.Errorf("sync message version must between 0 and %d, %w", HighestSyncMessageVersion, ErrorUnrecognizedSyncMessageVersion) + } + return nil +} + +// returns SyncMessage version from config, or highest available version if the config is missing or +// base if it is invalid +// the assumption is ValidateSyncMessageVersion() has been called before using SyncMessageVersionFromConfig() +func SyncMessageVersionFromConfig(v *int) SyncMessageVersion { + if v == nil { + return DefaultSyncMessageVersion + } + if *v < 0 || *v > int(HighestSyncMessageVersion) { + return Base + } + + return SyncMessageVersion(*v) +} + +// convert per-account supported versions to SyncMessageVersion +// the assumption is ValidateSyncMessageVersion() has been called before using SyncMessageVersionsFromMap() +func SyncMessageVersionsFromMap(toconvert map[string]int) map[string]SyncMessageVersion { + // no per-account overrides + if len(toconvert) == 0 { + return nil + } + + toret := make(map[string]SyncMessageVersion) + + for account, version := range toconvert { + toret[account] = SyncMessageVersionFromConfig(&version) + } + return toret +} + +// return highest common sync message version, or Default (which is always available) +func HighestCommonSyncMessageVersion(a SyncMessageVersion, b SyncMessageVersion) SyncMessageVersion { + if a > b { + return b + } + return a +} diff --git a/shared/management/grpc/sync_message_versions_test.go b/shared/management/grpc/sync_message_versions_test.go new file mode 100644 index 000000000..300274059 --- /dev/null +++ b/shared/management/grpc/sync_message_versions_test.go @@ -0,0 +1,39 @@ +package grpc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidation(t *testing.T) { + assert.NoError(t, ValidateSyncMessageVersion(nil)) + assert.NoError(t, ValidateSyncMessageVersion(toIntPtr(0))) + assert.NoError(t, ValidateSyncMessageVersion(toIntPtr(1))) + assert.ErrorIs(t, ValidateSyncMessageVersion(toIntPtr(int(^uint(0)>>1))), ErrorUnrecognizedSyncMessageVersion) + assert.ErrorIs(t, ValidateSyncMessageVersion(toIntPtr(-1)), ErrorUnrecognizedSyncMessageVersion) +} + +func TestVersionFromConfig(t *testing.T) { + assert.Equal(t, DefaultSyncMessageVersion, SyncMessageVersionFromConfig(nil)) + assert.Equal(t, Base, SyncMessageVersionFromConfig(toIntPtr(0))) + assert.Equal(t, ComponentNetworkMap, SyncMessageVersionFromConfig(toIntPtr(1))) + assert.Equal(t, DefaultSyncMessageVersion, SyncMessageVersionFromConfig(toIntPtr(-1))) + assert.Equal(t, DefaultSyncMessageVersion, SyncMessageVersionFromConfig(toIntPtr(int(^uint(0)>>1)))) +} + +func TestPerAccountConversionStringToEnum(t *testing.T) { + assert.Equal(t, map[string]SyncMessageVersion{"1": HighestSyncMessageVersion}, SyncMessageVersionsFromMap(map[string]int{"1": 1})) + assert.Equal(t, map[string]SyncMessageVersion{"2": DefaultSyncMessageVersion}, SyncMessageVersionsFromMap(map[string]int{"2": -1})) +} + +func TestCommonVersions(t *testing.T) { + assert.Equal(t, Base, HighestCommonSyncMessageVersion(Base, HighestSyncMessageVersion)) + assert.Equal(t, Base, HighestCommonSyncMessageVersion(HighestSyncMessageVersion, Base)) + assert.Equal(t, Base, HighestCommonSyncMessageVersion(Base, Base)) + assert.Equal(t, HighestSyncMessageVersion, HighestCommonSyncMessageVersion(HighestSyncMessageVersion, HighestSyncMessageVersion)) +} + +func toIntPtr(v int) *int { + return &v +} diff --git a/shared/management/networkmap/decode.go b/shared/management/networkmap/decode.go new file mode 100644 index 000000000..c66074b4f --- /dev/null +++ b/shared/management/networkmap/decode.go @@ -0,0 +1,550 @@ +package networkmap + +import ( + "encoding/base64" + "fmt" + "net" + "net/netip" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/types" +) + +// DecodeEnvelope converts a NetworkMapEnvelope into a NetworkMapComponents +// the client can run Calculate() over. Every ID-reference on the wire is a +// xid from corresponding public_id field. +// +// ID scheme on the client side: +// +// Peers base64(wg_pub_key) // stable across snapshots +func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, error) { + full := env.GetFull() + if full == nil { + return nil, fmt.Errorf("envelope has no Full payload") + } + + c := &types.NetworkMapComponents{ + PeerID: "", // engine fills its own peer id from PeerConfig + Network: decodeAccountNetwork(full.Network), + AccountSettings: decodeAccountSettings(full.AccountSettings), + CustomZoneDomain: full.CustomZoneDomain, + Peers: make(map[string]*nbpeer.Peer, len(full.Peers)), + Groups: make(map[string]*types.Group, len(full.Groups)), + Policies: make([]*types.Policy, 0, len(full.Policies)), + Routes: make([]*nbroute.Route, 0, len(full.Routes)), + NameServerGroups: make([]*nbdns.NameServerGroup, 0, len(full.NameserverGroups)), + AllDNSRecords: decodeSimpleRecords(full.AllDnsRecords), + AccountZones: decodeCustomZones(full.AccountZones), + ResourcePoliciesMap: make(map[string][]*types.Policy), + RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), + NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(full.NetworkResources)), + RouterPeers: make(map[string]*nbpeer.Peer), + AllowedUserIDs: stringSliceToSet(full.AllowedUserIds), + PostureFailedPeers: make(map[string]map[string]struct{}, len(full.PostureFailedPeers)), + GroupIDToUserIDs: make(map[string][]string, len(full.GroupIdToUserIds)), + } + + if full.DnsSettings != nil { + c.DNSSettings = &types.DNSSettings{ + DisabledManagementGroups: full.DnsSettings.DisabledManagementGroupIds, + } + } else { + c.DNSSettings = &types.DNSSettings{} + } + + // Phase 1: peers. The envelope's peers slice is index-addressed on the + // wire; we re-key by the peer's WireGuard public key (base64) so the + // in-memory components struct uses a stable identifier across + // snapshots. peerIDByIndex lets downstream phases resolve wire indexes + // back to that key. A peer with a missing or malformed wg_pub_key is + // skipped (and its index keeps "" so any cross-reference falls into the + // same missing-peer branch downstream) — matches legacy behaviour, which + // degrades gracefully rather than aborting the whole sync on a single + // bad row. + peerIDByIndex := make([]string, len(full.Peers)) + for idx, pc := range full.Peers { + if pc == nil { + log.Warnf("envelope: peers[%d] is nil, skipping", idx) + continue + } + if len(pc.WgPubKey) != 32 { + log.Warnf("envelope: peers[%d] wg_pub_key length %d (want 32), skipping", idx, len(pc.WgPubKey)) + continue + } + peerID := base64.StdEncoding.EncodeToString(pc.WgPubKey) + peer := decodePeerCompact(pc, peerID) + c.Peers[peerID] = peer + peerIDByIndex[idx] = peerID + } + + // Phase 2: groups. + for i, gc := range full.Groups { + if gc == nil { + return nil, fmt.Errorf("invalid envelope: groups[%d] is nil", i) + } + groupID := gc.Id + peerIDs := make([]string, 0, len(gc.PeerIndexes)) + for _, idx := range gc.PeerIndexes { + if int(idx) < len(peerIDByIndex) { + peerIDs = append(peerIDs, peerIDByIndex[idx]) + } else { + log.WithField("peer idx", idx).Error("unrecognized peer idx during decoding") + } + } + group := &types.Group{ + ID: groupID, + PublicID: gc.Id, + Peers: peerIDs, + } + if gc.IsAll { + group.Name = types.GroupAllName + } + c.Groups[groupID] = group + } + + // Phase 3: policies (PolicyCompact = one rule per entry; current data + // model is 1 rule per policy). + policyByID := make(map[string]*types.Policy, len(full.Policies)) + for i, pc := range full.Policies { + if pc == nil { + return nil, fmt.Errorf("invalid envelope: policies[%d] is nil", i) + } + policy := decodePolicyCompact(pc, pc.Id, peerIDByIndex) + c.Policies = append(c.Policies, policy) + policyByID[pc.Id] = policy + } + + // Phase 4: routes. + for i, rr := range full.Routes { + if rr == nil { + return nil, fmt.Errorf("invalid envelope: routes[%d] is nil", i) + } + c.Routes = append(c.Routes, decodeRouteRaw(rr, peerIDByIndex)) + } + + // Phase 5: NSGs. + for i, nsg := range full.NameserverGroups { + if nsg == nil { + return nil, fmt.Errorf("invalid envelope: nameserver_groups[%d] is nil", i) + } + c.NameServerGroups = append(c.NameServerGroups, decodeNameServerGroupRaw(nsg)) + } + + // Phase 6: network resources. + for i, nr := range full.NetworkResources { + if nr == nil { + return nil, fmt.Errorf("invalid envelope: network_resources[%d] is nil", i) + } + c.NetworkResources = append(c.NetworkResources, decodeNetworkResource(nr)) + } + + // Phase 7: routers_map (outer key = network seq id, inner key = peer-id + // reconstructed from peer_index). Synthesized network id is "net_". + for networkID, list := range full.RoutersMap { + inner := make(map[string]*routerTypes.NetworkRouter, len(list.Entries)) + for _, entry := range list.Entries { + if !entry.PeerIndexSet { + continue + } + if int(entry.PeerIndex) >= len(peerIDByIndex) { + log.WithField("peer idx", entry.PeerIndex).Error("unrecognized peer id when decoding router map") + continue + } + peerID := peerIDByIndex[entry.PeerIndex] + inner[peerID] = &routerTypes.NetworkRouter{ + ID: "", + NetworkID: networkID, + PublicID: entry.Id, + Peer: peerID, + PeerGroups: entry.PeerGroupIds, + Masquerade: entry.Masquerade, + Metric: int(entry.Metric), + Enabled: entry.Enabled, + } + } + if len(inner) > 0 { + c.RoutersMap[networkID] = inner + } + } + + // Phase 8: resource_policies_map (resource seq id → list of *types.Policy + // pointers from the decoded policies slice). Resource ID is synthesized + // the same way as in decodeNetworkResource. + for resourceID, ids := range full.ResourcePoliciesMap { + if len(ids.Ids) == 0 { + continue + } + policies := make([]*types.Policy, 0, len(ids.Ids)) + for _, id := range ids.Ids { + if p, ok := policyByID[id]; ok { + policies = append(policies, p) + } else { + log.WithField("policy id", id).Error("unrecognized policy when decoding resource policies") + } + } + if len(policies) > 0 { + c.ResourcePoliciesMap[resourceID] = policies + } + } + + // Phase 9: group_id_to_user_ids — wire keys are seq ids, synth to strings. + for groupId, list := range full.GroupIdToUserIds { + c.GroupIDToUserIDs[groupId] = append([]string(nil), list.UserIds...) + } + + // Phase 10: posture_failed_peers — wire keys are posture-check seq ids, + // values are peer indexes that need to be turned into peer ids. PolicyRule + // SourcePostureChecks (also synth ids) reference the same key space. + for checkID, set := range full.PostureFailedPeers { + failed := make(map[string]struct{}, len(set.PeerIndexes)) + for _, idx := range set.PeerIndexes { + if int(idx) < len(peerIDByIndex) { + failed[peerIDByIndex[idx]] = struct{}{} + } else { + log.WithField("peer idx", idx).Error("unrecognized peer when decoding posture failed peers") + } + } + if len(failed) > 0 { + c.PostureFailedPeers[checkID] = failed + } + } + + // Phase 11: router_peer_indexes — peers that act as routers. They're + // already in c.Peers (router peers are appended to the global peers + // list by the encoder); RouterPeers is the subset. + for _, idx := range full.RouterPeerIndexes { + if int(idx) < len(peerIDByIndex) { + peerID := peerIDByIndex[idx] + c.RouterPeers[peerID] = c.Peers[peerID] + } + } + + return c, nil +} + +func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network { + if an == nil { + return nil + } + n := &types.Network{ + Identifier: an.Identifier, + Dns: an.Dns, + Serial: an.Serial, + } + if an.NetCidr != "" { + if _, ipnet, err := net.ParseCIDR(an.NetCidr); err == nil && ipnet != nil { + n.Net = *ipnet + } + } + if an.NetV6Cidr != "" { + if _, ipnet, err := net.ParseCIDR(an.NetV6Cidr); err == nil && ipnet != nil { + n.NetV6 = *ipnet + } + } + return n +} + +func decodeAccountSettings(as *proto.AccountSettingsCompact) *types.AccountSettingsInfo { + if as == nil { + return &types.AccountSettingsInfo{} + } + return &types.AccountSettingsInfo{ + PeerLoginExpirationEnabled: as.PeerLoginExpirationEnabled, + PeerLoginExpiration: time.Duration(as.PeerLoginExpirationNs), + } +} + +func decodePeerCompact(pc *proto.PeerCompact, peerID string) *nbpeer.Peer { + var caps []int32 + if pc.SupportsSourcePrefixes { + caps = append(caps, nbpeer.PeerCapabilitySourcePrefixes) + } + if pc.SupportsIpv6 { + caps = append(caps, nbpeer.PeerCapabilityIPv6Overlay) + } + peer := &nbpeer.Peer{ + ID: peerID, + Key: peerID, + SSHKey: string(pc.SshPubKey), + SSHEnabled: pc.SshEnabled, + DNSLabel: pc.DnsLabel, + LoginExpirationEnabled: pc.LoginExpirationEnabled, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: pc.AgentVersion, + Capabilities: caps, + Flags: nbpeer.Flags{ + ServerSSHAllowed: pc.ServerSshAllowed, + }, + }, + } + if pc.AddedWithSsoLogin { + // Set a non-empty UserID so (*Peer).AddedWithSSOLogin() returns true. + // The original UserID isn't on the wire; the value is intentionally + // visibly synthetic so any future consumer that mistakes UserID for a + // real account user xid won't silently match (or worse, write the + // sentinel into a downstream record). + peer.UserID = "" + } + if pc.LastLoginUnixNano != 0 { + t := time.Unix(0, pc.LastLoginUnixNano) + peer.LastLogin = &t + } + switch len(pc.Ip) { + case 4: + peer.IP = netip.AddrFrom4([4]byte{pc.Ip[0], pc.Ip[1], pc.Ip[2], pc.Ip[3]}) + case 16: + var a [16]byte + copy(a[:], pc.Ip) + peer.IP = netip.AddrFrom16(a) + } + if len(pc.Ipv6) == 16 { + var a [16]byte + copy(a[:], pc.Ipv6) + peer.IPv6 = netip.AddrFrom16(a) + } + return peer +} + +func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex []string) *types.Policy { + rule := &types.PolicyRule{ + ID: policyID, // 1 rule per policy → reuse synthesized id + PolicyID: policyID, + Enabled: true, + Action: actionFromProto(pc.Action), + Protocol: protocolFromProto(pc.Protocol), + Bidirectional: pc.Bidirectional, + Ports: uint32SliceToStrings(pc.Ports), + PortRanges: portRangesFromProto(pc.PortRanges), + Sources: pc.SourceGroupIds, + Destinations: pc.DestinationGroupIds, + AuthorizedUser: pc.AuthorizedUser, + AuthorizedGroups: authorizedGroupsFromProto(pc.AuthorizedGroups), + SourceResource: resourceFromProto(pc.SourceResource, peerIDByIndex), + DestinationResource: resourceFromProto(pc.DestinationResource, peerIDByIndex), + } + return &types.Policy{ + ID: policyID, + PublicID: pc.Id, + Enabled: true, + Rules: []*types.PolicyRule{rule}, + SourcePostureChecks: pc.SourcePostureCheckIds, + } +} + +// resourceFromProto rebuilds types.Resource. For peer-typed resources the +// peer reference is reconstructed from the envelope's peer index — wire +// format ships no xid for peers, so we use the synthesized peer id. +func resourceFromProto(r *proto.ResourceCompact, peerIDByIndex []string) types.Resource { + if r == nil { + return types.Resource{} + } + out := types.Resource{Type: types.ResourceType(r.Type)} + if r.PeerIndexSet && int(r.PeerIndex) < len(peerIDByIndex) { + out.ID = peerIDByIndex[r.PeerIndex] + } + return out +} + +// authorizedGroupsFromProto inverts encodeAuthorizedGroups: the wire form +// keys by group account_seq_id, the typed PolicyRule field keys by group +// xid string. We rebuild using the same synthetic scheme the rest of the +// decoder uses ("g"). +func authorizedGroupsFromProto(m map[string]*proto.UserNameList) map[string][]string { + if len(m) == 0 { + return nil + } + out := make(map[string][]string, len(m)) + for id, list := range m { + if list == nil { + continue + } + out[id] = append([]string(nil), list.Names...) + } + return out +} + +func decodeRouteRaw(rr *proto.RouteRaw, peerIDByIndex []string) *nbroute.Route { + r := &nbroute.Route{ + ID: nbroute.ID(rr.Id), + PublicID: rr.Id, + NetID: nbroute.NetID(rr.NetId), + Description: rr.Description, + Domains: domainsFromPunycode(rr.Domains), + KeepRoute: rr.KeepRoute, + NetworkType: nbroute.NetworkType(rr.NetworkType), + Masquerade: rr.Masquerade, + Metric: int(rr.Metric), + Enabled: rr.Enabled, + Groups: rr.GroupIds, + AccessControlGroups: rr.AccessControlGroupIds, + PeerGroups: rr.PeerGroupIds, + SkipAutoApply: rr.SkipAutoApply, + } + if rr.NetworkCidr != "" { + if p, err := netip.ParsePrefix(rr.NetworkCidr); err == nil { + r.Network = p + } + } + if rr.PeerIndexSet && int(rr.PeerIndex) < len(peerIDByIndex) { + r.Peer = peerIDByIndex[rr.PeerIndex] + } + return r +} + +func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGroup { + out := &nbdns.NameServerGroup{ + ID: nsg.Id, + PublicID: nsg.Id, + Groups: nsg.GroupIds, + Primary: nsg.Primary, + Domains: nsg.Domains, + Enabled: nsg.Enabled, + SearchDomainsEnabled: nsg.SearchDomainsEnabled, + NameServers: make([]nbdns.NameServer, 0, len(nsg.Nameservers)), + } + for _, ns := range nsg.Nameservers { + if addr, err := netip.ParseAddr(ns.IP); err == nil { + out.NameServers = append(out.NameServers, nbdns.NameServer{ + IP: addr, + NSType: nbdns.NameServerType(ns.NSType), + Port: int(ns.Port), + }) + } + } + return out +} + +func decodeNetworkResource(nr *proto.NetworkResourceRaw) *resourceTypes.NetworkResource { + out := &resourceTypes.NetworkResource{ + ID: nr.Id, + PublicID: nr.Id, + NetworkID: nr.NetworkSeq, + Name: nr.Name, + Description: nr.Description, + Type: resourceTypes.NetworkResourceType(nr.Type), + Address: nr.Address, + Domain: nr.DomainValue, + Enabled: nr.Enabled, + } + if nr.PrefixCidr != "" { + if p, err := netip.ParsePrefix(nr.PrefixCidr); err == nil { + out.Prefix = p + } + } + return out +} + +func decodeSimpleRecords(records []*proto.SimpleRecord) []nbdns.SimpleRecord { + out := make([]nbdns.SimpleRecord, 0, len(records)) + for _, r := range records { + out = append(out, nbdns.SimpleRecord{ + Name: r.Name, + Type: int(r.Type), + Class: r.Class, + TTL: int(r.TTL), + RData: r.RData, + }) + } + return out +} + +func decodeCustomZones(zones []*proto.CustomZone) []nbdns.CustomZone { + out := make([]nbdns.CustomZone, 0, len(zones)) + for _, z := range zones { + out = append(out, nbdns.CustomZone{ + Domain: z.Domain, + Records: decodeSimpleRecords(z.Records), + SearchDomainDisabled: z.SearchDomainDisabled, + NonAuthoritative: z.NonAuthoritative, + }) + } + return out +} + +func uint32SliceToStrings(ports []uint32) []string { + if len(ports) == 0 { + return nil + } + out := make([]string, len(ports)) + for i, p := range ports { + out[i] = strconv.FormatUint(uint64(p), 10) + } + return out +} + +func portRangesFromProto(ranges []*proto.PortInfo_Range) []types.RulePortRange { + if len(ranges) == 0 { + return nil + } + out := make([]types.RulePortRange, 0, len(ranges)) + for _, r := range ranges { + if r == nil || r.Start > 65535 || r.End > 65535 { + continue + } + out = append(out, types.RulePortRange{ + Start: uint16(r.Start), + End: uint16(r.End), + }) + } + return out +} + +func actionFromProto(a proto.RuleAction) types.PolicyTrafficActionType { + if a == proto.RuleAction_DROP { + return types.PolicyTrafficActionDrop + } + return types.PolicyTrafficActionAccept +} + +func protocolFromProto(p proto.RuleProtocol) types.PolicyRuleProtocolType { + switch p { + case proto.RuleProtocol_TCP: + return types.PolicyRuleProtocolTCP + case proto.RuleProtocol_UDP: + return types.PolicyRuleProtocolUDP + case proto.RuleProtocol_ICMP: + return types.PolicyRuleProtocolICMP + case proto.RuleProtocol_ALL: + return types.PolicyRuleProtocolALL + case proto.RuleProtocol_NETBIRD_SSH: + return types.PolicyRuleProtocolNetbirdSSH + default: + return types.PolicyRuleProtocolALL + } +} + +func stringSliceToSet(s []string) map[string]struct{} { + if len(s) == 0 { + return nil + } + out := make(map[string]struct{}, len(s)) + for _, v := range s { + out[v] = struct{}{} + } + return out +} + +// domainsFromPunycode is a thin wrapper that converts a punycode list back to +// the domain.List type the route.Route struct expects. It accepts the +// punycode strings as-is (no extra decoding) — symmetric with +// route.Domains.ToPunycodeList() used in the encoder. +func domainsFromPunycode(punycoded []string) domain.List { + if len(punycoded) == 0 { + return nil + } + out := make(domain.List, 0, len(punycoded)) + for _, d := range punycoded { + out = append(out, domain.Domain(d)) + } + return out +} diff --git a/shared/management/networkmap/encode.go b/shared/management/networkmap/encode.go new file mode 100644 index 000000000..e808480ea --- /dev/null +++ b/shared/management/networkmap/encode.go @@ -0,0 +1,323 @@ +// Package networkmap contains the shared NetworkMap helpers that both the +// management server and the client agent need. +// +// The proto-conversion helpers (types.NetworkMap → proto.NetworkMap) live +// here so the client can run the same conversion locally after deriving its +// NetworkMap from a NetworkMapEnvelope, without taking a dependency on the +// server-side conversion package (which pulls in cloud integrations and is +// otherwise an unwanted internal import on the client). +// +// The helpers are pure functions over inputs — no caches, no IO, no logging +// beyond a context-aware error log when an individual user-id hash fails. +package networkmap + +import ( + "context" + + log "github.com/sirupsen/logrus" + goproto "google.golang.org/protobuf/proto" + + nbdns "github.com/netbirdio/netbird/dns" + "net/netip" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/shared/management/types" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" + "github.com/netbirdio/netbird/shared/sshauth" +) + +// ToProtocolRoutes converts a slice of typed routes to their proto form. +func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route { + protoRoutes := make([]*proto.Route, 0, len(routes)) + for _, r := range routes { + protoRoutes = append(protoRoutes, ToProtocolRoute(r)) + } + return protoRoutes +} + +// ToProtocolRoute converts one typed route to its proto form. +func ToProtocolRoute(route *nbroute.Route) *proto.Route { + return &proto.Route{ + ID: string(route.ID), + NetID: string(route.NetID), + Network: route.Network.String(), + Domains: route.Domains.ToPunycodeList(), + NetworkType: int64(route.NetworkType), + Peer: route.Peer, + Metric: int64(route.Metric), + Masquerade: route.Masquerade, + KeepRoute: route.KeepRoute, + SkipAutoApply: route.SkipAutoApply, + } +} + +// ToProtocolFirewallRules converts the firewall rules to the protocol form. +// When useSourcePrefixes is true, the compact SourcePrefixes field is +// populated alongside the deprecated PeerIP for forward compatibility. +// Wildcard rules ("0.0.0.0") are expanded into separate v4/v6 SourcePrefixes +// when includeIPv6 is true. +func ToProtocolFirewallRules(rules []*types.FirewallRule, includeIPv6, useSourcePrefixes bool) []*proto.FirewallRule { + result := make([]*proto.FirewallRule, 0, len(rules)) + for i := range rules { + rule := rules[i] + + fwRule := &proto.FirewallRule{ + PolicyID: []byte(rule.PolicyID), + PeerIP: rule.PeerIP, //nolint:staticcheck // populated for backward compatibility + Direction: GetProtoDirection(rule.Direction), + Action: GetProtoAction(rule.Action), + Protocol: GetProtoProtocol(rule.Protocol), + Port: rule.Port, + } + + if useSourcePrefixes && rule.PeerIP != "" { + result = append(result, populateSourcePrefixes(fwRule, rule, includeIPv6)...) + } + + if ShouldUsePortRange(fwRule) { + fwRule.PortInfo = rule.PortRange.ToProto() + } + + result = append(result, fwRule) + } + return result +} + +// populateSourcePrefixes sets SourcePrefixes on fwRule and returns any +// additional rules needed (e.g. a v6 wildcard clone when the peer IP is +// unspecified). +func populateSourcePrefixes(fwRule *proto.FirewallRule, rule *types.FirewallRule, includeIPv6 bool) []*proto.FirewallRule { + addr, err := netip.ParseAddr(rule.PeerIP) + if err != nil { + return nil + } + + if !addr.IsUnspecified() { + fwRule.SourcePrefixes = [][]byte{netiputil.EncodeAddr(addr.Unmap())} + return nil + } + + v4Wildcard, _ := netiputil.EncodePrefix(netip.PrefixFrom(netip.IPv4Unspecified(), 0)) + fwRule.SourcePrefixes = [][]byte{v4Wildcard} + + if !includeIPv6 { + return nil + } + + v6Rule := goproto.Clone(fwRule).(*proto.FirewallRule) + v6Rule.PeerIP = "::" //nolint:staticcheck // populated for backward compatibility + v6Wildcard, _ := netiputil.EncodePrefix(netip.PrefixFrom(netip.IPv6Unspecified(), 0)) + v6Rule.SourcePrefixes = [][]byte{v6Wildcard} + if ShouldUsePortRange(v6Rule) { + v6Rule.PortInfo = rule.PortRange.ToProto() + } + return []*proto.FirewallRule{v6Rule} +} + +// GetProtoDirection converts the direction to proto.RuleDirection. +func GetProtoDirection(direction int) proto.RuleDirection { + if direction == types.FirewallRuleDirectionOUT { + return proto.RuleDirection_OUT + } + return proto.RuleDirection_IN +} + +// GetProtoAction converts the action to proto.RuleAction. +func GetProtoAction(action string) proto.RuleAction { + if action == string(types.PolicyTrafficActionDrop) { + return proto.RuleAction_DROP + } + return proto.RuleAction_ACCEPT +} + +// GetProtoProtocol converts the protocol to proto.RuleProtocol. +func GetProtoProtocol(protocol string) proto.RuleProtocol { + switch types.PolicyRuleProtocolType(protocol) { + case types.PolicyRuleProtocolALL: + return proto.RuleProtocol_ALL + case types.PolicyRuleProtocolTCP: + return proto.RuleProtocol_TCP + case types.PolicyRuleProtocolUDP: + return proto.RuleProtocol_UDP + case types.PolicyRuleProtocolICMP: + return proto.RuleProtocol_ICMP + case types.PolicyRuleProtocolNetbirdSSH: + return proto.RuleProtocol_NETBIRD_SSH + default: + return proto.RuleProtocol_UNKNOWN + } +} + +// GetProtoPortInfo converts route-firewall-rule port info to proto.PortInfo. +func GetProtoPortInfo(rule *types.RouteFirewallRule) *proto.PortInfo { + var portInfo proto.PortInfo + if rule.Port != 0 { + portInfo.PortSelection = &proto.PortInfo_Port{Port: uint32(rule.Port)} + } else if portRange := rule.PortRange; portRange.Start != 0 && portRange.End != 0 { + portInfo.PortSelection = &proto.PortInfo_Range_{ + Range: &proto.PortInfo_Range{ + Start: uint32(portRange.Start), + End: uint32(portRange.End), + }, + } + } + return &portInfo +} + +// ShouldUsePortRange reports whether the firewall rule should use a port +// range rather than a single port (TCP/UDP without a single port). +func ShouldUsePortRange(rule *proto.FirewallRule) bool { + return rule.Port == "" && (rule.Protocol == proto.RuleProtocol_UDP || rule.Protocol == proto.RuleProtocol_TCP) +} + +// ToProtocolRoutesFirewallRules converts a slice of typed route-firewall +// rules to proto. +func ToProtocolRoutesFirewallRules(rules []*types.RouteFirewallRule) []*proto.RouteFirewallRule { + result := make([]*proto.RouteFirewallRule, len(rules)) + for i := range rules { + rule := rules[i] + result[i] = &proto.RouteFirewallRule{ + SourceRanges: rule.SourceRanges, + Action: GetProtoAction(rule.Action), + Destination: rule.Destination, + Protocol: GetProtoProtocol(rule.Protocol), + PortInfo: GetProtoPortInfo(rule), + IsDynamic: rule.IsDynamic, + Domains: rule.Domains.ToPunycodeList(), + PolicyID: []byte(rule.PolicyID), + RouteID: string(rule.RouteID), + } + } + return result +} + +// ConvertToProtoCustomZone converts an nbdns.CustomZone to its proto form. +func ConvertToProtoCustomZone(zone nbdns.CustomZone) *proto.CustomZone { + protoZone := &proto.CustomZone{ + Domain: zone.Domain, + Records: make([]*proto.SimpleRecord, 0, len(zone.Records)), + SearchDomainDisabled: zone.SearchDomainDisabled, + NonAuthoritative: zone.NonAuthoritative, + } + for _, record := range zone.Records { + protoZone.Records = append(protoZone.Records, &proto.SimpleRecord{ + Name: record.Name, + Type: int64(record.Type), + Class: record.Class, + TTL: int64(record.TTL), + RData: record.RData, + }) + } + return protoZone +} + +// ConvertToProtoNameServerGroup converts a NameServerGroup to its proto form. +func ConvertToProtoNameServerGroup(nsGroup *nbdns.NameServerGroup) *proto.NameServerGroup { + protoGroup := &proto.NameServerGroup{ + Primary: nsGroup.Primary, + Domains: nsGroup.Domains, + SearchDomainsEnabled: nsGroup.SearchDomainsEnabled, + NameServers: make([]*proto.NameServer, 0, len(nsGroup.NameServers)), + } + for _, ns := range nsGroup.NameServers { + protoGroup.NameServers = append(protoGroup.NameServers, &proto.NameServer{ + IP: ns.IP.String(), + Port: int64(ns.Port), + NSType: int64(ns.NSType), + }) + } + return protoGroup +} + +// DNSConfigCache is the cache contract for amortising NameServerGroup +// proto-conversion across peers in the same account. Server uses a concrete +// implementation; client passes nil (no cross-peer caching needed when +// rebuilding a single NetworkMap from an envelope). +type DNSConfigCache interface { + GetNameServerGroup(key string) (*proto.NameServerGroup, bool) + SetNameServerGroup(key string, value *proto.NameServerGroup) +} + +// ToProtocolDNSConfig converts nbdns.Config to proto.DNSConfig. If cache is +// non-nil, NameServerGroup proto values are cached by NSG.ID across calls — +// the server amortises this across peers, the client passes nil. +func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort int64) *proto.DNSConfig { + protoUpdate := &proto.DNSConfig{ + ServiceEnable: update.ServiceEnable, + CustomZones: make([]*proto.CustomZone, 0, len(update.CustomZones)), + NameServerGroups: make([]*proto.NameServerGroup, 0, len(update.NameServerGroups)), + ForwarderPort: forwardPort, + } + + for _, zone := range update.CustomZones { + protoUpdate.CustomZones = append(protoUpdate.CustomZones, ConvertToProtoCustomZone(zone)) + } + + for _, nsGroup := range update.NameServerGroups { + if cache != nil { + if cachedGroup, exists := cache.GetNameServerGroup(nsGroup.ID); exists { + protoUpdate.NameServerGroups = append(protoUpdate.NameServerGroups, cachedGroup) + continue + } + } + protoGroup := ConvertToProtoNameServerGroup(nsGroup) + if cache != nil { + cache.SetNameServerGroup(nsGroup.ID, protoGroup) + } + protoUpdate.NameServerGroups = append(protoUpdate.NameServerGroups, protoGroup) + } + + return protoUpdate +} + +// AppendRemotePeerConfig appends typed peers as proto.RemotePeerConfig +// entries to dst and returns the result. +func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { + for _, rPeer := range peers { + allowedIPs := []string{rPeer.IP.String() + "/32"} + if includeIPv6 && rPeer.IPv6.IsValid() { + allowedIPs = append(allowedIPs, rPeer.IPv6.String()+"/128") + } + dst = append(dst, &proto.RemotePeerConfig{ + WgPubKey: rPeer.Key, + AllowedIps: allowedIPs, + SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, + Fqdn: rPeer.FQDN(dnsName), + AgentVersion: rPeer.Meta.WtVersion, + }) + } + return dst +} + +// BuildAuthorizedUsersProto deduplicates user-IDs into a hashed list and +// builds per-machine-user index maps. Returns (hashedUsers, machineUsers). +// Errors from individual hash failures are logged via the provided context; +// they leave the offending user out of the result but don't abort the build. +func BuildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]map[string]struct{}) ([][]byte, map[string]*proto.MachineUserIndexes) { + userIDToIndex := make(map[string]uint32) + var hashedUsers [][]byte + machineUsers := make(map[string]*proto.MachineUserIndexes, len(authorizedUsers)) + + for machineUser, users := range authorizedUsers { + indexes := make([]uint32, 0, len(users)) + for userID := range users { + idx, exists := userIDToIndex[userID] + if !exists { + hash, err := sshauth.HashUserID(userID) + if err != nil { + log.WithContext(ctx).WithError(err).Error("failed to hash user id") + continue + } + idx = uint32(len(hashedUsers)) + userIDToIndex[userID] = idx + hashedUsers = append(hashedUsers, hash[:]) + } + indexes = append(indexes, idx) + } + machineUsers[machineUser] = &proto.MachineUserIndexes{Indexes: indexes} + } + + return hashedUsers, machineUsers +} diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go new file mode 100644 index 000000000..3f045a9eb --- /dev/null +++ b/shared/management/networkmap/envelope.go @@ -0,0 +1,189 @@ +package networkmap + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/types" +) + +// EnvelopeResult is what the client engine consumes after receiving a +// component-format NetworkMap. Both fields are populated: +// +// - NetworkMap is the *proto.NetworkMap shape the engine reads today via +// update.GetNetworkMap() — built from the envelope's components by +// running Calculate() locally + converting back through the shared +// proto helpers + merging the optional ProxyPatch. +// - Components is the *types.NetworkMapComponents the engine retains so +// future incremental delta updates have a base to apply changes +// against. The client keeps it under its sync lock. +type EnvelopeResult struct { + NetworkMap *proto.NetworkMap + Components *types.NetworkMapComponents +} + +// EnvelopeToNetworkMap is the full client-side pipeline: decode the +// component envelope back to a typed NetworkMapComponents, run Calculate() +// locally to produce the typed NetworkMap, convert it to the wire form the +// engine consumes, and fold in any ProxyPatch the server attached. +// +// localPeerKey is the receiving peer's WG pub key (used to derive +// includeIPv6 / useSourcePrefixes from the receiving peer's own record in +// the components struct, mirroring legacy ToSyncResponse behaviour). +// +// dnsName is the account's DNS domain ("netbird.cloud" etc.); used when +// rebuilding the per-peer FQDNs that proto.RemotePeerConfig carries. +func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, localPeerKey, dnsName string) (*EnvelopeResult, error) { + components, err := DecodeEnvelope(env) + if err != nil { + return nil, fmt.Errorf("decode envelope: %w", err) + } + + // Find the receiving peer in the decoded components by WG key. + // c.Peers is keyed by canonical base64 of the raw 32-byte pub key + // (decoder re-encodes the bytes off the wire). The caller may pass a + // non-canonical encoding (some persisted production keys carry + // non-zero trailing padding bits that survived a legacy import), so + // round-trip through raw bytes once to canonicalize before lookup. + canonicalKey := canonicalizeWgKey(localPeerKey) + localPeer := components.Peers[canonicalKey] + if localPeer == nil { + return nil, fmt.Errorf("receiving peer (wg_key prefix %q) not found among %d decoded peers — components have no PeerID, Calculate would return empty", trimKey(localPeerKey), len(components.Peers)) + } + components.PeerID = canonicalKey + + includeIPv6 := localPeer.SupportsIPv6() && localPeer.IPv6.IsValid() + useSourcePrefixes := localPeer.SupportsSourcePrefixes() + + typedNM := components.Calculate(ctx) + + full := env.GetFull() + dnsFwdPort := int64(0) + if full != nil { + dnsFwdPort = full.DnsForwarderPort + } + + protoNM := &proto.NetworkMap{ + Serial: typedNM.Network.CurrentSerial(), + } + if full != nil { + protoNM.PeerConfig = full.PeerConfig + } + protoNM.Routes = ToProtocolRoutes(typedNM.Routes) + protoNM.DNSConfig = ToProtocolDNSConfig(typedNM.DNSConfig, nil, dnsFwdPort) + + remotePeers := AppendRemotePeerConfig(nil, typedNM.Peers, dnsName, includeIPv6) + protoNM.RemotePeers = remotePeers + protoNM.RemotePeersIsEmpty = len(remotePeers) == 0 + + protoNM.OfflinePeers = AppendRemotePeerConfig(nil, typedNM.OfflinePeers, dnsName, includeIPv6) + + firewallRules := ToProtocolFirewallRules(typedNM.FirewallRules, includeIPv6, useSourcePrefixes) + protoNM.FirewallRules = firewallRules + protoNM.FirewallRulesIsEmpty = len(firewallRules) == 0 + + routesFirewallRules := ToProtocolRoutesFirewallRules(typedNM.RoutesFirewallRules) + protoNM.RoutesFirewallRules = routesFirewallRules + protoNM.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0 + + if typedNM.AuthorizedUsers != nil { + hashedUsers, machineUsers := BuildAuthorizedUsersProto(ctx, typedNM.AuthorizedUsers) + userIDClaim := "" + if full != nil { + userIDClaim = full.UserIdClaim + } + protoNM.SshAuth = &proto.SSHAuth{ + AuthorizedUsers: hashedUsers, + MachineUsers: machineUsers, + UserIDClaim: userIDClaim, + } + } + + if typedNM.ForwardingRules != nil { + forwardingRules := make([]*proto.ForwardingRule, 0, len(typedNM.ForwardingRules)) + for _, rule := range typedNM.ForwardingRules { + forwardingRules = append(forwardingRules, rule.ToProto()) + } + protoNM.ForwardingRules = forwardingRules + } + + // Merge the proxy patch the server attached. Mirrors the legacy + // NetworkMap.Merge step that the server runs after Calculate(). + if full != nil && full.ProxyPatch != nil { + mergeProxyPatch(protoNM, full.ProxyPatch) + } + + return &EnvelopeResult{ + NetworkMap: protoNM, + Components: components, + }, nil +} + +// mergeProxyPatch folds a ProxyPatch's pre-expanded fragments into the +// proto.NetworkMap that Calculate() produced. Mirrors types.NetworkMap.Merge +// — same six collections, deduplicated where the legacy merge dedupes. +func mergeProxyPatch(nm *proto.NetworkMap, patch *proto.ProxyPatch) { + nm.RemotePeers = appendUniquePeers(nm.RemotePeers, patch.Peers) + nm.OfflinePeers = appendUniquePeers(nm.OfflinePeers, patch.OfflinePeers) + nm.FirewallRules = append(nm.FirewallRules, patch.FirewallRules...) + nm.Routes = append(nm.Routes, patch.Routes...) + nm.RoutesFirewallRules = append(nm.RoutesFirewallRules, patch.RouteFirewallRules...) + nm.ForwardingRules = append(nm.ForwardingRules, patch.ForwardingRules...) + if len(nm.RemotePeers) > 0 { + nm.RemotePeersIsEmpty = false + } + if len(nm.FirewallRules) > 0 { + nm.FirewallRulesIsEmpty = false + } + if len(nm.RoutesFirewallRules) > 0 { + nm.RoutesFirewallRulesIsEmpty = false + } +} + +// appendUniquePeers dedupes by WgPubKey — mirrors legacy +// mergeUniquePeersByID's intent (legacy keyed off Peer.ID; in proto form the +// closest stable identifier is WgPubKey). +func appendUniquePeers(dst, extra []*proto.RemotePeerConfig) []*proto.RemotePeerConfig { + if len(extra) == 0 { + return dst + } + seen := make(map[string]struct{}, len(dst)) + for _, p := range dst { + if p == nil { + continue + } + seen[p.WgPubKey] = struct{}{} + } + for _, p := range extra { + if p == nil { + continue + } + if _, ok := seen[p.WgPubKey]; ok { + continue + } + seen[p.WgPubKey] = struct{}{} + dst = append(dst, p) + } + return dst +} + +func trimKey(s string) string { + if len(s) > 12 { + return s[:12] + } + return s +} + +// canonicalizeWgKey normalises a base64-encoded WireGuard public key so it +// matches the canonical encoding emitted by the envelope decoder. Returns +// the input unchanged when it does not decode to 32 raw bytes (caller will +// hit a miss in the peer map and surface the error). +func canonicalizeWgKey(s string) string { + raw, err := base64.StdEncoding.DecodeString(s) + if err != nil || len(raw) != 32 { + return s + } + return base64.StdEncoding.EncodeToString(raw) +} diff --git a/shared/management/networkmap/envelope_test.go b/shared/management/networkmap/envelope_test.go new file mode 100644 index 000000000..11a5335be --- /dev/null +++ b/shared/management/networkmap/envelope_test.go @@ -0,0 +1,295 @@ +package networkmap_test + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + goproto "google.golang.org/protobuf/proto" + + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// TestEnvelopeToNetworkMap_RoundTrip exercises the full client-side pipeline: +// build a small components struct, encode an envelope, marshal/unmarshal the +// wire bytes, decode back via EnvelopeToNetworkMap, and verify the result is +// non-empty and consistent. +func TestEnvelopeToNetworkMap_RoundTrip(t *testing.T) { + c, localPeerKey := buildSmokeComponents(t) + + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + + wire, err := goproto.Marshal(envelope) + require.NoError(t, err, "marshal envelope") + + var decoded proto.NetworkMapEnvelope + require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope") + + result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud") + require.NoError(t, err, "EnvelopeToNetworkMap") + require.NotNil(t, result) + require.NotNil(t, result.NetworkMap, "decoded NetworkMap must be non-nil") + require.NotNil(t, result.Components, "Components must be retained for future delta updates") + require.NotNil(t, result.Components.AccountSettings) + require.NotEmpty(t, result.NetworkMap.RemotePeers, "two-peer allow policy should produce one remote peer") + require.NotEmpty(t, result.NetworkMap.FirewallRules, "two-peer allow policy should produce firewall rules") +} + +// TestCalculate_FirewallRuleProtocol_NeverNetbirdSSH guards against the +// scenario where a rule with Protocol=NetbirdSSH leaks the enum value into +// proto.FirewallRule.Protocol. Calculate() must rewrite NetbirdSSH → TCP +// before forming firewall rules. Without that rewrite, agents fall into +// UNKNOWN-protocol handling, which on some platforms downgrades to +// allow-all — a real security regression. +func TestCalculate_FirewallRuleProtocol_NeverNetbirdSSH(t *testing.T) { + c, localPeerKey := buildSmokeComponents(t) + // Replace the smoke policy with a NetbirdSSH-protocol allow. + c.Policies = []*types.Policy{{ + ID: "pol-ssh", PublicID: "2", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-ssh", + Enabled: true, + Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolNetbirdSSH, + Bidirectional: true, + Sources: []string{"group-all"}, + Destinations: []string{"group-all"}, + }}, + }} + + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + wire, err := goproto.Marshal(envelope) + require.NoError(t, err) + var decoded proto.NetworkMapEnvelope + require.NoError(t, goproto.Unmarshal(wire, &decoded)) + + result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud") + require.NoError(t, err) + require.NotEmpty(t, result.NetworkMap.FirewallRules, "ssh policy should produce firewall rules") + for i, fr := range result.NetworkMap.FirewallRules { + require.NotEqualf(t, proto.RuleProtocol_NETBIRD_SSH, fr.Protocol, + "FirewallRules[%d].Protocol must be the rewritten TCP, not NETBIRD_SSH", i) + } +} + +func TestEnvelopeToNetworkMap_NilEnvelope(t *testing.T) { + _, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), nil, "key", "netbird.cloud") + require.Error(t, err, "nil envelope must produce an error rather than panic") +} + +func TestEnvelopeToNetworkMap_FullPayloadMissing(t *testing.T) { + env := &proto.NetworkMapEnvelope{} + _, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), env, "key", "netbird.cloud") + require.Error(t, err, "envelope with no Full payload must produce an error") +} + +// TestDecodeEnvelope_MalformedWgKeyPeerSkipped feeds an envelope where one +// peer has a wg_pub_key that is not 32 bytes long. The decoder must skip +// that peer (keeping the rest of the snapshot usable) instead of aborting +// the whole sync — mirrors legacy behaviour that tolerates an occasional +// bad row. +func TestDecodeEnvelope_MalformedWgKeyPeerSkipped(t *testing.T) { + c, localPeerKey := buildSmokeComponents(t) + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + require.NotNil(t, envelope.GetFull()) + + full := envelope.GetFull() + require.Len(t, full.Peers, 2, "smoke fixture should have two peers") + + // Truncate the second peer's wg_pub_key so it fails the length gate. + for _, p := range full.Peers { + if base64.StdEncoding.EncodeToString(p.WgPubKey) != localPeerKey { + p.WgPubKey = p.WgPubKey[:31] + } + } + + wire, err := goproto.Marshal(envelope) + require.NoError(t, err, "marshal envelope") + var decoded proto.NetworkMapEnvelope + require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope") + + result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud") + require.NoError(t, err, "EnvelopeToNetworkMap must tolerate one bad peer key") + require.NotNil(t, result) + require.NotNil(t, result.Components) + require.Len(t, result.Components.Peers, 1, "the well-formed peer survives, the malformed one is dropped") +} + +// TestEnvelopeRoundTrip_AllGroupShortCircuitParity reproduces prod accounts +// with several groups literally named "All" where the "All"-named group does +// not contain every peer. Server-side Calculate short-circuits destination +// expansion at the first group named "All" (getUniquePeerIDsFromGroupsIDs), +// ignoring the remaining destination groups. The wire must preserve enough +// group identity for the decoded components to short-circuit identically — +// otherwise the client unions all destination groups and emits extra +// firewall rules the server never produced. +func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { + ctx := context.Background() + + peers := map[string]*nbpeer.Peer{} + for i, id := range []string{"peer-T", "peer-S", "peer-ALL", "peer-O"} { + peers[id] = &nbpeer.Peer{ + ID: id, + Key: randomWgKey(t), + IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), + DNSLabel: id, + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + } + + c := &types.NetworkMapComponents{ + PeerID: "peer-T", + Network: &types.Network{ + Identifier: "net-all-groups", + Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, + Serial: 1, + }, + AccountSettings: &types.AccountSettingsInfo{}, + DNSSettings: &types.DNSSettings{}, + Peers: peers, + Groups: map[string]*types.Group{ + "g-src": {ID: "g-src", PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}}, + "g-all": {ID: "g-all", PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}}, + "g-two": {ID: "g-two", PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}}, + }, + Policies: []*types.Policy{{ + ID: "pol-multi-dest", PublicID: "10", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-multi-dest", + Enabled: true, + Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolALL, + Sources: []string{"g-src"}, + Destinations: []string{"g-all", "g-two"}, + }}, + }}, + } + + serverNM := c.Calculate(ctx) + require.NotNil(t, serverNM) + + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + wire, err := goproto.Marshal(envelope) + require.NoError(t, err, "marshal envelope") + var decodedEnv proto.NetworkMapEnvelope + require.NoError(t, goproto.Unmarshal(wire, &decodedEnv), "unmarshal envelope") + + result, err := nbnetworkmap.EnvelopeToNetworkMap(ctx, &decodedEnv, peers["peer-T"].Key, "netbird.cloud") + require.NoError(t, err, "EnvelopeToNetworkMap") + clientNM := result.NetworkMap + + serverRules := make([]string, 0, len(serverNM.FirewallRules)) + for _, r := range serverNM.FirewallRules { + serverRules = append(serverRules, fmt.Sprintf("%s/%d", r.PeerIP, r.Direction)) + } + clientRules := make([]string, 0, len(clientNM.FirewallRules)) + for _, r := range clientNM.FirewallRules { + clientRules = append(clientRules, fmt.Sprintf("%s/%d", r.PeerIP, r.Direction)) // nolint:staticcheck + } + require.ElementsMatch(t, serverRules, clientRules, + "client-side Calculate must expand destination groups exactly like the server") + + serverPeers := make([]string, 0, len(serverNM.Peers)) + for _, p := range serverNM.Peers { + serverPeers = append(serverPeers, p.Key) + } + clientPeers := make([]string, 0, len(clientNM.RemotePeers)) + for _, p := range clientNM.RemotePeers { + clientPeers = append(clientPeers, p.WgPubKey) + } + require.ElementsMatch(t, serverPeers, clientPeers, + "client-side Calculate must connect the same remote peers as the server") +} + +// buildSmokeComponents returns a minimal NetworkMapComponents (2 peers, 1 +// group, 1 allow policy) plus the receiving peer's WG public key. Sufficient +// to validate the encode → marshal → decode → Calculate pipeline produces +// non-empty output. +func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { + t.Helper() + + peerAKey := randomWgKey(t) + peerBKey := randomWgKey(t) + + peerA := &nbpeer.Peer{ + ID: "peer-A", + Key: peerAKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peerA", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + peerB := &nbpeer.Peer{ + ID: "peer-B", + Key: peerBKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + DNSLabel: "peerB", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + + group := &types.Group{ + ID: "group-all", PublicID: "1", Name: "All", + Peers: []string{"peer-A", "peer-B"}, + } + + policy := &types.Policy{ + ID: "pol-allow", PublicID: "1", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-allow", + Enabled: true, + Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolALL, + Bidirectional: true, + Sources: []string{"group-all"}, + Destinations: []string{"group-all"}, + }}, + } + + c := &types.NetworkMapComponents{ + PeerID: "peer-A", + Network: &types.Network{ + Identifier: "net-smoke", + Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, + Serial: 1, + }, + AccountSettings: &types.AccountSettingsInfo{}, + DNSSettings: &types.DNSSettings{}, + Peers: map[string]*nbpeer.Peer{ + "peer-A": peerA, + "peer-B": peerB, + }, + Groups: map[string]*types.Group{ + "group-all": group, + }, + Policies: []*types.Policy{policy}, + } + return c, peerAKey +} + +func randomWgKey(t *testing.T) string { + t.Helper() + var raw [32]byte + _, err := rand.Read(raw[:]) + require.NoError(t, err) + return base64.StdEncoding.EncodeToString(raw[:]) +} diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index faf21e60f..02273a036 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -81,6 +81,8 @@ const ( PeerCapability_PeerCapabilitySourcePrefixes PeerCapability = 1 // Client handles IPv6 overlay addresses and firewall rules. PeerCapability_PeerCapabilityIPv6Overlay PeerCapability = 2 + // Client receives NetworkMap as components and assembles it locally. + PeerCapability_PeerCapabilityComponentNetworkMap PeerCapability = 3 ) // Enum value maps for PeerCapability. @@ -89,11 +91,13 @@ var ( 0: "PeerCapabilityUnknown", 1: "PeerCapabilitySourcePrefixes", 2: "PeerCapabilityIPv6Overlay", + 3: "PeerCapabilityComponentNetworkMap", } PeerCapability_value = map[string]int32{ - "PeerCapabilityUnknown": 0, - "PeerCapabilitySourcePrefixes": 1, - "PeerCapabilityIPv6Overlay": 2, + "PeerCapabilityUnknown": 0, + "PeerCapabilitySourcePrefixes": 1, + "PeerCapabilityIPv6Overlay": 2, + "PeerCapabilityComponentNetworkMap": 3, } ) @@ -133,6 +137,13 @@ const ( RuleProtocol_UDP RuleProtocol = 3 RuleProtocol_ICMP RuleProtocol = 4 RuleProtocol_CUSTOM RuleProtocol = 5 + // NETBIRD_SSH (types.PolicyRuleProtocolType "netbird-ssh") is the marker + // policy rule that drives SSH-server activation in Calculate(). The legacy + // proto.FirewallRule path doesn't ship this value (Calculate already + // expands SSH rules into TCP/22 before encoding), but the components path + // ships RAW policies — the client must see this protocol to derive + // AuthorizedUsers locally. + RuleProtocol_NETBIRD_SSH RuleProtocol = 6 ) // Enum value maps for RuleProtocol. @@ -144,14 +155,16 @@ var ( 3: "UDP", 4: "ICMP", 5: "CUSTOM", + 6: "NETBIRD_SSH", } RuleProtocol_value = map[string]int32{ - "UNKNOWN": 0, - "ALL": 1, - "TCP": 2, - "UDP": 3, - "ICMP": 4, - "CUSTOM": 5, + "UNKNOWN": 0, + "ALL": 1, + "TCP": 2, + "UDP": 3, + "ICMP": 4, + "CUSTOM": 5, + "NETBIRD_SSH": 6, } ) @@ -852,6 +865,12 @@ type SyncResponse struct { // SSO-registered; client clears its anchor // set, valid timestamp → new absolute UTC deadline SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` + // NetworkMapEnvelope carries the component-based wire format for peers that + // advertise PeerCapabilityComponentNetworkMap. When set, NetworkMap (field 5) + // is left empty: management ships components and the client runs Calculate() + // locally instead of receiving an expanded NetworkMap. + NetworkMapEnvelope *NetworkMapEnvelope `protobuf:"bytes,8,opt,name=NetworkMapEnvelope,proto3" json:"NetworkMapEnvelope,omitempty"` + Version int32 `protobuf:"varint,9,opt,name=Version,proto3" json:"Version,omitempty"` } func (x *SyncResponse) Reset() { @@ -935,6 +954,20 @@ func (x *SyncResponse) GetSessionExpiresAt() *timestamppb.Timestamp { return nil } +func (x *SyncResponse) GetNetworkMapEnvelope() *NetworkMapEnvelope { + if x != nil { + return x.NetworkMapEnvelope + } + return nil +} + +func (x *SyncResponse) GetVersion() int32 { + if x != nil { + return x.Version + } + return 0 +} + type SyncMetaRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1423,24 +1456,25 @@ type PeerSystemMeta struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` - GoOS string `protobuf:"bytes,2,opt,name=goOS,proto3" json:"goOS,omitempty"` - Kernel string `protobuf:"bytes,3,opt,name=kernel,proto3" json:"kernel,omitempty"` - Core string `protobuf:"bytes,4,opt,name=core,proto3" json:"core,omitempty"` - Platform string `protobuf:"bytes,5,opt,name=platform,proto3" json:"platform,omitempty"` - OS string `protobuf:"bytes,6,opt,name=OS,proto3" json:"OS,omitempty"` - NetbirdVersion string `protobuf:"bytes,7,opt,name=netbirdVersion,proto3" json:"netbirdVersion,omitempty"` - UiVersion string `protobuf:"bytes,8,opt,name=uiVersion,proto3" json:"uiVersion,omitempty"` - KernelVersion string `protobuf:"bytes,9,opt,name=kernelVersion,proto3" json:"kernelVersion,omitempty"` - OSVersion string `protobuf:"bytes,10,opt,name=OSVersion,proto3" json:"OSVersion,omitempty"` - NetworkAddresses []*NetworkAddress `protobuf:"bytes,11,rep,name=networkAddresses,proto3" json:"networkAddresses,omitempty"` - SysSerialNumber string `protobuf:"bytes,12,opt,name=sysSerialNumber,proto3" json:"sysSerialNumber,omitempty"` - SysProductName string `protobuf:"bytes,13,opt,name=sysProductName,proto3" json:"sysProductName,omitempty"` - SysManufacturer string `protobuf:"bytes,14,opt,name=sysManufacturer,proto3" json:"sysManufacturer,omitempty"` - Environment *Environment `protobuf:"bytes,15,opt,name=environment,proto3" json:"environment,omitempty"` - Files []*File `protobuf:"bytes,16,rep,name=files,proto3" json:"files,omitempty"` - Flags *Flags `protobuf:"bytes,17,opt,name=flags,proto3" json:"flags,omitempty"` - Capabilities []PeerCapability `protobuf:"varint,18,rep,packed,name=capabilities,proto3,enum=management.PeerCapability" json:"capabilities,omitempty"` + Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` + GoOS string `protobuf:"bytes,2,opt,name=goOS,proto3" json:"goOS,omitempty"` + Kernel string `protobuf:"bytes,3,opt,name=kernel,proto3" json:"kernel,omitempty"` + Core string `protobuf:"bytes,4,opt,name=core,proto3" json:"core,omitempty"` + Platform string `protobuf:"bytes,5,opt,name=platform,proto3" json:"platform,omitempty"` + OS string `protobuf:"bytes,6,opt,name=OS,proto3" json:"OS,omitempty"` + NetbirdVersion string `protobuf:"bytes,7,opt,name=netbirdVersion,proto3" json:"netbirdVersion,omitempty"` + UiVersion string `protobuf:"bytes,8,opt,name=uiVersion,proto3" json:"uiVersion,omitempty"` + KernelVersion string `protobuf:"bytes,9,opt,name=kernelVersion,proto3" json:"kernelVersion,omitempty"` + OSVersion string `protobuf:"bytes,10,opt,name=OSVersion,proto3" json:"OSVersion,omitempty"` + NetworkAddresses []*NetworkAddress `protobuf:"bytes,11,rep,name=networkAddresses,proto3" json:"networkAddresses,omitempty"` + SysSerialNumber string `protobuf:"bytes,12,opt,name=sysSerialNumber,proto3" json:"sysSerialNumber,omitempty"` + SysProductName string `protobuf:"bytes,13,opt,name=sysProductName,proto3" json:"sysProductName,omitempty"` + SysManufacturer string `protobuf:"bytes,14,opt,name=sysManufacturer,proto3" json:"sysManufacturer,omitempty"` + Environment *Environment `protobuf:"bytes,15,opt,name=environment,proto3" json:"environment,omitempty"` + Files []*File `protobuf:"bytes,16,rep,name=files,proto3" json:"files,omitempty"` + Flags *Flags `protobuf:"bytes,17,opt,name=flags,proto3" json:"flags,omitempty"` + Capabilities []PeerCapability `protobuf:"varint,18,rep,packed,name=capabilities,proto3,enum=management.PeerCapability" json:"capabilities,omitempty"` + SyncMessageVersion int32 `protobuf:"varint,19,opt,name=syncMessageVersion,proto3" json:"syncMessageVersion,omitempty"` } func (x *PeerSystemMeta) Reset() { @@ -1601,6 +1635,13 @@ func (x *PeerSystemMeta) GetCapabilities() []PeerCapability { return nil } +func (x *PeerSystemMeta) GetSyncMessageVersion() int32 { + if x != nil { + return x.SyncMessageVersion + } + return 0 +} + type LoginResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -4682,6 +4723,1936 @@ func (*StopExposeResponse) Descriptor() ([]byte, []int) { return file_management_proto_rawDescGZIP(), []int{55} } +// NetworkMapEnvelope wraps either a full snapshot or a delta. Only Full is +// emitted today; Delta is reserved for the incremental-update work. +type NetworkMapEnvelope struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Payload: + // + // *NetworkMapEnvelope_Full + // *NetworkMapEnvelope_Delta + Payload isNetworkMapEnvelope_Payload `protobuf_oneof:"payload"` +} + +func (x *NetworkMapEnvelope) Reset() { + *x = NetworkMapEnvelope{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[56] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkMapEnvelope) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkMapEnvelope) ProtoMessage() {} + +func (x *NetworkMapEnvelope) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[56] + 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 NetworkMapEnvelope.ProtoReflect.Descriptor instead. +func (*NetworkMapEnvelope) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{56} +} + +func (m *NetworkMapEnvelope) GetPayload() isNetworkMapEnvelope_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *NetworkMapEnvelope) GetFull() *NetworkMapComponentsFull { + if x, ok := x.GetPayload().(*NetworkMapEnvelope_Full); ok { + return x.Full + } + return nil +} + +func (x *NetworkMapEnvelope) GetDelta() *NetworkMapComponentsDelta { + if x, ok := x.GetPayload().(*NetworkMapEnvelope_Delta); ok { + return x.Delta + } + return nil +} + +type isNetworkMapEnvelope_Payload interface { + isNetworkMapEnvelope_Payload() +} + +type NetworkMapEnvelope_Full struct { + Full *NetworkMapComponentsFull `protobuf:"bytes,1,opt,name=full,proto3,oneof"` +} + +type NetworkMapEnvelope_Delta struct { + Delta *NetworkMapComponentsDelta `protobuf:"bytes,2,opt,name=delta,proto3,oneof"` +} + +func (*NetworkMapEnvelope_Full) isNetworkMapEnvelope_Payload() {} + +func (*NetworkMapEnvelope_Delta) isNetworkMapEnvelope_Payload() {} + +// NetworkMapComponentsFull is the full per-peer component snapshot. The +// client decodes it into a types.NetworkMapComponents and runs Calculate() +// locally to produce the same NetworkMap the legacy server path would have +// produced. Every field carries RAW component data — no server-side +// expansion (firewall rules, DNS config, SSH auth, route firewall rules, +// forwarding rules) is shipped; the client computes those itself. +type NetworkMapComponentsFull struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Serial uint64 `protobuf:"varint,1,opt,name=serial,proto3" json:"serial,omitempty"` + // Peer config for the receiving peer (legacy proto.PeerConfig kept as-is — + // it carries the receiving peer's own overlay address, FQDN, SSH config). + PeerConfig *PeerConfig `protobuf:"bytes,2,opt,name=peer_config,json=peerConfig,proto3" json:"peer_config,omitempty"` + // Account-level network metadata (id, IPv4/IPv6 overlay subnets, DNS, + // serial). Mirrors types.Network. + Network *AccountNetwork `protobuf:"bytes,3,opt,name=network,proto3" json:"network,omitempty"` + // Account-level settings the client needs for its local Calculate(). + AccountSettings *AccountSettingsCompact `protobuf:"bytes,4,opt,name=account_settings,json=accountSettings,proto3" json:"account_settings,omitempty"` + // Account DNS settings (mirrors types.DNSSettings). + DnsSettings *DNSSettingsCompact `protobuf:"bytes,5,opt,name=dns_settings,json=dnsSettings,proto3" json:"dns_settings,omitempty"` + // Domain shared across all peers in this account, e.g. "netbird.cloud". + // Each peer's FQDN is dns_label + "." + dns_domain. + DnsDomain string `protobuf:"bytes,6,opt,name=dns_domain,json=dnsDomain,proto3" json:"dns_domain,omitempty"` + // Custom-zone domain for this peer's view (c.CustomZoneDomain). Empty when + // the peer has no custom zone records. + CustomZoneDomain string `protobuf:"bytes,7,opt,name=custom_zone_domain,json=customZoneDomain,proto3" json:"custom_zone_domain,omitempty"` + // Deduplicated agent versions; PeerCompact.agent_version_idx indexes here. + // Empty string at index 0 if any peer has no version. + AgentVersions []string `protobuf:"bytes,8,rep,name=agent_versions,json=agentVersions,proto3" json:"agent_versions,omitempty"` + // All peers (deduplicated). The client splits peers into online / offline + // locally using account_settings.peer_login_expiration on receive. + Peers []*PeerCompact `protobuf:"bytes,9,rep,name=peers,proto3" json:"peers,omitempty"` + // Indexes into peers for the subset that may act as routers. + RouterPeerIndexes []uint32 `protobuf:"varint,10,rep,packed,name=router_peer_indexes,json=routerPeerIndexes,proto3" json:"router_peer_indexes,omitempty"` + // Policies that affect the receiving peer. + Policies []*PolicyCompact `protobuf:"bytes,11,rep,name=policies,proto3" json:"policies,omitempty"` + // Groups in unspecified order — clients key off id (public_id). + Groups []*GroupCompact `protobuf:"bytes,12,rep,name=groups,proto3" json:"groups,omitempty"` + // Routes relevant to this peer, raw shape (mirrors []*route.Route). + Routes []*RouteRaw `protobuf:"bytes,13,rep,name=routes,proto3" json:"routes,omitempty"` + // Nameserver groups (mirrors []*nbdns.NameServerGroup). + NameserverGroups []*NameServerGroupRaw `protobuf:"bytes,14,rep,name=nameserver_groups,json=nameserverGroups,proto3" json:"nameserver_groups,omitempty"` + // All DNS records the client needs to assemble its custom zone. Reuses + // the existing SimpleRecord wire shape. + AllDnsRecords []*SimpleRecord `protobuf:"bytes,15,rep,name=all_dns_records,json=allDnsRecords,proto3" json:"all_dns_records,omitempty"` + // Custom zones (typically the peer's own zone). Reuses the existing + // CustomZone wire shape. + AccountZones []*CustomZone `protobuf:"bytes,16,rep,name=account_zones,json=accountZones,proto3" json:"account_zones,omitempty"` + // Network resources (mirrors []*resourceTypes.NetworkResource). + NetworkResources []*NetworkResourceRaw `protobuf:"bytes,17,rep,name=network_resources,json=networkResources,proto3" json:"network_resources,omitempty"` + // Routers per network. Outer key: network public_id. Each entry is + // the set of routers backing that network for this peer's view. + RoutersMap map[string]*NetworkRouterList `protobuf:"bytes,18,rep,name=routers_map,json=routersMap,proto3" json:"routers_map,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // For each NetworkResource public_id, the indexes into policies[] + // that apply to it. + ResourcePoliciesMap map[string]*PolicyIds `protobuf:"bytes,19,rep,name=resource_policies_map,json=resourcePoliciesMap,proto3" json:"resource_policies_map,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // Group-id (public_id) → user ids authorized for SSH on members. + GroupIdToUserIds map[string]*UserIDList `protobuf:"bytes,20,rep,name=group_id_to_user_ids,json=groupIdToUserIds,proto3" json:"group_id_to_user_ids,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // Account-level allowed user ids (used by Calculate() when assembling SSH + // authorized users for the receiving peer). + AllowedUserIds []string `protobuf:"bytes,21,rep,name=allowed_user_ids,json=allowedUserIds,proto3" json:"allowed_user_ids,omitempty"` + // Per posture-check public_id, the set of peer indexes that failed + // the check. Server-side evaluation result; clients do not re-evaluate. + PostureFailedPeers map[string]*PeerIndexSet `protobuf:"bytes,22,rep,name=posture_failed_peers,json=postureFailedPeers,proto3" json:"posture_failed_peers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // Account-level DNS forwarder port (mirrors the legacy + // proto.DNSConfig.ForwarderPort). Computed by the controller from peer + // versions; clients fold it into their Calculate() DNS output. + DnsForwarderPort int64 `protobuf:"varint,23,opt,name=dns_forwarder_port,json=dnsForwarderPort,proto3" json:"dns_forwarder_port,omitempty"` + // Pre-expanded NetworkMap fragments injected post-Calculate by external + // controllers (BYOP / port-forwarding proxies). The receiving client + // merges these into its locally-computed NetworkMap the same way the + // legacy server does via NetworkMap.Merge — so downstream consumers see + // a unified merged result regardless of source. + ProxyPatch *ProxyPatch `protobuf:"bytes,24,opt,name=proxy_patch,json=proxyPatch,proto3" json:"proxy_patch,omitempty"` + // SSH UserIDClaim — server-side HttpServerConfig.AuthUserIDClaim, or + // "sub" by default. Populated in proto.SSHAuth.UserIDClaim when the + // client rebuilds the NetworkMap from this envelope. Empty when the + // account has no AuthorizedUsers (and thus no SshAuth to populate). + UserIdClaim string `protobuf:"bytes,25,opt,name=user_id_claim,json=userIdClaim,proto3" json:"user_id_claim,omitempty"` +} + +func (x *NetworkMapComponentsFull) Reset() { + *x = NetworkMapComponentsFull{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[57] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkMapComponentsFull) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkMapComponentsFull) ProtoMessage() {} + +func (x *NetworkMapComponentsFull) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[57] + 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 NetworkMapComponentsFull.ProtoReflect.Descriptor instead. +func (*NetworkMapComponentsFull) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{57} +} + +func (x *NetworkMapComponentsFull) GetSerial() uint64 { + if x != nil { + return x.Serial + } + return 0 +} + +func (x *NetworkMapComponentsFull) GetPeerConfig() *PeerConfig { + if x != nil { + return x.PeerConfig + } + return nil +} + +func (x *NetworkMapComponentsFull) GetNetwork() *AccountNetwork { + if x != nil { + return x.Network + } + return nil +} + +func (x *NetworkMapComponentsFull) GetAccountSettings() *AccountSettingsCompact { + if x != nil { + return x.AccountSettings + } + return nil +} + +func (x *NetworkMapComponentsFull) GetDnsSettings() *DNSSettingsCompact { + if x != nil { + return x.DnsSettings + } + return nil +} + +func (x *NetworkMapComponentsFull) GetDnsDomain() string { + if x != nil { + return x.DnsDomain + } + return "" +} + +func (x *NetworkMapComponentsFull) GetCustomZoneDomain() string { + if x != nil { + return x.CustomZoneDomain + } + return "" +} + +func (x *NetworkMapComponentsFull) GetAgentVersions() []string { + if x != nil { + return x.AgentVersions + } + return nil +} + +func (x *NetworkMapComponentsFull) GetPeers() []*PeerCompact { + if x != nil { + return x.Peers + } + return nil +} + +func (x *NetworkMapComponentsFull) GetRouterPeerIndexes() []uint32 { + if x != nil { + return x.RouterPeerIndexes + } + return nil +} + +func (x *NetworkMapComponentsFull) GetPolicies() []*PolicyCompact { + if x != nil { + return x.Policies + } + return nil +} + +func (x *NetworkMapComponentsFull) GetGroups() []*GroupCompact { + if x != nil { + return x.Groups + } + return nil +} + +func (x *NetworkMapComponentsFull) GetRoutes() []*RouteRaw { + if x != nil { + return x.Routes + } + return nil +} + +func (x *NetworkMapComponentsFull) GetNameserverGroups() []*NameServerGroupRaw { + if x != nil { + return x.NameserverGroups + } + return nil +} + +func (x *NetworkMapComponentsFull) GetAllDnsRecords() []*SimpleRecord { + if x != nil { + return x.AllDnsRecords + } + return nil +} + +func (x *NetworkMapComponentsFull) GetAccountZones() []*CustomZone { + if x != nil { + return x.AccountZones + } + return nil +} + +func (x *NetworkMapComponentsFull) GetNetworkResources() []*NetworkResourceRaw { + if x != nil { + return x.NetworkResources + } + return nil +} + +func (x *NetworkMapComponentsFull) GetRoutersMap() map[string]*NetworkRouterList { + if x != nil { + return x.RoutersMap + } + return nil +} + +func (x *NetworkMapComponentsFull) GetResourcePoliciesMap() map[string]*PolicyIds { + if x != nil { + return x.ResourcePoliciesMap + } + return nil +} + +func (x *NetworkMapComponentsFull) GetGroupIdToUserIds() map[string]*UserIDList { + if x != nil { + return x.GroupIdToUserIds + } + return nil +} + +func (x *NetworkMapComponentsFull) GetAllowedUserIds() []string { + if x != nil { + return x.AllowedUserIds + } + return nil +} + +func (x *NetworkMapComponentsFull) GetPostureFailedPeers() map[string]*PeerIndexSet { + if x != nil { + return x.PostureFailedPeers + } + return nil +} + +func (x *NetworkMapComponentsFull) GetDnsForwarderPort() int64 { + if x != nil { + return x.DnsForwarderPort + } + return 0 +} + +func (x *NetworkMapComponentsFull) GetProxyPatch() *ProxyPatch { + if x != nil { + return x.ProxyPatch + } + return nil +} + +func (x *NetworkMapComponentsFull) GetUserIdClaim() string { + if x != nil { + return x.UserIdClaim + } + return "" +} + +// ProxyPatch carries NetworkMap fragments that don't fit the component-graph +// model — they're pre-expanded by external controllers (BYOP / +// port-forwarding proxies) and injected post-Calculate. Fields use the +// legacy wire types because the proxy delivers them pre-formed; there is +// no raw component shape to convert from. Empty when no proxy is active. +type ProxyPatch struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Peers []*RemotePeerConfig `protobuf:"bytes,1,rep,name=peers,proto3" json:"peers,omitempty"` + OfflinePeers []*RemotePeerConfig `protobuf:"bytes,2,rep,name=offline_peers,json=offlinePeers,proto3" json:"offline_peers,omitempty"` + FirewallRules []*FirewallRule `protobuf:"bytes,3,rep,name=firewall_rules,json=firewallRules,proto3" json:"firewall_rules,omitempty"` + Routes []*Route `protobuf:"bytes,4,rep,name=routes,proto3" json:"routes,omitempty"` + RouteFirewallRules []*RouteFirewallRule `protobuf:"bytes,5,rep,name=route_firewall_rules,json=routeFirewallRules,proto3" json:"route_firewall_rules,omitempty"` + ForwardingRules []*ForwardingRule `protobuf:"bytes,6,rep,name=forwarding_rules,json=forwardingRules,proto3" json:"forwarding_rules,omitempty"` +} + +func (x *ProxyPatch) Reset() { + *x = ProxyPatch{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[58] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProxyPatch) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProxyPatch) ProtoMessage() {} + +func (x *ProxyPatch) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[58] + 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 ProxyPatch.ProtoReflect.Descriptor instead. +func (*ProxyPatch) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{58} +} + +func (x *ProxyPatch) GetPeers() []*RemotePeerConfig { + if x != nil { + return x.Peers + } + return nil +} + +func (x *ProxyPatch) GetOfflinePeers() []*RemotePeerConfig { + if x != nil { + return x.OfflinePeers + } + return nil +} + +func (x *ProxyPatch) GetFirewallRules() []*FirewallRule { + if x != nil { + return x.FirewallRules + } + return nil +} + +func (x *ProxyPatch) GetRoutes() []*Route { + if x != nil { + return x.Routes + } + return nil +} + +func (x *ProxyPatch) GetRouteFirewallRules() []*RouteFirewallRule { + if x != nil { + return x.RouteFirewallRules + } + return nil +} + +func (x *ProxyPatch) GetForwardingRules() []*ForwardingRule { + if x != nil { + return x.ForwardingRules + } + return nil +} + +// AccountSettingsCompact carries the account-level settings the client needs +// to evaluate locally. Mirrors the subset of types.AccountSettingsInfo that +// Calculate() actually reads — login-expiration (used to filter expired +// peers). Inactivity expiration is purely server-side bookkeeping and is not +// shipped. +type AccountSettingsCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + PeerLoginExpirationEnabled bool `protobuf:"varint,1,opt,name=peer_login_expiration_enabled,json=peerLoginExpirationEnabled,proto3" json:"peer_login_expiration_enabled,omitempty"` + // Login expiration window. Unit is nanoseconds (matches time.Duration). + PeerLoginExpirationNs int64 `protobuf:"varint,2,opt,name=peer_login_expiration_ns,json=peerLoginExpirationNs,proto3" json:"peer_login_expiration_ns,omitempty"` +} + +func (x *AccountSettingsCompact) Reset() { + *x = AccountSettingsCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[59] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AccountSettingsCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountSettingsCompact) ProtoMessage() {} + +func (x *AccountSettingsCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[59] + 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 AccountSettingsCompact.ProtoReflect.Descriptor instead. +func (*AccountSettingsCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{59} +} + +func (x *AccountSettingsCompact) GetPeerLoginExpirationEnabled() bool { + if x != nil { + return x.PeerLoginExpirationEnabled + } + return false +} + +func (x *AccountSettingsCompact) GetPeerLoginExpirationNs() int64 { + if x != nil { + return x.PeerLoginExpirationNs + } + return 0 +} + +// AccountNetwork is the account-level overlay metadata. Mirrors types.Network +// so the client can populate NetworkMap.Network without a server round-trip. +type AccountNetwork struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Identifier string `protobuf:"bytes,1,opt,name=identifier,proto3" json:"identifier,omitempty"` + // IPv4 overlay subnet in CIDR form (e.g. "100.64.0.0/16"). + NetCidr string `protobuf:"bytes,2,opt,name=net_cidr,json=netCidr,proto3" json:"net_cidr,omitempty"` + // IPv6 ULA overlay subnet in CIDR form (e.g. "fd00:4e42::/64"). Empty when + // the account has no IPv6 overlay yet. + NetV6Cidr string `protobuf:"bytes,3,opt,name=net_v6_cidr,json=netV6Cidr,proto3" json:"net_v6_cidr,omitempty"` + Dns string `protobuf:"bytes,4,opt,name=dns,proto3" json:"dns,omitempty"` + Serial uint64 `protobuf:"varint,5,opt,name=serial,proto3" json:"serial,omitempty"` +} + +func (x *AccountNetwork) Reset() { + *x = AccountNetwork{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[60] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AccountNetwork) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountNetwork) ProtoMessage() {} + +func (x *AccountNetwork) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[60] + 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 AccountNetwork.ProtoReflect.Descriptor instead. +func (*AccountNetwork) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{60} +} + +func (x *AccountNetwork) GetIdentifier() string { + if x != nil { + return x.Identifier + } + return "" +} + +func (x *AccountNetwork) GetNetCidr() string { + if x != nil { + return x.NetCidr + } + return "" +} + +func (x *AccountNetwork) GetNetV6Cidr() string { + if x != nil { + return x.NetV6Cidr + } + return "" +} + +func (x *AccountNetwork) GetDns() string { + if x != nil { + return x.Dns + } + return "" +} + +func (x *AccountNetwork) GetSerial() uint64 { + if x != nil { + return x.Serial + } + return 0 +} + +// NetworkMapComponentsDelta is reserved for the incremental update +// protocol. Field numbers 1–100 are pre-allocated to keep room for the +// planned event types without needing a renumber. +type NetworkMapComponentsDelta struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *NetworkMapComponentsDelta) Reset() { + *x = NetworkMapComponentsDelta{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[61] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkMapComponentsDelta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkMapComponentsDelta) ProtoMessage() {} + +func (x *NetworkMapComponentsDelta) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[61] + 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 NetworkMapComponentsDelta.ProtoReflect.Descriptor instead. +func (*NetworkMapComponentsDelta) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{61} +} + +// PeerCompact is the wire-shape of a remote peer used by the component +// format. It carries every field of types.Peer that the client's local +// Calculate() reads — including the trio needed to evaluate +// LoginExpired() (added_with_sso_login + login_expiration_enabled + +// last_login_unix_nano). Fields the client does not consume (Status, +// CreatedAt, etc.) are not shipped. +type PeerCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Raw 32-byte WireGuard public key (no base64 wrapping). + WgPubKey []byte `protobuf:"bytes,1,opt,name=wg_pub_key,json=wgPubKey,proto3" json:"wg_pub_key,omitempty"` + // Raw 4-byte IPv4 overlay address. Always a /32 host route, so no prefix + // byte is needed. + Ip []byte `protobuf:"bytes,2,opt,name=ip,proto3" json:"ip,omitempty"` + // Raw 16-byte IPv6 overlay address; always a /128 host route. Empty when + // the peer has no IPv6 overlay address. + Ipv6 []byte `protobuf:"bytes,3,opt,name=ipv6,proto3" json:"ipv6,omitempty"` + // Raw SSH public key bytes (or empty). + SshPubKey []byte `protobuf:"bytes,4,opt,name=ssh_pub_key,json=sshPubKey,proto3" json:"ssh_pub_key,omitempty"` + // DNS label without the account's domain suffix. Full FQDN is + // dns_label + "." + NetworkMapComponentsFull.dns_domain. + DnsLabel string `protobuf:"bytes,5,opt,name=dns_label,json=dnsLabel,proto3" json:"dns_label,omitempty"` + AgentVersion string `protobuf:"bytes,6,opt,name=agent_version,json=agentVersion,proto3" json:"agent_version,omitempty"` + // True iff the peer was added via SSO login (i.e., types.Peer.UserID is + // non-empty). Combined with login_expiration_enabled and + // last_login_unix_nano this lets the client reproduce + // (*Peer).LoginExpired() locally. + AddedWithSsoLogin bool `protobuf:"varint,7,opt,name=added_with_sso_login,json=addedWithSsoLogin,proto3" json:"added_with_sso_login,omitempty"` + // True when the peer's login can expire — mirrors + // types.Peer.LoginExpirationEnabled. + LoginExpirationEnabled bool `protobuf:"varint,8,opt,name=login_expiration_enabled,json=loginExpirationEnabled,proto3" json:"login_expiration_enabled,omitempty"` + // Unix-nanosecond timestamp of the peer's last login. 0 when the peer has + // never logged in (server stores nil; client treats 0 as "epoch", which + // makes a fresh peer immediately expired iff login_expiration_enabled is + // true — the same semantics as types.Peer.GetLastLogin). + LastLoginUnixNano int64 `protobuf:"varint,9,opt,name=last_login_unix_nano,json=lastLoginUnixNano,proto3" json:"last_login_unix_nano,omitempty"` + // True when the peer has an SSH server enabled locally. Used by the + // legacy SSH path in Calculate() (`policyRuleImpliesLegacySSH`): a rule + // with protocol ALL/TCP-with-SSH-ports activates SSH for the receiving + // peer when this bit is set, even without an explicit NetbirdSSH rule. + SshEnabled bool `protobuf:"varint,10,opt,name=ssh_enabled,json=sshEnabled,proto3" json:"ssh_enabled,omitempty"` + // Mirror of types.Peer.SupportsIPv6() — !Meta.Flags.DisableIPv6 && + // HasCapability(PeerCapabilityIPv6Overlay). Used by the local peer's + // Calculate() when deciding whether to emit IPv6 firewall rules + // (appendIPv6FirewallRule) against this peer's IPv6 address. + SupportsIpv6 bool `protobuf:"varint,11,opt,name=supports_ipv6,json=supportsIpv6,proto3" json:"supports_ipv6,omitempty"` + // Mirror of types.Peer.SupportsSourcePrefixes() — + // HasCapability(PeerCapabilitySourcePrefixes). Determines whether the + // local peer's Calculate() emits SourcePrefixes alongside legacy PeerIP + // fields in proto.FirewallRule. + SupportsSourcePrefixes bool `protobuf:"varint,12,opt,name=supports_source_prefixes,json=supportsSourcePrefixes,proto3" json:"supports_source_prefixes,omitempty"` + // Mirror of types.Peer.Meta.Flags.ServerSSHAllowed. Read by Calculate() + // when expanding TCP port-22 firewall rules — the native SSH companion + // (port 22022) is only added when this flag is set and the peer agent + // version supports it. + ServerSshAllowed bool `protobuf:"varint,13,opt,name=server_ssh_allowed,json=serverSshAllowed,proto3" json:"server_ssh_allowed,omitempty"` +} + +func (x *PeerCompact) Reset() { + *x = PeerCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[62] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PeerCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PeerCompact) ProtoMessage() {} + +func (x *PeerCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[62] + 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 PeerCompact.ProtoReflect.Descriptor instead. +func (*PeerCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{62} +} + +func (x *PeerCompact) GetWgPubKey() []byte { + if x != nil { + return x.WgPubKey + } + return nil +} + +func (x *PeerCompact) GetIp() []byte { + if x != nil { + return x.Ip + } + return nil +} + +func (x *PeerCompact) GetIpv6() []byte { + if x != nil { + return x.Ipv6 + } + return nil +} + +func (x *PeerCompact) GetSshPubKey() []byte { + if x != nil { + return x.SshPubKey + } + return nil +} + +func (x *PeerCompact) GetDnsLabel() string { + if x != nil { + return x.DnsLabel + } + return "" +} + +func (x *PeerCompact) GetAgentVersion() string { + if x != nil { + return x.AgentVersion + } + return "" +} + +func (x *PeerCompact) GetAddedWithSsoLogin() bool { + if x != nil { + return x.AddedWithSsoLogin + } + return false +} + +func (x *PeerCompact) GetLoginExpirationEnabled() bool { + if x != nil { + return x.LoginExpirationEnabled + } + return false +} + +func (x *PeerCompact) GetLastLoginUnixNano() int64 { + if x != nil { + return x.LastLoginUnixNano + } + return 0 +} + +func (x *PeerCompact) GetSshEnabled() bool { + if x != nil { + return x.SshEnabled + } + return false +} + +func (x *PeerCompact) GetSupportsIpv6() bool { + if x != nil { + return x.SupportsIpv6 + } + return false +} + +func (x *PeerCompact) GetSupportsSourcePrefixes() bool { + if x != nil { + return x.SupportsSourcePrefixes + } + return false +} + +func (x *PeerCompact) GetServerSshAllowed() bool { + if x != nil { + return x.ServerSshAllowed + } + return false +} + +// PolicyCompact is the compact form of a policy rule. Group references use +// the public_ids; the client resolves +// them against NetworkMapComponentsFull.groups. Direction is derived per-peer +// on the client (ingress when the peer is in destination_group_ids, egress +// when in source_group_ids; both when bidirectional). +type PolicyCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // public_id. Used as a stable reference for + // ResourcePoliciesMap.indexes and future delta updates. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Action RuleAction `protobuf:"varint,2,opt,name=action,proto3,enum=management.RuleAction" json:"action,omitempty"` + Protocol RuleProtocol `protobuf:"varint,3,opt,name=protocol,proto3,enum=management.RuleProtocol" json:"protocol,omitempty"` + Bidirectional bool `protobuf:"varint,4,opt,name=bidirectional,proto3" json:"bidirectional,omitempty"` + // Single ports referenced by the rule. + Ports []uint32 `protobuf:"varint,5,rep,packed,name=ports,proto3" json:"ports,omitempty"` + // Port ranges (start..end) referenced by the rule. + PortRanges []*PortInfo_Range `protobuf:"bytes,6,rep,name=port_ranges,json=portRanges,proto3" json:"port_ranges,omitempty"` + // Group ids (public_ids) of source / destination groups. + SourceGroupIds []string `protobuf:"bytes,7,rep,name=source_group_ids,json=sourceGroupIds,proto3" json:"source_group_ids,omitempty"` + DestinationGroupIds []string `protobuf:"bytes,8,rep,name=destination_group_ids,json=destinationGroupIds,proto3" json:"destination_group_ids,omitempty"` + // SSH authorization fields. PolicyRule.AuthorizedGroups maps the rule's + // applicable group ids (public_ids) to a list of local-user names — + // when a peer in one of those groups is the SSH destination, the named + // local users gain access. AuthorizedUser is the single-user form + // (legacy: rule scopes SSH to one specific user id). + // + // Both fields are only consumed by Calculate() when the rule's protocol + // is NetbirdSSH (or the legacy implicit-SSH heuristic). + AuthorizedGroups map[string]*UserNameList `protobuf:"bytes,9,rep,name=authorized_groups,json=authorizedGroups,proto3" json:"authorized_groups,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + AuthorizedUser string `protobuf:"bytes,10,opt,name=authorized_user,json=authorizedUser,proto3" json:"authorized_user,omitempty"` + // Resource-typed rule sources/destinations. When a rule targets a specific + // peer (rather than groups), Calculate() reads SourceResource / + // DestinationResource — without these the rule's connection resources + // can't be produced on the client. ResourceCompact's peer_index refers to + // NetworkMapComponentsFull.peers; type is the raw ResourceType string + // ("peer", "host", "subnet", "domain"). Only "peer" is meaningful for + // Calculate's resource-typed rule path today. + SourceResource *ResourceCompact `protobuf:"bytes,11,opt,name=source_resource,json=sourceResource,proto3" json:"source_resource,omitempty"` + DestinationResource *ResourceCompact `protobuf:"bytes,12,opt,name=destination_resource,json=destinationResource,proto3" json:"destination_resource,omitempty"` + // Posture-check ids gating this policy's source peers. Calculate() + // reads them when filtering rule peers (peers that fail any listed check + // are dropped from sourcePeers). Match keys in + // NetworkMapComponentsFull.posture_failed_peers. + SourcePostureCheckIds []string `protobuf:"bytes,13,rep,name=source_posture_check_ids,json=sourcePostureCheckIds,proto3" json:"source_posture_check_ids,omitempty"` +} + +func (x *PolicyCompact) Reset() { + *x = PolicyCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[63] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PolicyCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyCompact) ProtoMessage() {} + +func (x *PolicyCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[63] + 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 PolicyCompact.ProtoReflect.Descriptor instead. +func (*PolicyCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{63} +} + +func (x *PolicyCompact) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *PolicyCompact) GetAction() RuleAction { + if x != nil { + return x.Action + } + return RuleAction_ACCEPT +} + +func (x *PolicyCompact) GetProtocol() RuleProtocol { + if x != nil { + return x.Protocol + } + return RuleProtocol_UNKNOWN +} + +func (x *PolicyCompact) GetBidirectional() bool { + if x != nil { + return x.Bidirectional + } + return false +} + +func (x *PolicyCompact) GetPorts() []uint32 { + if x != nil { + return x.Ports + } + return nil +} + +func (x *PolicyCompact) GetPortRanges() []*PortInfo_Range { + if x != nil { + return x.PortRanges + } + return nil +} + +func (x *PolicyCompact) GetSourceGroupIds() []string { + if x != nil { + return x.SourceGroupIds + } + return nil +} + +func (x *PolicyCompact) GetDestinationGroupIds() []string { + if x != nil { + return x.DestinationGroupIds + } + return nil +} + +func (x *PolicyCompact) GetAuthorizedGroups() map[string]*UserNameList { + if x != nil { + return x.AuthorizedGroups + } + return nil +} + +func (x *PolicyCompact) GetAuthorizedUser() string { + if x != nil { + return x.AuthorizedUser + } + return "" +} + +func (x *PolicyCompact) GetSourceResource() *ResourceCompact { + if x != nil { + return x.SourceResource + } + return nil +} + +func (x *PolicyCompact) GetDestinationResource() *ResourceCompact { + if x != nil { + return x.DestinationResource + } + return nil +} + +func (x *PolicyCompact) GetSourcePostureCheckIds() []string { + if x != nil { + return x.SourcePostureCheckIds + } + return nil +} + +// ResourceCompact mirrors types.Resource. Used by PolicyCompact to carry +// rule.SourceResource / rule.DestinationResource when the rule targets a +// specific resource (typically a peer) rather than groups. +// peer_index_set tells whether peer_index is valid (proto3 uint32 cannot +// disambiguate "0" from "unset"); set only when type == "peer". +type ResourceCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + PeerIndexSet bool `protobuf:"varint,2,opt,name=peer_index_set,json=peerIndexSet,proto3" json:"peer_index_set,omitempty"` + PeerIndex uint32 `protobuf:"varint,3,opt,name=peer_index,json=peerIndex,proto3" json:"peer_index,omitempty"` +} + +func (x *ResourceCompact) Reset() { + *x = ResourceCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[64] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResourceCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceCompact) ProtoMessage() {} + +func (x *ResourceCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[64] + 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 ResourceCompact.ProtoReflect.Descriptor instead. +func (*ResourceCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{64} +} + +func (x *ResourceCompact) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *ResourceCompact) GetPeerIndexSet() bool { + if x != nil { + return x.PeerIndexSet + } + return false +} + +func (x *ResourceCompact) GetPeerIndex() uint32 { + if x != nil { + return x.PeerIndex + } + return 0 +} + +// UserNameList is a list of local-user names — used as the value type in +// PolicyCompact.authorized_groups. +type UserNameList struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Names []string `protobuf:"bytes,1,rep,name=names,proto3" json:"names,omitempty"` +} + +func (x *UserNameList) Reset() { + *x = UserNameList{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[65] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UserNameList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserNameList) ProtoMessage() {} + +func (x *UserNameList) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[65] + 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 UserNameList.ProtoReflect.Descriptor instead. +func (*UserNameList) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{65} +} + +func (x *UserNameList) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + +// GroupCompact is the wire-shape of a group: public id, optional +// name, and indexes into NetworkMapComponentsFull.peers identifying members. +type GroupCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // id comes from PublicID. Used by PolicyCompact.source_group_ids / destination_group_ids. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Indexes into NetworkMapComponentsFull.peers. + PeerIndexes []uint32 `protobuf:"varint,2,rep,packed,name=peer_indexes,json=peerIndexes,proto3" json:"peer_indexes,omitempty"` + // True when the group is named "All" (types.Group.IsGroupAll). The + // client-side Calculate short-circuits group→peer expansion on such + // groups exactly like the server does; without this bit the decoded + // groups lose that property and the two sides expand policy + // destinations differently. + IsAll bool `protobuf:"varint,3,opt,name=is_all,json=isAll,proto3" json:"is_all,omitempty"` +} + +func (x *GroupCompact) Reset() { + *x = GroupCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[66] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GroupCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GroupCompact) ProtoMessage() {} + +func (x *GroupCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[66] + 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 GroupCompact.ProtoReflect.Descriptor instead. +func (*GroupCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{66} +} + +func (x *GroupCompact) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GroupCompact) GetPeerIndexes() []uint32 { + if x != nil { + return x.PeerIndexes + } + return nil +} + +func (x *GroupCompact) GetIsAll() bool { + if x != nil { + return x.IsAll + } + return false +} + +// DNSSettingsCompact mirrors types.DNSSettings. +type DNSSettingsCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Group ids (public_id) whose DNS management is disabled. + DisabledManagementGroupIds []string `protobuf:"bytes,1,rep,name=disabled_management_group_ids,json=disabledManagementGroupIds,proto3" json:"disabled_management_group_ids,omitempty"` +} + +func (x *DNSSettingsCompact) Reset() { + *x = DNSSettingsCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[67] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DNSSettingsCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DNSSettingsCompact) ProtoMessage() {} + +func (x *DNSSettingsCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[67] + 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 DNSSettingsCompact.ProtoReflect.Descriptor instead. +func (*DNSSettingsCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{67} +} + +func (x *DNSSettingsCompact) GetDisabledManagementGroupIds() []string { + if x != nil { + return x.DisabledManagementGroupIds + } + return nil +} + +// RouteRaw mirrors *route.Route (the domain type), trimmed to fields that +// types.NetworkMapComponents.Calculate() reads. Group references are +// public_ids; the routing peer (when set) is referenced by index into +// NetworkMapComponentsFull.peers. +type RouteRaw struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // public_id + NetId string `protobuf:"bytes,2,opt,name=net_id,json=netId,proto3" json:"net_id,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + // Either network_cidr (e.g. "10.0.0.0/16") or domains is set, not both. + NetworkCidr string `protobuf:"bytes,4,opt,name=network_cidr,json=networkCidr,proto3" json:"network_cidr,omitempty"` + Domains []string `protobuf:"bytes,5,rep,name=domains,proto3" json:"domains,omitempty"` + KeepRoute bool `protobuf:"varint,6,opt,name=keep_route,json=keepRoute,proto3" json:"keep_route,omitempty"` + // Routing peer reference: peer_index_set tells whether peer_index is valid + // (proto3 uint32 cannot disambiguate "0" from "unset"). Mutually exclusive + // with peer_group_ids. + // + // peer_index decodes back to types.Peer.ID (the peer's xid string), NOT + // to its WireGuard public key. This matches the server-side data flow: + // c.Routes carry route.Peer = peer.ID, and getRoutingPeerRoutes mutates + // it to peer.Key only after the route has been admitted to the network + // map. Decoders MUST set Route.Peer = peer.ID; the legacy Calculate() + // path will substitute the WG key downstream. + PeerIndexSet bool `protobuf:"varint,7,opt,name=peer_index_set,json=peerIndexSet,proto3" json:"peer_index_set,omitempty"` + PeerIndex uint32 `protobuf:"varint,8,opt,name=peer_index,json=peerIndex,proto3" json:"peer_index,omitempty"` + PeerGroupIds []string `protobuf:"bytes,9,rep,name=peer_group_ids,json=peerGroupIds,proto3" json:"peer_group_ids,omitempty"` + NetworkType int32 `protobuf:"varint,10,opt,name=network_type,json=networkType,proto3" json:"network_type,omitempty"` + Masquerade bool `protobuf:"varint,11,opt,name=masquerade,proto3" json:"masquerade,omitempty"` + Metric int32 `protobuf:"varint,12,opt,name=metric,proto3" json:"metric,omitempty"` + Enabled bool `protobuf:"varint,13,opt,name=enabled,proto3" json:"enabled,omitempty"` + GroupIds []string `protobuf:"bytes,14,rep,name=group_ids,json=groupIds,proto3" json:"group_ids,omitempty"` + AccessControlGroupIds []string `protobuf:"bytes,15,rep,name=access_control_group_ids,json=accessControlGroupIds,proto3" json:"access_control_group_ids,omitempty"` + SkipAutoApply bool `protobuf:"varint,16,opt,name=skip_auto_apply,json=skipAutoApply,proto3" json:"skip_auto_apply,omitempty"` +} + +func (x *RouteRaw) Reset() { + *x = RouteRaw{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[68] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RouteRaw) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RouteRaw) ProtoMessage() {} + +func (x *RouteRaw) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[68] + 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 RouteRaw.ProtoReflect.Descriptor instead. +func (*RouteRaw) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{68} +} + +func (x *RouteRaw) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *RouteRaw) GetNetId() string { + if x != nil { + return x.NetId + } + return "" +} + +func (x *RouteRaw) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *RouteRaw) GetNetworkCidr() string { + if x != nil { + return x.NetworkCidr + } + return "" +} + +func (x *RouteRaw) GetDomains() []string { + if x != nil { + return x.Domains + } + return nil +} + +func (x *RouteRaw) GetKeepRoute() bool { + if x != nil { + return x.KeepRoute + } + return false +} + +func (x *RouteRaw) GetPeerIndexSet() bool { + if x != nil { + return x.PeerIndexSet + } + return false +} + +func (x *RouteRaw) GetPeerIndex() uint32 { + if x != nil { + return x.PeerIndex + } + return 0 +} + +func (x *RouteRaw) GetPeerGroupIds() []string { + if x != nil { + return x.PeerGroupIds + } + return nil +} + +func (x *RouteRaw) GetNetworkType() int32 { + if x != nil { + return x.NetworkType + } + return 0 +} + +func (x *RouteRaw) GetMasquerade() bool { + if x != nil { + return x.Masquerade + } + return false +} + +func (x *RouteRaw) GetMetric() int32 { + if x != nil { + return x.Metric + } + return 0 +} + +func (x *RouteRaw) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *RouteRaw) GetGroupIds() []string { + if x != nil { + return x.GroupIds + } + return nil +} + +func (x *RouteRaw) GetAccessControlGroupIds() []string { + if x != nil { + return x.AccessControlGroupIds + } + return nil +} + +func (x *RouteRaw) GetSkipAutoApply() bool { + if x != nil { + return x.SkipAutoApply + } + return false +} + +// NameServerGroupRaw mirrors *nbdns.NameServerGroup. Distinct from the +// legacy NameServerGroup (which is the wire-trimmed shape consumed by +// proto.DNSConfig and lacks the Name/Description/Groups/Enabled fields). +type NameServerGroupRaw struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Reuses the legacy NameServer wire shape (IP as string). + Nameservers []*NameServer `protobuf:"bytes,2,rep,name=nameservers,proto3" json:"nameservers,omitempty"` + // Group ids the NSG distributes nameservers to. + GroupIds []string `protobuf:"bytes,3,rep,name=group_ids,json=groupIds,proto3" json:"group_ids,omitempty"` + Primary bool `protobuf:"varint,4,opt,name=primary,proto3" json:"primary,omitempty"` + Domains []string `protobuf:"bytes,5,rep,name=domains,proto3" json:"domains,omitempty"` + Enabled bool `protobuf:"varint,6,opt,name=enabled,proto3" json:"enabled,omitempty"` + SearchDomainsEnabled bool `protobuf:"varint,7,opt,name=search_domains_enabled,json=searchDomainsEnabled,proto3" json:"search_domains_enabled,omitempty"` +} + +func (x *NameServerGroupRaw) Reset() { + *x = NameServerGroupRaw{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[69] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NameServerGroupRaw) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NameServerGroupRaw) ProtoMessage() {} + +func (x *NameServerGroupRaw) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[69] + 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 NameServerGroupRaw.ProtoReflect.Descriptor instead. +func (*NameServerGroupRaw) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{69} +} + +func (x *NameServerGroupRaw) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *NameServerGroupRaw) GetNameservers() []*NameServer { + if x != nil { + return x.Nameservers + } + return nil +} + +func (x *NameServerGroupRaw) GetGroupIds() []string { + if x != nil { + return x.GroupIds + } + return nil +} + +func (x *NameServerGroupRaw) GetPrimary() bool { + if x != nil { + return x.Primary + } + return false +} + +func (x *NameServerGroupRaw) GetDomains() []string { + if x != nil { + return x.Domains + } + return nil +} + +func (x *NameServerGroupRaw) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *NameServerGroupRaw) GetSearchDomainsEnabled() bool { + if x != nil { + return x.SearchDomainsEnabled + } + return false +} + +// NetworkResourceRaw mirrors *resourceTypes.NetworkResource. +type NetworkResourceRaw struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + NetworkSeq string `protobuf:"bytes,2,opt,name=network_seq,json=networkSeq,proto3" json:"network_seq,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + // Resource type: "host" / "subnet" / "domain". + Type string `protobuf:"bytes,5,opt,name=type,proto3" json:"type,omitempty"` + Address string `protobuf:"bytes,6,opt,name=address,proto3" json:"address,omitempty"` + DomainValue string `protobuf:"bytes,7,opt,name=domain_value,json=domainValue,proto3" json:"domain_value,omitempty"` // resource.Domain + PrefixCidr string `protobuf:"bytes,8,opt,name=prefix_cidr,json=prefixCidr,proto3" json:"prefix_cidr,omitempty"` + Enabled bool `protobuf:"varint,9,opt,name=enabled,proto3" json:"enabled,omitempty"` +} + +func (x *NetworkResourceRaw) Reset() { + *x = NetworkResourceRaw{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[70] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkResourceRaw) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkResourceRaw) ProtoMessage() {} + +func (x *NetworkResourceRaw) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[70] + 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 NetworkResourceRaw.ProtoReflect.Descriptor instead. +func (*NetworkResourceRaw) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{70} +} + +func (x *NetworkResourceRaw) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *NetworkResourceRaw) GetNetworkSeq() string { + if x != nil { + return x.NetworkSeq + } + return "" +} + +func (x *NetworkResourceRaw) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *NetworkResourceRaw) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *NetworkResourceRaw) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *NetworkResourceRaw) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +func (x *NetworkResourceRaw) GetDomainValue() string { + if x != nil { + return x.DomainValue + } + return "" +} + +func (x *NetworkResourceRaw) GetPrefixCidr() string { + if x != nil { + return x.PrefixCidr + } + return "" +} + +func (x *NetworkResourceRaw) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +// NetworkRouterList carries the routers backing one network. +type NetworkRouterList struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Routers in this network, keyed by peer_index (the routing peer). + Entries []*NetworkRouterEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` +} + +func (x *NetworkRouterList) Reset() { + *x = NetworkRouterList{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[71] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkRouterList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkRouterList) ProtoMessage() {} + +func (x *NetworkRouterList) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[71] + 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 NetworkRouterList.ProtoReflect.Descriptor instead. +func (*NetworkRouterList) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{71} +} + +func (x *NetworkRouterList) GetEntries() []*NetworkRouterEntry { + if x != nil { + return x.Entries + } + return nil +} + +// NetworkRouterEntry mirrors a single *routerTypes.NetworkRouter; the routing +// peer is referenced by index into NetworkMapComponentsFull.peers. +type NetworkRouterEntry struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + PeerIndex uint32 `protobuf:"varint,2,opt,name=peer_index,json=peerIndex,proto3" json:"peer_index,omitempty"` + PeerIndexSet bool `protobuf:"varint,3,opt,name=peer_index_set,json=peerIndexSet,proto3" json:"peer_index_set,omitempty"` + PeerGroupIds []string `protobuf:"bytes,4,rep,name=peer_group_ids,json=peerGroupIds,proto3" json:"peer_group_ids,omitempty"` + Masquerade bool `protobuf:"varint,5,opt,name=masquerade,proto3" json:"masquerade,omitempty"` + Metric int32 `protobuf:"varint,6,opt,name=metric,proto3" json:"metric,omitempty"` + Enabled bool `protobuf:"varint,7,opt,name=enabled,proto3" json:"enabled,omitempty"` +} + +func (x *NetworkRouterEntry) Reset() { + *x = NetworkRouterEntry{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[72] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkRouterEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkRouterEntry) ProtoMessage() {} + +func (x *NetworkRouterEntry) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[72] + 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 NetworkRouterEntry.ProtoReflect.Descriptor instead. +func (*NetworkRouterEntry) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{72} +} + +func (x *NetworkRouterEntry) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *NetworkRouterEntry) GetPeerIndex() uint32 { + if x != nil { + return x.PeerIndex + } + return 0 +} + +func (x *NetworkRouterEntry) GetPeerIndexSet() bool { + if x != nil { + return x.PeerIndexSet + } + return false +} + +func (x *NetworkRouterEntry) GetPeerGroupIds() []string { + if x != nil { + return x.PeerGroupIds + } + return nil +} + +func (x *NetworkRouterEntry) GetMasquerade() bool { + if x != nil { + return x.Masquerade + } + return false +} + +func (x *NetworkRouterEntry) GetMetric() int32 { + if x != nil { + return x.Metric + } + return 0 +} + +func (x *NetworkRouterEntry) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +type PolicyIds struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ids []string `protobuf:"bytes,1,rep,name=ids,proto3" json:"ids,omitempty"` +} + +func (x *PolicyIds) Reset() { + *x = PolicyIds{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PolicyIds) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyIds) ProtoMessage() {} + +func (x *PolicyIds) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[73] + 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 PolicyIds.ProtoReflect.Descriptor instead. +func (*PolicyIds) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{73} +} + +func (x *PolicyIds) GetIds() []string { + if x != nil { + return x.Ids + } + return nil +} + +// UserIDList is a list of user ids — used as the value type in +// NetworkMapComponentsFull.group_id_to_user_ids. +type UserIDList struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserIds []string `protobuf:"bytes,1,rep,name=user_ids,json=userIds,proto3" json:"user_ids,omitempty"` +} + +func (x *UserIDList) Reset() { + *x = UserIDList{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UserIDList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserIDList) ProtoMessage() {} + +func (x *UserIDList) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[74] + 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 UserIDList.ProtoReflect.Descriptor instead. +func (*UserIDList) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{74} +} + +func (x *UserIDList) GetUserIds() []string { + if x != nil { + return x.UserIds + } + return nil +} + +// PeerIndexSet is a set of peer indexes — used as the value type in +// NetworkMapComponentsFull.posture_failed_peers. +type PeerIndexSet struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + PeerIndexes []uint32 `protobuf:"varint,1,rep,packed,name=peer_indexes,json=peerIndexes,proto3" json:"peer_indexes,omitempty"` +} + +func (x *PeerIndexSet) Reset() { + *x = PeerIndexSet{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[75] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PeerIndexSet) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PeerIndexSet) ProtoMessage() {} + +func (x *PeerIndexSet) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[75] + 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 PeerIndexSet.ProtoReflect.Descriptor instead. +func (*PeerIndexSet) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{75} +} + +func (x *PeerIndexSet) GetPeerIndexes() []uint32 { + if x != nil { + return x.PeerIndexes + } + return nil +} + type PortInfo_Range struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -4694,7 +6665,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[57] + mi := &file_management_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4707,7 +6678,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[57] + mi := &file_management_proto_msgTypes[77] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4786,7 +6757,7 @@ var file_management_proto_rawDesc = []byte{ 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, - 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xa3, 0x03, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, + 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0x8d, 0x04, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, @@ -4812,675 +6783,1060 @@ var file_management_proto_rawDesc = []byte{ 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x07, 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, 0x73, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x41, 0x0a, - 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, - 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, - 0x22, 0xc6, 0x01, 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, 0x2e, 0x0a, - 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, - 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, - 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x65, 0x65, - 0x72, 0x4b, 0x65, 0x79, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, - 0x73, 0x52, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x64, - 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, - 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x22, 0x44, 0x0a, 0x08, 0x50, 0x65, 0x65, - 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, - 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x22, - 0x3f, 0x0a, 0x0b, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x14, - 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x63, - 0x6c, 0x6f, 0x75, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, - 0x22, 0x5c, 0x0a, 0x04, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x14, 0x0a, 0x05, - 0x65, 0x78, 0x69, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x78, 0x69, - 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, - 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, - 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0xe1, - 0x05, 0x0a, 0x05, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, - 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, - 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x18, 0x02, 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, 0x2a, 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, - 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, - 0x75, 0x74, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, - 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, - 0x65, 0x44, 0x4e, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x61, - 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x12, 0x28, 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, - 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, - 0x12, 0x26, 0x0a, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, - 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, - 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x62, 0x6c, 0x6f, 0x63, - 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, - 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x34, 0x0a, 0x15, - 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, - 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x6c, 0x61, 0x7a, - 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, - 0x6f, 0x6f, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x12, 0x42, - 0x0a, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, - 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0d, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, - 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, - 0x6e, 0x67, 0x12, 0x44, 0x0a, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, - 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, - 0x69, 0x6e, 0x67, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x61, - 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, - 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x18, - 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, - 0x76, 0x36, 0x22, 0xb2, 0x05, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, - 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, - 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x12, 0x12, 0x0a, - 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x72, - 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x0e, 0x0a, - 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f, 0x53, 0x12, 0x26, 0x0a, - 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65, 0x72, 0x6e, - 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4f, 0x53, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x4f, 0x53, - 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x10, 0x6e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, - 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, - 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, - 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x79, 0x73, - 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, - 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, - 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x4d, - 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x12, 0x39, 0x0a, 0x0b, 0x65, - 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, - 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, - 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x12, 0x27, - 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, - 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x3e, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, - 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1a, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, - 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, - 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0xfc, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, - 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, - 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, - 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, - 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, - 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x46, - 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, - 0x41, 0x74, 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, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, - 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x66, 0x0a, 0x18, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, - 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, - 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, - 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0x63, - 0x0a, 0x19, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x10, 0x73, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, - 0x01, 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, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, - 0x73, 0x41, 0x74, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x65, 0x78, - 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, - 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x07, - 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xb4, 0x02, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, - 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74, 0x75, - 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, - 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x12, 0x2e, - 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x2d, - 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, - 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x12, 0x33, 0x0a, 0x07, 0x6d, 0x65, 0x74, - 0x72, 0x69, 0x63, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x22, 0x98, - 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, - 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, - 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, - 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, - 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, - 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, - 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, - 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, - 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, - 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, - 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, - 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, - 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, - 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, - 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, - 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, - 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, - 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, - 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, - 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, - 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, - 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x29, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x72, - 0x69, 0x63, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 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, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x75, 0x64, - 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x75, 0x64, - 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6b, 0x65, 0x79, - 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x78, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, - 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, - 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, - 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, 0x50, 0x72, 0x6f, - 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x68, 0x6f, - 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0a, 0x50, 0x65, 0x65, - 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, - 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, - 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x64, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, - 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x48, 0x0a, 0x1f, - 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, - 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, - 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, + 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x4e, 0x0a, + 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, + 0x6f, 0x70, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, + 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x52, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x41, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, + 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, + 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, + 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xc6, 0x01, 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, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, + 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x52, 0x08, 0x70, 0x65, 0x65, + 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, + 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, + 0x65, 0x6c, 0x73, 0x22, 0x44, 0x0a, 0x08, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, + 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, + 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x22, 0x3f, 0x0a, 0x0b, 0x45, 0x6e, 0x76, + 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x12, 0x1a, + 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x22, 0x5c, 0x0a, 0x04, 0x46, 0x69, + 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, + 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, + 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0xe1, 0x05, 0x0a, 0x05, 0x46, 0x6c, 0x61, + 0x67, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, + 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x30, + 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, + 0x73, 0x73, 0x69, 0x76, 0x65, 0x18, 0x02, 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, 0x2a, 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, + 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, + 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, + 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x30, + 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, + 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, + 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, + 0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, + 0x12, 0x28, 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, + 0x61, 0x6c, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, + 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x62, 0x6c, + 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, + 0x73, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, + 0x6e, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, + 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x10, 0x0a, 0x03, - 0x6d, 0x74, 0x75, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12, 0x3e, - 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1d, - 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x09, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36, 0x22, 0x52, 0x0a, - 0x12, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, - 0x6e, 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, - 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, - 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, - 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x12, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, - 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, - 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, - 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, + 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, + 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, + 0x6f, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, + 0x46, 0x54, 0x50, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x12, 0x42, 0x0a, 0x1c, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, + 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, + 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, + 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x44, 0x0a, 0x1d, + 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, + 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0e, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, + 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, + 0x6e, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, + 0x41, 0x75, 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x69, 0x73, 0x61, + 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, + 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x22, 0xe2, 0x05, 0x0a, + 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, + 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, + 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, + 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, + 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, + 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f, 0x53, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, + 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, + 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, + 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, + 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, + 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, + 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, + 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, + 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, + 0x74, 0x75, 0x72, 0x65, 0x72, 0x12, 0x39, 0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, + 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, + 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, + 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, + 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, + 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, + 0x73, 0x12, 0x3e, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, + 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, + 0x69, 0x74, 0x79, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, + 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x73, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x13, 0x20, 0x01, 0x28, 0x05, 0x52, 0x12, 0x73, + 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x22, 0xfc, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a, 0x0a, 0x06, + 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, + 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x46, 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 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, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, + 0x22, 0x66, 0x0a, 0x18, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, + 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, + 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0x63, 0x0a, 0x19, 0x45, 0x78, 0x74, 0x65, + 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x01, 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, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x79, 0x0a, + 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, + 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x22, 0xb4, 0x02, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, + 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, + 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, + 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x2d, 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, + 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, + 0x6c, 0x6f, 0x77, 0x12, 0x33, 0x0a, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, + 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, + 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, + 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, + 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, + 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, + 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, + 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, + 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, + 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e, + 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, + 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, + 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, + 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, + 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x22, 0x29, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 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, 0xa3, 0x01, + 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, + 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, + 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, + 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, + 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, + 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, + 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, + 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0a, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, + 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x33, 0x0a, + 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, + 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x48, 0x0a, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, + 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, + 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, + 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, + 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12, 0x3e, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x6f, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0a, 0x61, 0x75, + 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x72, + 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36, 0x22, 0x52, 0x0a, 0x12, 0x41, 0x75, 0x74, 0x6f, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x18, 0x0a, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, + 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, + 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, + 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, + 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, + 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x0b, 0x72, 0x65, + 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, + 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0b, 0x72, + 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, - 0x75, 0x74, 0x65, 0x52, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x44, - 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, - 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, - 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, - 0x6c, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, - 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, - 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, - 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, - 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, - 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0a, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, - 0x6c, 0x65, 0x52, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, - 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, - 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72, 0x6f, 0x75, - 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, - 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, - 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, - 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, - 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82, 0x02, 0x0a, - 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x55, - 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, - 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, - 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, - 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, - 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, - 0x73, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, - 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, - 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, - 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, - 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, - 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, - 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09, 0x6a, 0x77, - 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, - 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x48, 0x0a, - 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x65, 0x76, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, + 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, + 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, 0x52, + 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, + 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, + 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, + 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, + 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0d, + 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x08, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x46, + 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, + 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, + 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, + 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, + 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, + 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x13, 0x72, 0x6f, + 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, + 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, + 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, + 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, + 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, + 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, + 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, + 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, + 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, + 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82, 0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, + 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, + 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, + 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, + 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, + 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, + 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, + 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, + 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, + 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, + 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, + 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x22, 0xbb, 0x01, 0x0a, 0x10, + 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, + 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, + 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, + 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, + 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, + 0x62, 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, + 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x08, 0x50, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, - 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, - 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, - 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a, 0x08, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53, 0x54, 0x45, - 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42, 0x0a, 0x0e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x12, - 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, - 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, - 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, - 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x12, 0x44, - 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, - 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, - 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, - 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55, 0x73, 0x65, - 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, - 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x22, 0x0a, - 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18, 0x0a, 0x20, - 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, - 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, - 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x44, + 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, + 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, + 0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, + 0x15, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, + 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, + 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, + 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x02, 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, + 0x74, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, + 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, + 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, + 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, + 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, + 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, + 0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, + 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, + 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, + 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, - 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x18, 0x0c, - 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x22, - 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e, 0x65, 0x74, - 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, - 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, - 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d, 0x65, 0x74, - 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, - 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, - 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, - 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x09, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, - 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, - 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, - 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, - 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, 0x61, 0x6d, - 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, + 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, + 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, + 0x75, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, + 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, + 0x12, 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, + 0x65, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, + 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, + 0x65, 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, + 0x44, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, + 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, + 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, + 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, + 0xde, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, + 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, + 0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, + 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, + 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, + 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, + 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, + 0x01, 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, + 0x22, 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, + 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, + 0x2a, 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, + 0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, + 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, + 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, + 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, + 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, + 0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, + 0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, + 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, + 0x74, 0x22, 0xfb, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, + 0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, + 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, + 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, + 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, + 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, + 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, + 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, + 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, + 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, + 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, + 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, + 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, + 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, + 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, + 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, + 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, + 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, + 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, + 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, + 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, + 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, + 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, + 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, + 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, + 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, + 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, + 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, + 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, + 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, + 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, + 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, + 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, + 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, + 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, + 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, + 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, + 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, + 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, + 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, + 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, + 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, + 0x74, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, + 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, + 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, + 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, + 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, + 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, + 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, + 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, + 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, + 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, + 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, + 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, + 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, + 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, + 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, + 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, + 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, + 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x9a, 0x01, 0x0a, 0x12, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, + 0x70, 0x65, 0x12, 0x3a, 0x0a, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, + 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, + 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x12, 0x3d, + 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, + 0x65, 0x6c, 0x74, 0x61, 0x48, 0x00, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x42, 0x09, 0x0a, + 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x92, 0x0f, 0x0a, 0x18, 0x4e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, + 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x37, 0x0a, + 0x0b, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x34, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, + 0x6f, 0x72, 0x6b, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x4d, 0x0a, 0x10, + 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, + 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0f, 0x61, 0x63, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x64, + 0x6e, 0x73, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, + 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, + 0x74, 0x52, 0x0b, 0x64, 0x6e, 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1d, + 0x0a, 0x0a, 0x64, 0x6e, 0x73, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x64, 0x6e, 0x73, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, + 0x12, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x5f, 0x64, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x75, 0x73, 0x74, 0x6f, + 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x73, 0x12, 0x2d, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, + 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, + 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x65, 0x65, 0x72, + 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x11, + 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, + 0x73, 0x12, 0x35, 0x0a, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x18, 0x0b, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x08, + 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, + 0x70, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61, + 0x63, 0x74, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2c, 0x0a, 0x06, 0x72, 0x6f, + 0x75, 0x74, 0x65, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x61, 0x77, + 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x61, 0x6d, 0x65, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0e, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, - 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, - 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, - 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0d, - 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, - 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, 0x73, 0x74, 0x6f, - 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x32, 0x0a, - 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, + 0x52, 0x61, 0x77, 0x52, 0x10, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x40, 0x0a, 0x0f, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x6e, 0x73, + 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, - 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, - 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, - 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, - 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, - 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, 0x6c, 0x61, - 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x12, - 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x54, 0x54, - 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, 0x6d, 0x65, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, 0x0b, 0x4e, - 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, - 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, - 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, - 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x48, 0x0a, - 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, - 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x4e, - 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, 0x53, 0x54, - 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x72, 0x65, - 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, 0x65, 0x65, 0x72, - 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x06, 0x50, 0x65, - 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, - 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, - 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, - 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, - 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, - 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, - 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, - 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, - 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, - 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, - 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x26, 0x0a, - 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, - 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, - 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, 0x10, 0x0a, - 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, 0x63, 0x22, - 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x69, 0x6c, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, - 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a, 0x04, - 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6f, - 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, - 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, 0x00, 0x52, - 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, - 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, - 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, 0x74, 0x53, - 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, 0x6f, 0x75, - 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x22, - 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, - 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, - 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x6f, - 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, - 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, 0x0a, 0x09, - 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, - 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, - 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, - 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, - 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, 0x75, 0x74, - 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, - 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, - 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, 0x0f, 0x64, - 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, - 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x74, - 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, - 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, - 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, - 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f, - 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, - 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, - 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10, 0x0a, 0x03, - 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x1a, - 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x73, - 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, - 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72, - 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, - 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, - 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, - 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72, - 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70, - 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, - 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, 0x75, 0x74, - 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e, - 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77, - 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, - 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a, 0x12, 0x53, - 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, - 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x10, - 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, 0x6c, 0x0a, + 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x0d, 0x61, 0x6c, 0x6c, 0x44, 0x6e, 0x73, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x3b, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, + 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5a, + 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x77, 0x52, + 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x73, 0x12, 0x55, 0x0a, 0x0b, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x5f, 0x6d, 0x61, 0x70, + 0x18, 0x12, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, + 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x72, 0x6f, + 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x71, 0x0a, 0x15, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x5f, 0x6d, 0x61, + 0x70, 0x18, 0x13, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, + 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, + 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x6a, 0x0a, 0x14, 0x67, + 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, + 0x69, 0x64, 0x73, 0x18, 0x14, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, + 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x10, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, + 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, + 0x65, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, + 0x73, 0x12, 0x6e, 0x0a, 0x14, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x61, 0x69, + 0x6c, 0x65, 0x64, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x16, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x3c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, + 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, + 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x12, 0x70, + 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, + 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x64, 0x6e, 0x73, 0x5f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x65, 0x72, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x64, + 0x6e, 0x73, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x12, + 0x37, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x63, 0x68, 0x18, 0x18, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x52, 0x0a, 0x70, 0x72, + 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, + 0x5f, 0x69, 0x64, 0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x1a, 0x5c, 0x0a, 0x0f, + 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x33, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, + 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5d, 0x0a, 0x18, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, + 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x73, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5b, 0x0a, 0x15, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 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, 0x2c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5f, 0x0a, 0x17, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, + 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x6b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x4a, 0x04, 0x08, 0x1a, 0x10, 0x33, 0x22, 0x87, 0x03, + 0x0a, 0x0a, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x32, 0x0a, 0x05, + 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, + 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, + 0x12, 0x41, 0x0a, 0x0d, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x70, 0x65, 0x65, 0x72, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, + 0x65, 0x72, 0x73, 0x12, 0x3f, 0x0a, 0x0e, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, + 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, + 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, + 0x75, 0x6c, 0x65, 0x73, 0x12, 0x29, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, + 0x4f, 0x0a, 0x14, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x5f, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, + 0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, + 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x12, 0x72, 0x6f, + 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, + 0x12, 0x45, 0x0a, 0x10, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x72, + 0x75, 0x6c, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, + 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, + 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x16, 0x41, 0x63, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, + 0x63, 0x74, 0x12, 0x41, 0x0a, 0x1d, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, + 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x70, 0x65, 0x65, 0x72, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, + 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, + 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x70, 0x65, 0x65, 0x72, 0x4c, 0x6f, 0x67, + 0x69, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x73, 0x22, 0x95, + 0x01, 0x0a, 0x0e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, + 0x72, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x74, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x43, 0x69, 0x64, 0x72, 0x12, 0x1e, 0x0a, 0x0b, + 0x6e, 0x65, 0x74, 0x5f, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x6e, 0x65, 0x74, 0x56, 0x36, 0x43, 0x69, 0x64, 0x72, 0x12, 0x10, 0x0a, 0x03, + 0x64, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x16, + 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, + 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x22, 0x21, 0x0a, 0x19, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, 0x65, + 0x6c, 0x74, 0x61, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x65, 0x22, 0xfb, 0x03, 0x0a, 0x0b, 0x50, 0x65, + 0x65, 0x72, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x1c, 0x0a, 0x0a, 0x77, 0x67, 0x5f, + 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x77, + 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x70, 0x76, 0x36, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x69, 0x70, 0x76, 0x36, 0x12, 0x1e, 0x0a, 0x0b, 0x73, + 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1b, 0x0a, 0x09, 0x64, + 0x6e, 0x73, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2f, 0x0a, + 0x14, 0x61, 0x64, 0x64, 0x65, 0x64, 0x5f, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x73, 0x6f, 0x5f, + 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x61, 0x64, 0x64, + 0x65, 0x64, 0x57, 0x69, 0x74, 0x68, 0x53, 0x73, 0x6f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x38, + 0x0a, 0x18, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x16, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2f, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, + 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6e, 0x61, 0x6e, 0x6f, + 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x67, 0x69, + 0x6e, 0x55, 0x6e, 0x69, 0x78, 0x4e, 0x61, 0x6e, 0x6f, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x73, 0x68, + 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, + 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, + 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x18, 0x0b, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x0c, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x49, 0x70, 0x76, 0x36, 0x12, + 0x38, 0x0a, 0x18, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x16, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x53, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x5f, 0x73, 0x73, 0x68, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, + 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x73, 0x68, + 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x22, 0x91, 0x06, 0x0a, 0x0d, 0x50, 0x6f, 0x6c, 0x69, + 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, + 0x24, 0x0a, 0x0d, 0x62, 0x69, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x62, 0x69, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x18, 0x05, + 0x20, 0x03, 0x28, 0x0d, 0x52, 0x05, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x12, 0x3b, 0x0a, 0x0b, 0x70, + 0x6f, 0x72, 0x74, 0x5f, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, + 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x0a, 0x70, 0x6f, + 0x72, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x07, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, + 0x64, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x13, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x5c, 0x0a, 0x11, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x2f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, + 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x2e, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x52, 0x10, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x12, 0x44, 0x0a, + 0x0f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d, 0x70, + 0x61, 0x63, 0x74, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x12, 0x4e, 0x0a, 0x14, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x13, + 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x18, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, + 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x0d, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x73, + 0x74, 0x75, 0x72, 0x65, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x64, 0x73, 0x1a, 0x5d, 0x0a, 0x15, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x2e, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x4c, 0x69, 0x73, 0x74, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x70, 0x0a, 0x0f, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x12, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, + 0x5f, 0x73, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, + 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, + 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, + 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0x24, 0x0a, + 0x0c, 0x55, 0x73, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x14, 0x0a, + 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, + 0x6d, 0x65, 0x73, 0x22, 0x58, 0x0a, 0x0c, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, + 0x61, 0x63, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, + 0x78, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x49, + 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x15, 0x0a, 0x06, 0x69, 0x73, 0x5f, 0x61, 0x6c, 0x6c, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x69, 0x73, 0x41, 0x6c, 0x6c, 0x22, 0x57, 0x0a, + 0x12, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, + 0x61, 0x63, 0x74, 0x12, 0x41, 0x0a, 0x1d, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x5f, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, + 0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x1a, 0x64, 0x69, 0x73, 0x61, + 0x62, 0x6c, 0x65, 0x64, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0x8d, 0x04, 0x0a, 0x08, 0x52, 0x6f, 0x75, 0x74, 0x65, + 0x52, 0x61, 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x64, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, + 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, + 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x69, 0x64, 0x72, 0x12, + 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6b, 0x65, 0x65, + 0x70, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, + 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, + 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x1d, + 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x24, 0x0a, + 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, + 0x49, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x74, + 0x79, 0x70, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, 0x65, + 0x72, 0x61, 0x64, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x6d, 0x61, 0x73, 0x71, + 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, + 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x18, + 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, + 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, + 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, + 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, + 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x43, + 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x26, + 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x70, 0x70, 0x6c, + 0x79, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, + 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x12, 0x4e, 0x61, 0x6d, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77, 0x12, 0x0e, 0x0a, + 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x38, 0x0a, + 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x6e, 0x61, 0x6d, 0x65, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, + 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, + 0x70, 0x49, 0x64, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, + 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x05, 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, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x12, 0x34, 0x0a, 0x16, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x5f, 0x64, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x73, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x14, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x87, 0x02, 0x0a, 0x12, 0x4e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x77, 0x12, + 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, + 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x73, 0x65, 0x71, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x71, + 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, + 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x5f, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x69, + 0x78, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, + 0x65, 0x66, 0x69, 0x78, 0x43, 0x69, 0x64, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x22, 0x4d, 0x0a, 0x11, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x38, 0x0a, 0x07, 0x65, 0x6e, 0x74, 0x72, 0x69, + 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x65, 0x6e, 0x74, 0x72, 0x69, 0x65, + 0x73, 0x22, 0xe1, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, + 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, + 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, + 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x24, 0x0a, + 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, + 0x49, 0x64, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, + 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, + 0x61, 0x64, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x65, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x1d, 0x0a, 0x09, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, + 0x64, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x03, 0x69, 0x64, 0x73, 0x22, 0x27, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x4c, 0x69, + 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x22, 0x31, 0x0a, + 0x0c, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x21, 0x0a, + 0x0c, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0d, 0x52, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, + 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, + 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x10, + 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x10, 0x01, + 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, 0x93, 0x01, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, 0x1d, 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x49, 0x50, - 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x2a, 0x4c, 0x0a, 0x0c, 0x52, - 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, - 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, - 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, - 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, - 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, - 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, - 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, - 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, - 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x2a, - 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, - 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, - 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, - 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55, - 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, - 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, - 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, - 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, - 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, - 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, - 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, - 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, - 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, - 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, - 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, - 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, - 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, + 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x12, 0x25, 0x0a, 0x21, 0x50, + 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x43, 0x6f, 0x6d, + 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, + 0x10, 0x03, 0x2a, 0x5d, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, + 0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, + 0x02, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, + 0x4d, 0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, + 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x45, 0x54, 0x42, 0x49, 0x52, 0x44, 0x5f, 0x53, 0x53, 0x48, 0x10, + 0x06, 0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, + 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, + 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, + 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, + 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, + 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, + 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, + 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, + 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a, + 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x51, 0x0a, - 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, + 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, + 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, + 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, + 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, + 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, + 0x74, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, + 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, + 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, - 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, - 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, - 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, - 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, - 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, - 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, + 0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, + 0x4a, 0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, + 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, + 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, + 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, + 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, + 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, + 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, + 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, } var ( @@ -5496,7 +7852,7 @@ func file_management_proto_rawDescGZIP() []byte { } var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 58) +var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 83) var file_management_proto_goTypes = []interface{}{ (JobStatus)(0), // 0: management.JobStatus (PeerCapability)(0), // 1: management.PeerCapability @@ -5562,111 +7918,175 @@ var file_management_proto_goTypes = []interface{}{ (*RenewExposeResponse)(nil), // 61: management.RenewExposeResponse (*StopExposeRequest)(nil), // 62: management.StopExposeRequest (*StopExposeResponse)(nil), // 63: management.StopExposeResponse - nil, // 64: management.SSHAuth.MachineUsersEntry - (*PortInfo_Range)(nil), // 65: management.PortInfo.Range - (*timestamppb.Timestamp)(nil), // 66: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 67: google.protobuf.Duration + (*NetworkMapEnvelope)(nil), // 64: management.NetworkMapEnvelope + (*NetworkMapComponentsFull)(nil), // 65: management.NetworkMapComponentsFull + (*ProxyPatch)(nil), // 66: management.ProxyPatch + (*AccountSettingsCompact)(nil), // 67: management.AccountSettingsCompact + (*AccountNetwork)(nil), // 68: management.AccountNetwork + (*NetworkMapComponentsDelta)(nil), // 69: management.NetworkMapComponentsDelta + (*PeerCompact)(nil), // 70: management.PeerCompact + (*PolicyCompact)(nil), // 71: management.PolicyCompact + (*ResourceCompact)(nil), // 72: management.ResourceCompact + (*UserNameList)(nil), // 73: management.UserNameList + (*GroupCompact)(nil), // 74: management.GroupCompact + (*DNSSettingsCompact)(nil), // 75: management.DNSSettingsCompact + (*RouteRaw)(nil), // 76: management.RouteRaw + (*NameServerGroupRaw)(nil), // 77: management.NameServerGroupRaw + (*NetworkResourceRaw)(nil), // 78: management.NetworkResourceRaw + (*NetworkRouterList)(nil), // 79: management.NetworkRouterList + (*NetworkRouterEntry)(nil), // 80: management.NetworkRouterEntry + (*PolicyIds)(nil), // 81: management.PolicyIds + (*UserIDList)(nil), // 82: management.UserIDList + (*PeerIndexSet)(nil), // 83: management.PeerIndexSet + nil, // 84: management.SSHAuth.MachineUsersEntry + (*PortInfo_Range)(nil), // 85: management.PortInfo.Range + nil, // 86: management.NetworkMapComponentsFull.RoutersMapEntry + nil, // 87: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry + nil, // 88: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry + nil, // 89: management.NetworkMapComponentsFull.PostureFailedPeersEntry + nil, // 90: management.PolicyCompact.AuthorizedGroupsEntry + (*timestamppb.Timestamp)(nil), // 91: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 92: google.protobuf.Duration } var file_management_proto_depIdxs = []int32{ - 11, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters - 0, // 1: management.JobResponse.status:type_name -> management.JobStatus - 12, // 2: management.JobResponse.bundle:type_name -> management.BundleResult - 21, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta - 27, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig - 34, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig - 39, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig - 36, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap - 54, // 8: management.SyncResponse.Checks:type_name -> management.Checks - 66, // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp - 21, // 10: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta - 21, // 11: management.LoginRequest.meta:type_name -> management.PeerSystemMeta - 17, // 12: management.LoginRequest.peerKeys:type_name -> management.PeerKeys - 53, // 13: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress - 18, // 14: management.PeerSystemMeta.environment:type_name -> management.Environment - 19, // 15: management.PeerSystemMeta.files:type_name -> management.File - 20, // 16: management.PeerSystemMeta.flags:type_name -> management.Flags - 1, // 17: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability - 27, // 18: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig - 34, // 19: management.LoginResponse.peerConfig:type_name -> management.PeerConfig - 54, // 20: management.LoginResponse.Checks:type_name -> management.Checks - 66, // 21: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp - 21, // 22: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta - 66, // 23: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp - 66, // 24: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp - 28, // 25: management.NetbirdConfig.stuns:type_name -> management.HostConfig - 33, // 26: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig - 28, // 27: management.NetbirdConfig.signal:type_name -> management.HostConfig - 29, // 28: management.NetbirdConfig.relay:type_name -> management.RelayConfig - 30, // 29: management.NetbirdConfig.flow:type_name -> management.FlowConfig - 31, // 30: management.NetbirdConfig.metrics:type_name -> management.MetricsConfig - 6, // 31: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol - 67, // 32: management.FlowConfig.interval:type_name -> google.protobuf.Duration - 28, // 33: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig - 40, // 34: management.PeerConfig.sshConfig:type_name -> management.SSHConfig - 35, // 35: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings - 34, // 36: management.NetworkMap.peerConfig:type_name -> management.PeerConfig - 39, // 37: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig - 46, // 38: management.NetworkMap.Routes:type_name -> management.Route - 47, // 39: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig - 39, // 40: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig - 52, // 41: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule - 56, // 42: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule - 57, // 43: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule - 37, // 44: management.NetworkMap.sshAuth:type_name -> management.SSHAuth - 64, // 45: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry - 40, // 46: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig - 32, // 47: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig - 7, // 48: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider - 45, // 49: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 45, // 50: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 50, // 51: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup - 48, // 52: management.DNSConfig.CustomZones:type_name -> management.CustomZone - 49, // 53: management.CustomZone.Records:type_name -> management.SimpleRecord - 51, // 54: management.NameServerGroup.NameServers:type_name -> management.NameServer - 3, // 55: management.FirewallRule.Direction:type_name -> management.RuleDirection - 4, // 56: management.FirewallRule.Action:type_name -> management.RuleAction - 2, // 57: management.FirewallRule.Protocol:type_name -> management.RuleProtocol - 55, // 58: management.FirewallRule.PortInfo:type_name -> management.PortInfo - 65, // 59: management.PortInfo.range:type_name -> management.PortInfo.Range - 4, // 60: management.RouteFirewallRule.action:type_name -> management.RuleAction - 2, // 61: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol - 55, // 62: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo - 2, // 63: management.ForwardingRule.protocol:type_name -> management.RuleProtocol - 55, // 64: management.ForwardingRule.destinationPort:type_name -> management.PortInfo - 55, // 65: management.ForwardingRule.translatedPort:type_name -> management.PortInfo - 5, // 66: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol - 38, // 67: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes - 8, // 68: management.ManagementService.Login:input_type -> management.EncryptedMessage - 8, // 69: management.ManagementService.Sync:input_type -> management.EncryptedMessage - 26, // 70: management.ManagementService.GetServerKey:input_type -> management.Empty - 26, // 71: management.ManagementService.isHealthy:input_type -> management.Empty - 8, // 72: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 73: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 74: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage - 8, // 75: management.ManagementService.Logout:input_type -> management.EncryptedMessage - 8, // 76: management.ManagementService.Job:input_type -> management.EncryptedMessage - 8, // 77: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage - 8, // 78: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage - 8, // 79: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage - 8, // 80: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage - 8, // 81: management.ManagementService.Login:output_type -> management.EncryptedMessage - 8, // 82: management.ManagementService.Sync:output_type -> management.EncryptedMessage - 25, // 83: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse - 26, // 84: management.ManagementService.isHealthy:output_type -> management.Empty - 8, // 85: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage - 8, // 86: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage - 26, // 87: management.ManagementService.SyncMeta:output_type -> management.Empty - 26, // 88: management.ManagementService.Logout:output_type -> management.Empty - 8, // 89: management.ManagementService.Job:output_type -> management.EncryptedMessage - 8, // 90: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage - 8, // 91: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage - 8, // 92: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage - 8, // 93: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage - 81, // [81:94] is the sub-list for method output_type - 68, // [68:81] is the sub-list for method input_type - 68, // [68:68] is the sub-list for extension type_name - 68, // [68:68] is the sub-list for extension extendee - 0, // [0:68] is the sub-list for field type_name + 11, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters + 0, // 1: management.JobResponse.status:type_name -> management.JobStatus + 12, // 2: management.JobResponse.bundle:type_name -> management.BundleResult + 21, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta + 27, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig + 34, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig + 39, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig + 36, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap + 54, // 8: management.SyncResponse.Checks:type_name -> management.Checks + 91, // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 64, // 10: management.SyncResponse.NetworkMapEnvelope:type_name -> management.NetworkMapEnvelope + 21, // 11: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta + 21, // 12: management.LoginRequest.meta:type_name -> management.PeerSystemMeta + 17, // 13: management.LoginRequest.peerKeys:type_name -> management.PeerKeys + 53, // 14: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress + 18, // 15: management.PeerSystemMeta.environment:type_name -> management.Environment + 19, // 16: management.PeerSystemMeta.files:type_name -> management.File + 20, // 17: management.PeerSystemMeta.flags:type_name -> management.Flags + 1, // 18: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability + 27, // 19: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig + 34, // 20: management.LoginResponse.peerConfig:type_name -> management.PeerConfig + 54, // 21: management.LoginResponse.Checks:type_name -> management.Checks + 91, // 22: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 21, // 23: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta + 91, // 24: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 91, // 25: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp + 28, // 26: management.NetbirdConfig.stuns:type_name -> management.HostConfig + 33, // 27: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig + 28, // 28: management.NetbirdConfig.signal:type_name -> management.HostConfig + 29, // 29: management.NetbirdConfig.relay:type_name -> management.RelayConfig + 30, // 30: management.NetbirdConfig.flow:type_name -> management.FlowConfig + 31, // 31: management.NetbirdConfig.metrics:type_name -> management.MetricsConfig + 6, // 32: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol + 92, // 33: management.FlowConfig.interval:type_name -> google.protobuf.Duration + 28, // 34: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig + 40, // 35: management.PeerConfig.sshConfig:type_name -> management.SSHConfig + 35, // 36: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings + 34, // 37: management.NetworkMap.peerConfig:type_name -> management.PeerConfig + 39, // 38: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig + 46, // 39: management.NetworkMap.Routes:type_name -> management.Route + 47, // 40: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig + 39, // 41: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig + 52, // 42: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule + 56, // 43: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule + 57, // 44: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule + 37, // 45: management.NetworkMap.sshAuth:type_name -> management.SSHAuth + 84, // 46: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry + 40, // 47: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig + 32, // 48: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig + 7, // 49: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider + 45, // 50: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 45, // 51: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 50, // 52: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup + 48, // 53: management.DNSConfig.CustomZones:type_name -> management.CustomZone + 49, // 54: management.CustomZone.Records:type_name -> management.SimpleRecord + 51, // 55: management.NameServerGroup.NameServers:type_name -> management.NameServer + 3, // 56: management.FirewallRule.Direction:type_name -> management.RuleDirection + 4, // 57: management.FirewallRule.Action:type_name -> management.RuleAction + 2, // 58: management.FirewallRule.Protocol:type_name -> management.RuleProtocol + 55, // 59: management.FirewallRule.PortInfo:type_name -> management.PortInfo + 85, // 60: management.PortInfo.range:type_name -> management.PortInfo.Range + 4, // 61: management.RouteFirewallRule.action:type_name -> management.RuleAction + 2, // 62: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol + 55, // 63: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo + 2, // 64: management.ForwardingRule.protocol:type_name -> management.RuleProtocol + 55, // 65: management.ForwardingRule.destinationPort:type_name -> management.PortInfo + 55, // 66: management.ForwardingRule.translatedPort:type_name -> management.PortInfo + 5, // 67: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol + 65, // 68: management.NetworkMapEnvelope.full:type_name -> management.NetworkMapComponentsFull + 69, // 69: management.NetworkMapEnvelope.delta:type_name -> management.NetworkMapComponentsDelta + 34, // 70: management.NetworkMapComponentsFull.peer_config:type_name -> management.PeerConfig + 68, // 71: management.NetworkMapComponentsFull.network:type_name -> management.AccountNetwork + 67, // 72: management.NetworkMapComponentsFull.account_settings:type_name -> management.AccountSettingsCompact + 75, // 73: management.NetworkMapComponentsFull.dns_settings:type_name -> management.DNSSettingsCompact + 70, // 74: management.NetworkMapComponentsFull.peers:type_name -> management.PeerCompact + 71, // 75: management.NetworkMapComponentsFull.policies:type_name -> management.PolicyCompact + 74, // 76: management.NetworkMapComponentsFull.groups:type_name -> management.GroupCompact + 76, // 77: management.NetworkMapComponentsFull.routes:type_name -> management.RouteRaw + 77, // 78: management.NetworkMapComponentsFull.nameserver_groups:type_name -> management.NameServerGroupRaw + 49, // 79: management.NetworkMapComponentsFull.all_dns_records:type_name -> management.SimpleRecord + 48, // 80: management.NetworkMapComponentsFull.account_zones:type_name -> management.CustomZone + 78, // 81: management.NetworkMapComponentsFull.network_resources:type_name -> management.NetworkResourceRaw + 86, // 82: management.NetworkMapComponentsFull.routers_map:type_name -> management.NetworkMapComponentsFull.RoutersMapEntry + 87, // 83: management.NetworkMapComponentsFull.resource_policies_map:type_name -> management.NetworkMapComponentsFull.ResourcePoliciesMapEntry + 88, // 84: management.NetworkMapComponentsFull.group_id_to_user_ids:type_name -> management.NetworkMapComponentsFull.GroupIdToUserIdsEntry + 89, // 85: management.NetworkMapComponentsFull.posture_failed_peers:type_name -> management.NetworkMapComponentsFull.PostureFailedPeersEntry + 66, // 86: management.NetworkMapComponentsFull.proxy_patch:type_name -> management.ProxyPatch + 39, // 87: management.ProxyPatch.peers:type_name -> management.RemotePeerConfig + 39, // 88: management.ProxyPatch.offline_peers:type_name -> management.RemotePeerConfig + 52, // 89: management.ProxyPatch.firewall_rules:type_name -> management.FirewallRule + 46, // 90: management.ProxyPatch.routes:type_name -> management.Route + 56, // 91: management.ProxyPatch.route_firewall_rules:type_name -> management.RouteFirewallRule + 57, // 92: management.ProxyPatch.forwarding_rules:type_name -> management.ForwardingRule + 4, // 93: management.PolicyCompact.action:type_name -> management.RuleAction + 2, // 94: management.PolicyCompact.protocol:type_name -> management.RuleProtocol + 85, // 95: management.PolicyCompact.port_ranges:type_name -> management.PortInfo.Range + 90, // 96: management.PolicyCompact.authorized_groups:type_name -> management.PolicyCompact.AuthorizedGroupsEntry + 72, // 97: management.PolicyCompact.source_resource:type_name -> management.ResourceCompact + 72, // 98: management.PolicyCompact.destination_resource:type_name -> management.ResourceCompact + 51, // 99: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer + 80, // 100: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry + 38, // 101: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes + 79, // 102: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList + 81, // 103: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds + 82, // 104: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList + 83, // 105: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet + 73, // 106: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList + 8, // 107: management.ManagementService.Login:input_type -> management.EncryptedMessage + 8, // 108: management.ManagementService.Sync:input_type -> management.EncryptedMessage + 26, // 109: management.ManagementService.GetServerKey:input_type -> management.Empty + 26, // 110: management.ManagementService.isHealthy:input_type -> management.Empty + 8, // 111: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 112: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 113: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage + 8, // 114: management.ManagementService.Logout:input_type -> management.EncryptedMessage + 8, // 115: management.ManagementService.Job:input_type -> management.EncryptedMessage + 8, // 116: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage + 8, // 117: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage + 8, // 118: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage + 8, // 119: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage + 8, // 120: management.ManagementService.Login:output_type -> management.EncryptedMessage + 8, // 121: management.ManagementService.Sync:output_type -> management.EncryptedMessage + 25, // 122: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse + 26, // 123: management.ManagementService.isHealthy:output_type -> management.Empty + 8, // 124: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage + 8, // 125: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage + 26, // 126: management.ManagementService.SyncMeta:output_type -> management.Empty + 26, // 127: management.ManagementService.Logout:output_type -> management.Empty + 8, // 128: management.ManagementService.Job:output_type -> management.EncryptedMessage + 8, // 129: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage + 8, // 130: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage + 8, // 131: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage + 8, // 132: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage + 120, // [120:133] is the sub-list for method output_type + 107, // [107:120] is the sub-list for method input_type + 107, // [107:107] is the sub-list for extension type_name + 107, // [107:107] is the sub-list for extension extendee + 0, // [0:107] is the sub-list for field type_name } func init() { file_management_proto_init() } @@ -6347,7 +8767,247 @@ func file_management_proto_init() { return nil } } + file_management_proto_msgTypes[56].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkMapEnvelope); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } file_management_proto_msgTypes[57].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkMapComponentsFull); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[58].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProxyPatch); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[59].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AccountSettingsCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[60].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AccountNetwork); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[61].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkMapComponentsDelta); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[62].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PeerCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[63].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PolicyCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[64].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResourceCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[65].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UserNameList); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[66].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GroupCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[67].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DNSSettingsCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[68].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RouteRaw); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[69].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NameServerGroupRaw); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[70].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkResourceRaw); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[71].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkRouterList); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[72].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkRouterEntry); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[73].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PolicyIds); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[74].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UserIDList); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[75].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PeerIndexSet); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[77].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*PortInfo_Range); i { case 0: return &v.state @@ -6370,13 +9030,17 @@ func file_management_proto_init() { (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } + file_management_proto_msgTypes[56].OneofWrappers = []interface{}{ + (*NetworkMapEnvelope_Full)(nil), + (*NetworkMapEnvelope_Delta)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_management_proto_rawDesc, NumEnums: 8, - NumMessages: 58, + NumMessages: 83, NumExtensions: 0, NumServices: 1, }, diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index 6b41a78d0..598f7a579 100644 --- a/shared/management/proto/management.proto +++ b/shared/management/proto/management.proto @@ -150,6 +150,14 @@ message SyncResponse { // SSO-registered; client clears its anchor // set, valid timestamp → new absolute UTC deadline google.protobuf.Timestamp sessionExpiresAt = 7; + + // NetworkMapEnvelope carries the component-based wire format for peers that + // advertise PeerCapabilityComponentNetworkMap. When set, NetworkMap (field 5) + // is left empty: management ships components and the client runs Calculate() + // locally instead of receiving an expanded NetworkMap. + NetworkMapEnvelope NetworkMapEnvelope = 8; + + int32 Version = 9; } message SyncMetaRequest { @@ -229,6 +237,8 @@ enum PeerCapability { PeerCapabilitySourcePrefixes = 1; // Client handles IPv6 overlay addresses and firewall rules. PeerCapabilityIPv6Overlay = 2; + // Client receives NetworkMap as components and assembles it locally. + PeerCapabilityComponentNetworkMap = 3; } // PeerSystemMeta is machine meta data like OS and version. @@ -252,6 +262,7 @@ message PeerSystemMeta { Flags flags = 17; repeated PeerCapability capabilities = 18; + int32 syncMessageVersion = 19; } message LoginResponse { @@ -617,6 +628,13 @@ enum RuleProtocol { UDP = 3; ICMP = 4; CUSTOM = 5; + // NETBIRD_SSH (types.PolicyRuleProtocolType "netbird-ssh") is the marker + // policy rule that drives SSH-server activation in Calculate(). The legacy + // proto.FirewallRule path doesn't ship this value (Calculate already + // expands SSH rules into TCP/22 before encoding), but the components path + // ships RAW policies — the client must see this protocol to derive + // AuthorizedUsers locally. + NETBIRD_SSH = 6; } enum RuleDirection { @@ -757,3 +775,435 @@ message StopExposeRequest { } message StopExposeResponse {} + +// ===================================================================== +// Component-based NetworkMap wire format (PeerCapabilityComponentNetworkMap). +// +// Peers that advertise this capability receive NetworkMap building blocks +// (peers + groups + policies + routes + dns + ssh + forwarding) and run the +// expansion (Calculate) locally instead of receiving a fully-expanded +// NetworkMap from the server. +// ===================================================================== + +// NetworkMapEnvelope wraps either a full snapshot or a delta. Only Full is +// emitted today; Delta is reserved for the incremental-update work. +message NetworkMapEnvelope { + oneof payload { + NetworkMapComponentsFull full = 1; + NetworkMapComponentsDelta delta = 2; + } +} + +// NetworkMapComponentsFull is the full per-peer component snapshot. The +// client decodes it into a types.NetworkMapComponents and runs Calculate() +// locally to produce the same NetworkMap the legacy server path would have +// produced. Every field carries RAW component data — no server-side +// expansion (firewall rules, DNS config, SSH auth, route firewall rules, +// forwarding rules) is shipped; the client computes those itself. +message NetworkMapComponentsFull { + uint64 serial = 1; + + // Peer config for the receiving peer (legacy proto.PeerConfig kept as-is — + // it carries the receiving peer's own overlay address, FQDN, SSH config). + PeerConfig peer_config = 2; + + // Account-level network metadata (id, IPv4/IPv6 overlay subnets, DNS, + // serial). Mirrors types.Network. + AccountNetwork network = 3; + + // Account-level settings the client needs for its local Calculate(). + AccountSettingsCompact account_settings = 4; + + // Account DNS settings (mirrors types.DNSSettings). + DNSSettingsCompact dns_settings = 5; + + // Domain shared across all peers in this account, e.g. "netbird.cloud". + // Each peer's FQDN is dns_label + "." + dns_domain. + string dns_domain = 6; + + // Custom-zone domain for this peer's view (c.CustomZoneDomain). Empty when + // the peer has no custom zone records. + string custom_zone_domain = 7; + + // Deduplicated agent versions; PeerCompact.agent_version_idx indexes here. + // Empty string at index 0 if any peer has no version. + repeated string agent_versions = 8; + + // All peers (deduplicated). The client splits peers into online / offline + // locally using account_settings.peer_login_expiration on receive. + repeated PeerCompact peers = 9; + + // Indexes into peers for the subset that may act as routers. + repeated uint32 router_peer_indexes = 10; + + // Policies that affect the receiving peer. + repeated PolicyCompact policies = 11; + + // Groups in unspecified order — clients key off id (public_id). + repeated GroupCompact groups = 12; + + // Routes relevant to this peer, raw shape (mirrors []*route.Route). + repeated RouteRaw routes = 13; + + // Nameserver groups (mirrors []*nbdns.NameServerGroup). + repeated NameServerGroupRaw nameserver_groups = 14; + + // All DNS records the client needs to assemble its custom zone. Reuses + // the existing SimpleRecord wire shape. + repeated SimpleRecord all_dns_records = 15; + + // Custom zones (typically the peer's own zone). Reuses the existing + // CustomZone wire shape. + repeated CustomZone account_zones = 16; + + // Network resources (mirrors []*resourceTypes.NetworkResource). + repeated NetworkResourceRaw network_resources = 17; + + // Routers per network. Outer key: network public_id. Each entry is + // the set of routers backing that network for this peer's view. + map routers_map = 18; + + // For each NetworkResource public_id, the indexes into policies[] + // that apply to it. + map resource_policies_map = 19; + + // Group-id (public_id) → user ids authorized for SSH on members. + map group_id_to_user_ids = 20; + + // Account-level allowed user ids (used by Calculate() when assembling SSH + // authorized users for the receiving peer). + repeated string allowed_user_ids = 21; + + // Per posture-check public_id, the set of peer indexes that failed + // the check. Server-side evaluation result; clients do not re-evaluate. + map posture_failed_peers = 22; + + // Account-level DNS forwarder port (mirrors the legacy + // proto.DNSConfig.ForwarderPort). Computed by the controller from peer + // versions; clients fold it into their Calculate() DNS output. + int64 dns_forwarder_port = 23; + + // Pre-expanded NetworkMap fragments injected post-Calculate by external + // controllers (BYOP / port-forwarding proxies). The receiving client + // merges these into its locally-computed NetworkMap the same way the + // legacy server does via NetworkMap.Merge — so downstream consumers see + // a unified merged result regardless of source. + ProxyPatch proxy_patch = 24; + + // SSH UserIDClaim — server-side HttpServerConfig.AuthUserIDClaim, or + // "sub" by default. Populated in proto.SSHAuth.UserIDClaim when the + // client rebuilds the NetworkMap from this envelope. Empty when the + // account has no AuthorizedUsers (and thus no SshAuth to populate). + string user_id_claim = 25; + + // Reserved for future component additions (incremental_serial, parent_seq, + // etc.) without forcing a renumber. + reserved 26 to 50; +} + +// ProxyPatch carries NetworkMap fragments that don't fit the component-graph +// model — they're pre-expanded by external controllers (BYOP / +// port-forwarding proxies) and injected post-Calculate. Fields use the +// legacy wire types because the proxy delivers them pre-formed; there is +// no raw component shape to convert from. Empty when no proxy is active. +message ProxyPatch { + repeated RemotePeerConfig peers = 1; + repeated RemotePeerConfig offline_peers = 2; + repeated FirewallRule firewall_rules = 3; + repeated Route routes = 4; + repeated RouteFirewallRule route_firewall_rules = 5; + repeated ForwardingRule forwarding_rules = 6; +} + +// AccountSettingsCompact carries the account-level settings the client needs +// to evaluate locally. Mirrors the subset of types.AccountSettingsInfo that +// Calculate() actually reads — login-expiration (used to filter expired +// peers). Inactivity expiration is purely server-side bookkeeping and is not +// shipped. +message AccountSettingsCompact { + bool peer_login_expiration_enabled = 1; + // Login expiration window. Unit is nanoseconds (matches time.Duration). + int64 peer_login_expiration_ns = 2; +} + +// AccountNetwork is the account-level overlay metadata. Mirrors types.Network +// so the client can populate NetworkMap.Network without a server round-trip. +message AccountNetwork { + string identifier = 1; + // IPv4 overlay subnet in CIDR form (e.g. "100.64.0.0/16"). + string net_cidr = 2; + // IPv6 ULA overlay subnet in CIDR form (e.g. "fd00:4e42::/64"). Empty when + // the account has no IPv6 overlay yet. + string net_v6_cidr = 3; + string dns = 4; + uint64 serial = 5; +} + +// NetworkMapComponentsDelta is reserved for the incremental update +// protocol. Field numbers 1–100 are pre-allocated to keep room for the +// planned event types without needing a renumber. +message NetworkMapComponentsDelta { + reserved 1 to 100; +} + +// PeerCompact is the wire-shape of a remote peer used by the component +// format. It carries every field of types.Peer that the client's local +// Calculate() reads — including the trio needed to evaluate +// LoginExpired() (added_with_sso_login + login_expiration_enabled + +// last_login_unix_nano). Fields the client does not consume (Status, +// CreatedAt, etc.) are not shipped. +message PeerCompact { + // Raw 32-byte WireGuard public key (no base64 wrapping). + bytes wg_pub_key = 1; + + // Raw 4-byte IPv4 overlay address. Always a /32 host route, so no prefix + // byte is needed. + bytes ip = 2; + + // Raw 16-byte IPv6 overlay address; always a /128 host route. Empty when + // the peer has no IPv6 overlay address. + bytes ipv6 = 3; + + // Raw SSH public key bytes (or empty). + bytes ssh_pub_key = 4; + + // DNS label without the account's domain suffix. Full FQDN is + // dns_label + "." + NetworkMapComponentsFull.dns_domain. + string dns_label = 5; + + string agent_version = 6; + + // True iff the peer was added via SSO login (i.e., types.Peer.UserID is + // non-empty). Combined with login_expiration_enabled and + // last_login_unix_nano this lets the client reproduce + // (*Peer).LoginExpired() locally. + bool added_with_sso_login = 7; + + // True when the peer's login can expire — mirrors + // types.Peer.LoginExpirationEnabled. + bool login_expiration_enabled = 8; + + // Unix-nanosecond timestamp of the peer's last login. 0 when the peer has + // never logged in (server stores nil; client treats 0 as "epoch", which + // makes a fresh peer immediately expired iff login_expiration_enabled is + // true — the same semantics as types.Peer.GetLastLogin). + int64 last_login_unix_nano = 9; + + // True when the peer has an SSH server enabled locally. Used by the + // legacy SSH path in Calculate() (`policyRuleImpliesLegacySSH`): a rule + // with protocol ALL/TCP-with-SSH-ports activates SSH for the receiving + // peer when this bit is set, even without an explicit NetbirdSSH rule. + bool ssh_enabled = 10; + + // Mirror of types.Peer.SupportsIPv6() — !Meta.Flags.DisableIPv6 && + // HasCapability(PeerCapabilityIPv6Overlay). Used by the local peer's + // Calculate() when deciding whether to emit IPv6 firewall rules + // (appendIPv6FirewallRule) against this peer's IPv6 address. + bool supports_ipv6 = 11; + + // Mirror of types.Peer.SupportsSourcePrefixes() — + // HasCapability(PeerCapabilitySourcePrefixes). Determines whether the + // local peer's Calculate() emits SourcePrefixes alongside legacy PeerIP + // fields in proto.FirewallRule. + bool supports_source_prefixes = 12; + + // Mirror of types.Peer.Meta.Flags.ServerSSHAllowed. Read by Calculate() + // when expanding TCP port-22 firewall rules — the native SSH companion + // (port 22022) is only added when this flag is set and the peer agent + // version supports it. + bool server_ssh_allowed = 13; +} + +// PolicyCompact is the compact form of a policy rule. Group references use +// the public_ids; the client resolves +// them against NetworkMapComponentsFull.groups. Direction is derived per-peer +// on the client (ingress when the peer is in destination_group_ids, egress +// when in source_group_ids; both when bidirectional). +message PolicyCompact { + // public_id. Used as a stable reference for + // ResourcePoliciesMap.indexes and future delta updates. + string id = 1; + + RuleAction action = 2; + RuleProtocol protocol = 3; + bool bidirectional = 4; + + // Single ports referenced by the rule. + repeated uint32 ports = 5; + + // Port ranges (start..end) referenced by the rule. + repeated PortInfo.Range port_ranges = 6; + + // Group ids (public_ids) of source / destination groups. + repeated string source_group_ids = 7; + repeated string destination_group_ids = 8; + + // SSH authorization fields. PolicyRule.AuthorizedGroups maps the rule's + // applicable group ids (public_ids) to a list of local-user names — + // when a peer in one of those groups is the SSH destination, the named + // local users gain access. AuthorizedUser is the single-user form + // (legacy: rule scopes SSH to one specific user id). + // + // Both fields are only consumed by Calculate() when the rule's protocol + // is NetbirdSSH (or the legacy implicit-SSH heuristic). + map authorized_groups = 9; + string authorized_user = 10; + + // Resource-typed rule sources/destinations. When a rule targets a specific + // peer (rather than groups), Calculate() reads SourceResource / + // DestinationResource — without these the rule's connection resources + // can't be produced on the client. ResourceCompact's peer_index refers to + // NetworkMapComponentsFull.peers; type is the raw ResourceType string + // ("peer", "host", "subnet", "domain"). Only "peer" is meaningful for + // Calculate's resource-typed rule path today. + ResourceCompact source_resource = 11; + ResourceCompact destination_resource = 12; + + // Posture-check ids gating this policy's source peers. Calculate() + // reads them when filtering rule peers (peers that fail any listed check + // are dropped from sourcePeers). Match keys in + // NetworkMapComponentsFull.posture_failed_peers. + repeated string source_posture_check_ids = 13; +} + +// ResourceCompact mirrors types.Resource. Used by PolicyCompact to carry +// rule.SourceResource / rule.DestinationResource when the rule targets a +// specific resource (typically a peer) rather than groups. +// peer_index_set tells whether peer_index is valid (proto3 uint32 cannot +// disambiguate "0" from "unset"); set only when type == "peer". +message ResourceCompact { + string type = 1; + bool peer_index_set = 2; + uint32 peer_index = 3; + reserved 4; // future: host/subnet/domain references when needed +} + +// UserNameList is a list of local-user names — used as the value type in +// PolicyCompact.authorized_groups. +message UserNameList { + repeated string names = 1; +} + +// GroupCompact is the wire-shape of a group: public id, optional +// name, and indexes into NetworkMapComponentsFull.peers identifying members. +message GroupCompact { + // id comes from PublicID. Used by PolicyCompact.source_group_ids / destination_group_ids. + string id = 1; + + // Indexes into NetworkMapComponentsFull.peers. + repeated uint32 peer_indexes = 2; + + // True when the group is named "All" (types.Group.IsGroupAll). The + // client-side Calculate short-circuits group→peer expansion on such + // groups exactly like the server does; without this bit the decoded + // groups lose that property and the two sides expand policy + // destinations differently. + bool is_all = 3; +} + +// DNSSettingsCompact mirrors types.DNSSettings. +message DNSSettingsCompact { + // Group ids (public_id) whose DNS management is disabled. + repeated string disabled_management_group_ids = 1; +} + +// RouteRaw mirrors *route.Route (the domain type), trimmed to fields that +// types.NetworkMapComponents.Calculate() reads. Group references are +// public_ids; the routing peer (when set) is referenced by index into +// NetworkMapComponentsFull.peers. +message RouteRaw { + string id = 1; // public_id + string net_id = 2; + string description = 3; + + // Either network_cidr (e.g. "10.0.0.0/16") or domains is set, not both. + string network_cidr = 4; + repeated string domains = 5; + bool keep_route = 6; + + // Routing peer reference: peer_index_set tells whether peer_index is valid + // (proto3 uint32 cannot disambiguate "0" from "unset"). Mutually exclusive + // with peer_group_ids. + // + // peer_index decodes back to types.Peer.ID (the peer's xid string), NOT + // to its WireGuard public key. This matches the server-side data flow: + // c.Routes carry route.Peer = peer.ID, and getRoutingPeerRoutes mutates + // it to peer.Key only after the route has been admitted to the network + // map. Decoders MUST set Route.Peer = peer.ID; the legacy Calculate() + // path will substitute the WG key downstream. + bool peer_index_set = 7; + uint32 peer_index = 8; + repeated string peer_group_ids = 9; + + int32 network_type = 10; + bool masquerade = 11; + int32 metric = 12; + bool enabled = 13; + repeated string group_ids = 14; + repeated string access_control_group_ids = 15; + bool skip_auto_apply = 16; +} + +// NameServerGroupRaw mirrors *nbdns.NameServerGroup. Distinct from the +// legacy NameServerGroup (which is the wire-trimmed shape consumed by +// proto.DNSConfig and lacks the Name/Description/Groups/Enabled fields). +message NameServerGroupRaw { + string id = 1; + // Reuses the legacy NameServer wire shape (IP as string). + repeated NameServer nameservers = 2; + // Group ids the NSG distributes nameservers to. + repeated string group_ids = 3; + bool primary = 4; + repeated string domains = 5; + bool enabled = 6; + bool search_domains_enabled = 7; +} + +// NetworkResourceRaw mirrors *resourceTypes.NetworkResource. +// +message NetworkResourceRaw { + string id = 1; + string network_seq = 2; + string name = 3; + string description = 4; + // Resource type: "host" / "subnet" / "domain". + string type = 5; + string address = 6; + string domain_value = 7; // resource.Domain + string prefix_cidr = 8; + bool enabled = 9; +} + +// NetworkRouterList carries the routers backing one network. +message NetworkRouterList { + // Routers in this network, keyed by peer_index (the routing peer). + repeated NetworkRouterEntry entries = 1; +} + +// NetworkRouterEntry mirrors a single *routerTypes.NetworkRouter; the routing +// peer is referenced by index into NetworkMapComponentsFull.peers. +message NetworkRouterEntry { + string id = 1; + uint32 peer_index = 2; + bool peer_index_set = 3; + repeated string peer_group_ids = 4; + bool masquerade = 5; + int32 metric = 6; + bool enabled = 7; +} + +message PolicyIds { + repeated string ids = 1; +} + +// UserIDList is a list of user ids — used as the value type in +// NetworkMapComponentsFull.group_id_to_user_ids. +message UserIDList { + repeated string user_ids = 1; +} + +// PeerIndexSet is a set of peer indexes — used as the value type in +// NetworkMapComponentsFull.posture_failed_peers. +message PeerIndexSet { + repeated uint32 peer_indexes = 1; +} diff --git a/management/server/types/dns_settings.go b/shared/management/types/dns_settings.go similarity index 100% rename from management/server/types/dns_settings.go rename to shared/management/types/dns_settings.go diff --git a/shared/management/types/firewall_helpers.go b/shared/management/types/firewall_helpers.go new file mode 100644 index 000000000..dd174abe4 --- /dev/null +++ b/shared/management/types/firewall_helpers.go @@ -0,0 +1,131 @@ +package types + +import ( + "strconv" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/version" +) + +const ( + firewallRuleMinPortRangesVer = "0.48.0" + firewallRuleMinNativeSSHVer = "0.60.0" + + nativeSSHPortString = "22022" + nativeSSHPortNumber = 22022 + defaultSSHPortString = "22" + defaultSSHPortNumber = 22 +) + +type supportedFeatures struct { + nativeSSH bool + portRanges bool +} + +type LookupMap map[string]struct{} + +func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { + return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges))) +} + +func portRangeIncludesSSH(portRanges []RulePortRange) bool { + for _, pr := range portRanges { + if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) { + return true + } + } + return false +} + +func portsIncludesSSH(ports []string) bool { + for _, port := range ports { + if port == defaultSSHPortString || port == nativeSSHPortString { + return true + } + } + return false +} + +// ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules. +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { + features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) + + var expanded []*FirewallRule + + for _, port := range rule.Ports { + fr := base + fr.Port = port + expanded = append(expanded, &fr) + } + + for _, portRange := range rule.PortRanges { + if len(rule.Ports) > 0 { + break + } + fr := base + + if features.portRanges { + fr.PortRange = portRange + } else { + if portRange.Start != portRange.End { + continue + } + fr.Port = strconv.FormatUint(uint64(portRange.Start), 10) + } + expanded = append(expanded, &fr) + } + + if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == PolicyRuleProtocolNetbirdSSH { + expanded = addNativeSSHRule(base, expanded) + } + + return expanded +} + +func addNativeSSHRule(base FirewallRule, expanded []*FirewallRule) []*FirewallRule { + shouldAdd := false + for _, fr := range expanded { + if isPortInRule(nativeSSHPortString, 22022, fr) { + return expanded + } + if isPortInRule(defaultSSHPortString, 22, fr) { + shouldAdd = true + } + } + if !shouldAdd { + return expanded + } + + fr := base + fr.Port = nativeSSHPortString + return append(expanded, &fr) +} + +func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool { + return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End) +} + +func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *nbpeer.Peer) bool { + return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP +} + +func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { + if version.IsDevelopmentVersion(peerVer) { + return supportedFeatures{true, true} + } + + var features supportedFeatures + + meetMinVer, err := posture.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) + features.nativeSSH = err == nil && meetMinVer + + if features.nativeSSH { + features.portRanges = true + } else { + meetMinVer, err = posture.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) + features.portRanges = err == nil && meetMinVer + } + + return features +} diff --git a/management/server/types/firewall_rule.go b/shared/management/types/firewall_rule.go similarity index 97% rename from management/server/types/firewall_rule.go rename to shared/management/types/firewall_rule.go index b76a94290..87dcfe307 100644 --- a/management/server/types/firewall_rule.go +++ b/shared/management/types/firewall_rule.go @@ -47,11 +47,11 @@ func (r *FirewallRule) Equal(other *FirewallRule) bool { return reflect.DeepEqual(r, other) } -// generateRouteFirewallRules generates a list of firewall rules for a given route. +// GenerateRouteFirewallRules generates a list of firewall rules for a given route. // For static routes, source ranges match the destination family (v4 or v6). // For dynamic routes (domain-based), separate v4 and v6 rules are generated // so the routing peer's forwarding chain allows both address families. -func generateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { rulesExists := make(map[string]struct{}) rules := make([]*RouteFirewallRule, 0) diff --git a/management/server/types/firewall_rule_test.go b/shared/management/types/firewall_rule_test.go similarity index 92% rename from management/server/types/firewall_rule_test.go rename to shared/management/types/firewall_rule_test.go index 8d97a46bc..9de4ca04a 100644 --- a/management/server/types/firewall_rule_test.go +++ b/shared/management/types/firewall_rule_test.go @@ -57,7 +57,7 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) require.Len(t, rules, 1) assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges, "v4 route should only have v4 sources") @@ -86,7 +86,7 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) require.Len(t, rules, 1) assert.Equal(t, []string{"fd00::1/128"}, rules[0].SourceRanges, "v6 route should only have v6 sources") @@ -115,7 +115,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) require.Len(t, rules, 2, "dynamic route should produce both v4 and v6 rules") assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges) @@ -143,7 +143,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) require.Len(t, rules, 1, "no v6 peers means only v4 rule") assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges) @@ -173,7 +173,7 @@ func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) assert.Empty(t, rules, "v6 route should produce no rules when includeIPv6 is false") }) @@ -190,7 +190,7 @@ func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) require.Len(t, rules, 1, "dynamic route with includeIPv6=false should produce only v4 rule") assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges) }) diff --git a/management/server/types/group.go b/shared/management/types/group.go similarity index 98% rename from management/server/types/group.go rename to shared/management/types/group.go index b4f50080a..e6e285e62 100644 --- a/management/server/types/group.go +++ b/shared/management/types/group.go @@ -19,6 +19,8 @@ type Group struct { // AccountID is a reference to Account that this object belongs AccountID string `json:"-" gorm:"index"` + PublicID string `json:"-"` + // Name visible in the UI Name string @@ -74,6 +76,7 @@ func (g *Group) Copy() *Group { group := &Group{ ID: g.ID, AccountID: g.AccountID, + PublicID: g.PublicID, Name: g.Name, Issued: g.Issued, Peers: make([]string, len(g.Peers)), diff --git a/management/server/types/network.go b/shared/management/types/network.go similarity index 100% rename from management/server/types/network.go rename to shared/management/types/network.go diff --git a/management/server/types/network_test.go b/shared/management/types/network_test.go similarity index 100% rename from management/server/types/network_test.go rename to shared/management/types/network_test.go diff --git a/management/server/types/networkmap_components.go b/shared/management/types/networkmap_components.go similarity index 93% rename from management/server/types/networkmap_components.go rename to shared/management/types/networkmap_components.go index a3f2d15e9..fdb70f2f7 100644 --- a/management/server/types/networkmap_components.go +++ b/shared/management/types/networkmap_components.go @@ -44,8 +44,21 @@ type NetworkMapComponents struct { RouterPeers map[string]*nbpeer.Peer - routesByPeerOnce sync.Once - routesByPeerIdx map[string][]routeIndexEntry + // NetworkXIDToPublicID maps Network.ID (xid) → PublicID. + // Consumed by the envelope encoder to + // translate RoutersMap keys and NetworkResource.NetworkID references + // to compact uint32 ids. Legacy Calculate() doesn't consult it. + NetworkXIDToPublicID map[string]string + + // PostureCheckXIDToPublicID maps posture.Checks.ID (xid) → PublicID. + // Same role as NetworkXIDToPublicID, used for PostureFailedPeers keys and + // policy SourcePostureChecks references. + PostureCheckXIDToPublicID map[string]string + routesByPeerOnce sync.Once + routesByPeerIdx map[string][]routeIndexEntry + + // true when returning an empty-like map (returned instead of nil) + empty bool } type routeIndexEntry struct { @@ -60,6 +73,11 @@ type AccountSettingsInfo struct { PeerInactivityExpiration time.Duration } +func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents { + nm.empty = true + return nm +} + func (c *NetworkMapComponents) GetPeerInfo(peerID string) *nbpeer.Peer { return c.Peers[peerID] } @@ -178,6 +196,10 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { } } +func (c *NetworkMapComponents) IsEmpty() bool { + return c.empty +} + func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) { targetPeer := c.GetPeerInfo(targetPeerID) if targetPeer == nil { @@ -261,7 +283,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ( default: authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() } - } else if peerInDestinations && policyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled { + } else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled { sshEnabled = true authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() } @@ -328,15 +350,15 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) ( if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) } else { - rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...) + rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...) } - rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{ - direction: direction, - dirStr: dirStr, - protocolStr: protocolStr, - actionStr: actionStr, - portsJoined: portsJoined, + rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{ + Direction: direction, + DirStr: dirStr, + ProtocolStr: protocolStr, + ActionStr: actionStr, + PortsJoined: portsJoined, }) } }, func() ([]*nbpeer.Peer, []*FirewallRule) { @@ -703,7 +725,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID } rulePeers := c.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers) - rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) + rules := GenerateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) fwRules = append(fwRules, rules...) } } @@ -972,21 +994,21 @@ func (c *NetworkMapComponents) addNetworksRoutingPeers( return peersToConnect } -type firewallRuleContext struct { - direction int - dirStr string - protocolStr string - actionStr string - portsJoined string +type FirewallRuleContext struct { + Direction int + DirStr string + ProtocolStr string + ActionStr string + PortsJoined string } -func appendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc firewallRuleContext) []*FirewallRule { +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6() || !targetPeer.IPv6.IsValid() { return rules } v6IP := peer.IPv6.String() - v6RuleID := rule.ID + v6IP + rc.dirStr + rc.protocolStr + rc.actionStr + rc.portsJoined + v6RuleID := rule.ID + v6IP + rc.DirStr + rc.ProtocolStr + rc.ActionStr + rc.PortsJoined if _, ok := rulesExists[v6RuleID]; ok { return rules } @@ -995,12 +1017,12 @@ func appendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct v6fr := FirewallRule{ PolicyID: rule.ID, PeerIP: v6IP, - Direction: rc.direction, - Action: rc.actionStr, - Protocol: rc.protocolStr, + Direction: rc.Direction, + Action: rc.ActionStr, + Protocol: rc.ProtocolStr, } if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { return append(rules, &v6fr) } - return append(rules, expandPortsAndRanges(v6fr, rule, targetPeer)...) + return append(rules, ExpandPortsAndRanges(v6fr, rule, targetPeer)...) } diff --git a/management/server/types/networkmap_components_compact.go b/shared/management/types/networkmap_components_compact.go similarity index 100% rename from management/server/types/networkmap_components_compact.go rename to shared/management/types/networkmap_components_compact.go diff --git a/management/server/types/policy.go b/shared/management/types/policy.go similarity index 99% rename from management/server/types/policy.go rename to shared/management/types/policy.go index d410aec8d..b8f605b94 100644 --- a/management/server/types/policy.go +++ b/shared/management/types/policy.go @@ -56,6 +56,8 @@ type Policy struct { // ID of the policy' ID string `gorm:"primaryKey"` + PublicID string `json:"-"` + // AccountID is a reference to Account that this object belongs AccountID string `json:"-" gorm:"index"` @@ -80,6 +82,7 @@ func (p *Policy) Copy() *Policy { c := &Policy{ ID: p.ID, AccountID: p.AccountID, + PublicID: p.PublicID, Name: p.Name, Description: p.Description, Enabled: p.Enabled, diff --git a/management/server/types/policyrule.go b/shared/management/types/policyrule.go similarity index 100% rename from management/server/types/policyrule.go rename to shared/management/types/policyrule.go diff --git a/management/server/types/resource.go b/shared/management/types/resource.go similarity index 100% rename from management/server/types/resource.go rename to shared/management/types/resource.go diff --git a/management/server/types/route_firewall_rule.go b/shared/management/types/route_firewall_rule.go similarity index 100% rename from management/server/types/route_firewall_rule.go rename to shared/management/types/route_firewall_rule.go From b0c1ed31b80ec136dda6889484d7f01a3d4d5c84 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Wed, 22 Jul 2026 18:47:19 +0200 Subject: [PATCH 16/47] [infrastructure] Add unified admin CLI for self-hosted helpers (#6507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a unified `admin` CLI for self-hosted instance administrators in both the management and combined binaries. ## User Management ### `admin user change-password` - Changes a local embedded IdP user's password. - Selects the user with `--email` or `--user-id`. - Reads the new password from `--password` or `--password-file`. - Clears the user's local authentication session so the new password is required on the next login. - **Alias:** `admin user set-password`. ### `admin user reset-mfa` - Resets a local embedded IdP user's MFA enrollment. - Selects the user with `--email` or `--user-id`. - Clears TOTP/WebAuthn enrollment data and removes the local authentication session. - The user will re-enroll MFA on the next login. ## MFA Management ### `admin mfa status` - Shows whether local MFA is enabled in the account settings. - Checks the embedded IdP client configuration and reports whether MFA is enabled there. ### `admin mfa enable` - Enables local MFA for embedded IdP users. - Updates embedded IdP clients and saves the account MFA setting. - Records an audit event on a best-effort basis. ### `admin mfa disable` - Disables local MFA for embedded IdP users. - Updates embedded IdP clients and saves the account MFA setting. - Records an audit event on a best-effort basis. ## Reverse Proxy Tokens ### `admin token create --name [--expires-in ]` - Creates a reverse proxy access token. - Prints the plaintext token once, along with the token ID. - `--expires-in` supports values such as `24h`, `30d`, or `365d`. If omitted, the token never expires. ### `admin token list` - Lists reverse proxy access tokens. - Shows the token ID, name, creation date, expiration, last-used time, and revocation status. - **Alias:** `admin token ls`. ### `admin token revoke ` - Revokes a reverse proxy access token. - Revoked tokens can no longer authenticate reverse proxy instances. ## Reverse Proxy Management ### `admin proxy disconnect-all` - Lists registered reverse proxy instances and force-marks all connected instances as disconnected. - Useful for repairing stale proxy state after an unclean management server shutdown. - Prompts for confirmation by default. - `--dry-run` previews the changes without applying them. - `--force` skips the confirmation prompt. - Live proxies may appear again after their next heartbeat, reconnect, or re-registration. ## Compatibility Commands ### `token ...` - Deprecated top-level compatibility path. - Behaves the same as `admin token ...`. - Retained so existing scripts using `token create`, `token list`, or `token revoke` continue to work. ## Changes - Adds reusable `management/cmd/admin` command package. - Wires `admin` into `netbird-mgmt` and `combined`. - Adds local user password reset with existing password strength validation. - Adds local MFA enrollment reset by clearing Dex TOTP/WebAuthn credentials and local auth sessions. - Adds local MFA enable/disable/status helpers for embedded IdP deployments. - Moves proxy access token commands under `admin token` for a single admin-focused CLI entry point. - Exports `server.ValidatePassword` for reuse by CLI helpers. ## Tests ```bash go test ./management/cmd/... go test ./management/cmd/admin ./management/cmd ./combined/cmd go test ./management/server -run TestValidatePassword ``` Pre-push lint also passed. ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [x] I added/updated documentation for this change - [ ] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/832 ## Summary by CodeRabbit ## Release Notes * **New Features** * Added self-hosted admin CLI commands for changing passwords, resetting MFA (including WebAuthn), and managing embedded IdP client MFA (enable/disable/status). * Introduced a unified admin command entry point and improved data-directory handling for embedded IdP storage. * **Refactor** * Centralized password strength validation into a shared exported validator. * **Tests** * Added a comprehensive admin command test suite covering password input, selectors, MFA reset, and client MFA state handling. --- combined/cmd/admin.go | 151 +++++ combined/cmd/admin_config_test.go | 47 ++ combined/cmd/config.go | 24 +- combined/cmd/root.go | 60 +- combined/cmd/token.go | 63 -- idp/dex/provider.go | 66 +- idp/dex/provider_test.go | 35 ++ infrastructure_files/getting-started.sh | 2 +- management/cmd/admin.go | 177 ++++++ management/cmd/admin/admin.go | 577 ++++++++++++++++++ management/cmd/admin/admin_test.go | 250 ++++++++ management/cmd/admin_config_test.go | 80 +++ management/cmd/management.go | 3 +- management/cmd/proxy/proxy.go | 141 +++++ management/cmd/proxy/proxy_test.go | 180 ++++++ management/cmd/root.go | 7 +- management/cmd/token.go | 55 -- management/internals/shared/grpc/proxy.go | 2 +- management/server/idp/embedded.go | 25 +- management/server/idp/embedded_test.go | 2 +- management/server/store/sql_store.go | 37 +- .../store/sql_store_proxy_disconnect_test.go | 156 +++++ management/server/store/store.go | 2 + management/server/store/store_mock.go | 30 + management/server/user.go | 9 +- 25 files changed, 2000 insertions(+), 181 deletions(-) create mode 100644 combined/cmd/admin.go create mode 100644 combined/cmd/admin_config_test.go delete mode 100644 combined/cmd/token.go create mode 100644 management/cmd/admin.go create mode 100644 management/cmd/admin/admin.go create mode 100644 management/cmd/admin/admin_test.go create mode 100644 management/cmd/admin_config_test.go create mode 100644 management/cmd/proxy/proxy.go create mode 100644 management/cmd/proxy/proxy_test.go delete mode 100644 management/cmd/token.go create mode 100644 management/server/store/sql_store_proxy_disconnect_test.go diff --git a/combined/cmd/admin.go b/combined/cmd/admin.go new file mode 100644 index 000000000..66fac4ac9 --- /dev/null +++ b/combined/cmd/admin.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/dexidp/dex/storage" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/formatter/hook" + admincmd "github.com/netbirdio/netbird/management/cmd/admin" + tokencmd "github.com/netbirdio/netbird/management/cmd/token" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/activity" + activitystore "github.com/netbirdio/netbird/management/server/activity/store" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/util" +) + +// newAdminCommands creates the admin command tree with combined-specific resource openers. +func newAdminCommands() *cobra.Command { + return admincmd.NewCommands(admincmd.Openers{ + Resources: withAdminResources, + Store: withAdminStoreOnly, + IDP: withAdminIDPOnly, + }) +} + +func newLegacyTokenCommand() *cobra.Command { + cmd := tokencmd.NewCommands(tokencmd.StoreOpener(withAdminStoreOnly)) + cmd.Deprecated = "use 'admin token' instead" + return cmd +} + +// withAdminResources loads the combined YAML config, initializes stores, and calls fn. +func withAdminResources(cmd *cobra.Command, fn func(ctx context.Context, resources admincmd.Resources) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + mgmtConfig, err := adminManagementConfig(cfg) + if err != nil { + return err + } + + managementStore, err := openAdminStore(ctx, cfg) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + eventStore, esErr := openAdminEventStore(ctx, cfg, mgmtConfig) + if esErr != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: audit events will not be recorded: %v\n", esErr) + } + if eventStore != nil { + defer func() { + if err := eventStore.Close(ctx); err != nil { + log.Debugf("close activity event store: %v", err) + } + }() + } + + return fn(ctx, admincmd.Resources{Store: managementStore, IDPStorage: idpStorage, IDPStorageFile: idpStorageFile, EventStore: eventStore}) + }) +} + +// withAdminStoreOnly opens only the management store for admin subcommands that do not +// need embedded IdP storage. +func withAdminStoreOnly(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + managementStore, err := openAdminStore(ctx, cfg) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + return fn(ctx, managementStore) + }) +} + +func withAdminIDPOnly(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + mgmtConfig, err := adminManagementConfig(cfg) + if err != nil { + return err + } + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + return fn(ctx, idpStorage, idpStorageFile) + }) +} + +func withAdminConfig(cmd *cobra.Command, fn func(ctx context.Context, cfg *CombinedConfig) error) error { + if err := util.InitLog("error", "console"); err != nil { + return fmt.Errorf("init log: %w", err) + } + + ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck + + cfg, err := LoadConfig(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + cfg.ApplyAdminDefaults() + applyServerStoreEnv(cfg.Server.Store) + + return fn(ctx, cfg) +} + +func adminManagementConfig(cfg *CombinedConfig) (*nbconfig.Config, error) { + mgmtConfig, err := cfg.ToManagementConfig() + if err != nil { + return nil, fmt.Errorf("create management config: %w", err) + } + return mgmtConfig, nil +} + +func openAdminStore(ctx context.Context, cfg *CombinedConfig) (store.Store, error) { + managementStore, err := store.NewStore(ctx, types.Engine(cfg.Management.Store.Engine), cfg.Management.DataDir, nil, true) + if err != nil { + return nil, fmt.Errorf("create store: %w", err) + } + return managementStore, nil +} + +func openAdminEventStore(ctx context.Context, cfg *CombinedConfig, config *nbconfig.Config) (activity.Store, error) { + if config.DataStoreEncryptionKey == "" { + return nil, fmt.Errorf("data store encryption key is not configured") + } + if err := applyActivityStoreEnv(cfg.Server.ActivityStore); err != nil { + return nil, fmt.Errorf("configure activity event store: %w", err) + } + eventStore, err := activitystore.NewSqlStore(ctx, config.Datadir, config.DataStoreEncryptionKey) + if err != nil { + return nil, fmt.Errorf("open activity event store: %w", err) + } + if eventStore == nil { + return nil, fmt.Errorf("open activity event store: returned nil store") + } + return eventStore, nil +} diff --git a/combined/cmd/admin_config_test.go b/combined/cmd/admin_config_test.go new file mode 100644 index 000000000..ff7045d38 --- /dev/null +++ b/combined/cmd/admin_config_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" +) + +func TestApplyAdminDefaultsCopiesServerStoreWithoutExposedAddress(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ExposedAddress = "" + cfg.Server.DataDir = "/srv/netbird" + cfg.Server.Store = StoreConfig{ + Engine: "postgres", + DSN: "postgres://user:pass@example.com/netbird", + } + + cfg.ApplyAdminDefaults() + + require.Equal(t, "/srv/netbird", cfg.Management.DataDir) + require.Equal(t, "postgres", cfg.Management.Store.Engine) + require.Equal(t, cfg.Server.Store.DSN, cfg.Management.Store.DSN) +} + +func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T) { + eventStore, err := openAdminEventStore(context.Background(), &CombinedConfig{}, &nbconfig.Config{}) + require.Error(t, err) + require.Contains(t, err.Error(), "encryption key") + require.Nil(t, eventStore) +} + +func TestApplyServerStoreEnv(t *testing.T) { + t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "") + t.Setenv("NB_STORE_ENGINE_MYSQL_DSN", "") + t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "") + + applyServerStoreEnv(StoreConfig{Engine: "postgres", DSN: "postgres-dsn", File: "store.db"}) + require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN")) + require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE")) + + applyServerStoreEnv(StoreConfig{Engine: "mysql", DSN: "mysql-dsn"}) + require.Equal(t, "mysql-dsn", os.Getenv("NB_STORE_ENGINE_MYSQL_DSN")) +} diff --git a/combined/cmd/config.go b/combined/cmd/config.go index d022c2197..7f30cd8a8 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -6,8 +6,7 @@ import ( "net" "net/netip" "os" - "path" - "path/filepath" + filePath "path/filepath" "strings" "time" @@ -303,6 +302,19 @@ func (c *CombinedConfig) ApplySimplifiedDefaults() { c.autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort, hasExternalStuns, hasExternalRelay, hasExternalSignal) } +// ApplyAdminDefaults applies the management settings needed by admin commands even +// when the full server config is invalid and ApplySimplifiedDefaults cannot run. +func (c *CombinedConfig) ApplyAdminDefaults() { + if c.Management.DataDir == "" || c.Management.DataDir == "/var/lib/netbird/" { + c.Management.DataDir = c.Server.DataDir + } + if c.Management.Store.Engine == "" || c.Management.Store.Engine == "sqlite" { + if c.Server.Store.Engine != "" || c.Server.Store.File != "" || c.Server.Store.DSN != "" { + c.Management.Store = c.Server.Store + } + } +} + // applyRelayDefaults configures the relay service if no external relay is configured. func (c *CombinedConfig) applyRelayDefaults(exposedProto, exposedHostPort string, hasExternalRelay, hasExternalStuns bool) { if hasExternalRelay { @@ -580,11 +592,11 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb return nil, fmt.Errorf("authStore.dsn is required when authStore.engine is postgres") } } else { - authStorageFile = path.Join(mgmt.DataDir, "idp.db") + authStorageFile = filePath.Join(mgmt.DataDir, "idp.db") if c.Server.AuthStore.File != "" { authStorageFile = c.Server.AuthStore.File - if !filepath.IsAbs(authStorageFile) { - authStorageFile = filepath.Join(mgmt.DataDir, authStorageFile) + if !filePath.IsAbs(authStorageFile) { + authStorageFile = filePath.Join(mgmt.DataDir, authStorageFile) } } } @@ -734,7 +746,7 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config, mgmtPort cfg.EmbeddedIdP.Storage.Type = "sqlite3" } if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" { - cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db") + cfg.EmbeddedIdP.Storage.Config.File = filePath.Join(cfg.Datadir, "idp.db") } issuer := cfg.EmbeddedIdP.Issuer diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 1a0127ff3..5f2564e3a 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -65,7 +65,8 @@ func init() { rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to YAML configuration file (required)") _ = rootCmd.MarkPersistentFlagRequired("config") - rootCmd.AddCommand(newTokenCommands()) + rootCmd.AddCommand(newAdminCommands()) + rootCmd.AddCommand(newLegacyTokenCommand()) } func RootCmd() *cobra.Command { @@ -123,6 +124,37 @@ func execute(cmd *cobra.Command, _ []string) error { } // initializeConfig loads and validates the configuration, then initializes logging. +func applyServerStoreEnv(storeConfig StoreConfig) { + if dsn := storeConfig.DSN; dsn != "" { + switch strings.ToLower(storeConfig.Engine) { + case "postgres": + os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) + case "mysql": + os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) + } + } + if file := storeConfig.File; file != "" { + os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) + } +} + +func applyActivityStoreEnv(storeConfig StoreConfig) error { + if engine := storeConfig.Engine; engine != "" { + engineLower := strings.ToLower(engine) + if engineLower == "postgres" && storeConfig.DSN == "" { + return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres") + } + os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower) + if dsn := storeConfig.DSN; dsn != "" { + os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn) + } + } + if file := storeConfig.File; file != "" { + os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file) + } + return nil +} + func initializeConfig() error { var err error config, err = LoadConfig(configPath) @@ -138,30 +170,10 @@ func initializeConfig() error { return fmt.Errorf("failed to initialize log: %w", err) } - if dsn := config.Server.Store.DSN; dsn != "" { - switch strings.ToLower(config.Server.Store.Engine) { - case "postgres": - os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) - case "mysql": - os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) - } - } - if file := config.Server.Store.File; file != "" { - os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) - } + applyServerStoreEnv(config.Server.Store) - if engine := config.Server.ActivityStore.Engine; engine != "" { - engineLower := strings.ToLower(engine) - if engineLower == "postgres" && config.Server.ActivityStore.DSN == "" { - return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres") - } - os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower) - if dsn := config.Server.ActivityStore.DSN; dsn != "" { - os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn) - } - } - if file := config.Server.ActivityStore.File; file != "" { - os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file) + if err := applyActivityStoreEnv(config.Server.ActivityStore); err != nil { + return err } log.Infof("Starting combined NetBird server") diff --git a/combined/cmd/token.go b/combined/cmd/token.go deleted file mode 100644 index 550480062..000000000 --- a/combined/cmd/token.go +++ /dev/null @@ -1,63 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "strings" - - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - - "github.com/netbirdio/netbird/formatter/hook" - tokencmd "github.com/netbirdio/netbird/management/cmd/token" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" - "github.com/netbirdio/netbird/util" -) - -// newTokenCommands creates the token command tree with combined-specific store opener. -func newTokenCommands() *cobra.Command { - return tokencmd.NewCommands(withTokenStore) -} - -// withTokenStore loads the combined YAML config, initializes the store, and calls fn. -func withTokenStore(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { - if err := util.InitLog("error", "console"); err != nil { - return fmt.Errorf("init log: %w", err) - } - - ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck - - cfg, err := LoadConfig(configPath) - if err != nil { - return fmt.Errorf("load config: %w", err) - } - - if dsn := cfg.Server.Store.DSN; dsn != "" { - switch strings.ToLower(cfg.Server.Store.Engine) { - case "postgres": - os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) - case "mysql": - os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) - } - } - if file := cfg.Server.Store.File; file != "" { - os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) - } - - datadir := cfg.Management.DataDir - engine := types.Engine(cfg.Management.Store.Engine) - - s, err := store.NewStore(ctx, engine, datadir, nil, true) - if err != nil { - return fmt.Errorf("create store: %w", err) - } - defer func() { - if err := s.Close(ctx); err != nil { - log.Debugf("close store: %v", err) - } - }() - - return fn(ctx, s) -} diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 5582af528..f40b96a58 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -40,7 +40,7 @@ type Config struct { GRPCAddr string } -const localConnectorID = "local" +const LocalConnectorID = "local" // Provider wraps a Dex server type Provider struct { @@ -494,18 +494,60 @@ func (p *Provider) Storage() storage.Storage { return p.storage } +// SetClientsMFAChain updates the MFAChain field on OAuth2 clients in Dex storage. +// Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it. +func SetClientsMFAChain(ctx context.Context, st storage.Storage, clientIDs []string, mfaChain []string) error { + previousChains := make(map[string][]string, len(clientIDs)) + for _, clientID := range clientIDs { + client, err := st.GetClient(ctx, clientID) + if err != nil { + return fmt.Errorf("failed to get client %s before MFA chain update: %w", clientID, err) + } + previousChains[clientID] = cloneMFAChain(client.MFAChain) + } + + updatedClientIDs := make([]string, 0, len(clientIDs)) + for _, clientID := range clientIDs { + if err := st.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { + old.MFAChain = cloneMFAChain(mfaChain) + return old, nil + }); err != nil { + if rollbackErr := rollbackClientsMFAChain(ctx, st, updatedClientIDs, previousChains); rollbackErr != nil { + return fmt.Errorf("failed to update MFA chain on client %s: %w (also failed to roll back previous MFA chains: %v)", clientID, err, rollbackErr) + } + return fmt.Errorf("failed to update MFA chain on client %s: %w", clientID, err) + } + updatedClientIDs = append(updatedClientIDs, clientID) + } + return nil +} + +func rollbackClientsMFAChain(ctx context.Context, st storage.Storage, clientIDs []string, previousChains map[string][]string) error { + var rollbackErrs []error + for i := len(clientIDs) - 1; i >= 0; i-- { + clientID := clientIDs[i] + previousChain := cloneMFAChain(previousChains[clientID]) + if err := st.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { + old.MFAChain = previousChain + return old, nil + }); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("client %s: %w", clientID, err)) + } + } + return errors.Join(rollbackErrs...) +} + +func cloneMFAChain(chain []string) []string { + if chain == nil { + return nil + } + return append([]string(nil), chain...) +} + // SetClientsMFAChain updates the MFAChain field on the dashboard and CLI OAuth2 clients. // Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it. func (p *Provider) SetClientsMFAChain(ctx context.Context, clientIDs []string, mfaChain []string) error { - for _, clientID := range clientIDs { - if err := p.storage.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { - old.MFAChain = mfaChain - return old, nil - }); err != nil { - return fmt.Errorf("failed to update MFA chain on client %s: %w", clientID, err) - } - } - return nil + return SetClientsMFAChain(ctx, p.storage, clientIDs, mfaChain) } // Handler returns the Dex server as an http.Handler for embedding in another server. @@ -545,7 +587,7 @@ func (p *Provider) CreateUser(ctx context.Context, email, username, password str // Encode the user ID in Dex's format: base64(protobuf{user_id, connector_id}) // This matches the format Dex uses in JWT tokens - encodedID := EncodeDexUserID(userID, localConnectorID) + encodedID := EncodeDexUserID(userID, LocalConnectorID) return encodedID, nil } @@ -624,7 +666,7 @@ func DecodeDexUserID(encodedID string) (userID, connectorID string, err error) { // local password connector. func IsLocalUserID(encodedID string) bool { _, connectorID, err := DecodeDexUserID(encodedID) - return err == nil && connectorID == localConnectorID + return err == nil && connectorID == LocalConnectorID } // GetUser returns a user by email diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go index 0fce1b2c9..5e132d544 100644 --- a/idp/dex/provider_test.go +++ b/idp/dex/provider_test.go @@ -3,6 +3,8 @@ package dex import ( "context" "encoding/json" + "errors" + "io" "log/slog" "net/http" "net/http/httptest" @@ -11,11 +13,44 @@ import ( "testing" "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" sqllib "github.com/dexidp/dex/storage/sql" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type updateFailingStorage struct { + storage.Storage + failClientID string +} + +func (s *updateFailingStorage) UpdateClient(ctx context.Context, id string, updater func(storage.Client) (storage.Client, error)) error { + if id == s.failClientID { + return errors.New("forced update failure") + } + return s.Storage.UpdateClient(ctx, id, updater) +} + +func TestSetClientsMFAChainRollsBackUpdatedClients(t *testing.T) { + ctx := context.Background() + st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + + require.NoError(t, st.CreateClient(ctx, storage.Client{ID: "client-1", MFAChain: []string{"old-1"}})) + require.NoError(t, st.CreateClient(ctx, storage.Client{ID: "client-2", MFAChain: []string{"old-2"}})) + + err := SetClientsMFAChain(ctx, &updateFailingStorage{Storage: st, failClientID: "client-2"}, []string{"client-1", "client-2"}, []string{"new"}) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to update MFA chain on client client-2") + + client1, err := st.GetClient(ctx, "client-1") + require.NoError(t, err) + require.Equal(t, []string{"old-1"}, client1.MFAChain) + + client2, err := st.GetClient(ctx, "client-2") + require.NoError(t, err) + require.Equal(t, []string{"old-2"}, client2.MFAChain) +} + func TestUserCreationFlow(t *testing.T) { ctx := context.Background() diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 837cc42e6..0206c269a 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -556,7 +556,7 @@ start_services_and_show_instructions() { echo "Creating proxy access token..." # Use docker exec with bash to run the token command directly PROXY_TOKEN=$($DOCKER_COMPOSE_COMMAND exec -T netbird-server \ - /go/bin/netbird-server token create --name "default-proxy" --config /etc/netbird/config.yaml 2>/dev/null | grep "^Token:" | awk '{print $2}') + /go/bin/netbird-server admin token create --name "default-proxy" --config /etc/netbird/config.yaml 2>/dev/null | grep "^Token:" | awk '{print $2}') if [[ -z "$PROXY_TOKEN" ]]; then echo "ERROR: Failed to create proxy token. Check netbird-server logs." > /dev/stderr diff --git a/management/cmd/admin.go b/management/cmd/admin.go new file mode 100644 index 000000000..e5c0f6ac9 --- /dev/null +++ b/management/cmd/admin.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "context" + "fmt" + "path" + "path/filepath" + + "github.com/dexidp/dex/storage" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/formatter/hook" + admincmd "github.com/netbirdio/netbird/management/cmd/admin" + tokencmd "github.com/netbirdio/netbird/management/cmd/token" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/activity" + activitystore "github.com/netbirdio/netbird/management/server/activity/store" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/util" +) + +var adminDatadir string + +// newAdminCommands creates the admin command tree with management-specific resource openers. +func newAdminCommands() *cobra.Command { + cmd := admincmd.NewCommands(admincmd.Openers{ + Resources: withAdminResources, + Store: withAdminStoreOnly, + IDP: withAdminIDPOnly, + }) + cmd.PersistentFlags().StringVar(&adminDatadir, "datadir", "", "Override the data directory from config (used for store.db and the default idp.db)") + return cmd +} + +func newLegacyTokenCommand() *cobra.Command { + cmd := tokencmd.NewCommands(tokencmd.StoreOpener(withAdminStoreOnly)) + cmd.Deprecated = "use 'admin token' instead" + cmd.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location") + return cmd +} + +// withAdminResources initializes logging, loads config, opens the management store +// and embedded IdP storage, and calls fn. +func withAdminResources(cmd *cobra.Command, fn func(ctx context.Context, resources admincmd.Resources) error) error { + return withAdminConfig(cmd, true, func(ctx context.Context, config *nbconfig.Config, datadir string) error { + managementStore, err := openAdminStore(ctx, config, datadir) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(config) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + eventStore, esErr := openAdminEventStore(ctx, config, datadir) + if esErr != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: audit events will not be recorded: %v\n", esErr) + } + if eventStore != nil { + defer func() { + if err := eventStore.Close(ctx); err != nil { + log.Debugf("close activity event store: %v", err) + } + }() + } + + return fn(ctx, admincmd.Resources{Store: managementStore, IDPStorage: idpStorage, IDPStorageFile: idpStorageFile, EventStore: eventStore}) + }) +} + +// withAdminStoreOnly opens only the management store for admin subcommands that do not +// need embedded IdP storage. +func withAdminStoreOnly(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + return withAdminConfig(cmd, false, func(ctx context.Context, config *nbconfig.Config, datadir string) error { + managementStore, err := openAdminStore(ctx, config, datadir) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + return fn(ctx, managementStore) + }) +} + +func withAdminIDPOnly(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error { + return withAdminConfig(cmd, true, func(ctx context.Context, config *nbconfig.Config, _ string) error { + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(config) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + return fn(ctx, idpStorage, idpStorageFile) + }) +} + +func withAdminConfig(cmd *cobra.Command, applyIDPDefaults bool, fn func(ctx context.Context, config *nbconfig.Config, datadir string) error) error { + if err := util.InitLog("error", "console"); err != nil { + return fmt.Errorf("init log: %w", err) + } + + ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck + + config, datadir, err := loadAdminMgmtConfig(ctx, applyIDPDefaults) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + return fn(ctx, config, datadir) +} + +func loadAdminMgmtConfig(ctx context.Context, applyIDPDefaults bool) (*nbconfig.Config, string, error) { + config := &nbconfig.Config{} + if _, err := util.ReadJsonWithEnvSub(nbconfig.MgmtConfigPath, config); err != nil { + return nil, "", err + } + + if applyIDPDefaults { + if err := ApplyEmbeddedIdPConfig(ctx, config); err != nil { + return nil, "", err + } + } + + datadir := config.Datadir + applyAdminDatadirOverride(config, &datadir) + return config, datadir, nil +} + +func applyAdminDatadirOverride(config *nbconfig.Config, datadir *string) { + if adminDatadir == "" { + return + } + + oldDatadir := *datadir + *datadir = adminDatadir + if config.EmbeddedIdP != nil && config.EmbeddedIdP.Storage.Type == "sqlite3" && isDefaultIDPStorageFile(config.EmbeddedIdP.Storage.Config.File, oldDatadir) { + config.EmbeddedIdP.Storage.Config.File = filepath.Join(*datadir, "idp.db") + } +} + +func isDefaultIDPStorageFile(file, datadir string) bool { + if file == "" { + return true + } + defaultFile := filepath.Join(datadir, "idp.db") + legacyDefaultFile := path.Join(datadir, "idp.db") + legacySlashDefaultFile := path.Join(filepath.ToSlash(datadir), "idp.db") + return filepath.Clean(file) == filepath.Clean(defaultFile) || + file == legacyDefaultFile || + filepath.ToSlash(file) == legacySlashDefaultFile +} + +func openAdminStore(ctx context.Context, config *nbconfig.Config, datadir string) (store.Store, error) { + managementStore, err := store.NewStore(ctx, config.StoreConfig.Engine, datadir, nil, true) + if err != nil { + return nil, fmt.Errorf("create store: %w", err) + } + return managementStore, nil +} + +func openAdminEventStore(ctx context.Context, config *nbconfig.Config, datadir string) (activity.Store, error) { + if config.DataStoreEncryptionKey == "" { + return nil, fmt.Errorf("data store encryption key is not configured") + } + eventStore, err := activitystore.NewSqlStore(ctx, datadir, config.DataStoreEncryptionKey) + if err != nil { + return nil, fmt.Errorf("open activity event store: %w", err) + } + if eventStore == nil { + return nil, fmt.Errorf("open activity event store: returned nil store") + } + return eventStore, nil +} diff --git a/management/cmd/admin/admin.go b/management/cmd/admin/admin.go new file mode 100644 index 000000000..bd56af39b --- /dev/null +++ b/management/cmd/admin/admin.go @@ -0,0 +1,577 @@ +// Package admincmd provides reusable cobra commands for self-hosted administrator helpers. +// Both the management and combined binaries use these commands, each providing +// their own opener to handle config loading and storage initialization. +package admincmd + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "strings" + "time" + + "github.com/dexidp/dex/storage" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" + + "github.com/netbirdio/netbird/formatter/hook" + nbdex "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/cmd/proxy" + "github.com/netbirdio/netbird/management/cmd/token" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// Resources contains the storages required by the admin commands. +type Resources struct { + Store store.Store + IDPStorage storage.Storage + IDPStorageFile string + EventStore activity.Store +} + +// Opener initializes command resources from the command context and calls fn. +type Opener func(cmd *cobra.Command, fn func(ctx context.Context, resources Resources) error) error + +// StoreOpener initializes only the management store from the command context and calls fn. +type StoreOpener func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error + +// IDPOpener initializes only the embedded IdP storage from the command context and calls fn. +type IDPOpener func(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error + +// Openers contains the resource openers needed by the admin command tree. +type Openers struct { + Resources Opener + Store StoreOpener + IDP IDPOpener +} + +type userSelector struct { + email string + userID string +} + +func (s userSelector) normalized() userSelector { + return userSelector{ + email: strings.TrimSpace(s.email), + userID: strings.TrimSpace(s.userID), + } +} + +func (s userSelector) validate() error { + s = s.normalized() + if (s.email == "") == (s.userID == "") { + return fmt.Errorf("provide exactly one of --email or --user-id") + } + return nil +} + +// NewCommands creates the admin command tree with the given resource openers. +func NewCommands(openers Openers) *cobra.Command { + adminCmd := &cobra.Command{ + Use: "admin", + Short: "Self-hosted administrator helpers", + Long: "Administrative helpers for self-hosted deployments using the embedded identity provider.", + } + + userCmd := &cobra.Command{ + Use: "user", + Short: "Manage local embedded IdP users", + } + + var passwordSelector userSelector + var password string + var passwordFile string + passwordCmd := &cobra.Command{ + Use: "change-password (--email email | --user-id id) (--password password | --password-file path)", + Aliases: []string{"set-password"}, + Short: "Change a local user's password", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := passwordSelector.validate(); err != nil { + return err + } + newPassword, err := resolvePasswordInput(cmd, password, passwordFile) + if err != nil { + return err + } + return openers.IDP(cmd, func(ctx context.Context, idpStorage storage.Storage, storageFile string) error { + return runChangePassword(ctx, idpStorage, cmd.OutOrStdout(), passwordSelector, newPassword, storageFile) + }) + }, + } + addUserSelectorFlags(passwordCmd, &passwordSelector) + passwordCmd.Flags().StringVar(&password, "password", "", "New password for the user") + passwordCmd.Flags().StringVar(&passwordFile, "password-file", "", "Read new password from file ('-' for stdin)") + + var resetSelector userSelector + resetMFACmd := &cobra.Command{ + Use: "reset-mfa (--email email | --user-id id)", + Short: "Reset a local user's MFA enrollment", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := resetSelector.validate(); err != nil { + return err + } + return openers.IDP(cmd, func(ctx context.Context, idpStorage storage.Storage, storageFile string) error { + return runResetMFA(ctx, idpStorage, cmd.OutOrStdout(), resetSelector, storageFile) + }) + }, + } + addUserSelectorFlags(resetMFACmd, &resetSelector) + + userCmd.AddCommand(passwordCmd, resetMFACmd) + + mfaCmd := &cobra.Command{ + Use: "mfa", + Short: "Manage local MFA for embedded IdP users", + } + + enableCmd := &cobra.Command{ + Use: "enable", + Short: "Enable MFA for local embedded IdP users", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return openers.Resources(cmd, func(ctx context.Context, resources Resources) error { + return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), true) + }) + }, + } + + disableCmd := &cobra.Command{ + Use: "disable", + Short: "Disable MFA for local embedded IdP users", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return openers.Resources(cmd, func(ctx context.Context, resources Resources) error { + return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), false) + }) + }, + } + + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show local MFA status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return openers.Resources(cmd, func(ctx context.Context, resources Resources) error { + return runMFAStatus(ctx, resources, cmd.OutOrStdout()) + }) + }, + } + + mfaCmd.AddCommand(enableCmd, disableCmd, statusCmd) + adminCmd.AddCommand(userCmd, mfaCmd) + if openers.Store != nil { + adminCmd.AddCommand(tokencmd.NewCommands(tokencmd.StoreOpener(openers.Store))) + adminCmd.AddCommand(proxycmd.NewCommands(proxycmd.StoreOpener(openers.Store))) + } + return adminCmd +} + +// OpenEmbeddedIDPStorage opens the Dex storage configured for the embedded IdP. +func OpenEmbeddedIDPStorage(cfg *idp.EmbeddedIdPConfig) (storage.Storage, error) { + if cfg == nil || !cfg.Enabled { + return nil, fmt.Errorf("admin commands require the embedded IdP to be enabled") + } + + yamlConfig, err := cfg.ToYAMLConfig() + if err != nil { + return nil, fmt.Errorf("build embedded IdP config: %w", err) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + st, err := yamlConfig.Storage.OpenStorage(logger) + if err != nil { + return nil, fmt.Errorf("open embedded IdP storage: %w", err) + } + return st, nil +} + +// CloseStore closes the management store and logs cleanup errors at debug level. +func CloseStore(ctx context.Context, s store.Store) { + if s == nil { + return + } + if err := s.Close(ctx); err != nil { + log.Debugf("close store: %v", err) + } +} + +// OpenIDPStorage opens embedded IdP storage and returns its sqlite file path when applicable. +func OpenIDPStorage(config *nbconfig.Config) (storage.Storage, string, error) { + if config == nil { + return nil, "", fmt.Errorf("management config is required") + } + idpStorage, err := OpenEmbeddedIDPStorage(config.EmbeddedIdP) + if err != nil { + return nil, "", err + } + return idpStorage, embeddedIDPStorageFile(config), nil +} + +func embeddedIDPStorageFile(config *nbconfig.Config) string { + if config.EmbeddedIdP == nil || config.EmbeddedIdP.Storage.Type != "sqlite3" { + return "" + } + return config.EmbeddedIdP.Storage.Config.File +} + +// CloseIDPStorage closes embedded IdP storage and logs cleanup errors at debug level. +func CloseIDPStorage(s storage.Storage) { + if s == nil { + return + } + if err := s.Close(); err != nil { + log.Debugf("close embedded IdP storage: %v", err) + } +} + +func addUserSelectorFlags(cmd *cobra.Command, selector *userSelector) { + cmd.Flags().StringVar(&selector.email, "email", "", "User email") + cmd.Flags().StringVar(&selector.userID, "user-id", "", "User ID") +} + +func resolvePasswordInput(cmd *cobra.Command, password, passwordFile string) (string, error) { + if password != "" && passwordFile != "" { + return "", fmt.Errorf("provide only one of --password or --password-file") + } + if passwordFile == "" { + return password, nil + } + + var data []byte + var err error + if passwordFile == "-" { + data, err = io.ReadAll(cmd.InOrStdin()) + } else { + data, err = os.ReadFile(passwordFile) + } + if err != nil { + return "", fmt.Errorf("read password: %w", err) + } + return strings.TrimRight(string(data), "\r\n"), nil +} + +func runChangePassword(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector, password string, idpStorageFile string) error { + if idpStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + selector = selector.normalized() + if err := selector.validate(); err != nil { + return err + } + if password == "" { + return fmt.Errorf("password is required") + } + if err := server.ValidatePassword(password); err != nil { + return fmt.Errorf("invalid password: %w", err) + } + + user, err := findLocalUser(ctx, idpStorage, selector, idpStorageFile) + if err != nil { + return err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + if err := idpStorage.UpdatePassword(ctx, user.Email, func(old storage.Password) (storage.Password, error) { + old.Hash = hash + return old, nil + }); err != nil { + return fmt.Errorf("update password for %s: %w", user.Email, err) + } + + if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil { + return err + } + + _, _ = fmt.Fprintf(w, "Password updated for %s.\n", user.Email) + return nil +} + +func runResetMFA(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector, idpStorageFile string) error { + if idpStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + selector = selector.normalized() + if err := selector.validate(); err != nil { + return err + } + + user, err := findLocalUser(ctx, idpStorage, selector, idpStorageFile) + if err != nil { + return err + } + + reset := false + err = idpStorage.UpdateUserIdentity(ctx, user.UserID, idp.LocalConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + reset = reset || len(old.MFASecrets) > 0 || len(old.WebAuthnCredentials) > 0 + old.MFASecrets = map[string]*storage.MFASecret{} + old.WebAuthnCredentials = map[string][]storage.WebAuthnCredential{} + return old, nil + }) + if errors.Is(err, storage.ErrNotFound) { + if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil { + return err + } + _, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email) + return nil + } + if err != nil { + return fmt.Errorf("reset MFA for %s: %w", user.Email, err) + } + + if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil { + return err + } + + if reset { + _, _ = fmt.Fprintf(w, "MFA reset for %s. The user will re-enroll at next login.\n", user.Email) + } else { + _, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email) + } + return nil +} + +func runSetMFAEnabled(ctx context.Context, resources Resources, w io.Writer, enabled bool) error { + if resources.Store == nil { + return fmt.Errorf("management store is required") + } + if resources.IDPStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + + accountID, settings, err := getSingleAccountSettings(ctx, resources.Store) + if err != nil { + return err + } + + oldEnabled := settings.LocalMfaEnabled + newSettings := settings.Copy() + newSettings.LocalMfaEnabled = enabled + + if err := setIDPClientsMFA(ctx, resources.IDPStorage, enabled); err != nil { + return err + } + + if err := resources.Store.SaveAccountSettings(ctx, accountID, newSettings); err != nil { + if rollbackErr := setIDPClientsMFA(ctx, resources.IDPStorage, oldEnabled); rollbackErr != nil { + return fmt.Errorf("save local MFA account setting: %w (also failed to roll back embedded IdP MFA state: %v)", err, rollbackErr) + } + return fmt.Errorf("save local MFA account setting: %w", err) + } + + if err := storeMFAActivity(ctx, resources.EventStore, accountID, enabled); err != nil { + _, _ = fmt.Fprintf(w, "Warning: failed to record audit event: %v\n", err) + } + + state := "disabled" + if enabled { + state = "enabled" + } + _, _ = fmt.Fprintf(w, "Local MFA %s.\n", state) + return nil +} + +func runMFAStatus(ctx context.Context, resources Resources, w io.Writer) error { + if resources.Store == nil { + return fmt.Errorf("management store is required") + } + if resources.IDPStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + + _, settings, err := getSingleAccountSettings(ctx, resources.Store) + if err != nil { + return err + } + accountStatus := "disabled" + if settings.LocalMfaEnabled { + accountStatus = "enabled" + } + + clientStatus, err := idpClientsMFAStatus(ctx, resources.IDPStorage) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(w, "Account setting: %s\n", accountStatus) + _, _ = fmt.Fprintf(w, "Embedded IdP clients: %s\n", clientStatus) + return nil +} + +func getSingleAccountSettings(ctx context.Context, s store.Store) (string, *types.Settings, error) { + count, err := s.GetAccountsCounter(ctx) + if err != nil { + return "", nil, fmt.Errorf("count accounts: %w", err) + } + if count != 1 { + return "", nil, fmt.Errorf("expected exactly one account, got %d; local MFA is supported only in single-account embedded IdP deployments", count) + } + + accountID, err := s.GetAnyAccountID(ctx) + if err != nil { + return "", nil, fmt.Errorf("get account ID: %w", err) + } + + settings, err := s.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return "", nil, fmt.Errorf("get account settings: %w", err) + } + if settings == nil { + settings = &types.Settings{} + } + return accountID, settings, nil +} + +func storeMFAActivity(ctx context.Context, eventStore activity.Store, accountID string, enabled bool) error { + if eventStore == nil { + return nil + } + event := activity.AccountLocalMfaDisabled + if enabled { + event = activity.AccountLocalMfaEnabled + } + _, err := eventStore.Save(ctx, &activity.Event{ + Timestamp: time.Now().UTC(), + Activity: event, + InitiatorID: string(hook.SystemSource), + TargetID: accountID, + AccountID: accountID, + }) + if err != nil { + return fmt.Errorf("save local MFA audit event: %w", err) + } + return nil +} + +func findLocalUser(ctx context.Context, idpStorage storage.Storage, selector userSelector, idpStorageFile string) (storage.Password, error) { + selector = selector.normalized() + if err := selector.validate(); err != nil { + return storage.Password{}, err + } + + if selector.email != "" { + user, err := idpStorage.GetPassword(ctx, selector.email) + if errors.Is(err, storage.ErrNotFound) { + if empty, listErr := localUsersEmpty(ctx, idpStorage); listErr != nil { + return storage.Password{}, listErr + } else if empty { + return storage.Password{}, noLocalUsersError(idpStorageFile) + } + return storage.Password{}, fmt.Errorf("local user with email %q not found", selector.email) + } + if err != nil { + return storage.Password{}, fmt.Errorf("get local user by email %q: %w", selector.email, err) + } + return user, nil + } + + rawUserID := selector.userID + if decodedUserID, _, err := nbdex.DecodeDexUserID(selector.userID); err == nil && decodedUserID != "" { + rawUserID = decodedUserID + } + + users, err := idpStorage.ListPasswords(ctx) + if err != nil { + return storage.Password{}, fmt.Errorf("list local users: %w", err) + } + for _, user := range users { + if user.UserID == rawUserID || user.UserID == selector.userID { + return user, nil + } + } + + if len(users) == 0 { + return storage.Password{}, noLocalUsersError(idpStorageFile) + } + + return storage.Password{}, fmt.Errorf("local user with ID %q not found", selector.userID) +} + +func localUsersEmpty(ctx context.Context, idpStorage storage.Storage) (bool, error) { + users, err := idpStorage.ListPasswords(ctx) + if err != nil { + return false, fmt.Errorf("list local users: %w", err) + } + return len(users) == 0, nil +} + +func noLocalUsersError(idpStorageFile string) error { + location := "" + if idpStorageFile != "" { + location = fmt.Sprintf(" (%s)", idpStorageFile) + } + return fmt.Errorf("no local users exist in the embedded IdP storage%s; the management server may never have started with this config, or --datadir points at the wrong location", location) +} + +func deleteLocalAuthSession(ctx context.Context, idpStorage storage.Storage, userID string) error { + err := idpStorage.DeleteAuthSession(ctx, userID, idp.LocalConnectorID) + if err == nil || errors.Is(err, storage.ErrNotFound) { + return nil + } + return fmt.Errorf("delete local auth session for user %s: %w", userID, err) +} + +func setIDPClientsMFA(ctx context.Context, idpStorage storage.Storage, enabled bool) error { + var mfaChain []string + if enabled { + mfaChain = []string{idp.DefaultTOTPAuthenticatorID} + } + + clientIDs := []string{idp.StaticClientCLI, idp.StaticClientDashboard} + if err := nbdex.SetClientsMFAChain(ctx, idpStorage, clientIDs, mfaChain); err != nil { + if errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("embedded IdP client not found; start the management server once before toggling MFA: %w", err) + } + return fmt.Errorf("update MFA chain on embedded IdP clients: %w", err) + } + return nil +} + +func idpClientsMFAStatus(ctx context.Context, idpStorage storage.Storage) (string, error) { + clientIDs := []string{idp.StaticClientCLI, idp.StaticClientDashboard} + enabledCount := 0 + for _, clientID := range clientIDs { + client, err := idpStorage.GetClient(ctx, clientID) + if errors.Is(err, storage.ErrNotFound) { + return "unknown", fmt.Errorf("embedded IdP client %q not found", clientID) + } + if err != nil { + return "unknown", fmt.Errorf("get embedded IdP client %q: %w", clientID, err) + } + if hasAuthenticator(client.MFAChain, idp.DefaultTOTPAuthenticatorID) { + enabledCount++ + } + } + + switch enabledCount { + case 0: + return "disabled", nil + case len(clientIDs): + return "enabled", nil + default: + return "partially enabled", nil + } +} + +func hasAuthenticator(chain []string, authenticatorID string) bool { + for _, id := range chain { + if id == authenticatorID { + return true + } + } + return false +} diff --git a/management/cmd/admin/admin_test.go b/management/cmd/admin/admin_test.go new file mode 100644 index 000000000..dd1b8ed06 --- /dev/null +++ b/management/cmd/admin/admin_test.go @@ -0,0 +1,250 @@ +package admincmd + +import ( + "bytes" + "context" + "io" + "log/slog" + "strings" + "testing" + "time" + + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + nbdex "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/server/idp" + mgmtstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +func newTestIDPStorage(t *testing.T) storage.Storage { + t.Helper() + + st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + hash, err := bcrypt.GenerateFromPassword([]byte("OldPass1!"), bcrypt.DefaultCost) + require.NoError(t, err) + + require.NoError(t, st.CreatePassword(context.Background(), storage.Password{ + Email: "user@example.com", + Username: "User", + UserID: "user-1", + Hash: hash, + })) + require.NoError(t, st.CreateUserIdentity(context.Background(), storage.UserIdentity{ + UserID: "user-1", + ConnectorID: idp.LocalConnectorID, + MFASecrets: map[string]*storage.MFASecret{ + idp.DefaultTOTPAuthenticatorID: { + AuthenticatorID: idp.DefaultTOTPAuthenticatorID, + Type: "TOTP", + Secret: "otpauth://totp/NetBird:user@example.com?secret=ABC", + Confirmed: true, + CreatedAt: time.Now(), + }, + }, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{ + "webauthn": {{CredentialID: []byte("credential")}}, + }, + })) + require.NoError(t, st.CreateAuthSession(context.Background(), storage.AuthSession{ + UserID: "user-1", + ConnectorID: idp.LocalConnectorID, + Nonce: "nonce", + })) + require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientCLI, Name: "CLI"})) + require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientDashboard, Name: "Dashboard"})) + + return st +} + +func TestRunChangePassword(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + var out bytes.Buffer + + err := runChangePassword(ctx, st, &out, userSelector{email: "user@example.com"}, "NewPass1!", "") + require.NoError(t, err) + require.Contains(t, out.String(), "Password updated") + + user, err := st.GetPassword(ctx, "user@example.com") + require.NoError(t, err) + require.NoError(t, bcrypt.CompareHashAndPassword(user.Hash, []byte("NewPass1!"))) + + _, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID) + require.ErrorIs(t, err, storage.ErrNotFound) +} + +func TestRunChangePasswordValidatesPassword(t *testing.T) { + st := newTestIDPStorage(t) + err := runChangePassword(context.Background(), st, io.Discard, userSelector{email: "user@example.com"}, "short", "") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid password") +} + +func TestRunResetMFA(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + var out bytes.Buffer + + encodedUserID := nbdex.EncodeDexUserID("user-1", idp.LocalConnectorID) + err := runResetMFA(ctx, st, &out, userSelector{userID: encodedUserID}, "") + require.NoError(t, err) + require.Contains(t, out.String(), "MFA reset") + + identity, err := st.GetUserIdentity(ctx, "user-1", idp.LocalConnectorID) + require.NoError(t, err) + require.Empty(t, identity.MFASecrets) + require.Empty(t, identity.WebAuthnCredentials) + + _, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID) + require.ErrorIs(t, err, storage.ErrNotFound) +} + +func TestRunResetMFAWithoutEnrollment(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + require.NoError(t, st.UpdateUserIdentity(ctx, "user-1", idp.LocalConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + old.MFASecrets = nil + old.WebAuthnCredentials = nil + return old, nil + })) + + var out bytes.Buffer + err := runResetMFA(ctx, st, &out, userSelector{email: "user@example.com"}, "") + require.NoError(t, err) + require.Contains(t, out.String(), "No MFA enrollment found") +} + +func TestSetIDPClientsMFA(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + + require.NoError(t, setIDPClientsMFA(ctx, st, true)) + status, err := idpClientsMFAStatus(ctx, st) + require.NoError(t, err) + require.Equal(t, "enabled", status) + + require.NoError(t, setIDPClientsMFA(ctx, st, false)) + status, err = idpClientsMFAStatus(ctx, st) + require.NoError(t, err) + require.Equal(t, "disabled", status) +} + +func newTestManagementStore(t *testing.T, localMFAEnabled bool) mgmtstore.Store { + t.Helper() + ctx := context.Background() + st, err := mgmtstore.NewStore(ctx, types.SqliteStoreEngine, t.TempDir(), nil, false) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, st.Close(ctx)) }) + require.NoError(t, st.SaveAccount(ctx, &types.Account{ + Id: "account-1", + Settings: &types.Settings{LocalMfaEnabled: localMFAEnabled}, + })) + return st +} + +func TestRunSetMFAEnabledDoesNotSaveWhenIDPUpdateFails(t *testing.T) { + ctx := context.Background() + managementStore := newTestManagementStore(t, false) + idpStorage := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + + err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true) + require.Error(t, err) + require.Contains(t, err.Error(), "embedded IdP client") + + settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1") + require.NoError(t, err) + require.False(t, settings.LocalMfaEnabled) +} + +func TestRunSetMFAEnabledUpdatesSettingsAfterIDP(t *testing.T) { + ctx := context.Background() + managementStore := newTestManagementStore(t, false) + idpStorage := newTestIDPStorage(t) + + err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true) + require.NoError(t, err) + + settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1") + require.NoError(t, err) + require.True(t, settings.LocalMfaEnabled) + clientStatus, err := idpClientsMFAStatus(ctx, idpStorage) + require.NoError(t, err) + require.Equal(t, "enabled", clientStatus) +} + +func TestRunSetMFAEnabledSucceedsWithNilEventStore(t *testing.T) { + ctx := context.Background() + managementStore := newTestManagementStore(t, false) + idpStorage := newTestIDPStorage(t) + var out bytes.Buffer + var err error + + require.NotPanics(t, func() { + err = runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage, EventStore: nil}, &out, true) + }) + require.NoError(t, err) + require.Contains(t, out.String(), "Local MFA enabled") + + settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1") + require.NoError(t, err) + require.True(t, settings.LocalMfaEnabled) +} + +func TestUserSelectorValidate(t *testing.T) { + require.NoError(t, userSelector{email: " user@example.com "}.validate()) + require.NoError(t, userSelector{userID: "user-1"}.validate()) + require.Error(t, userSelector{}.validate()) + require.Error(t, userSelector{email: "user@example.com", userID: "user-1"}.validate()) +} + +func TestFindLocalUserNotFound(t *testing.T) { + st := newTestIDPStorage(t) + _, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "") + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "not found")) +} + +func TestFindLocalUserZeroUsersIncludesStoragePath(t *testing.T) { + st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + _, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "/var/lib/netbird/idp.db") + require.Error(t, err) + require.Contains(t, err.Error(), "no local users exist") + require.Contains(t, err.Error(), "/var/lib/netbird/idp.db") +} + +func TestUserCommandValidatesSelectorBeforeOpeningStorage(t *testing.T) { + opened := false + cmd := NewCommands(Openers{ + IDP: func(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error { + opened = true + return nil + }, + }) + cmd.SetArgs([]string{"user", "change-password", "--password", "NewPass1!"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "provide exactly one") + require.False(t, opened) +} + +func TestResolvePasswordInputFromStdin(t *testing.T) { + cmd := &cobra.Command{} + cmd.SetIn(strings.NewReader("NewPass1!\n")) + + password, err := resolvePasswordInput(cmd, "", "-") + require.NoError(t, err) + require.Equal(t, "NewPass1!", password) +} + +func TestResolvePasswordInputRejectsMultipleSources(t *testing.T) { + _, err := resolvePasswordInput(&cobra.Command{}, "NewPass1!", "-") + require.Error(t, err) +} diff --git a/management/cmd/admin_config_test.go b/management/cmd/admin_config_test.go new file mode 100644 index 000000000..6da8580a8 --- /dev/null +++ b/management/cmd/admin_config_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "context" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/idp" +) + +func TestApplyAdminDatadirOverrideRelocatesDefaultIDPStorage(t *testing.T) { + oldDatadir := filepath.Join(t.TempDir(), "old") + newDatadir := filepath.Join(t.TempDir(), "new") + + for _, defaultFile := range []string{ + "", + filepath.Join(oldDatadir, "idp.db"), + path.Join(oldDatadir, "idp.db"), + } { + t.Run(defaultFile, func(t *testing.T) { + cfg := &nbconfig.Config{ + EmbeddedIdP: &idp.EmbeddedIdPConfig{ + Enabled: true, + Storage: idp.EmbeddedStorageConfig{ + Type: "sqlite3", + Config: idp.EmbeddedStorageTypeConfig{ + File: defaultFile, + }, + }, + }, + } + datadir := oldDatadir + oldAdminDatadir := adminDatadir + adminDatadir = newDatadir + t.Cleanup(func() { adminDatadir = oldAdminDatadir }) + + applyAdminDatadirOverride(cfg, &datadir) + + require.Equal(t, newDatadir, datadir) + require.Equal(t, filepath.Join(newDatadir, "idp.db"), cfg.EmbeddedIdP.Storage.Config.File) + }) + } +} + +func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T) { + eventStore, err := openAdminEventStore(context.Background(), &nbconfig.Config{}, t.TempDir()) + require.Error(t, err) + require.Contains(t, err.Error(), "encryption key") + require.Nil(t, eventStore) +} + +func TestApplyAdminDatadirOverrideKeepsExplicitIDPStorage(t *testing.T) { + oldDatadir := filepath.Join(t.TempDir(), "old") + newDatadir := filepath.Join(t.TempDir(), "new") + explicitFile := filepath.Join(t.TempDir(), "custom-idp.db") + cfg := &nbconfig.Config{ + EmbeddedIdP: &idp.EmbeddedIdPConfig{ + Enabled: true, + Storage: idp.EmbeddedStorageConfig{ + Type: "sqlite3", + Config: idp.EmbeddedStorageTypeConfig{ + File: explicitFile, + }, + }, + }, + } + datadir := oldDatadir + oldAdminDatadir := adminDatadir + adminDatadir = newDatadir + t.Cleanup(func() { adminDatadir = oldAdminDatadir }) + + applyAdminDatadirOverride(cfg, &datadir) + + require.Equal(t, newDatadir, datadir) + require.Equal(t, explicitFile, cfg.EmbeddedIdP.Storage.Config.File) +} diff --git a/management/cmd/management.go b/management/cmd/management.go index 19e93c762..79c838ec4 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -13,6 +13,7 @@ import ( "os" "os/signal" "path" + "path/filepath" "strings" "syscall" @@ -222,7 +223,7 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error { cfg.EmbeddedIdP.Storage.Type = "sqlite3" } if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" { - cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db") + cfg.EmbeddedIdP.Storage.Config.File = filepath.Join(cfg.Datadir, "idp.db") } issuer := cfg.EmbeddedIdP.Issuer diff --git a/management/cmd/proxy/proxy.go b/management/cmd/proxy/proxy.go new file mode 100644 index 000000000..73f83b3d6 --- /dev/null +++ b/management/cmd/proxy/proxy.go @@ -0,0 +1,141 @@ +// Package proxycmd provides reusable cobra commands for managing reverse proxy instances. +// Both the management and combined binaries use these commands, each providing +// their own StoreOpener to handle config loading and store initialization. +package proxycmd + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/server/store" +) + +// StoreOpener initializes a store from the command context and calls fn. +type StoreOpener func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error + +const disconnectAllConfirmation = "disconnect all proxies" + +// NewCommands creates the proxy command tree with the given store opener. +// Returns the parent "proxy" command with the disconnect-all subcommand. +func NewCommands(opener StoreOpener) *cobra.Command { + var dryRun bool + var force bool + + proxyCmd := &cobra.Command{ + Use: "proxy", + Short: "Manage reverse proxy instances", + Long: "Commands for inspecting and repairing the reverse proxy instances registered with the management server.", + } + + disconnectAllCmd := &cobra.Command{ + Use: "disconnect-all", + Short: "Force-mark all reverse proxy instances as disconnected", + Long: "Lists all reverse proxy instances and force-marks them as disconnected, regardless of their session state. " + + "Use this to repair stale connection state, e.g. after an unclean management server shutdown. " + + "By default, it asks for manual confirmation before changing state. Use --dry-run to preview without changing state, or --force to skip confirmation. " + + "Run during a maintenance window; affected live proxies may stay hidden until their next heartbeat or reconnect/re-register.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return opener(cmd, func(ctx context.Context, s store.Store) error { + return runDisconnectAll(ctx, s, cmd.OutOrStdout(), cmd.InOrStdin(), dryRun, force) + }) + }, + } + disconnectAllCmd.Flags().BoolVar(&dryRun, "dry-run", false, "List reverse proxy instances that would be disconnected without changing state") + disconnectAllCmd.Flags().BoolVar(&force, "force", false, "Skip the confirmation prompt and apply the repair") + + proxyCmd.AddCommand(disconnectAllCmd) + return proxyCmd +} + +func runDisconnectAll(ctx context.Context, s store.Store, out io.Writer, in io.Reader, dryRun, force bool) error { + proxies, err := s.GetAllProxies(ctx) + if err != nil { + return fmt.Errorf("list proxies: %w", err) + } + + if len(proxies) == 0 { + _, _ = fmt.Fprintln(out, "No reverse proxy instances found.") + return nil + } + + toDisconnect := 0 + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tCLUSTER\tIP\tACCOUNT\tSTATUS\tLAST SEEN") + _, _ = fmt.Fprintln(w, "--\t-------\t--\t-------\t------\t---------") + + for _, p := range proxies { + if p.Status != rpproxy.StatusDisconnected { + toDisconnect++ + } + + account := "-" + if p.AccountID != nil { + account = *p.AccountID + } + + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + p.ID, + p.ClusterAddress, + p.IPAddress, + account, + p.Status, + p.LastSeen.Format("2006-01-02 15:04:05"), + ) + } + if err := w.Flush(); err != nil { + return fmt.Errorf("write proxy list: %w", err) + } + + if dryRun { + _, _ = fmt.Fprintf(out, "\nDry run: would force-mark %d of %d reverse proxy instance(s) as disconnected.\n", toDisconnect, len(proxies)) + return nil + } + + if !force { + confirmed, err := confirmDisconnectAll(out, in) + if err != nil { + return err + } + if !confirmed { + _, _ = fmt.Fprintln(out, "Aborted. No reverse proxy instances were changed.") + return nil + } + } + + disconnected, err := s.DisconnectAllProxies(ctx) + if err != nil { + return fmt.Errorf("disconnect proxies: %w", err) + } + + _, _ = fmt.Fprintf(out, "\nForce-marked %d of %d reverse proxy instance(s) as disconnected.\n", disconnected, len(proxies)) + return nil +} + +func confirmDisconnectAll(out io.Writer, in io.Reader) (bool, error) { + if in == nil { + in = strings.NewReader("") + } + + _, _ = fmt.Fprintln(out, "\nWARNING: This command changes stored reverse proxy state for every non-disconnected instance.") + _, _ = fmt.Fprintln(out, "Run it during a maintenance window; affected live proxies may stay hidden until "+ + "their next heartbeat or reconnect/re-register.") + _, _ = fmt.Fprintf(out, "Type %q to continue: ", disconnectAllConfirmation) + + scanner := bufio.NewScanner(in) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return false, fmt.Errorf("read confirmation: %w", err) + } + return false, nil + } + + return strings.EqualFold(strings.TrimSpace(scanner.Text()), disconnectAllConfirmation), nil +} diff --git a/management/cmd/proxy/proxy_test.go b/management/cmd/proxy/proxy_test.go new file mode 100644 index 000000000..ff0dc8119 --- /dev/null +++ b/management/cmd/proxy/proxy_test.go @@ -0,0 +1,180 @@ +package proxycmd + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/server/store" +) + +func newTestStore(t *testing.T) store.Store { + t.Helper() + + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + return s +} + +func seedProxies(t *testing.T, ctx context.Context, s store.Store) { + t.Helper() + + accountID := "account-1" + alreadyDisconnectedAt := time.Now().Add(-time.Hour) + seed := []*rpproxy.Proxy{ + { + ID: "proxy-1", + SessionID: "session-1", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.1", + LastSeen: time.Now(), + Status: rpproxy.StatusConnected, + }, + { + ID: "proxy-2", + SessionID: "session-2", + ClusterAddress: "cluster-b.example.com", + IPAddress: "10.0.0.2", + AccountID: &accountID, + LastSeen: time.Now(), + Status: rpproxy.StatusConnected, + }, + { + ID: "proxy-3", + SessionID: "session-3", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.3", + LastSeen: time.Now().Add(-time.Hour), + Status: rpproxy.StatusDisconnected, + DisconnectedAt: &alreadyDisconnectedAt, + }, + } + for _, p := range seed { + require.NoError(t, s.SaveProxy(ctx, p)) + } +} + +func proxiesByID(t *testing.T, ctx context.Context, s store.Store) map[string]*rpproxy.Proxy { + t.Helper() + + proxies, err := s.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, proxies, 3) + + byID := make(map[string]*rpproxy.Proxy, len(proxies)) + for _, p := range proxies { + byID[p.ID] = p + } + return byID +} + +func TestRunDisconnectAllWithConfirmation(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(disconnectAllConfirmation+"\n"), false, false)) + + output := out.String() + require.Contains(t, output, "proxy-1") + require.Contains(t, output, "proxy-2") + require.Contains(t, output, "proxy-3") + require.Contains(t, output, "cluster-a.example.com") + require.Contains(t, output, "account-1") + require.Contains(t, output, "Type \"disconnect all proxies\" to continue") + require.Contains(t, output, "Force-marked 2 of 3 reverse proxy instance(s) as disconnected.") + + for _, p := range proxiesByID(t, ctx, s) { + require.Equal(t, rpproxy.StatusDisconnected, p.Status, "proxy %s should be disconnected", p.ID) + require.NotNil(t, p.DisconnectedAt, "proxy %s should have a disconnected timestamp", p.ID) + } +} + +func TestRunDisconnectAllForceSkipsConfirmation(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), false, true)) + + output := out.String() + require.NotContains(t, output, "Type \"disconnect all proxies\" to continue") + require.Contains(t, output, "Force-marked 2 of 3 reverse proxy instance(s) as disconnected.") +} + +func TestRunDisconnectAllAbortLeavesProxiesUnchanged(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader("no\n"), false, false)) + + output := out.String() + require.Contains(t, output, "Type \"disconnect all proxies\" to continue") + require.Contains(t, output, "Aborted. No reverse proxy instances were changed.") + + byID := proxiesByID(t, ctx, s) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-1"].Status) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-2"].Status) + require.Equal(t, rpproxy.StatusDisconnected, byID["proxy-3"].Status) +} + +func TestRunDisconnectAllDryRunLeavesProxiesUnchanged(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), true, false)) + + output := out.String() + require.Contains(t, output, "Dry run: would force-mark 2 of 3 reverse proxy instance(s) as disconnected.") + require.NotContains(t, output, "Type \"disconnect all proxies\" to continue") + + byID := proxiesByID(t, ctx, s) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-1"].Status) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-2"].Status) + require.Equal(t, rpproxy.StatusDisconnected, byID["proxy-3"].Status) +} + +func TestNewCommandsDisconnectAllDryRun(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + opened := false + cmd := NewCommands(func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + opened = true + return fn(cmd.Context(), s) + }) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetIn(strings.NewReader("")) + cmd.SetArgs([]string{"disconnect-all", "--dry-run"}) + + require.NoError(t, cmd.ExecuteContext(ctx)) + require.True(t, opened) + require.Contains(t, out.String(), "Dry run: would force-mark 2 of 3 reverse proxy instance(s) as disconnected.") +} + +func TestRunDisconnectAllEmpty(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), false, false)) + require.Contains(t, out.String(), "No reverse proxy instances found.") +} diff --git a/management/cmd/root.go b/management/cmd/root.go index fc43d315d..969dd60dd 100644 --- a/management/cmd/root.go +++ b/management/cmd/root.go @@ -83,7 +83,8 @@ func init() { rootCmd.AddCommand(migrationCmd) - tc := newTokenCommands() - tc.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location") - rootCmd.AddCommand(tc) + ac := newAdminCommands() + ac.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location") + rootCmd.AddCommand(ac) + rootCmd.AddCommand(newLegacyTokenCommand()) } diff --git a/management/cmd/token.go b/management/cmd/token.go deleted file mode 100644 index 67af1a5f5..000000000 --- a/management/cmd/token.go +++ /dev/null @@ -1,55 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - - "github.com/netbirdio/netbird/formatter/hook" - tokencmd "github.com/netbirdio/netbird/management/cmd/token" - nbconfig "github.com/netbirdio/netbird/management/internals/server/config" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/util" -) - -var tokenDatadir string - -// newTokenCommands creates the token command tree with management-specific store opener. -func newTokenCommands() *cobra.Command { - cmd := tokencmd.NewCommands(withTokenStore) - cmd.PersistentFlags().StringVar(&tokenDatadir, "datadir", "", "Override the data directory from config (where store.db is located)") - return cmd -} - -// withTokenStore initializes logging, loads config, opens the store, and calls fn. -func withTokenStore(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { - if err := util.InitLog("error", "console"); err != nil { - return fmt.Errorf("init log: %w", err) - } - - ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck - - config, err := LoadMgmtConfig(ctx, nbconfig.MgmtConfigPath) - if err != nil { - return fmt.Errorf("load config: %w", err) - } - - datadir := config.Datadir - if tokenDatadir != "" { - datadir = tokenDatadir - } - - s, err := store.NewStore(ctx, config.StoreConfig.Engine, datadir, nil, true) - if err != nil { - return fmt.Errorf("create store: %w", err) - } - defer func() { - if err := s.Close(ctx); err != nil { - log.Debugf("close store: %v", err) - } - }() - - return fn(ctx, s) -} diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 0dfa24bc4..b289f8c71 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -608,11 +608,11 @@ func (s *ProxyServiceServer) disconnectProxy(conn *proxyConnection) { if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, conn.proxyID); err != nil { log.Warnf("Failed to unregister proxy %s from cluster: %v", conn.proxyID, err) } + conn.cancel() if err := s.proxyManager.Disconnect(context.Background(), conn.proxyID, conn.sessionID); err != nil { log.Warnf("Failed to mark proxy %s as disconnected: %v", conn.proxyID, err) } - conn.cancel() log.Infof("Proxy %s session %s disconnected", conn.proxyID, conn.sessionID) } diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go index 029749a25..b045d6ad6 100644 --- a/management/server/idp/embedded.go +++ b/management/server/idp/embedded.go @@ -21,8 +21,11 @@ import ( ) const ( - staticClientDashboard = "netbird-dashboard" - staticClientCLI = "netbird-cli" + StaticClientDashboard = "netbird-dashboard" + StaticClientCLI = "netbird-cli" + DefaultTOTPAuthenticatorID = "default-totp" + LocalConnectorID = dex.LocalConnectorID + defaultCLIRedirectURL1 = "http://localhost:53000/" defaultCLIRedirectURL2 = "http://localhost:54000/" defaultScopes = "openid profile email groups" @@ -189,14 +192,14 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { EnablePasswordDB: true, StaticClients: []storage.Client{ { - ID: staticClientDashboard, + ID: StaticClientDashboard, Name: "NetBird Dashboard", Public: true, RedirectURIs: redirectURIs, PostLogoutRedirectURIs: sanitizePostLogoutRedirectURIs(dashboardPostLogoutRedirectURIs), }, { - ID: staticClientCLI, + ID: StaticClientCLI, Name: "NetBird CLI", Public: true, RedirectURIs: redirectURIs, @@ -258,13 +261,13 @@ func sanitizePostLogoutRedirectURIs(uris []string) []string { func configureMFA(cfg *dex.YAMLConfig, sessionMaxLifetime, sessionIdleTimeout string, rememberMe bool, sessionCookieEncryptionKey string) error { cfg.MFA.Authenticators = []dex.MFAAuthenticator{{ - ID: "default-totp", + ID: DefaultTOTPAuthenticatorID, // Has to be caps otherwise it will fail Type: "TOTP", Config: map[string]interface{}{ "issuer": "NetBird", }, - ConnectorTypes: []string{"local"}, + ConnectorTypes: []string{LocalConnectorID}, }} if sessionMaxLifetime == "" { @@ -740,7 +743,7 @@ func (m *EmbeddedIdPManager) GetDefaultScopes() string { // GetCLIClientID returns the client ID for CLI authentication. func (m *EmbeddedIdPManager) GetCLIClientID() string { - return staticClientCLI + return StaticClientCLI } // GetCLIRedirectURLs returns the redirect URLs configured for the CLI client. @@ -779,7 +782,7 @@ func (m *EmbeddedIdPManager) GetLocalKeysLocation() string { // GetClientIDs returns the OAuth2 client IDs configured for this provider. func (m *EmbeddedIdPManager) GetClientIDs() []string { - return []string{staticClientDashboard, staticClientCLI} + return []string{StaticClientDashboard, StaticClientCLI} } // GetUserIDClaim returns the JWT claim name used for user identification. @@ -796,11 +799,11 @@ func (m *EmbeddedIdPManager) IsLocalAuthDisabled() bool { func (m *EmbeddedIdPManager) SetMFAEnabled(ctx context.Context, enabled bool) error { var mfaChain []string if enabled { - mfaChain = []string{"default-totp"} + mfaChain = []string{DefaultTOTPAuthenticatorID} } if err := m.provider.SetClientsMFAChain(ctx, []string{ - staticClientCLI, - staticClientDashboard, + StaticClientCLI, + StaticClientDashboard, }, mfaChain); err != nil { return fmt.Errorf("failed to set MFA enabled=%v: %w", enabled, err) } diff --git a/management/server/idp/embedded_test.go b/management/server/idp/embedded_test.go index 91cd27aee..cf3fcf7ff 100644 --- a/management/server/idp/embedded_test.go +++ b/management/server/idp/embedded_test.go @@ -331,7 +331,7 @@ func TestEmbeddedIdPConfig_ToYAMLConfig_IncludesDeviceCallbackRedirectURI(t *tes var cliRedirectURIs []string for _, client := range yamlConfig.StaticClients { - if client.ID == staticClientCLI { + if client.ID == StaticClientCLI { cliRedirectURIs = client.RedirectURIs break } diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 7bf6110d8..3ad870ad3 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -6140,6 +6140,37 @@ func (s *SqlStore) DisconnectProxy(ctx context.Context, proxyID, sessionID strin return nil } +// GetAllProxies returns all reverse proxy instance rows. +func (s *SqlStore) GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) { + var proxies []*proxy.Proxy + result := s.db.Order("cluster_address, id").Find(&proxies) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get proxies: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get proxies") + } + return proxies, nil +} + +// DisconnectAllProxies force-marks every proxy that is not already disconnected +// as disconnected, regardless of session ID. Unlike DisconnectProxy it is not +// session-guarded: it is an administrative repair helper, not part of the +// connection lifecycle. last_seen is left untouched so the stale-proxy reaper +// keeps working off the real last heartbeat. Returns the number of proxies updated. +func (s *SqlStore) DisconnectAllProxies(ctx context.Context) (int64, error) { + result := s.db. + Model(&proxy.Proxy{}). + Where("status != ?", proxy.StatusDisconnected). + Updates(map[string]any{ + "status": proxy.StatusDisconnected, + "disconnected_at": time.Now(), + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to disconnect all proxies: %v", result.Error) + return 0, status.Errorf(status.Internal, "failed to disconnect all proxies") + } + return result.RowsAffected, nil +} + // UpdateProxyHeartbeat updates the last_seen timestamp for the proxy's current session. func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error { now := time.Now() @@ -6147,7 +6178,11 @@ func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) err result := s.db. Model(&proxy.Proxy{}). Where("id = ? AND session_id = ?", p.ID, p.SessionID). - Update("last_seen", now) + Updates(map[string]any{ + "last_seen": now, + "status": proxy.StatusConnected, + "disconnected_at": nil, + }) if result.Error != nil { log.WithContext(ctx).Errorf("failed to update proxy heartbeat: %v", result.Error) diff --git a/management/server/store/sql_store_proxy_disconnect_test.go b/management/server/store/sql_store_proxy_disconnect_test.go new file mode 100644 index 000000000..2d0f34680 --- /dev/null +++ b/management/server/store/sql_store_proxy_disconnect_test.go @@ -0,0 +1,156 @@ +package store + +import ( + "context" + "os" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" +) + +// TestSqlStore_DisconnectAllProxies guards the administrative +// force-disconnect helper: +// +// 1. Every proxy that is not already disconnected is marked +// disconnected regardless of its session ID (unlike +// DisconnectProxy, which is session-guarded). +// 2. Rows that are already disconnected are left untouched, so their +// original disconnected_at is preserved and the returned count +// reflects only the rows that actually changed. +// 3. last_seen is not modified — the stale-proxy reaper keeps working +// off the real last heartbeat. +func TestSqlStore_DisconnectAllProxies(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + + lastSeenFresh := time.Now().Add(-30 * time.Second) + lastSeenStale := time.Now().Add(-30 * time.Minute) + oldDisconnectedAt := time.Now().Add(-time.Hour) + + accountID := "acct-disconnect" + proxies := []*rpproxy.Proxy{ + { + ID: "p-connected-fresh", + SessionID: "sess-1", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.1", + LastSeen: lastSeenFresh, + Status: rpproxy.StatusConnected, + }, + { + ID: "p-connected-stale", + SessionID: "sess-2", + ClusterAddress: "cluster-b.example.com", + IPAddress: "10.0.0.2", + AccountID: &accountID, + LastSeen: lastSeenStale, + Status: rpproxy.StatusConnected, + }, + { + ID: "p-already-disconnected", + SessionID: "sess-3", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.3", + LastSeen: lastSeenStale, + Status: rpproxy.StatusDisconnected, + DisconnectedAt: &oldDisconnectedAt, + }, + } + for _, p := range proxies { + require.NoError(t, store.SaveProxy(ctx, p)) + } + + all, err := store.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, all, 3) + + disconnected, err := store.DisconnectAllProxies(ctx) + require.NoError(t, err) + assert.Equal(t, int64(2), disconnected) + + all, err = store.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, all, 3) + + byID := make(map[string]*rpproxy.Proxy, len(all)) + for _, p := range all { + byID[p.ID] = p + } + + for id, p := range byID { + assert.Equal(t, rpproxy.StatusDisconnected, p.Status, "proxy %s should be disconnected", id) + require.NotNil(t, p.DisconnectedAt, "proxy %s should have disconnected_at set", id) + } + + // force-marked rows carry a fresh disconnected_at; the untouched row keeps its original one + assert.WithinDuration(t, time.Now(), *byID["p-connected-fresh"].DisconnectedAt, 10*time.Second) + assert.WithinDuration(t, time.Now(), *byID["p-connected-stale"].DisconnectedAt, 10*time.Second) + assert.WithinDuration(t, oldDisconnectedAt, *byID["p-already-disconnected"].DisconnectedAt, time.Second) + + // last_seen is preserved so the stale reaper schedule is unaffected + assert.WithinDuration(t, lastSeenFresh, byID["p-connected-fresh"].LastSeen, time.Second) + assert.WithinDuration(t, lastSeenStale, byID["p-connected-stale"].LastSeen, time.Second) + + // idempotent: a second run has nothing left to update + disconnected, err = store.DisconnectAllProxies(ctx) + require.NoError(t, err) + assert.Equal(t, int64(0), disconnected) + }) +} + +func TestSqlStore_UpdateProxyHeartbeatRestoresDisconnectedCurrentSession(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + proxy := &rpproxy.Proxy{ + ID: "p-heartbeat", + SessionID: "sess-heartbeat", + ClusterAddress: "cluster-heartbeat.example.com", + IPAddress: "10.0.0.10", + LastSeen: time.Now().Add(-30 * time.Second), + Status: rpproxy.StatusConnected, + } + require.NoError(t, store.SaveProxy(ctx, proxy)) + + disconnected, err := store.DisconnectAllProxies(ctx) + require.NoError(t, err) + require.Equal(t, int64(1), disconnected) + + require.NoError(t, store.UpdateProxyHeartbeat(ctx, &rpproxy.Proxy{ID: proxy.ID, SessionID: proxy.SessionID})) + + all, err := store.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, all, 1) + assert.Equal(t, rpproxy.StatusConnected, all[0].Status) + assert.Nil(t, all[0].DisconnectedAt) + assert.WithinDuration(t, time.Now(), all[0].LastSeen, 10*time.Second) + + addresses, err := store.GetActiveProxyClusterAddresses(ctx) + require.NoError(t, err) + assert.Contains(t, addresses, proxy.ClusterAddress) + }) +} + +func TestSqlStore_GetAllProxies_Empty(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + all, err := store.GetAllProxies(context.Background()) + require.NoError(t, err) + assert.Empty(t, all) + }) +} diff --git a/management/server/store/store.go b/management/server/store/store.go index 0bc385d83..b78dd9d0f 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -323,6 +323,8 @@ type Store interface { GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error + GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) + DisconnectAllProxies(ctx context.Context) (int64, error) GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 2da9881de..428632a86 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -845,6 +845,21 @@ func (mr *MockStoreMockRecorder) DeleteZoneDNSRecords(ctx, accountID, zoneID int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoneDNSRecords", reflect.TypeOf((*MockStore)(nil).DeleteZoneDNSRecords), ctx, accountID, zoneID) } +// DisconnectAllProxies mocks base method. +func (m *MockStore) DisconnectAllProxies(ctx context.Context) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DisconnectAllProxies", ctx) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DisconnectAllProxies indicates an expected call of DisconnectAllProxies. +func (mr *MockStoreMockRecorder) DisconnectAllProxies(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisconnectAllProxies", reflect.TypeOf((*MockStore)(nil).DisconnectAllProxies), ctx) +} + // DisconnectProxy mocks base method. func (m *MockStore) DisconnectProxy(ctx context.Context, proxyID, sessionID string) error { m.ctrl.T.Helper() @@ -1761,6 +1776,21 @@ func (mr *MockStoreMockRecorder) GetAllEphemeralPeers(ctx, lockStrength interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllEphemeralPeers", reflect.TypeOf((*MockStore)(nil).GetAllEphemeralPeers), ctx, lockStrength) } +// GetAllProxies mocks base method. +func (m *MockStore) GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllProxies", ctx) + ret0, _ := ret[0].([]*proxy.Proxy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllProxies indicates an expected call of GetAllProxies. +func (mr *MockStoreMockRecorder) GetAllProxies(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllProxies", reflect.TypeOf((*MockStore)(nil).GetAllProxies), ctx) +} + // GetAllProxyAccessTokens mocks base method. func (m *MockStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types3.ProxyAccessToken, error) { m.ctrl.T.Helper() diff --git a/management/server/user.go b/management/server/user.go index b4b9ebe01..1de63c302 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -1849,12 +1849,17 @@ func (am *DefaultAccountManager) DeleteUserInvite(ctx context.Context, accountID const minPasswordLength = 8 -// validatePassword checks password strength requirements: +// validatePassword checks password strength requirements. +func validatePassword(password string) error { + return ValidatePassword(password) +} + +// ValidatePassword checks password strength requirements: // - Minimum 8 characters // - At least 1 digit // - At least 1 uppercase letter // - At least 1 special character -func validatePassword(password string) error { +func ValidatePassword(password string) error { if len(password) < minPasswordLength { return errors.New("password must be at least 8 characters long") } From 9770814f39a11e2a8efeabc3beec6e780870e3c3 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 23 Jul 2026 01:47:48 +0900 Subject: [PATCH 17/47] [client] warm lazy connections from the DNS resolver (#6854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Supersedes #6767 (same change, moved to an unprefixed branch; review feedback from there is addressed here). With lazy connections enabled, a peer is not dialed until on-demand traffic arrives, so the first request to a peer resolved by name (e.g. the agent-network reverse-proxy) races — or loses to — the WireGuard handshake. Activation was previously reactive only: a data-path packet or an inbound signal. This adds a proactive, DNS-time trigger. When the local resolver answers for an overlay name, it now warms the lazy connection to the peer(s) the answer points at and waits briefly for one to connect before returning the response — so by the time the client sends its first packet the tunnel is already up. - `dns/local`: new `PeerActivator` capability + `SetPeerActivator` setter (mirrors the existing `PeerConnectivity`/`SetPeerConnectivity` injection). `ServeDNS` warms on the pre-filter answer, so activating a lazily-idle peer also lets it survive the disconnected-peer filter. Warm-up is scoped to match-only (non-authoritative) zones — the synthesized private-service zones and user-created zones — so plain peer-name lookups in the account's authoritative peer zone never wake idle peers. No-op when no activator is wired (lazy off) or the answer carries no peer IPs. Budget is `NB_DNS_LAZY_WARMUP_TIMEOUT` (default 2s, parsed once at construction, invalid values logged); on timeout the answer is returned anyway (never SERVFAIL). - The resolver-facing interfaces (`PeerActivator`, `PeerConnectivity`) take `netip.Addr` instead of string IPs; record addresses are extracted as `netip.Addr` (v4-mapped forms unmapped) and converted to string only at the `peer.Status` boundary. - `SetPeerActivator` is part of the `dns.Server` interface (no-op on the mock), so the engine wires it without a type assertion. - `client/internal`: a small engine-side adapter (`dnsPeerActivator`) resolves answer IPs to peers via `Status.PeerStateByIP`, activates them through `ConnMgr.ActivatePeer` (HA fan-out included), and polls `PeerStateByIP` until one is connected. The activation dial is tied to the engine's long-lived context so a handshake that outlasts the per-query wait still completes in the background. `ConnMgr.ActivatePeer` is safe for concurrent use (the lazy manager pointer is guarded by a dedicated RWMutex and the manager is internally synchronized), so the DNS path never contends with network-map processing on `syncMsgMux`. Scope is overlay-only for free: the trigger lives in the local resolver, which only answers for NetBird-managed names; public/upstream DNS is unaffected. Already-connected peers short-circuit, so steady-state DNS latency is unchanged. ## Issue ticket number and link N/A — follow-up to the lazy-connection rollout; fixes the agent-network cold-start observed in the e2e (proxy peer stuck disconnected until traffic). ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal client behavior; the only knob is the optional `NB_DNS_LAZY_WARMUP_TIMEOUT` tuning env var with a safe default. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ ## Tests - `dns/local`: warm-up invokes the activator with the answer's peer address in match-only zones; authoritative-zone answers never trigger warm-up; no-activator path unchanged; no-answer queries don't invoke the activator; `NB_DNS_LAZY_WARMUP_TIMEOUT` parsing (valid/invalid/non-positive); `extractRecordAddr` unmaps v4-mapped record data. - `client/internal`: `dnsPeerActivator` skips connected/unknown/conn-less peers with no wait, returns as soon as a pending peer connects, and releases the DNS response at the budget when the peer stays idle; `ConnMgr.ActivatePeer` races the manager lifecycle cleanly under `-race`. - Agent-network e2e green on this change (with lazy connections enabled): https://github.com/netbirdio/netbird/actions/runs/29891467544 --- client/internal/conn_mgr.go | 28 ++- client/internal/conn_mgr_test.go | 66 +++++++ client/internal/dns/local/local.go | 130 +++++++++++-- client/internal/dns/local/local_test.go | 4 +- client/internal/dns/local/warmup_test.go | 204 +++++++++++++++++++++ client/internal/dns/mock_server.go | 6 + client/internal/dns/server.go | 12 +- client/internal/dns_peer_activator.go | 76 ++++++++ client/internal/dns_peer_activator_test.go | 129 +++++++++++++ client/internal/engine.go | 10 + e2e/agentnetwork/chat_test.go | 25 ++- e2e/agentnetwork/guardrail_test.go | 29 ++- e2e/agentnetwork/skiptls_test.go | 6 +- e2e/agentnetwork/vllm_test.go | 6 +- 14 files changed, 691 insertions(+), 40 deletions(-) create mode 100644 client/internal/dns/local/warmup_test.go create mode 100644 client/internal/dns_peer_activator.go create mode 100644 client/internal/dns_peer_activator_test.go diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 77d1e6ca5..754ce37a3 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -34,6 +34,8 @@ const ( // - Handling connection establishment based on peer signaling // // The implementation is not thread-safe; it is protected by engine.syncMsgMux. +// The only exception is ActivatePeer, which is safe for concurrent use so the +// DNS warm-up path can call it without contending on the engine mutex. type ConnMgr struct { peerStore *peerstore.Store statusRecorder *peer.Status @@ -42,6 +44,10 @@ type ConnMgr struct { rosenpassEnabled bool lazyConnMgr *manager.Manager + // lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the + // engine loop (ActivatePeer). Writers hold it in addition to + // engine.syncMsgMux; all other reads stay under engine.syncMsgMux only. + lazyConnMgrMu sync.RWMutex wg sync.WaitGroup lazyCtx context.Context @@ -238,12 +244,20 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) { conn.Log.Infof("removed peer from lazy conn manager") } +// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is +// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu +// and the manager itself is internally synchronized, so callers outside the +// engine loop (DNS warm-up) do not need engine.syncMsgMux. func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) { - if !e.isStartedWithLazyMgr() { + e.lazyConnMgrMu.RLock() + lazyConnMgr := e.lazyConnMgr + started := lazyConnMgr != nil && e.lazyCtxCancel != nil + e.lazyConnMgrMu.RUnlock() + if !started { return } - if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found { + if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found { if err := conn.Open(ctx); err != nil { conn.Log.Errorf("failed to open connection: %v", err) } @@ -268,16 +282,21 @@ func (e *ConnMgr) Close() { e.lazyCtxCancel() e.wg.Wait() + + e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil + e.lazyConnMgrMu.Unlock() } func (e *ConnMgr) initLazyManager(engineCtx context.Context) { cfg := manager.Config{ InactivityThreshold: inactivityThresholdEnv(), } - e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface) + e.lazyConnMgrMu.Lock() + e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface) e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx) + e.lazyConnMgrMu.Unlock() e.wg.Add(1) go func() { @@ -316,7 +335,10 @@ func (e *ConnMgr) closeManager(ctx context.Context) { e.lazyCtxCancel() e.wg.Wait() + + e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil + e.lazyConnMgrMu.Unlock() for _, peerID := range e.peerStore.PeersPubKey() { e.peerStore.PeerConnOpen(ctx, peerID) diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go index 5e2c53e35..e027fd4f2 100644 --- a/client/internal/conn_mgr_test.go +++ b/client/internal/conn_mgr_test.go @@ -1,10 +1,21 @@ package internal import ( + "context" + "net" + "net/netip" "os" + "sync" "testing" + "time" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/lazyconn" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" + "github.com/netbirdio/netbird/monotime" ) func TestResolveLazyForce(t *testing.T) { @@ -38,3 +49,58 @@ func TestResolveLazyForce(t *testing.T) { }) } } + +type mockLazyWGIface struct{} + +func (mockLazyWGIface) RemovePeer(string) error { return nil } +func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error { + return nil +} +func (mockLazyWGIface) IsUserspaceBind() bool { return false } +func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} } +func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil } +func (mockLazyWGIface) MTU() uint16 { return 1280 } + +// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from +// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle, +// which stays on the engine loop. Run with -race: it fails if ActivatePeer +// still requires engine.syncMsgMux for safety. +func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) { + t.Setenv(lazyconn.EnvLazyConn, "on") + + status := peer.NewRecorder("https://mgm") + store := peerstore.NewConnStore() + connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{}) + + conn := newTestPeerConn(t, "peerA") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + connMgr.Start(ctx) + + done := make(chan struct{}) + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-done: + return + default: + connMgr.ActivatePeer(ctx, conn) + } + } + }() + } + + // Let the activators spin against the started manager, then tear it down + // underneath them and let them spin against the stopped manager. + time.Sleep(100 * time.Millisecond) + connMgr.Close() + time.Sleep(50 * time.Millisecond) + + close(done) + wg.Wait() +} diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go index d0268186c..fef35fd41 100644 --- a/client/internal/dns/local/local.go +++ b/client/internal/dns/local/local.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/netip" + "os" "slices" "strings" "sync" @@ -36,7 +37,43 @@ type resolver interface { // record is left alone (it points at something outside our mesh, e.g. // a non-peer upstream). type PeerConnectivity interface { - IsConnectedByIP(ip string) (known, connected bool) + IsConnectedByIP(ip netip.Addr) (known, connected bool) +} + +// PeerActivator wakes lazy-connection peers on demand. The local resolver calls +// it with the tunnel IPs an answer points at, so a peer that is idle (lazily +// disconnected) starts connecting at DNS-resolution time rather than racing the +// client's first request packet. nil disables warm-up. +type PeerActivator interface { + // ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks + // until one is connected or ctx (a short per-query budget) expires. It is a + // fast no-op for unknown or already-connected addresses. + ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) +} + +const ( + defaultLazyWarmupTimeout = 2 * time.Second + envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT" +) + +// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a +// lazy-connection peer a DNS answer points at. Tunable via +// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time. +func lazyWarmupTimeoutFromEnv() time.Duration { + v := os.Getenv(envLazyWarmupTimeout) + if v == "" { + return defaultLazyWarmupTimeout + } + d, err := time.ParseDuration(v) + if err != nil { + log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err) + return defaultLazyWarmupTimeout + } + if d <= 0 { + log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout) + return defaultLazyWarmupTimeout + } + return d } type Resolver struct { @@ -51,6 +88,12 @@ type Resolver struct { // filter and preserves the legacy "return whatever is registered" // behaviour for callers that never wire a status source. peerConn PeerConnectivity + // peerActivator, when non-nil, is called at resolution time to warm the + // lazy connection to the peer(s) an answer points at. nil disables warm-up. + peerActivator PeerActivator + // warmupTimeout is the per-query budget for the lazy-connection warm-up + // wait, resolved from the environment once at construction time. + warmupTimeout time.Duration ctx context.Context cancel context.CancelFunc @@ -59,11 +102,12 @@ type Resolver struct { func NewResolver() *Resolver { ctx, cancel := context.WithCancel(context.Background()) return &Resolver{ - records: make(map[dns.Question][]dns.RR), - domains: make(map[domain.Domain]struct{}), - zones: make(map[domain.Domain]bool), - ctx: ctx, - cancel: cancel, + records: make(map[dns.Question][]dns.RR), + domains: make(map[domain.Domain]struct{}), + zones: make(map[domain.Domain]bool), + warmupTimeout: lazyWarmupTimeoutFromEnv(), + ctx: ctx, + cancel: cancel, } } @@ -76,6 +120,14 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) { d.peerConn = p } +// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to +// disable. Safe to call multiple times; the latest value wins. +func (d *Resolver) SetPeerActivator(a PeerActivator) { + d.mu.Lock() + defer d.mu.Unlock() + d.peerActivator = a +} + func (d *Resolver) MatchSubdomains() bool { return true } @@ -122,6 +174,9 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { replyMessage.RecursionAvailable = true result := d.lookupRecords(logger, question) + // Warm before filtering: activation flips a lazily-idle target to connected, + // which then lets it survive the disconnected-peer filter below. + d.warmLazyPeers(question, result.records) result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records) replyMessage.Authoritative = !result.hasExternalData replyMessage.Answer = result.records @@ -495,8 +550,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns kept := make([]dns.RR, 0, len(records)) var dropped int for _, rr := range records { - ip := extractRecordIP(rr) - if ip == "" { + ip, ok := extractRecordAddr(rr) + if !ok { kept = append(kept, rr) continue } @@ -518,22 +573,57 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns return kept } -// extractRecordIP returns the dotted-decimal / colon-hex IP carried by -// an A or AAAA record, or "" for any other record type. -func extractRecordIP(rr dns.RR) string { +// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved +// answer points at and waits briefly for one to connect, so the caller's first +// request doesn't race the connection establishment. Warm-up is scoped to +// match-only (non-authoritative) zones — the synthesized private-service zones +// and user-created zones whose records point at specific peers. The account's +// peer zone is authoritative, so plain peer-name lookups never trigger warm-up; +// otherwise resolving any peer's name would wake its idle connection, defeating +// laziness mesh-wide. No-op when no activator is wired (lazy connections +// disabled) or the answer carries no peer IPs. +func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) { + if len(records) < 2 { + return + } + d.mu.RLock() + activator := d.peerActivator + var nonAuth, found bool + if activator != nil { + nonAuth, found = d.findZone(question.Name) + } + d.mu.RUnlock() + if activator == nil || !found || !nonAuth { + return + } + + var addrs []netip.Addr + for _, rr := range records { + if addr, ok := extractRecordAddr(rr); ok { + addrs = append(addrs, addr) + } + } + if len(addrs) == 0 { + return + } + + ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout) + defer cancel() + activator.ActivatePeersByIP(ctx, addrs) +} + +// extractRecordAddr returns the IP address carried by an A or AAAA record. +// ok is false for any other record type or a record with no address. +func extractRecordAddr(rr dns.RR) (netip.Addr, bool) { switch r := rr.(type) { case *dns.A: - if r.A == nil { - return "" - } - return r.A.String() + addr, ok := netip.AddrFromSlice(r.A) + return addr.Unmap(), ok case *dns.AAAA: - if r.AAAA == nil { - return "" - } - return r.AAAA.String() + addr, ok := netip.AddrFromSlice(r.AAAA) + return addr.Unmap(), ok } - return "" + return netip.Addr{}, false } // Update replaces all zones and their records diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go index 9b7dac231..89e896c0a 100644 --- a/client/internal/dns/local/local_test.go +++ b/client/internal/dns/local/local_test.go @@ -37,8 +37,8 @@ type mockPeerConnectivity struct { byIP map[string]struct{ known, connected bool } } -func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) { - v, ok := m.byIP[ip] +func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) { + v, ok := m.byIP[ip.String()] if !ok { return false, false } diff --git a/client/internal/dns/local/warmup_test.go b/client/internal/dns/local/warmup_test.go new file mode 100644 index 000000000..0e77aa963 --- /dev/null +++ b/client/internal/dns/local/warmup_test.go @@ -0,0 +1,204 @@ +package local + +import ( + "context" + "net" + "net/netip" + "sync" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/dns/test" + nbdns "github.com/netbirdio/netbird/dns" +) + +// recordingActivator records the addresses it was asked to warm and returns +// immediately, so ServeDNS is not blocked by the test. +type recordingActivator struct { + mu sync.Mutex + called bool + addrs []netip.Addr +} + +func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) { + r.mu.Lock() + defer r.mu.Unlock() + r.called = true + r.addrs = append(r.addrs, addrs...) +} + +func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg { + t.Helper() + var resp *dns.Msg + w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }} + resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA)) + return resp +} + +// serviceZone registers rec in a match-only (non-authoritative) zone, the shape +// the synthesized private-service zones arrive in. +func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) { + t.Helper() + resolver.Update([]nbdns.CustomZone{{ + Domain: zone, + Records: records, + NonAuthoritative: true, + }}) +} + +func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) { + // Warm-up fires only for multi-record answers (the HA / round-robin shape of + // the synthesized private-service zones), so register two peer targets. + const name = "svc.proxy.netbird.cloud." + recs := []nbdns.SimpleRecord{ + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}, + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"}, + } + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", recs...) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A records") + + act.mu.Lock() + defer act.mu.Unlock() + assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer") + assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP") + assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP") +} + +func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) { + // A single-record answer does not trigger warm-up; the resolver only warms + // multi-record answers. + rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"} + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", rec) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, rec.Name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A record") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked for a single-record answer") +} + +func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) { + // With no activator wired the resolver behaves exactly as before. + rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"} + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", rec) + + resp := serveA(t, resolver, rec.Name) + require.NotNil(t, resp, "resolver must still answer without an activator") + require.NotEmpty(t, resp.Answer, "answer must carry the A record") +} + +func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) { + // A query that resolves to nothing must not invoke the activator (no IPs). + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", + nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + serveA(t, resolver, "absent.proxy.netbird.cloud.") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked when there is no answer") +} + +func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) { + // The account's peer zone is authoritative; resolving a peer's name there + // must not wake its lazy connection — warm-up is scoped to match-only + // (non-authoritative) zones such as the synthesized private-service zones. + // Use a multi-record answer so the authoritative-zone scoping is the only + // reason warm-up is skipped, not the single-record guard. + const name = "peer.netbird.cloud." + recs := []nbdns.SimpleRecord{ + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"}, + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"}, + } + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "netbird.cloud", + Records: recs, + }}) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A records") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers") +} + +func TestLazyWarmupTimeoutFromEnv(t *testing.T) { + tests := []struct { + name string + value string + envSet bool + want time.Duration + }{ + {name: "unset uses default", want: defaultLazyWarmupTimeout}, + {name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second}, + {name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout}, + {name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout}, + {name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envSet { + t.Setenv(envLazyWarmupTimeout, tt.value) + } + assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv()) + assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once") + }) + } +} + +func TestExtractRecordAddr(t *testing.T) { + t.Run("A record yields unmapped v4", func(t *testing.T) { + // net.ParseIP returns the 16-byte v4-in-v6 form, the same shape + // miekg/dns stores after parsing an A record; the extracted address + // must compare equal to a plain v4 netip.Addr. + addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")}) + require.True(t, ok) + assert.True(t, addr.Is4()) + assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr) + }) + + t.Run("AAAA record yields v6", func(t *testing.T) { + addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")}) + require.True(t, ok) + assert.Equal(t, netip.MustParseAddr("fd00::1"), addr) + }) + + t.Run("A record without address", func(t *testing.T) { + _, ok := extractRecordAddr(&dns.A{}) + assert.False(t, ok) + }) + + t.Run("non-address record", func(t *testing.T) { + _, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."}) + assert.False(t, ok) + }) +} diff --git a/client/internal/dns/mock_server.go b/client/internal/dns/mock_server.go index 31fedd9e5..b19862c2f 100644 --- a/client/internal/dns/mock_server.go +++ b/client/internal/dns/mock_server.go @@ -8,6 +8,7 @@ import ( "github.com/miekg/dns" dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" + "github.com/netbirdio/netbird/client/internal/dns/local" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" @@ -92,6 +93,11 @@ func (m *MockServer) SetFirewall(Firewall) { // Mock implementation - no-op } +// SetPeerActivator mock implementation of SetPeerActivator from Server interface +func (m *MockServer) SetPeerActivator(local.PeerActivator) { + // Mock implementation - no-op +} + // BeginBatch mock implementation of BeginBatch from Server interface func (m *MockServer) BeginBatch() { // Mock implementation - no-op diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index 7556c66cc..f79454457 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -82,6 +82,7 @@ type Server interface { PopulateManagementDomain(mgmtURL *url.URL) error SetRouteSources(selected, active func() route.HAMap) SetFirewall(Firewall) + SetPeerActivator(local.PeerActivator) } type nsGroupsByDomain struct { @@ -491,6 +492,13 @@ func (s *DefaultServer) SetFirewall(fw Firewall) { } } +// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local +// resolver. Injected after the connection manager exists (it does not at +// DNS-server construction time). Pass nil to disable. +func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) { + s.localResolver.SetPeerActivator(a) +} + // Stop stops the server func (s *DefaultServer) Stop() { s.ctxCancel() @@ -1435,11 +1443,11 @@ type localPeerConnectivity struct { // IsConnectedByIP looks the IP up in the peerstore and surfaces both // the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers. -func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) { +func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) { if l.status == nil { return false, false } - state, ok := l.status.PeerStateByIP(ip) + state, ok := l.status.PeerStateByIP(ip.String()) if !ok { return false, false } diff --git a/client/internal/dns_peer_activator.go b/client/internal/dns_peer_activator.go new file mode 100644 index 000000000..c283d6251 --- /dev/null +++ b/client/internal/dns_peer_activator.go @@ -0,0 +1,76 @@ +package internal + +import ( + "context" + "net/netip" + "time" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" +) + +const dnsActivationPollInterval = 50 * time.Millisecond + +// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It +// implements dns/local.PeerActivator. DNS queries run on their own goroutines, +// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer, +// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux, +// keeping DNS resolution from contending with network-map processing. +type dnsPeerActivator struct { + connMgr *ConnMgr + peerStore *peerstore.Store + status *peer.Status + // ctx is the engine's long-lived context. The connection dial is tied to it + // (not the per-query DNS wait budget) so a handshake that outlasts the wait + // still completes in the background rather than being cancelled at the deadline. + ctx context.Context +} + +// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits +// until one is connected or ctx (the per-query DNS wait budget) expires. +// Activation itself is tied to the engine's long-lived context so the dial +// survives a wait that times out. Unknown or already-connected addresses are +// skipped, so the steady-state (warm) path adds no latency. +func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) { + if a == nil || a.connMgr == nil { + return + } + + var pending []string + for _, addr := range addrs { + ip := addr.String() + st, ok := a.status.PeerStateByIP(ip) + if !ok || st.ConnStatus == peer.StatusConnected { + continue + } + conn, ok := a.peerStore.PeerConn(st.PubKey) + if !ok { + continue + } + a.connMgr.ActivatePeer(a.ctx, conn) + pending = append(pending, ip) + } + + if len(pending) == 0 { + return + } + a.waitConnected(ctx, pending) +} + +// waitConnected blocks until any of ips reports a connected peer or ctx expires. +func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) { + ticker := time.NewTicker(dnsActivationPollInterval) + defer ticker.Stop() + for { + for _, ip := range ips { + if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected { + return + } + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/client/internal/dns_peer_activator_test.go b/client/internal/dns_peer_activator_test.go new file mode 100644 index 000000000..8c3b75e59 --- /dev/null +++ b/client/internal/dns_peer_activator_test.go @@ -0,0 +1,129 @@ +package internal + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" +) + +func newTestPeerConn(t *testing.T, key string) *peer.Conn { + t.Helper() + conn, err := peer.NewConn(peer.ConnConfig{ + Key: key, + LocalKey: "local", + WgConfig: peer.WgConfig{ + AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, + }, + }, peer.ServiceDependencies{}) + require.NoError(t, err) + return conn +} + +func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) { + t.Helper() + status := peer.NewRecorder("https://mgm") + store := peerstore.NewConnStore() + // ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a + // no-op — these tests exercise the activator's skip/wait logic. + connMgr := NewConnMgr(&EngineConfig{}, status, store, nil) + return &dnsPeerActivator{ + connMgr: connMgr, + peerStore: store, + status: status, + ctx: context.Background(), + }, status, store +} + +func TestDNSPeerActivator_NilSafe(t *testing.T) { + var a *dnsPeerActivator + a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")}) +} + +// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state +// (warm) path adds no latency: already-connected and unknown addresses never +// enter the wait loop. +func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1")) + require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped + netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped + netip.MustParseAddr("100.64.0.99"), // unknown -> skipped + }) + require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait") +} + +// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop +// returns as soon as a pending peer reports connected, well before the +// per-query budget expires. +func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + go func() { + time.Sleep(150 * time.Millisecond) + _ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + elapsed := time.Since(start) + + require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer") + require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline") +} + +// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that +// never connects releases the DNS response at the per-query budget instead of +// blocking it indefinitely. +func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + elapsed := time.Since(start) + + require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer") + require.Less(t, elapsed, 5*time.Second, "must not block past the budget") +} + +// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer +// with no connection object in the store is not waited on: there is nothing to +// activate, so waiting could only ever time out. +func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) { + a, status, _ := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on") +} diff --git a/client/internal/engine.go b/client/internal/engine.go index 79f916a12..e1b03e878 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -665,6 +665,16 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface) e.connMgr.Start(e.ctx) + // Wire DNS-time lazy-connection warm-up now that the connection manager + // exists (it does not at DNS-server construction time). A DNS answer that + // points at an idle peer then wakes it before the client's first request. + e.dnsServer.SetPeerActivator(&dnsPeerActivator{ + connMgr: e.connMgr, + peerStore: e.peerStore, + status: e.statusRecorder, + ctx: e.ctx, + }) + e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg) e.srWatcher.Start(peer.IsForceRelayed()) diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index 487aa3cea..17ed40d5f 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -91,7 +91,14 @@ func availableProviders() []providerCase { if region == "" { region = "us-east-1" } - ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireBedrock}) + // A valid Bedrock inference-profile id (region prefix + date + version), + // overridable per account. `global.` profiles can be invoked from any + // region; set AWS_BEDROCK_MODEL to match the enabled profile for the token. + model := os.Getenv("AWS_BEDROCK_MODEL") + if model == "" { + model = "global.anthropic.claude-haiku-4-5-20251001-v1:0" + } + ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock}) } return ps } @@ -108,8 +115,16 @@ func providerRequest(pc providerCase) api.AgentNetworkProviderRequest { Enabled: ptr(true), } if pc.kind != harness.WireVertex { + // The router matches the normalized catalog id. Bedrock's request model + // travels as a region-prefixed inference-profile id in the URL path + // (us.anthropic...), which the router strips before matching, so register + // the normalized form here or routing fails as model_not_routable. + modelID := pc.model + if pc.kind == harness.WireBedrock { + modelID = catalogModel(pc) + } req.Models = &[]api.AgentNetworkProviderModel{ - {Id: pc.model, InputPer1k: 0.001, OutputPer1k: 0.002}, + {Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002}, } } return req @@ -201,11 +216,13 @@ func TestProvidersMatrix(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking + // the proxy peer so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) } - proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) - require.NoError(t, err, "resolve agent-network endpoint to proxy IP") for _, pc := range matrix { pc := pc diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go index bb952044f..d4fe98dda 100644 --- a/e2e/agentnetwork/guardrail_test.go +++ b/e2e/agentnetwork/guardrail_test.go @@ -4,6 +4,7 @@ package agentnetwork import ( "context" + "regexp" "strings" "testing" "time" @@ -15,13 +16,29 @@ import ( "github.com/netbirdio/netbird/shared/management/http/api" ) +// bedrockRegionPrefixes and bedrockVersionSuffix mirror the proxy's Bedrock +// model normalization (region/inference-profile prefix + version suffix) so the +// provider is registered under the same catalog key the router matches against. +var ( + bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} + bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) +) + // catalogModel returns the normalized catalog id the proxy stamps for a -// path-routed provider's configured model — the form the guardrail allowlist is -// compared against (region prefix / @version stripped). +// path-routed provider's configured model — the form the router and guardrail +// allowlist compare against (Bedrock region prefix + version stripped, Vertex +// @version stripped). func catalogModel(pc providerCase) string { switch pc.kind { case harness.WireBedrock: - return strings.TrimPrefix(pc.model, "us.") + m := pc.model + for _, p := range bedrockRegionPrefixes { + if strings.HasPrefix(m, p) { + m = m[len(p):] + break + } + } + return bedrockVersionSuffix.ReplaceAllString(m, "") case harness.WireVertex: return strings.SplitN(pc.model, "@", 2)[0] default: @@ -147,11 +164,13 @@ func TestModelAllowlistEnforced(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking + // the proxy peer so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) } - proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) - require.NoError(t, err, "resolve agent-network endpoint to proxy IP") for _, pc := range providers { pc := pc diff --git a/e2e/agentnetwork/skiptls_test.go b/e2e/agentnetwork/skiptls_test.go index 077fd6005..44e0b4dca 100644 --- a/e2e/agentnetwork/skiptls_test.go +++ b/e2e/agentnetwork/skiptls_test.go @@ -104,11 +104,13 @@ func TestProviderSkipTLSVerification(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking + // the proxy peer so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) } - proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) - require.NoError(t, err, "resolve endpoint to proxy IP") // Positive: skip=true reaches the self-signed upstream. Retry to absorb // tunnel/DNS jitter on the first call; success also proves the path works. diff --git a/e2e/agentnetwork/vllm_test.go b/e2e/agentnetwork/vllm_test.go index 329994ca9..53855da34 100644 --- a/e2e/agentnetwork/vllm_test.go +++ b/e2e/agentnetwork/vllm_test.go @@ -106,11 +106,13 @@ func TestVLLMProvider(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking + // the proxy peer so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) } - proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) - require.NoError(t, err, "resolve endpoint to proxy IP") before, _ := srv.ListAccessLogs(ctx) sessionID := "e2e-session-vllm" From 96963b67515b85284b329542c175c1f87b5cae8b Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 23 Jul 2026 04:24:44 +0900 Subject: [PATCH 18/47] [proxy] fix proxy multistage build (#6864) --- proxy/Dockerfile.multistage | 1 + 1 file changed, 1 insertion(+) diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage index 976984256..4f360a811 100644 --- a/proxy/Dockerfile.multistage +++ b/proxy/Dockerfile.multistage @@ -10,6 +10,7 @@ COPY encryption ./encryption COPY flow ./flow COPY formatter ./formatter COPY monotime ./monotime +COPY management ./management COPY proxy ./proxy COPY route ./route COPY shared ./shared From 178e6a85301810edcef5d052769bf7563d618c83 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 23 Jul 2026 15:30:40 +0900 Subject: [PATCH 19/47] [misc] Probe the agent-network endpoint with a GET instead of getent (#6867) ## Describe your changes Use http get to trigger lazy connections as e2e will have single proxy and status checks would fail without proper lazy wake-up --- e2e/agentnetwork/chat_test.go | 3 +- e2e/agentnetwork/guardrail_test.go | 3 +- e2e/agentnetwork/skiptls_test.go | 3 +- e2e/agentnetwork/vllm_test.go | 3 +- e2e/harness/client.go | 59 +++++++++++++++++++++++------- 5 files changed, 50 insertions(+), 21 deletions(-) diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index 17ed40d5f..a928d1265 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -216,8 +216,7 @@ func TestProvidersMatrix(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve agent-network endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go index d4fe98dda..1e4b222f0 100644 --- a/e2e/agentnetwork/guardrail_test.go +++ b/e2e/agentnetwork/guardrail_test.go @@ -164,8 +164,7 @@ func TestModelAllowlistEnforced(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve agent-network endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/agentnetwork/skiptls_test.go b/e2e/agentnetwork/skiptls_test.go index 44e0b4dca..1f57605e3 100644 --- a/e2e/agentnetwork/skiptls_test.go +++ b/e2e/agentnetwork/skiptls_test.go @@ -104,8 +104,7 @@ func TestProviderSkipTLSVerification(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/agentnetwork/vllm_test.go b/e2e/agentnetwork/vllm_test.go index 53855da34..cd598f1ed 100644 --- a/e2e/agentnetwork/vllm_test.go +++ b/e2e/agentnetwork/vllm_test.go @@ -106,8 +106,7 @@ func TestVLLMProvider(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 19210349f..4c9983e4a 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -4,6 +4,7 @@ package harness import ( "context" + "errors" "fmt" "io" "os/exec" @@ -167,22 +168,54 @@ func (cl *Client) pollStatus(ctx context.Context, timeout time.Duration, want st return fmt.Errorf("timed out waiting for %q; last status:\n%s", want, last) } -// ResolveProxyIP resolves the agent-network endpoint to the proxy peer's -// NetBird IP from inside the client (via magic DNS). +const ( + // curlExitCouldNotResolve is curl's exit code for a DNS resolution failure, distinct from connection-level failures. + curlExitCouldNotResolve = 6 + // dnsProbeRetryWindow bounds DNS-failure retries: the synthesized zone lands a beat after management connects, so early NXDOMAIN is propagation; a zone still absent after this window is a real failure. + dnsProbeRetryWindow = 30 * time.Second + dnsProbeRetryInterval = 2 * time.Second +) + +// ResolveProxyIP GETs https:/// from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; only DNS failures retry, within dnsProbeRetryWindow. Returns the connected IP for --resolve pinning. func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, error) { - code, reader, err := cl.container.Exec(ctx, []string{"getent", "hosts", endpoint}, tcexec.Multiplexed()) - if err != nil { - return "", err + args := []string{ + "run", "--rm", + "--network", "container:" + cl.container.GetContainerID(), + curlImage, + "-ksS", "-o", "/dev/null", + "--connect-timeout", "30", "--max-time", "60", + "-w", "%{remote_ip}", + "https://" + endpoint + "/", } - out, _ := io.ReadAll(reader) - if code != 0 { - return "", fmt.Errorf("getent hosts %s exited %d", endpoint, code) + deadline := time.Now().Add(dnsProbeRetryWindow) + for { + cmd := exec.CommandContext(ctx, "docker", args...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + ip := strings.TrimSpace(stdout.String()) + if ip == "" { + return "", fmt.Errorf("got an HTTP response from %s but no remote IP", endpoint) + } + return ip, nil + } + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != curlExitCouldNotResolve { + return "", fmt.Errorf("no HTTP response from %s: %w (%s)", endpoint, err, strings.TrimSpace(stderr.String())) + } + dnsErr := fmt.Errorf("DNS resolution failed for %s: %s", endpoint, strings.TrimSpace(stderr.String())) + if time.Until(deadline) < dnsProbeRetryInterval { + return "", dnsErr + } + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w (%w)", dnsErr, ctx.Err()) + case <-time.After(dnsProbeRetryInterval): + } } - fields := strings.Fields(string(out)) - if len(fields) == 0 { - return "", fmt.Errorf("no address for %s", endpoint) - } - return fields[0], nil } // Wire shapes for Chat. From 31ed241a1a3a42ff8cc7ba4815f9b1c445e3de6b Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:37:09 +0200 Subject: [PATCH 20/47] [management] component types (#6866) --- .../shared/grpc/components_encoder.go | 25 ++--- .../shared/grpc/components_encoder_test.go | 100 ++++++++--------- .../grpc/components_envelope_response_test.go | 10 +- management/server/groups/manager.go | 9 +- .../http/handlers/peers/peers_handler.go | 21 ++-- .../networks/resources/types/resource.go | 22 ++++ .../server/networks/routers/types/router.go | 31 ++++++ management/server/peer.go | 4 +- management/server/peer/peer.go | 30 +++++ management/server/peer_test.go | 4 +- management/server/posture/nb_version.go | 30 +---- management/server/posture/nb_version_test.go | 65 ----------- management/server/types/account.go | 15 +-- management/server/types/account_components.go | 60 +++++----- .../types/account_private_netmap_test.go | 3 +- management/server/types/account_test.go | 2 +- management/server/types/aliases.go | 29 ++--- .../server}/types/group.go | 36 ++++-- management/server/types/ipv6_endtoend_test.go | 3 +- .../types/networkmap_components_test.go | 2 +- management/server/util/util.go | 31 ------ shared/management/networkmap/decode.go | 60 ++++------ shared/management/networkmap/encode.go | 7 +- shared/management/networkmap/envelope.go | 4 +- shared/management/networkmap/envelope_test.go | 47 ++++---- shared/management/types/component_types.go | 103 ++++++++++++++++++ shared/management/types/firewall_helpers.go | 14 +-- shared/management/types/firewall_rule.go | 5 +- shared/management/types/firewall_rule_test.go | 13 +-- shared/management/types/network.go | 49 +++++++-- .../management/types/network_merge_test.go | 8 +- .../management/types/networkmap_components.go | 79 +++++++------- .../types/networkmap_components_compact.go | 19 ++-- version/version.go | 24 ++++ version/version_test.go | 71 +++++++++++- 35 files changed, 609 insertions(+), 426 deletions(-) rename {shared/management => management/server}/types/group.go (83%) create mode 100644 shared/management/types/component_types.go rename management/server/util/util_test.go => shared/management/types/network_merge_test.go (88%) diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go index d7b787464..7e43cf478 100644 --- a/management/internals/shared/grpc/components_encoder.go +++ b/management/internals/shared/grpc/components_encoder.go @@ -5,9 +5,6 @@ import ( "strconv" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/networkmap" @@ -166,7 +163,7 @@ func (e *componentEncoder) indexAllPeers() { } } -func (e *componentEncoder) appendPeer(p *nbpeer.Peer) uint32 { +func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 { if idx, ok := e.peerOrder[p.ID]; ok { return idx } @@ -180,7 +177,7 @@ func (e *componentEncoder) appendPeer(p *nbpeer.Peer) uint32 { // (c.RouterPeers may contain peers not in c.Peers when validation rules drop // them) and returns their wire indexes for the RouterPeerIndexes field. Must // run before any encoder that resolves peer ids via e.peerOrder. -func (e *componentEncoder) indexRouterPeers(routers map[string]*nbpeer.Peer) []uint32 { +func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentPeer) []uint32 { if len(routers) == 0 { return nil } @@ -514,7 +511,7 @@ func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone { return out } -func (e *componentEncoder) encodeNetworkResources(resources []*resourceTypes.NetworkResource) []*proto.NetworkResourceRaw { +func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw { if len(resources) == 0 { return nil } @@ -543,7 +540,7 @@ func (e *componentEncoder) encodeNetworkResources(resources []*resourceTypes.Net return out } -func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*routerTypes.NetworkRouter) map[string]*proto.NetworkRouterList { +func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList { if len(routersMap) == 0 { return nil } @@ -692,20 +689,20 @@ func toAccountNetwork(n *types.Network) *proto.AccountNetwork { return out } -func toPeerCompact(p *nbpeer.Peer) *proto.PeerCompact { +func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact { pc := &proto.PeerCompact{ WgPubKey: decodeWgKey(p.Key), SshPubKey: []byte(p.SSHKey), DnsLabel: p.DNSLabel, - AgentVersion: p.Meta.WtVersion, - AddedWithSsoLogin: p.UserID != "", + AgentVersion: p.AgentVersion, + AddedWithSsoLogin: p.AddedWithSSOLogin, LoginExpirationEnabled: p.LoginExpirationEnabled, SshEnabled: p.SSHEnabled, - SupportsIpv6: p.SupportsIPv6(), - SupportsSourcePrefixes: p.SupportsSourcePrefixes(), - ServerSshAllowed: p.Meta.Flags.ServerSSHAllowed, + SupportsIpv6: p.SupportsIPv6, + SupportsSourcePrefixes: p.SupportsSourcePrefixes, + ServerSshAllowed: p.ServerSSHAllowed, } - if p.LastLogin != nil { + if !p.LastLogin.IsZero() { pc.LastLoginUnixNano = p.LastLogin.UnixNano() } switch { diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go index d82bba362..100ab0948 100644 --- a/management/internals/shared/grpc/components_encoder_test.go +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -15,9 +15,6 @@ import ( goproto "google.golang.org/protobuf/proto" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" @@ -155,29 +152,28 @@ func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool { } func newTestComponents() *types.NetworkMapComponents { - peerA := &nbpeer.Peer{ - ID: "peer-a", - Key: testWgKeyA, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), - DNSLabel: "peera", - SSHKey: "ssh-a", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerA := &types.ComponentPeer{ + ID: "peer-a", + Key: testWgKeyA, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peera", + SSHKey: "ssh-a", + AgentVersion: "0.40.0", } - peerB := &nbpeer.Peer{ - ID: "peer-b", - Key: testWgKeyB, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), - IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), - DNSLabel: "peerb", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.25.0"}, + peerB := &types.ComponentPeer{ + ID: "peer-b", + Key: testWgKeyB, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), + DNSLabel: "peerb", + AgentVersion: "0.25.0", } - peerC := &nbpeer.Peer{ - ID: "peer-c", - Key: testWgKeyC, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), - DNSLabel: "peerc", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerC := &types.ComponentPeer{ + ID: "peer-c", + Key: testWgKeyC, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), + DNSLabel: "peerc", + AgentVersion: "0.40.0", } return &types.NetworkMapComponents{ @@ -191,12 +187,12 @@ func newTestComponents() *types.NetworkMapComponents { PeerLoginExpirationEnabled: true, PeerLoginExpiration: 2 * time.Hour, }, - Peers: map[string]*nbpeer.Peer{ + Peers: map[string]*types.ComponentPeer{ "peer-a": peerA, "peer-b": peerB, "peer-c": peerC, }, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}}, "group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}}, }, @@ -215,7 +211,7 @@ func newTestComponents() *types.NetworkMapComponents { }}, }, }, - RouterPeers: map[string]*nbpeer.Peer{"peer-c": peerC}, + RouterPeers: map[string]*types.ComponentPeer{"peer-c": peerC}, } } @@ -381,12 +377,12 @@ func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) { func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { c := newTestComponents() - v6Only := &nbpeer.Peer{ - ID: "peer-v6", - Key: testWgKeyA, - IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), - DNSLabel: "peerv6", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + v6Only := &types.ComponentPeer{ + ID: "peer-v6", + Key: testWgKeyA, + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), + DNSLabel: "peerv6", + AgentVersion: "0.40.0", } c.Peers["peer-v6"] = v6Only @@ -405,11 +401,11 @@ func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) { c := newTestComponents() - c.Peers["peer-noip"] = &nbpeer.Peer{ - ID: "peer-noip", - Key: testWgKeyA, - DNSLabel: "peernoip", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + c.Peers["peer-noip"] = &types.ComponentPeer{ + ID: "peer-noip", + Key: testWgKeyA, + DNSLabel: "peernoip", + AgentVersion: "0.40.0", } full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -444,9 +440,9 @@ func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) { func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) { c := newTestComponents() now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) - c.Peers["peer-a"].UserID = "user-1" + c.Peers["peer-a"].AddedWithSSOLogin = true c.Peers["peer-a"].LoginExpirationEnabled = true - c.Peers["peer-a"].LastLogin = &now + c.Peers["peer-a"].LastLogin = now full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -557,7 +553,7 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing } // Resource must appear in components.NetworkResources with a seq id — // encoder uses that to translate the xid map key to uint32. - c.NetworkResources = []*resourceTypes.NetworkResource{ + c.NetworkResources = []*types.ComponentResource{ {ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true}, } @@ -625,11 +621,11 @@ func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) { func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) { c := newTestComponents() c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} - c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ + c.RoutersMap = map[string]map[string]*types.ComponentRouter{ "net-1": { "peer-c": { - ID: "router-1", PublicID: "200", - Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, + PublicID: "200", + Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, }, }, } @@ -655,14 +651,14 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) { // peer_index reference must still resolve. c := newTestComponents() delete(c.Peers, "peer-c") - routerPeer := &nbpeer.Peer{ + routerPeer := &types.ComponentPeer{ ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), - DNSLabel: "peerc", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + DNSLabel: "peerc", AgentVersion: "0.40.0", } - c.RouterPeers = map[string]*nbpeer.Peer{"peer-c": routerPeer} + c.RouterPeers = map[string]*types.ComponentPeer{"peer-c": routerPeer} c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} - c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ - "net-1": {"peer-c": {ID: "r-1", PublicID: "1", Peer: "peer-c", Enabled: true}}, + c.RoutersMap = map[string]map[string]*types.ComponentRouter{ + "net-1": {"peer-c": {PublicID: "1", Peer: "peer-c", Enabled: true}}, } full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -695,9 +691,9 @@ func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) { func TestToProxyPatch_PopulatesAllFields(t *testing.T) { nm := &types.NetworkMap{ - Peers: []*nbpeer.Peer{{ + Peers: []*types.ComponentPeer{{ ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}), - DNSLabel: "extpeer", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + DNSLabel: "extpeer", AgentVersion: "0.40.0", }}, FirewallRules: []*types.FirewallRule{{ PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp", @@ -780,6 +776,6 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) { func emptyNetworkMapComponents() *types.NetworkMapComponents { return types.EmptyNetworkMapComponents( &types.NetworkMapComponents{ - PeerID: "peer-id", Peers: map[string]*nbpeer.Peer{"peer-id": {}}}, + PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}}, ) } diff --git a/management/internals/shared/grpc/components_envelope_response_test.go b/management/internals/shared/grpc/components_envelope_response_test.go index bf35bb7b9..20f4e6824 100644 --- a/management/internals/shared/grpc/components_envelope_response_test.go +++ b/management/internals/shared/grpc/components_envelope_response_test.go @@ -19,10 +19,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) { peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled} - group := &types.Group{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} + group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} return &types.NetworkMapComponents{ - Peers: map[string]*nbpeer.Peer{targetPeerID: peer}, - Groups: map[string]*types.Group{targetGroupID: group}, + Peers: map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()}, + Groups: map[string]*types.ComponentGroup{targetGroupID: group}, Policies: []*types.Policy{{ ID: "p", Enabled: true, @@ -158,8 +158,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) { peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true} c := &types.NetworkMapComponents{ - Peers: map[string]*nbpeer.Peer{}, // target peer NOT present - Groups: map[string]*types.Group{ + Peers: map[string]*types.ComponentPeer{}, // target peer NOT present + Groups: map[string]*types.ComponentGroup{ "g": {ID: "g", Peers: []string{"missing"}}, }, Policies: []*types.Policy{{ diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go index c9a877d6f..6d19b1c35 100644 --- a/management/server/groups/manager.go +++ b/management/server/groups/manager.go @@ -6,6 +6,7 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" @@ -30,6 +31,10 @@ type managerImpl struct { accountManager account.Manager } +func eventMetaResource(group *types.Group, resource *resourceTypes.NetworkResource) map[string]any { + return map[string]any{"name": group.Name, "id": group.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} +} + type mockManager struct { } @@ -109,7 +114,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, eventMetaResource(group, networkResource)) } return event, nil @@ -133,7 +138,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context, } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, eventMetaResource(group, networkResource)) } return event, nil diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 310f90653..03a37c3ec 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -446,7 +446,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) { netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers()) - util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, dnsDomain)) + util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, account.Peers, dnsDomain)) } func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) { @@ -534,15 +534,20 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) util.WriteJSONObject(r.Context(), w, resp) } -func toAccessiblePeers(netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer { +// toAccessiblePeers rehydrates the calculated map's component peers into the +// account's full peer objects, which carry the location/status/meta fields +// the API response needs. +func toAccessiblePeers(netMap *types.NetworkMap, accountPeers map[string]*nbpeer.Peer, dnsDomain string) []api.AccessiblePeer { accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers)) - for _, p := range netMap.Peers { - accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain)) - } - - for _, p := range netMap.OfflinePeers { - accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain)) + add := func(peers []*types.ComponentPeer) { + for _, p := range peers { + if peer := accountPeers[p.ID]; peer != nil { + accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(peer, dnsDomain)) + } + } } + add(netMap.Peers) + add(netMap.OfflinePeers) return accessiblePeers } diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go index 4cf7f7ea3..643f9cdd6 100644 --- a/management/server/networks/resources/types/resource.go +++ b/management/server/networks/resources/types/resource.go @@ -14,6 +14,7 @@ import ( nbDomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkResourceType string @@ -64,6 +65,27 @@ func NewNetworkResource(accountID, networkID, name, description, address string, }, nil } +// ToComponent converts the resource to its self-contained components +// representation. Returns nil for a nil resource. +func (n *NetworkResource) ToComponent() *sharedTypes.ComponentResource { + if n == nil { + return nil + } + return &sharedTypes.ComponentResource{ + ID: n.ID, + PublicID: n.PublicID, + NetworkID: n.NetworkID, + AccountID: n.AccountID, + Name: n.Name, + Description: n.Description, + Type: sharedTypes.ComponentResourceType(n.Type), + Address: n.Address, + Domain: n.Domain, + Prefix: n.Prefix, + Enabled: n.Enabled, + } +} + func (n *NetworkResource) ToAPIResponse(groups []api.GroupMinimum) *api.NetworkResource { addr := n.Prefix.String() if n.Type == Domain { diff --git a/management/server/networks/routers/types/router.go b/management/server/networks/routers/types/router.go index 189d7f792..b8097cdbb 100644 --- a/management/server/networks/routers/types/router.go +++ b/management/server/networks/routers/types/router.go @@ -7,6 +7,7 @@ import ( "github.com/netbirdio/netbird/management/server/networks/types" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkRouter struct { @@ -21,6 +22,36 @@ type NetworkRouter struct { Enabled bool } +// ToComponent converts the router to its self-contained components +// representation. Returns nil for a nil router. +func (n *NetworkRouter) ToComponent() *sharedTypes.ComponentRouter { + if n == nil { + return nil + } + return &sharedTypes.ComponentRouter{ + NetworkID: n.NetworkID, + PublicID: n.PublicID, + Peer: n.Peer, + PeerGroups: n.PeerGroups, + Masquerade: n.Masquerade, + Metric: n.Metric, + Enabled: n.Enabled, + } +} + +// ToComponentMap converts a peer-keyed router map to its components +// representation. +func ToComponentMap(routers map[string]*NetworkRouter) map[string]*sharedTypes.ComponentRouter { + if routers == nil { + return nil + } + out := make(map[string]*sharedTypes.ComponentRouter, len(routers)) + for id, r := range routers { + out[id] = r.ToComponent() + } + return out +} + func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) { r := &NetworkRouter{ ID: xid.New().String(), diff --git a/management/server/peer.go b/management/server/peer.go index 5f2f5d2a2..589cf9abf 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -405,7 +405,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p return status.NewPeerNotPartOfAccountError() } - meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion) + meetMinVer, err := version.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion) if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!meetMinVer || err != nil) { return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer) } @@ -1588,7 +1588,7 @@ func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) [] } seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers)) ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers)) - add := func(peers []*nbpeer.Peer) { + add := func(peers []*types.ComponentPeer) { for _, p := range peers { if p == nil || p.ID == "" || p.ID == selfPeerID { continue diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 39022d095..7c4971285 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -13,6 +13,7 @@ import ( "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) // Peer capability constants mirror the proto enum values. @@ -205,6 +206,35 @@ func (p *Peer) AddedWithSSOLogin() bool { return p.UserID != "" } +// ToComponent converts the peer to its self-contained components +// representation, carrying exactly the subset of peer data that crosses the +// components wire format. Returns nil for a nil peer so callers can convert +// possibly-missing peers without guarding. +func (p *Peer) ToComponent() *sharedTypes.ComponentPeer { + if p == nil { + return nil + } + cp := &sharedTypes.ComponentPeer{ + ID: p.ID, + Key: p.Key, + IP: p.IP, + IPv6: p.IPv6, + DNSLabel: p.DNSLabel, + SSHKey: p.SSHKey, + SSHEnabled: p.SSHEnabled, + ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed, + AgentVersion: p.Meta.WtVersion, + SupportsSourcePrefixes: p.SupportsSourcePrefixes(), + SupportsIPv6: p.SupportsIPv6(), + LoginExpirationEnabled: p.LoginExpirationEnabled, + AddedWithSSOLogin: p.AddedWithSSOLogin(), + } + if p.LastLogin != nil { + cp.LastLogin = *p.LastLogin + } + return cp +} + // HasCapability reports whether the peer has the given capability. func (p *Peer) HasCapability(capability int32) bool { return slices.Contains(p.Meta.Capabilities, capability) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index d471a1302..a7f8ba695 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -1092,14 +1092,14 @@ func TestToSyncResponse(t *testing.T) { } networkMap := &types.NetworkMap{ Network: &types.Network{Net: *ipnet, Serial: 1000}, - Peers: []*nbpeer.Peer{{ + Peers: []*types.ComponentPeer{{ IP: netip.MustParseAddr("192.168.1.2"), IPv6: netip.MustParseAddr("fd00::2"), Key: "peer2-key", DNSLabel: "peer2", SSHEnabled: true, SSHKey: "peer2-ssh-key"}}, - OfflinePeers: []*nbpeer.Peer{{ + OfflinePeers: []*types.ComponentPeer{{ IP: netip.MustParseAddr("192.168.1.3"), IPv6: netip.MustParseAddr("fd00::3"), Key: "peer3-key", diff --git a/management/server/posture/nb_version.go b/management/server/posture/nb_version.go index 6e4757021..3cace3b5f 100644 --- a/management/server/posture/nb_version.go +++ b/management/server/posture/nb_version.go @@ -3,11 +3,9 @@ package posture import ( "context" "fmt" - "strings" - - "github.com/hashicorp/go-version" nbpeer "github.com/netbirdio/netbird/management/server/peer" + nbversion "github.com/netbirdio/netbird/version" ) type NBVersionCheck struct { @@ -16,14 +14,8 @@ type NBVersionCheck struct { var _ Check = (*NBVersionCheck)(nil) -// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.) -func sanitizeVersion(version string) string { - parts := strings.Split(version, "-") - return parts[0] -} - func (n *NBVersionCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, error) { - meetsMin, err := MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion) + meetsMin, err := nbversion.MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion) if err != nil { return false, err } @@ -48,21 +40,3 @@ func (n *NBVersionCheck) Validate() error { } return nil } - -// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version -func MeetsMinVersion(minVer, peerVer string) (bool, error) { - peerVer = sanitizeVersion(peerVer) - minVer = sanitizeVersion(minVer) - - peerNBVer, err := version.NewVersion(peerVer) - if err != nil { - return false, err - } - - constraints, err := version.NewConstraint(">= " + minVer) - if err != nil { - return false, err - } - - return constraints.Check(peerNBVer), nil -} diff --git a/management/server/posture/nb_version_test.go b/management/server/posture/nb_version_test.go index d3478afc2..1bf485453 100644 --- a/management/server/posture/nb_version_test.go +++ b/management/server/posture/nb_version_test.go @@ -139,68 +139,3 @@ func TestNBVersionCheck_Validate(t *testing.T) { }) } } - -func TestMeetsMinVersion(t *testing.T) { - tests := []struct { - name string - minVer string - peerVer string - want bool - wantErr bool - }{ - { - name: "Peer version greater than min version", - minVer: "0.26.0", - peerVer: "0.60.1", - want: true, - wantErr: false, - }, - { - name: "Peer version equals min version", - minVer: "1.0.0", - peerVer: "1.0.0", - want: true, - wantErr: false, - }, - { - name: "Peer version less than min version", - minVer: "1.0.0", - peerVer: "0.9.9", - want: false, - wantErr: false, - }, - { - name: "Peer version with pre-release tag greater than min version", - minVer: "1.0.0", - peerVer: "1.0.1-alpha", - want: true, - wantErr: false, - }, - { - name: "Invalid peer version format", - minVer: "1.0.0", - peerVer: "dev", - want: false, - wantErr: true, - }, - { - name: "Invalid min version format", - minVer: "invalid.version", - peerVer: "1.0.0", - want: false, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := MeetsMinVersion(tt.minVer, tt.peerVer) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/management/server/types/account.go b/management/server/types/account.go index 05033ae15..588e63a09 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -283,8 +283,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon // it, adding a single private service would black-hole every // other name under the zone apex. zone = &nbdns.CustomZone{ - Domain: dns.Fqdn(serviceDomainZone), - Records: []nbdns.SimpleRecord{}, + Domain: dns.Fqdn(serviceDomainZone), + Records: []nbdns.SimpleRecord{}, NonAuthoritative: true, SearchDomainDisabled: true, } @@ -1082,6 +1082,7 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer peersExists := make(map[string]struct{}) rules := make([]*FirewallRule, 0) peers := make([]*nbpeer.Peer, 0) + targetComponent := targetPeer.ToComponent() return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { for _, peer := range groupPeers { @@ -1117,10 +1118,10 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) } else { - rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...) + rules = append(rules, ExpandPortsAndRanges(fr, rule, targetComponent)...) } - rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{ + rules = AppendIPv6FirewallRule(rules, rulesExists, peer.ToComponent(), targetComponent, rule, FirewallRuleContext{ Direction: direction, DirStr: strconv.Itoa(direction), ProtocolStr: string(protocol), @@ -1280,7 +1281,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli return fwRules } -func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*nbpeer.Peer { +func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*ComponentPeer { distPeersWithPolicy := make(map[string]struct{}) for _, id := range rule.Sources { group := a.Groups[id] @@ -1307,13 +1308,13 @@ func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID } } - distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) + distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) for pID := range distPeersWithPolicy { peer := a.Peers[pID] if peer == nil { continue } - distributionGroupPeers = append(distributionGroupPeers, peer) + distributionGroupPeers = append(distributionGroupPeers, peer.ToComponent()) } return distributionGroupPeers } diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go index 0205a1f55..af27788d8 100644 --- a/management/server/types/account_components.go +++ b/management/server/types/account_components.go @@ -9,9 +9,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/modules/zones" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/route" ) @@ -113,7 +111,7 @@ func (a *Account) GetPeerNetworkMapComponents( PeerID: peerID, Network: a.Network.Copy(), // must include the target peer as it's required on the client - Peers: map[string]*nbpeer.Peer{peerID: peer}, + Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()}, }) } @@ -126,7 +124,7 @@ func (a *Account) GetPeerNetworkMapComponents( PeerID: peerID, Network: a.Network.Copy(), // must include the target peer as it's required on the client - Peers: map[string]*nbpeer.Peer{peerID: peer}, + Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()}, }) } @@ -136,10 +134,10 @@ func (a *Account) GetPeerNetworkMapComponents( NameServerGroups: make([]*nbdns.NameServerGroup, 0), CustomZoneDomain: peersCustomZone.Domain, ResourcePoliciesMap: make(map[string][]*Policy), - RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), - NetworkResources: make([]*resourceTypes.NetworkResource, 0), + RoutersMap: make(map[string]map[string]*ComponentRouter), + NetworkResources: make([]*ComponentResource, 0), PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), - RouterPeers: make(map[string]*nbpeer.Peer), + RouterPeers: make(map[string]*ComponentPeer), NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), } @@ -174,7 +172,7 @@ func (a *Account) GetPeerNetworkMapComponents( } components.Peers = relevantPeers - components.Groups = relevantGroups + components.Groups = GroupsToComponent(relevantGroups) components.Policies = relevantPolicies components.Routes = relevantRoutes components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid()) @@ -223,7 +221,7 @@ func (a *Account) GetPeerNetworkMapComponents( } for _, pID := range a.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) { if _, exists := components.Peers[pID]; !exists { - components.Peers[pID] = a.GetPeer(pID) + components.Peers[pID] = a.GetPeer(pID).ToComponent() } } } else { @@ -256,14 +254,14 @@ func (a *Account) GetPeerNetworkMapComponents( for _, srcGroupID := range rule.Sources { if g := a.Groups[srcGroupID]; g != nil { if _, exists := components.Groups[srcGroupID]; !exists { - components.Groups[srcGroupID] = g + components.Groups[srcGroupID] = g.ToComponent() } } } for _, dstGroupID := range rule.Destinations { if g := a.Groups[dstGroupID]; g != nil { if _, exists := components.Groups[dstGroupID]; !exists { - components.Groups[dstGroupID] = g + components.Groups[dstGroupID] = g.ToComponent() } } } @@ -278,20 +276,22 @@ func (a *Account) GetPeerNetworkMapComponents( // network in the account — accounts with many tenants/networks // shipped tens of unrelated peers in `peers[]` and `routers_map`. if addSourcePeers { - components.RoutersMap[resource.NetworkID] = networkRoutingPeers + components.RoutersMap[resource.NetworkID] = routerTypes.ToComponentMap(networkRoutingPeers) for peerIDKey := range networkRoutingPeers { if p := a.Peers[peerIDKey]; p != nil { - if _, exists := components.RouterPeers[peerIDKey]; !exists { - components.RouterPeers[peerIDKey] = p + cp := components.RouterPeers[peerIDKey] + if cp == nil { + cp = p.ToComponent() + components.RouterPeers[peerIDKey] = cp } if _, exists := components.Peers[peerIDKey]; !exists { if _, validated := validatedPeersMap[peerIDKey]; validated { - components.Peers[peerIDKey] = p + components.Peers[peerIDKey] = cp } } } } - components.NetworkResources = append(components.NetworkResources, resource) + components.NetworkResources = append(components.NetworkResources, resource.ToComponent()) } } @@ -312,14 +312,14 @@ func (a *Account) getPeersGroupsPoliciesRoutes( peerSSHEnabled bool, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}, -) (map[string]*nbpeer.Peer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { - relevantPeerIDs := make(map[string]*nbpeer.Peer, len(a.Peers)/4) +) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { + relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4) relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4) relevantPolicies := make([]*Policy, 0, len(a.Policies)) relevantRoutes := make([]*route.Route, 0, len(a.Routes)) sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})} - relevantPeerIDs[peerID] = a.GetPeer(peerID) + relevantPeerIDs[peerID] = a.GetPeer(peerID).ToComponent() peerGroupSet := make(map[string]struct{}, 8) for groupID, group := range a.Groups { @@ -384,7 +384,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if r.Peer != "" { if _, ok := validatedPeersMap[r.Peer]; ok { if p := a.GetPeer(r.Peer); p != nil { - relevantPeerIDs[r.Peer] = p + relevantPeerIDs[r.Peer] = p.ToComponent() } } } @@ -401,7 +401,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes( continue } if p := a.GetPeer(pid); p != nil { - relevantPeerIDs[pid] = p + relevantPeerIDs[pid] = p.ToComponent() } } } @@ -458,7 +458,9 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if peerInSources { policyRelevant = true for _, pid := range destinationPeers { - relevantPeerIDs[pid] = a.GetPeer(pid) + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() + } } for _, dstGroupID := range rule.Destinations { relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) @@ -468,7 +470,9 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if peerInDestinations { policyRelevant = true for _, pid := range sourcePeers { - relevantPeerIDs[pid] = a.GetPeer(pid) + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() + } } for _, srcGroupID := range rule.Sources { relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) @@ -624,7 +628,7 @@ func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChe // that name them. Calculate() tolerates groups with empty Peers (the inner // loops simply iterate zero times), so retaining them is behaviourally a // no-op for the legacy path that consumes the same NetworkMapComponents. -func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) { +func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) { for groupID, groupInfo := range *groups { filteredPeers := make([]string, 0, len(groupInfo.Peers)) for _, pid := range groupInfo.Peers { @@ -634,14 +638,14 @@ func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) } if len(filteredPeers) != len(groupInfo.Peers) { - ng := groupInfo.Copy() + ng := *groupInfo ng.Peers = filteredPeers - (*groups)[groupID] = ng + (*groups)[groupID] = &ng } } } -func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*nbpeer.Peer) { +func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) { if len(*postureFailedPeers) == 0 { return } @@ -676,7 +680,7 @@ func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{} } } -func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*nbpeer.Peer, includeIPv6 bool) []nbdns.SimpleRecord { +func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord { if len(records) == 0 || len(peers) == 0 { return nil } diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go index dc097ce26..11b3d985a 100644 --- a/management/server/types/account_private_netmap_test.go +++ b/management/server/types/account_private_netmap_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" - nbpeer "github.com/netbirdio/netbird/management/server/peer" ) func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { @@ -49,7 +48,7 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { }) } -func netmapPeerIDs(peers []*nbpeer.Peer) []string { +func netmapPeerIDs(peers []*ComponentPeer) []string { ids := make([]string, 0, len(peers)) for _, p := range peers { if p == nil { diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index e5b5708fa..67d9e1c6f 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -666,7 +666,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer) + result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer.ToComponent()) var ports []string for _, fr := range result { diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go index f5837a343..9324cfa1e 100644 --- a/management/server/types/aliases.go +++ b/management/server/types/aliases.go @@ -6,7 +6,6 @@ import ( "net" "net/netip" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" sharedtypes "github.com/netbirdio/netbird/shared/management/types" ) @@ -18,9 +17,6 @@ type DNSSettings = sharedtypes.DNSSettings type FirewallRule = sharedtypes.FirewallRule -type Group = sharedtypes.Group -type GroupPeer = sharedtypes.GroupPeer - type Network = sharedtypes.Network type NetworkMap = sharedtypes.NetworkMap type ForwardingRule = sharedtypes.ForwardingRule @@ -42,6 +38,18 @@ type RouteFirewallRule = sharedtypes.RouteFirewallRule type NetworkMapComponents = sharedtypes.NetworkMapComponents +type ComponentPeer = sharedtypes.ComponentPeer +type ComponentGroup = sharedtypes.ComponentGroup +type ComponentRouter = sharedtypes.ComponentRouter +type ComponentResource = sharedtypes.ComponentResource +type ComponentResourceType = sharedtypes.ComponentResourceType + +const ( + ComponentResourceHost = sharedtypes.ComponentResourceHost + ComponentResourceSubnet = sharedtypes.ComponentResourceSubnet + ComponentResourceDomain = sharedtypes.ComponentResourceDomain +) + var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents type AccountSettingsInfo = sharedtypes.AccountSettingsInfo @@ -52,12 +60,7 @@ type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact type LookupMap = sharedtypes.LookupMap type FirewallRuleContext = sharedtypes.FirewallRuleContext -const ( - GroupIssuedAPI = sharedtypes.GroupIssuedAPI - GroupIssuedJWT = sharedtypes.GroupIssuedJWT - GroupIssuedIntegration = sharedtypes.GroupIssuedIntegration - GroupAllName = sharedtypes.GroupAllName -) +const GroupAllName = sharedtypes.GroupAllName // Function forwarders preserve types.X(...) call sites that previously // resolved to package-local funcs. Plain forwarders (not var aliases) keep @@ -67,11 +70,11 @@ func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { return sharedtypes.PolicyRuleImpliesLegacySSH(rule) } -func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { return sharedtypes.ExpandPortsAndRanges(base, rule, peer) } -func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc) } @@ -79,7 +82,7 @@ func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkM return sharedtypes.CalculateNetworkMapFromComponents(ctx, components) } -func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6) } diff --git a/shared/management/types/group.go b/management/server/types/group.go similarity index 83% rename from shared/management/types/group.go rename to management/server/types/group.go index e6e285e62..a5e196997 100644 --- a/shared/management/types/group.go +++ b/management/server/types/group.go @@ -2,7 +2,6 @@ package types import ( "github.com/netbirdio/netbird/management/server/integration_reference" - "github.com/netbirdio/netbird/management/server/networks/resources/types" ) const ( @@ -68,10 +67,6 @@ func (g *Group) EventMeta() map[string]any { return map[string]any{"name": g.Name} } -func (g *Group) EventMetaResource(resource *types.NetworkResource) map[string]any { - return map[string]any{"name": g.Name, "id": g.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} -} - func (g *Group) Copy() *Group { group := &Group{ ID: g.ID, @@ -95,14 +90,39 @@ func (g *Group) HasPeers() bool { return len(g.Peers) > 0 } -// GroupAllName is the reserved name of the default group that contains every peer in an account. -const GroupAllName = "All" - // IsGroupAll checks if the group is a default "All" group. func (g *Group) IsGroupAll() bool { return g.Name == GroupAllName } +// ToComponent converts the group to its self-contained components +// representation. The Peers slice is shared, not copied — components are +// treated as immutable snapshots. Returns nil for a nil group. +func (g *Group) ToComponent() *ComponentGroup { + if g == nil { + return nil + } + return &ComponentGroup{ + ID: g.ID, + PublicID: g.PublicID, + Name: g.Name, + Peers: g.Peers, + } +} + +// GroupsToComponent converts an id-keyed group map to its components +// representation, preserving nil entries. +func GroupsToComponent(groups map[string]*Group) map[string]*ComponentGroup { + if groups == nil { + return nil + } + out := make(map[string]*ComponentGroup, len(groups)) + for id, g := range groups { + out[id] = g.ToComponent() + } + return out +} + // AddPeer adds peerID to Peers if not present, returning true if added. func (g *Group) AddPeer(peerID string) bool { if peerID == "" { diff --git a/management/server/types/ipv6_endtoend_test.go b/management/server/types/ipv6_endtoend_test.go index ddd1f649f..d83603abe 100644 --- a/management/server/types/ipv6_endtoend_test.go +++ b/management/server/types/ipv6_endtoend_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" ) func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) { @@ -104,7 +105,7 @@ func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) { require.NotNil(t, nm) t.Run("AllowedIPs include remote v6", func(t *testing.T) { - var dst *nbpeer.Peer + var dst *types.ComponentPeer for _, p := range nm.Peers { if p.ID == "peer-dst-1" { dst = p diff --git a/management/server/types/networkmap_components_test.go b/management/server/types/networkmap_components_test.go index 1a99b4511..3f2288f88 100644 --- a/management/server/types/networkmap_components_test.go +++ b/management/server/types/networkmap_components_test.go @@ -49,7 +49,7 @@ func allPeersValidated(account *types.Account, excludePeerIDs ...string) map[str return validated } -func peerIDs(peers []*nbpeer.Peer) []string { +func peerIDs(peers []*types.ComponentPeer) []string { ids := make([]string, len(peers)) for i, p := range peers { ids[i] = p.ID diff --git a/management/server/util/util.go b/management/server/util/util.go index 617484274..d85b55f02 100644 --- a/management/server/util/util.go +++ b/management/server/util/util.go @@ -19,34 +19,3 @@ func Difference(a, b []string) []string { func ToPtr[T any](value T) *T { return &value } - -type comparableObject[T any] interface { - Equal(other T) bool -} - -func MergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { - var result []T - - for _, item := range arr1 { - if !contains(result, item) { - result = append(result, item) - } - } - - for _, item := range arr2 { - if !contains(result, item) { - result = append(result, item) - } - } - - return result -} - -func contains[T comparableObject[T]](slice []T, element T) bool { - for _, item := range slice { - if item.Equal(element) { - return true - } - } - return false -} diff --git a/shared/management/networkmap/decode.go b/shared/management/networkmap/decode.go index c66074b4f..d15117b6e 100644 --- a/shared/management/networkmap/decode.go +++ b/shared/management/networkmap/decode.go @@ -11,9 +11,6 @@ import ( log "github.com/sirupsen/logrus" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/proto" @@ -38,17 +35,17 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, Network: decodeAccountNetwork(full.Network), AccountSettings: decodeAccountSettings(full.AccountSettings), CustomZoneDomain: full.CustomZoneDomain, - Peers: make(map[string]*nbpeer.Peer, len(full.Peers)), - Groups: make(map[string]*types.Group, len(full.Groups)), + Peers: make(map[string]*types.ComponentPeer, len(full.Peers)), + Groups: make(map[string]*types.ComponentGroup, len(full.Groups)), Policies: make([]*types.Policy, 0, len(full.Policies)), Routes: make([]*nbroute.Route, 0, len(full.Routes)), NameServerGroups: make([]*nbdns.NameServerGroup, 0, len(full.NameserverGroups)), AllDNSRecords: decodeSimpleRecords(full.AllDnsRecords), AccountZones: decodeCustomZones(full.AccountZones), ResourcePoliciesMap: make(map[string][]*types.Policy), - RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), - NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(full.NetworkResources)), - RouterPeers: make(map[string]*nbpeer.Peer), + RoutersMap: make(map[string]map[string]*types.ComponentRouter), + NetworkResources: make([]*types.ComponentResource, 0, len(full.NetworkResources)), + RouterPeers: make(map[string]*types.ComponentPeer), AllowedUserIDs: stringSliceToSet(full.AllowedUserIds), PostureFailedPeers: make(map[string]map[string]struct{}, len(full.PostureFailedPeers)), GroupIDToUserIDs: make(map[string][]string, len(full.GroupIdToUserIds)), @@ -101,7 +98,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, log.WithField("peer idx", idx).Error("unrecognized peer idx during decoding") } } - group := &types.Group{ + group := &types.ComponentGroup{ ID: groupID, PublicID: gc.Id, Peers: peerIDs, @@ -151,7 +148,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, // Phase 7: routers_map (outer key = network seq id, inner key = peer-id // reconstructed from peer_index). Synthesized network id is "net_". for networkID, list := range full.RoutersMap { - inner := make(map[string]*routerTypes.NetworkRouter, len(list.Entries)) + inner := make(map[string]*types.ComponentRouter, len(list.Entries)) for _, entry := range list.Entries { if !entry.PeerIndexSet { continue @@ -161,8 +158,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, continue } peerID := peerIDByIndex[entry.PeerIndex] - inner[peerID] = &routerTypes.NetworkRouter{ - ID: "", + inner[peerID] = &types.ComponentRouter{ NetworkID: networkID, PublicID: entry.Id, Peer: peerID, @@ -264,40 +260,22 @@ func decodeAccountSettings(as *proto.AccountSettingsCompact) *types.AccountSetti } } -func decodePeerCompact(pc *proto.PeerCompact, peerID string) *nbpeer.Peer { - var caps []int32 - if pc.SupportsSourcePrefixes { - caps = append(caps, nbpeer.PeerCapabilitySourcePrefixes) - } - if pc.SupportsIpv6 { - caps = append(caps, nbpeer.PeerCapabilityIPv6Overlay) - } - peer := &nbpeer.Peer{ +func decodePeerCompact(pc *proto.PeerCompact, peerID string) *types.ComponentPeer { + peer := &types.ComponentPeer{ ID: peerID, Key: peerID, SSHKey: string(pc.SshPubKey), SSHEnabled: pc.SshEnabled, DNSLabel: pc.DnsLabel, LoginExpirationEnabled: pc.LoginExpirationEnabled, - Meta: nbpeer.PeerSystemMeta{ - WtVersion: pc.AgentVersion, - Capabilities: caps, - Flags: nbpeer.Flags{ - ServerSSHAllowed: pc.ServerSshAllowed, - }, - }, - } - if pc.AddedWithSsoLogin { - // Set a non-empty UserID so (*Peer).AddedWithSSOLogin() returns true. - // The original UserID isn't on the wire; the value is intentionally - // visibly synthetic so any future consumer that mistakes UserID for a - // real account user xid won't silently match (or worse, write the - // sentinel into a downstream record). - peer.UserID = "" + AgentVersion: pc.AgentVersion, + SupportsSourcePrefixes: pc.SupportsSourcePrefixes, + SupportsIPv6: pc.SupportsIpv6, + ServerSSHAllowed: pc.ServerSshAllowed, + AddedWithSSOLogin: pc.AddedWithSsoLogin, } if pc.LastLoginUnixNano != 0 { - t := time.Unix(0, pc.LastLoginUnixNano) - peer.LastLogin = &t + peer.LastLogin = time.Unix(0, pc.LastLoginUnixNano) } switch len(pc.Ip) { case 4: @@ -424,14 +402,14 @@ func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGr return out } -func decodeNetworkResource(nr *proto.NetworkResourceRaw) *resourceTypes.NetworkResource { - out := &resourceTypes.NetworkResource{ +func decodeNetworkResource(nr *proto.NetworkResourceRaw) *types.ComponentResource { + out := &types.ComponentResource{ ID: nr.Id, PublicID: nr.Id, NetworkID: nr.NetworkSeq, Name: nr.Name, Description: nr.Description, - Type: resourceTypes.NetworkResourceType(nr.Type), + Type: types.ComponentResourceType(nr.Type), Address: nr.Address, Domain: nr.DomainValue, Enabled: nr.Enabled, diff --git a/shared/management/networkmap/encode.go b/shared/management/networkmap/encode.go index e808480ea..ccde32faf 100644 --- a/shared/management/networkmap/encode.go +++ b/shared/management/networkmap/encode.go @@ -20,10 +20,9 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "net/netip" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/shared/management/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/types" "github.com/netbirdio/netbird/shared/netiputil" "github.com/netbirdio/netbird/shared/sshauth" ) @@ -274,7 +273,7 @@ func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort // AppendRemotePeerConfig appends typed peers as proto.RemotePeerConfig // entries to dst and returns the result. -func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { +func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.ComponentPeer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { for _, rPeer := range peers { allowedIPs := []string{rPeer.IP.String() + "/32"} if includeIPv6 && rPeer.IPv6.IsValid() { @@ -285,7 +284,7 @@ func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, AllowedIps: allowedIPs, SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, Fqdn: rPeer.FQDN(dnsName), - AgentVersion: rPeer.Meta.WtVersion, + AgentVersion: rPeer.AgentVersion, }) } return dst diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go index 3f045a9eb..a928c5059 100644 --- a/shared/management/networkmap/envelope.go +++ b/shared/management/networkmap/envelope.go @@ -54,8 +54,8 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo } components.PeerID = canonicalKey - includeIPv6 := localPeer.SupportsIPv6() && localPeer.IPv6.IsValid() - useSourcePrefixes := localPeer.SupportsSourcePrefixes() + includeIPv6 := localPeer.SupportsIPv6 && localPeer.IPv6.IsValid() + useSourcePrefixes := localPeer.SupportsSourcePrefixes typedNM := components.Calculate(ctx) diff --git a/shared/management/networkmap/envelope_test.go b/shared/management/networkmap/envelope_test.go index 11a5335be..a81478aff 100644 --- a/shared/management/networkmap/envelope_test.go +++ b/shared/management/networkmap/envelope_test.go @@ -13,7 +13,6 @@ import ( goproto "google.golang.org/protobuf/proto" mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" "github.com/netbirdio/netbird/shared/management/proto" @@ -144,14 +143,14 @@ func TestDecodeEnvelope_MalformedWgKeyPeerSkipped(t *testing.T) { func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { ctx := context.Background() - peers := map[string]*nbpeer.Peer{} + peers := map[string]*types.ComponentPeer{} for i, id := range []string{"peer-T", "peer-S", "peer-ALL", "peer-O"} { - peers[id] = &nbpeer.Peer{ - ID: id, - Key: randomWgKey(t), - IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), - DNSLabel: id, - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peers[id] = &types.ComponentPeer{ + ID: id, + Key: randomWgKey(t), + IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), + DNSLabel: id, + AgentVersion: "0.40.0", } } @@ -165,7 +164,7 @@ func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { AccountSettings: &types.AccountSettingsInfo{}, DNSSettings: &types.DNSSettings{}, Peers: peers, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "g-src": {ID: "g-src", PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}}, "g-all": {ID: "g-all", PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}}, "g-two": {ID: "g-two", PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}}, @@ -232,22 +231,22 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { peerAKey := randomWgKey(t) peerBKey := randomWgKey(t) - peerA := &nbpeer.Peer{ - ID: "peer-A", - Key: peerAKey, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), - DNSLabel: "peerA", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerA := &types.ComponentPeer{ + ID: "peer-A", + Key: peerAKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peerA", + AgentVersion: "0.40.0", } - peerB := &nbpeer.Peer{ - ID: "peer-B", - Key: peerBKey, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), - DNSLabel: "peerB", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerB := &types.ComponentPeer{ + ID: "peer-B", + Key: peerBKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + DNSLabel: "peerB", + AgentVersion: "0.40.0", } - group := &types.Group{ + group := &types.ComponentGroup{ ID: "group-all", PublicID: "1", Name: "All", Peers: []string{"peer-A", "peer-B"}, } @@ -274,11 +273,11 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { }, AccountSettings: &types.AccountSettingsInfo{}, DNSSettings: &types.DNSSettings{}, - Peers: map[string]*nbpeer.Peer{ + Peers: map[string]*types.ComponentPeer{ "peer-A": peerA, "peer-B": peerB, }, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "group-all": group, }, Policies: []*types.Policy{policy}, diff --git a/shared/management/types/component_types.go b/shared/management/types/component_types.go new file mode 100644 index 000000000..a511097b1 --- /dev/null +++ b/shared/management/types/component_types.go @@ -0,0 +1,103 @@ +package types + +import ( + "net/netip" + "time" +) + +// ComponentPeer is the self-contained peer representation used by +// NetworkMapComponents and the calculated NetworkMap. It carries exactly the +// subset of peer data that crosses the components wire format, so the shared +// calculation layer stays independent of the management server's domain +// types. +type ComponentPeer struct { + ID string + Key string + IP netip.Addr + IPv6 netip.Addr + DNSLabel string + SSHKey string + SSHEnabled bool + ServerSSHAllowed bool + AgentVersion string + SupportsSourcePrefixes bool + SupportsIPv6 bool + LoginExpirationEnabled bool + AddedWithSSOLogin bool + LastLogin time.Time +} + +// FQDN returns the peer's FQDN combined of the peer's DNS label and the system's DNS domain. +func (p *ComponentPeer) FQDN(dnsDomain string) string { + if dnsDomain == "" { + return "" + } + return p.DNSLabel + "." + dnsDomain +} + +// LoginExpired indicates whether the peer's login has expired, mirroring the +// server-side peer semantics: only SSO-added peers with login expiration +// enabled can expire. +func (p *ComponentPeer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) { + if !p.AddedWithSSOLogin || !p.LoginExpirationEnabled { + return false, 0 + } + timeLeft := time.Until(p.LastLogin.Add(expiresIn)) + return timeLeft <= 0, timeLeft +} + +// GroupAllName is the reserved name of the default group that contains every peer in an account. +const GroupAllName = "All" + +// ComponentGroup is the self-contained group representation used by +// NetworkMapComponents: just the membership view the network-map calculation +// needs, without the server's storage fields. +type ComponentGroup struct { + ID string + PublicID string + Name string + Peers []string +} + +// IsGroupAll checks if the group is a default "All" group. +func (g *ComponentGroup) IsGroupAll() bool { + return g.Name == GroupAllName +} + +// ComponentRouter is the self-contained network-router representation used by +// NetworkMapComponents. +type ComponentRouter struct { + NetworkID string + PublicID string + Peer string + PeerGroups []string + Masquerade bool + Metric int + Enabled bool +} + +// ComponentResourceType mirrors the network-resource type enum on the +// components wire format. +type ComponentResourceType string + +const ( + ComponentResourceHost ComponentResourceType = "host" + ComponentResourceSubnet ComponentResourceType = "subnet" + ComponentResourceDomain ComponentResourceType = "domain" +) + +// ComponentResource is the self-contained network-resource representation +// used by NetworkMapComponents. +type ComponentResource struct { + ID string + PublicID string + NetworkID string + AccountID string + Name string + Description string + Type ComponentResourceType + Address string + Domain string + Prefix netip.Prefix + Enabled bool +} diff --git a/shared/management/types/firewall_helpers.go b/shared/management/types/firewall_helpers.go index dd174abe4..6e43af33e 100644 --- a/shared/management/types/firewall_helpers.go +++ b/shared/management/types/firewall_helpers.go @@ -3,8 +3,6 @@ package types import ( "strconv" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/version" ) @@ -48,8 +46,8 @@ func portsIncludesSSH(ports []string) bool { } // ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules. -func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { - features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { + features := peerSupportedFirewallFeatures(peer.AgentVersion) var expanded []*FirewallRule @@ -106,8 +104,8 @@ func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool { return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End) } -func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *nbpeer.Peer) bool { - return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP +func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *ComponentPeer) bool { + return supportsNative && peer.SSHEnabled && peer.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP } func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { @@ -117,13 +115,13 @@ func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { var features supportedFeatures - meetMinVer, err := posture.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) + meetMinVer, err := version.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) features.nativeSSH = err == nil && meetMinVer if features.nativeSSH { features.portRanges = true } else { - meetMinVer, err = posture.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) + meetMinVer, err = version.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) features.portRanges = err == nil && meetMinVer } diff --git a/shared/management/types/firewall_rule.go b/shared/management/types/firewall_rule.go index 87dcfe307..67cb581a2 100644 --- a/shared/management/types/firewall_rule.go +++ b/shared/management/types/firewall_rule.go @@ -9,7 +9,6 @@ import ( log "github.com/sirupsen/logrus" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" ) @@ -51,7 +50,7 @@ func (r *FirewallRule) Equal(other *FirewallRule) bool { // For static routes, source ranges match the destination family (v4 or v6). // For dynamic routes (domain-based), separate v4 and v6 rules are generated // so the routing peer's forwarding chain allows both address families. -func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { rulesExists := make(map[string]struct{}) rules := make([]*RouteFirewallRule, 0) @@ -107,7 +106,7 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule } // splitPeerSourcesByFamily separates peer IPs into v4 (/32) and v6 (/128) source ranges. -func splitPeerSourcesByFamily(groupPeers []*nbpeer.Peer) (v4, v6 []string) { +func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) { v4 = make([]string, 0, len(groupPeers)) v6 = make([]string, 0, len(groupPeers)) for _, peer := range groupPeers { diff --git a/shared/management/types/firewall_rule_test.go b/shared/management/types/firewall_rule_test.go index 9de4ca04a..c21cfa2df 100644 --- a/shared/management/types/firewall_rule_test.go +++ b/shared/management/types/firewall_rule_test.go @@ -8,13 +8,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" ) func TestSplitPeerSourcesByFamily(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -36,7 +35,7 @@ func TestSplitPeerSourcesByFamily(t *testing.T) { } func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -65,7 +64,7 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { } func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -93,7 +92,7 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { } func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -126,7 +125,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { } func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ {IP: netip.MustParseAddr("100.64.0.1")}, {IP: netip.MustParseAddr("100.64.0.2")}, } @@ -150,7 +149,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { } func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), diff --git a/shared/management/types/network.go b/shared/management/types/network.go index fe67bfd97..72a5cc5b3 100644 --- a/shared/management/types/network.go +++ b/shared/management/types/network.go @@ -15,8 +15,6 @@ import ( "golang.org/x/exp/maps" nbdns "github.com/netbirdio/netbird/dns" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/status" @@ -39,11 +37,11 @@ const ( ) type NetworkMap struct { - Peers []*nbpeer.Peer + Peers []*ComponentPeer Network *Network Routes []*route.Route DNSConfig nbdns.Config - OfflinePeers []*nbpeer.Peer + OfflinePeers []*ComponentPeer FirewallRules []*FirewallRule RoutesFirewallRules []*RouteFirewallRule ForwardingRules []*ForwardingRule @@ -53,15 +51,46 @@ type NetworkMap struct { func (nm *NetworkMap) Merge(other *NetworkMap) { nm.Peers = mergeUniquePeersByID(nm.Peers, other.Peers) - nm.Routes = util.MergeUnique(nm.Routes, other.Routes) + nm.Routes = mergeUnique(nm.Routes, other.Routes) nm.OfflinePeers = mergeUniquePeersByID(nm.OfflinePeers, other.OfflinePeers) - nm.FirewallRules = util.MergeUnique(nm.FirewallRules, other.FirewallRules) - nm.RoutesFirewallRules = util.MergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) - nm.ForwardingRules = util.MergeUnique(nm.ForwardingRules, other.ForwardingRules) + nm.FirewallRules = mergeUnique(nm.FirewallRules, other.FirewallRules) + nm.RoutesFirewallRules = mergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) + nm.ForwardingRules = mergeUnique(nm.ForwardingRules, other.ForwardingRules) } -func mergeUniquePeersByID(peers1, peers2 []*nbpeer.Peer) []*nbpeer.Peer { - result := make(map[string]*nbpeer.Peer) +type comparableObject[T any] interface { + Equal(other T) bool +} + +func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { + var result []T + + for _, item := range arr1 { + if !containsEqual(result, item) { + result = append(result, item) + } + } + + for _, item := range arr2 { + if !containsEqual(result, item) { + result = append(result, item) + } + } + + return result +} + +func containsEqual[T comparableObject[T]](slice []T, element T) bool { + for _, item := range slice { + if item.Equal(element) { + return true + } + } + return false +} + +func mergeUniquePeersByID(peers1, peers2 []*ComponentPeer) []*ComponentPeer { + result := make(map[string]*ComponentPeer) for _, peer := range peers1 { result[peer.ID] = peer } diff --git a/management/server/util/util_test.go b/shared/management/types/network_merge_test.go similarity index 88% rename from management/server/util/util_test.go rename to shared/management/types/network_merge_test.go index 5c928b369..a7ef24c1e 100644 --- a/management/server/util/util_test.go +++ b/shared/management/types/network_merge_test.go @@ -1,4 +1,4 @@ -package util +package types import ( "testing" @@ -17,7 +17,7 @@ func (t testObject) Equal(other testObject) bool { func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { arr1 := []testObject{{value: 1}, {value: 2}} arr2 := []testObject{{value: 2}, {value: 3}} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Len(t, result, 3) assert.Contains(t, result, testObject{value: 1}) assert.Contains(t, result, testObject{value: 2}) @@ -27,14 +27,14 @@ func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) { arr1 := []testObject{} arr2 := []testObject{} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Empty(t, result) } func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) { arr1 := []testObject{{value: 1}, {value: 2}} arr2 := []testObject{} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Len(t, result, 2) assert.Contains(t, result, testObject{value: 1}) assert.Contains(t, result, testObject{value: 2}) diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go index fdb70f2f7..a708e99e1 100644 --- a/shared/management/types/networkmap_components.go +++ b/shared/management/types/networkmap_components.go @@ -12,9 +12,6 @@ import ( "github.com/netbirdio/netbird/client/ssh/auth" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" ) @@ -27,22 +24,22 @@ type NetworkMapComponents struct { DNSSettings *DNSSettings CustomZoneDomain string - Peers map[string]*nbpeer.Peer - Groups map[string]*Group + Peers map[string]*ComponentPeer + Groups map[string]*ComponentGroup Policies []*Policy Routes []*route.Route NameServerGroups []*nbdns.NameServerGroup AllDNSRecords []nbdns.SimpleRecord AccountZones []nbdns.CustomZone ResourcePoliciesMap map[string][]*Policy - RoutersMap map[string]map[string]*routerTypes.NetworkRouter - NetworkResources []*resourceTypes.NetworkResource + RoutersMap map[string]map[string]*ComponentRouter + NetworkResources []*ComponentResource GroupIDToUserIDs map[string][]string AllowedUserIDs map[string]struct{} PostureFailedPeers map[string]map[string]struct{} - RouterPeers map[string]*nbpeer.Peer + RouterPeers map[string]*ComponentPeer // NetworkXIDToPublicID maps Network.ID (xid) → PublicID. // Consumed by the envelope encoder to @@ -78,15 +75,15 @@ func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents { return nm } -func (c *NetworkMapComponents) GetPeerInfo(peerID string) *nbpeer.Peer { +func (c *NetworkMapComponents) GetPeerInfo(peerID string) *ComponentPeer { return c.Peers[peerID] } -func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *nbpeer.Peer { +func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *ComponentPeer { return c.RouterPeers[peerID] } -func (c *NetworkMapComponents) GetGroupInfo(groupID string) *Group { +func (c *NetworkMapComponents) GetGroupInfo(groupID string) *ComponentGroup { return c.Groups[groupID] } @@ -142,7 +139,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { includeIPv6 := false if p := c.Peers[targetPeerID]; p != nil { - includeIPv6 = p.SupportsIPv6() && p.IPv6.IsValid() + includeIPv6 = p.SupportsIPv6 && p.IPv6.IsValid() } routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6) routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6) @@ -200,7 +197,7 @@ func (c *NetworkMapComponents) IsEmpty() bool { return c.empty } -func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) { +func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*ComponentPeer, []*FirewallRule, map[string]map[string]struct{}, bool) { targetPeer := c.GetPeerInfo(targetPeerID) if targetPeer == nil { return nil, nil, nil, false @@ -220,7 +217,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ( continue } - var sourcePeers, destinationPeers []*nbpeer.Peer + var sourcePeers, destinationPeers []*ComponentPeer var peerInSources, peerInDestinations bool if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { @@ -303,13 +300,13 @@ func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} { return make(map[string]struct{}) } -func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (func(*PolicyRule, []*nbpeer.Peer, int), func() ([]*nbpeer.Peer, []*FirewallRule)) { +func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer) (func(*PolicyRule, []*ComponentPeer, int), func() ([]*ComponentPeer, []*FirewallRule)) { rulesExists := make(map[string]struct{}) peersExists := make(map[string]struct{}) rules := make([]*FirewallRule, 0) - peers := make([]*nbpeer.Peer, 0) + peers := make([]*ComponentPeer, 0) - return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { + return func(rule *PolicyRule, groupPeers []*ComponentPeer, direction int) { protocol := rule.Protocol if protocol == PolicyRuleProtocolNetbirdSSH { protocol = PolicyRuleProtocolTCP @@ -361,15 +358,15 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) ( PortsJoined: portsJoined, }) } - }, func() ([]*nbpeer.Peer, []*FirewallRule) { + }, func() ([]*ComponentPeer, []*FirewallRule) { return peers, rules } } -func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*nbpeer.Peer, bool) { +func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*ComponentPeer, bool) { peerInGroups := false uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups) - filteredPeers := make([]*nbpeer.Peer, 0, len(uniquePeerIDs)) + filteredPeers := make([]*ComponentPeer, 0, len(uniquePeerIDs)) for _, p := range uniquePeerIDs { peerInfo := c.GetPeerInfo(p) @@ -421,22 +418,22 @@ func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) [] return ids } -func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*nbpeer.Peer, bool) { +func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*ComponentPeer, bool) { if resource.ID == peerID { - return []*nbpeer.Peer{}, true + return []*ComponentPeer{}, true } peerInfo := c.GetPeerInfo(resource.ID) if peerInfo == nil { - return []*nbpeer.Peer{}, false + return []*ComponentPeer{}, false } - return []*nbpeer.Peer{peerInfo}, false + return []*ComponentPeer{peerInfo}, false } -func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nbpeer.Peer) ([]*nbpeer.Peer, []*nbpeer.Peer) { - peersToConnect := make([]*nbpeer.Peer, 0, len(aclPeers)) - var expiredPeers []*nbpeer.Peer +func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*ComponentPeer) ([]*ComponentPeer, []*ComponentPeer) { + peersToConnect := make([]*ComponentPeer, 0, len(aclPeers)) + var expiredPeers []*ComponentPeer for _, p := range aclPeers { expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration) @@ -518,7 +515,7 @@ func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Rou return filtered } -func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*nbpeer.Peer, peerGroups LookupMap) []*route.Route { +func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*ComponentPeer, peerGroups LookupMap) []*route.Route { routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID) peerRoutesMembership := make(LookupMap) for _, r := range append(routes, peerDisabledRoutes...) { @@ -732,7 +729,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID return fwRules } -func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*nbpeer.Peer { +func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*ComponentPeer { distPeersWithPolicy := make(map[string]struct{}) for _, id := range rule.Sources { group := c.GetGroupInfo(id) @@ -759,7 +756,7 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st } } - distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) + distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) for pID := range distPeersWithPolicy { peerInfo := c.GetPeerInfo(pID) if peerInfo == nil { @@ -799,8 +796,8 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (b func (c *NetworkMapComponents) processResourcePolicies( peerID string, - resource *resourceTypes.NetworkResource, - networkRoutingPeers map[string]*routerTypes.NetworkRouter, + resource *ComponentResource, + networkRoutingPeers map[string]*ComponentRouter, addSourcePeers bool, allSourcePeers map[string]struct{}, ) []*route.Route { @@ -833,7 +830,7 @@ func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string { return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) } -func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *resourceTypes.NetworkResource, peerID string, router *routerTypes.NetworkRouter) []*route.Route { +func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentResource, peerID string, router *ComponentRouter) []*route.Route { resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID] var routes []*route.Route @@ -847,7 +844,7 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *resourceTypes return routes } -func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.NetworkResource, peer *nbpeer.Peer, router *routerTypes.NetworkRouter) *route.Route { +func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResource, peer *ComponentPeer, router *ComponentRouter) *route.Route { r := &route.Route{ ID: route.ID(resource.ID + ":" + peer.ID), AccountID: resource.AccountID, @@ -861,7 +858,7 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.Ne Description: resource.Description, } - if resource.Type == resourceTypes.Host || resource.Type == resourceTypes.Subnet { + if resource.Type == ComponentResourceHost || resource.Type == ComponentResourceSubnet { r.Network = resource.Prefix r.NetworkType = route.IPv4Network @@ -870,7 +867,7 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.Ne } } - if resource.Type == resourceTypes.Domain { + if resource.Type == ComponentResourceDomain { domainList, err := domain.FromStringList([]string{resource.Domain}) if err == nil { r.Domains = domainList @@ -948,11 +945,11 @@ func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[st func (c *NetworkMapComponents) addNetworksRoutingPeers( networkResourcesRoutes []*route.Route, peerID string, - peersToConnect []*nbpeer.Peer, - expiredPeers []*nbpeer.Peer, + peersToConnect []*ComponentPeer, + expiredPeers []*ComponentPeer, isRouter bool, sourcePeers map[string]struct{}, -) []*nbpeer.Peer { +) []*ComponentPeer { networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes)) for _, r := range networkResourcesRoutes { @@ -1002,8 +999,8 @@ type FirewallRuleContext struct { PortsJoined string } -func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { - if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6() || !targetPeer.IPv6.IsValid() { +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { + if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6 || !targetPeer.IPv6.IsValid() { return rules } diff --git a/shared/management/types/networkmap_components_compact.go b/shared/management/types/networkmap_components_compact.go index b60f8bdb1..a1f53690d 100644 --- a/shared/management/types/networkmap_components_compact.go +++ b/shared/management/types/networkmap_components_compact.go @@ -2,9 +2,6 @@ package types import ( nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" ) @@ -21,7 +18,7 @@ type NetworkMapComponentsCompact struct { DNSSettings *DNSSettings CustomZoneDomain string - AllPeers []*nbpeer.Peer + AllPeers []*ComponentPeer PeerIndexes []int RouterPeerIndexes []int @@ -34,8 +31,8 @@ type NetworkMapComponentsCompact struct { AllDNSRecords []nbdns.SimpleRecord AccountZones []nbdns.CustomZone - RoutersMap map[string]map[string]*routerTypes.NetworkRouter - NetworkResources []*resourceTypes.NetworkResource + RoutersMap map[string]map[string]*ComponentRouter + NetworkResources []*ComponentResource GroupIDToUserIDs map[string][]string AllowedUserIDs map[string]struct{} @@ -44,7 +41,7 @@ type NetworkMapComponentsCompact struct { func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { peerToIndex := make(map[string]int) - var allPeers []*nbpeer.Peer + var allPeers []*ComponentPeer for id, peer := range c.Peers { if _, exists := peerToIndex[id]; !exists { @@ -150,7 +147,7 @@ func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { } func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { - peers := make(map[string]*nbpeer.Peer, len(c.PeerIndexes)) + peers := make(map[string]*ComponentPeer, len(c.PeerIndexes)) for _, idx := range c.PeerIndexes { if idx >= 0 && idx < len(c.AllPeers) { peer := c.AllPeers[idx] @@ -158,7 +155,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { } } - routerPeers := make(map[string]*nbpeer.Peer, len(c.RouterPeerIndexes)) + routerPeers := make(map[string]*ComponentPeer, len(c.RouterPeerIndexes)) for _, idx := range c.RouterPeerIndexes { if idx >= 0 && idx < len(c.AllPeers) { peer := c.AllPeers[idx] @@ -166,7 +163,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { } } - groups := make(map[string]*Group, len(c.Groups)) + groups := make(map[string]*ComponentGroup, len(c.Groups)) for id, gc := range c.Groups { peerIDs := make([]string, 0, len(gc.PeerIndexes)) for _, idx := range gc.PeerIndexes { @@ -174,7 +171,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { peerIDs = append(peerIDs, c.AllPeers[idx].ID) } } - groups[id] = &Group{ + groups[id] = &ComponentGroup{ ID: id, Name: gc.Name, Peers: peerIDs, diff --git a/version/version.go b/version/version.go index 074305bd6..b92e5ac7e 100644 --- a/version/version.go +++ b/version/version.go @@ -71,6 +71,30 @@ func NetbirdCommit() string { return revision } +// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.) +func sanitizeVersion(version string) string { + parts := strings.Split(version, "-") + return parts[0] +} + +// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version +func MeetsMinVersion(minVer, peerVer string) (bool, error) { + peerVer = sanitizeVersion(peerVer) + minVer = sanitizeVersion(minVer) + + peerNBVer, err := v.NewVersion(peerVer) + if err != nil { + return false, err + } + + constraints, err := v.NewConstraint(">= " + minVer) + if err != nil { + return false, err + } + + return constraints.Check(peerNBVer), nil +} + // IsDevelopmentVersion reports whether the given version string identifies // a non-release / development build. It is the single source of truth for // "is this a dev build" checks across the codebase; use it instead of diff --git a/version/version_test.go b/version/version_test.go index cdba6b804..f05bcbd87 100644 --- a/version/version_test.go +++ b/version/version_test.go @@ -1,6 +1,10 @@ package version -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestIsDevelopmentVersion(t *testing.T) { tests := []struct { @@ -26,3 +30,68 @@ func TestIsDevelopmentVersion(t *testing.T) { }) } } + +func TestMeetsMinVersion(t *testing.T) { + tests := []struct { + name string + minVer string + peerVer string + want bool + wantErr bool + }{ + { + name: "Peer version greater than min version", + minVer: "0.26.0", + peerVer: "0.60.1", + want: true, + wantErr: false, + }, + { + name: "Peer version equals min version", + minVer: "1.0.0", + peerVer: "1.0.0", + want: true, + wantErr: false, + }, + { + name: "Peer version less than min version", + minVer: "1.0.0", + peerVer: "0.9.9", + want: false, + wantErr: false, + }, + { + name: "Peer version with pre-release tag greater than min version", + minVer: "1.0.0", + peerVer: "1.0.1-alpha", + want: true, + wantErr: false, + }, + { + name: "Invalid peer version format", + minVer: "1.0.0", + peerVer: "dev", + want: false, + wantErr: true, + }, + { + name: "Invalid min version format", + minVer: "invalid.version", + peerVer: "1.0.0", + want: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MeetsMinVersion(tt.minVer, tt.peerVer) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } +} From d4a4418969a2c11b01b13d1ecf9a239273c2f8bb Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Thu, 23 Jul 2026 12:41:13 +0300 Subject: [PATCH 21/47] [misc] Simplify enterprise bootstrap (#6869) --- infrastructure_files/getting-started-enterprise.sh | 7 ------- infrastructure_files/migrate-to-enterprise.sh | 7 ------- 2 files changed, 14 deletions(-) diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 135440180..31f3f1c13 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -234,9 +234,6 @@ init_environment() { NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)") - GHCR_USERNAME="netbirdExtAccess1" - GHCR_TOKEN=$(read_secret "Enter GHCR token (input hidden)") - POSTGRES_USER="netbird" POSTGRES_DB="netbird" POSTGRES_PASSWORD=$(rand_secret) @@ -263,10 +260,6 @@ init_environment() { install -m 600 /dev/null config.yaml render_config_yaml >> config.yaml - echo "Logging in to ghcr.io ..." - printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin - unset GHCR_TOKEN - echo "" echo "Pulling images ..." $DOCKER_COMPOSE_COMMAND pull diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index 8e8a41114..d4c59699b 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -490,8 +490,6 @@ init_migration() { echo "" echo "Step 1: Image swap (community → Enterprise). License key required." NB_LICENSE_KEY=$(read_secret " License key") - GHCR_USERNAME="netbirdExtAccess1" - GHCR_TOKEN=$(read_secret " GHCR token (input hidden)") # Step 2 — optional echo "" @@ -588,11 +586,6 @@ apply_changes() { fi } >> "$ENV_FILE" - echo "" - echo "Logging in to ghcr.io ..." - printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin - unset GHCR_TOKEN - echo "" echo "Pulling enterprise images ..." $DOCKER_COMPOSE_COMMAND pull From 0936918d2411f6cdc6dc4733f29f2ee81fb5310b Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 23 Jul 2026 20:32:32 +0900 Subject: [PATCH 22/47] [management, proxy] add Kimi (Moonshot AI) provider to Agent Network (#6853) --- .github/workflows/agent-network-e2e.yml | 3 ++ e2e/agentnetwork/chat_test.go | 49 ++++++++++++++----- e2e/agentnetwork/guardrail_test.go | 6 ++- e2e/harness/client.go | 13 ++++- .../modules/agentnetwork/catalog/catalog.go | 41 ++++++++++++++++ .../llm/pricing/defaults_pricing.yaml | 24 +++++++++ 6 files changed, 121 insertions(+), 15 deletions(-) diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml index d78e3bbd3..bf4868871 100644 --- a/.github/workflows/agent-network-e2e.yml +++ b/.github/workflows/agent-network-e2e.yml @@ -51,6 +51,9 @@ jobs: # token (and URL, for gateways) is unset, so partial coverage is fine. OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }} ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }} + # Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire + # shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api. + KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }} VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }} VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }} OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }} diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index a928d1265..90c3766ec 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -20,14 +20,15 @@ import ( // covers whatever credentials are present (source ~/.llm-keys locally / set the // Actions secrets in CI). type providerCase struct { - name string - catalogID string - upstream string - apiKey string - model string // body model (chat/messages) or path model@version (vertex) - kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex - project string // vertex only: GCP project for the rawPredict path - region string // vertex only: GCP region for the rawPredict path + name string + catalogID string + upstream string + apiKey string + model string // body model (chat/messages) or path model@version (vertex) + kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex + project string // vertex only: GCP project for the rawPredict path + region string // vertex only: GCP region for the rawPredict path + pathPrefix string // base-URL path prefix the agent carries (e.g. "/anthropic" for Kimi) } // availableProviders builds the matrix from the provider env vars that are set. @@ -39,6 +40,24 @@ func availableProviders() []providerCase { if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { ps = append(ps, providerCase{name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, model: "claude-haiku-4-5", kind: harness.WireMessages}) } + if k := os.Getenv("KIMI_TOKEN"); k != "" { + // Kimi (Moonshot AI) serves two body shapes from the same key: OpenAI + // Chat Completions on the bare host (/v1/...) and the Anthropic + // Messages API under the /anthropic path prefix (the endpoint + // Moonshot's Claude Code guide uses). The provider keeps the bare + // default upstream and the AGENT carries the /anthropic prefix in + // its base URL — exactly the documented Claude Code / Kimi CLI + // setup (ANTHROPIC_BASE_URL=https:///anthropic) — so one + // provider serves both shapes and the prefix rides through to + // Moonshot. Run the Anthropic shape, the flagship Claude Code path; + // the OpenAI wire shape is covered live by the other chat-shaped + // matrix providers, and Kimi-over-chat passed with kimi-k3 before + // the single-model constraint surfaced (run #73 on the kimi feature + // branch). The platform serves this account exactly ONE model — + // kimi-k3 (kimi-k2-thinking and even kimi-latest return + // resource_not_found_error on both surfaces). + ps = append(ps, providerCase{name: "kimi", catalogID: "kimi_api", upstream: "https://api.moonshot.ai", apiKey: k, model: "kimi-k3", kind: harness.WireMessages, pathPrefix: "/anthropic"}) + } if k, u := os.Getenv("VERCEL_TOKEN"), os.Getenv("VERCEL_URL"); k != "" && u != "" { ps = append(ps, providerCase{name: "vercel", catalogID: "vercel_ai_gateway", upstream: u, apiKey: k, model: "openai/gpt-4o-mini", kind: harness.WireChat}) } @@ -84,12 +103,18 @@ func availableProviders() []providerCase { } } - // Bedrock: path-routed, bearer auth. Model is a cross-region inference - // profile id (distinct string from the first-party Anthropic case). + // Bedrock: path-routed, bearer auth. Model is the FULL cross-region + // inference-profile id exactly as AWS issues it — region-family prefix + // plus the date/version suffix. A bare or wrong-region id makes Bedrock + // reject the request with "The provided model identifier is invalid" + // before any inference runs. The proxy normalizes this id to the catalog + // key (anthropic.claude-haiku-4-5) for routing/pricing/allowlists. + // Defaults pair eu-central-1 with the eu.* profile; AWS_REGION overrides + // the region and the prefix follows its family. if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { region := os.Getenv("AWS_REGION") if region == "" { - region = "us-east-1" + region = "eu-central-1" } // A valid Bedrock inference-profile id (region prefix + date + version), // overridable per account. `global.` profiles can be invoked from any @@ -246,7 +271,7 @@ func TestProvidersMatrix(t *testing.T) { case harness.WireBedrock: c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID) default: - c, b, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, pc.kind, pc.model, "Reply with exactly: pong", sessionID) + c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, "Reply with exactly: pong", sessionID) } if cerr == nil { code, body = c, b diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go index 1e4b222f0..6a2487a88 100644 --- a/e2e/agentnetwork/guardrail_test.go +++ b/e2e/agentnetwork/guardrail_test.go @@ -52,7 +52,9 @@ func catalogModel(pc providerCase) string { func disallowedModel(pc providerCase) string { switch pc.kind { case harness.WireBedrock: - return "us.anthropic.claude-opus-4-8" + // Same profile prefix as the allowed model so only the model name + // differs; the guardrail must deny it before it reaches AWS. + return strings.SplitN(pc.model, ".", 2)[0] + ".anthropic.claude-opus-4-8" case harness.WireVertex: return "claude-opus-4-8@20250101" default: @@ -72,7 +74,7 @@ func sendModel(ctx context.Context, t *testing.T, cl *harness.Client, endpoint, case harness.WireVertex: code, _, err = cl.Vertex(ctx, endpoint, proxyIP, pc.project, pc.region, model, "Reply with exactly: pong", "") default: - code, _, err = cl.Chat(ctx, endpoint, proxyIP, pc.kind, model, "Reply with exactly: pong", "") + code, _, err = cl.ChatPrefixed(ctx, endpoint, proxyIP, pc.pathPrefix, pc.kind, model, "Reply with exactly: pong", "") } require.NoError(t, err, "request must reach the proxy for %s", pc.name) return code diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 4c9983e4a..2ffcf653d 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -239,6 +239,17 @@ const ( // the wire shape: WireChat (OpenAI) or WireMessages (Anthropic). A non-empty // sessionID is sent as the universal x-session-id header the proxy records. func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) { + return cl.ChatPrefixed(ctx, endpoint, proxyIP, "", kind, model, prompt, sessionID) +} + +// ChatPrefixed is Chat with a base-URL path prefix prepended to the wire +// path, mirroring agents whose base URL carries a shape-selecting prefix that +// rides through to the upstream — e.g. Claude Code against a Kimi provider +// sets ANTHROPIC_BASE_URL=https:///anthropic so the proxy forwards +// /anthropic/v1/messages to Moonshot's Anthropic surface while the provider's +// upstream URL stays the bare https://api.moonshot.ai. Empty prefix is plain +// Chat. +func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefix, kind, model, prompt, sessionID string) (int, string, error) { var path, body string var headers []string switch kind { @@ -250,7 +261,7 @@ func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prom path = "/v1/chat/completions" body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt) } - return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID)) + return cl.post(ctx, endpoint, proxyIP, pathPrefix+path, body, withSessionID(headers, sessionID)) } // Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index f82cffae6..f82e94bf3 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -420,6 +420,47 @@ var providers = []Provider{ {ID: "mistral-embed", Label: "Mistral Embed", InputPer1k: 0.0001, OutputPer1k: 0, ContextWindow: 8192}, }, }, + { + ID: "kimi_api", + Kind: KindProvider, + Name: "Kimi (Moonshot AI) API", + Description: "Kimi K3 / K2 models via the Moonshot AI platform", + DefaultHost: "api.moonshot.ai", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#1A1A2E", + // ParserID empty on purpose: Moonshot serves two body shapes on + // the same host and key, and the proxy's URL sniffer dispatches + // both (same pattern as Bifrost). /v1/chat/completions matches + // OpenAIParser; the Anthropic-compatible endpoint the official + // Claude Code guide uses (/anthropic/v1/messages) contains + // "/v1/messages" and matches AnthropicParser. Pinning "openai" + // here would misparse the Claude Code path — the primary way + // teams consume Kimi for coding today. Both endpoints accept the + // same Moonshot key via Authorization: Bearer (Claude Code's + // ANTHROPIC_AUTH_TOKEN rides that header too). + // + // api.moonshot.ai is the international platform; mainland-China + // accounts live on api.moonshot.cn with separate billing — + // operators there override the host on the provider record. The + // kimi.com subscription coding endpoint (api.kimi.com/coding, + // model id "k3") is account-bound seat licensing rather than a + // meterable platform key, so it's deliberately not the default. + ParserID: "", + // Pricing per Moonshot's platform rates at K3 launch (July 2026): + // $3/$15 per MTok with $0.30 cached input, flat across the 1M-token + // window. kimi-k3 is the ONLY model the platform serves newer + // accounts — K2-era ids (kimi-k2-thinking) and even the kimi-latest + // alias return resource_not_found_error, verified live 2026-07-21 — + // so it's the only catalog entry. Grandfathered accounts with K2 + // access can still type those ids on the provider's model rows. + // The consumer app's "K3 Swarm Max" mode is not an API SKU, so it + // doesn't appear here. + Models: []Model{ + {ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + }, + }, { ID: "litellm_proxy", Kind: KindGateway, diff --git a/proxy/internal/llm/pricing/defaults_pricing.yaml b/proxy/internal/llm/pricing/defaults_pricing.yaml index cd5c64fbf..3fba8fe3f 100644 --- a/proxy/internal/llm/pricing/defaults_pricing.yaml +++ b/proxy/internal/llm/pricing/defaults_pricing.yaml @@ -161,6 +161,16 @@ openai: input_per_1k: 0.0001 output_per_1k: 0 + # Kimi / Moonshot AI (kimi_api) — OpenAI-compatible /v1 endpoint. Moonshot + # reports cache hits OpenAI-style when present; cached input is 10% of + # input ($0.30 vs $3.00 per MTok). kimi-k3 is the only model the platform + # serves newer accounts (K2-era ids and kimi-latest 404), matching the + # management catalog. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cached_input_per_1k: 0.0003 + anthropic: # Claude 4.x family — cache reads ≈10% of input, cache writes ≈125% of input. # Pricing source: Anthropic's current published rates per million tokens, @@ -206,6 +216,20 @@ anthropic: cache_read_per_1k: 0.0001 cache_creation_per_1k: 0.00125 + # Kimi / Moonshot AI (kimi_api) via the Anthropic-compatible endpoint + # (/anthropic/v1/messages — the official Claude Code setup). Same rates + # as the OpenAI-shape entry above. "kimi-k3[1m]" is the model id some + # Claude Code guides set for the 1M-context alias; priced identically so + # cost metering doesn't silently skip those requests. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + "kimi-k3[1m]": + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + bedrock: # AWS Bedrock model ids, normalised by the request parser (cross-region # inference-profile prefix + version/throughput suffix stripped), e.g. From 6d15d0729ae5e7d8812f9e981ab0ae1cc78c1a09 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:37:52 +0900 Subject: [PATCH 23/47] [client] Clear stale UDP checksum in eBPF XDP proxy after port rewrite (#6861) --- client/internal/ebpf/ebpf/bpf_bpfeb.o | Bin 14032 -> 14408 bytes client/internal/ebpf/ebpf/bpf_bpfel.o | Bin 14032 -> 14408 bytes client/internal/ebpf/ebpf/src/dns_fwd.c | 3 +++ client/internal/ebpf/ebpf/src/wg_proxy.c | 6 ++++++ 4 files changed, 9 insertions(+) diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o index 6e9cda44a653f1d726a95d1cab30c898967402f6..7433ad740ac150d19705f49d188d055d3c4c8cc2 100644 GIT binary patch literal 14408 zcmds7Z)_aLb)UUEN@SfTR8rSW@sBL2kma5gDW(CzzQ{647qRzFyd`-M zd3U-yN~cz>*}^Uuphfw?MTo*kh}JELAVv8hfdwRi_`yMlwkX&(HCQA~RiFXrG#}Wa zKxm+d`}@tj*_#`ciHf@YkO6n+{ocHJ^WK}8x3jl=`TT`XrBZ=PN}zrS+F>LuAdYuy zl#}}b74-FLI=S_Q38{%!4Gi8a7~+1V4v;EJzi*j3^!rWr8~JLt^@yptJrJnCqKTiq2-5<~T5Bm;L{{O)5k5TWv`t93S zeuTZz|Ab1hesNUoXWci6Xs`vkUo!#H75Zb%f znO%psWj#M1DwuY+obRuV4}?EX`S&p{A93}`zTR{H6%U(r1iL?gWb^W`=vjL2y|UgV zSQq`Ohx+_^v3|c9vQ@7ey-v_iuV1^4?D%$l`s-+~{k>PbZWr`$>i&YNkSac)GP=HQ z__q(6dVi?v!K3}EOzP?b>SuL**sS;IkVthu+n%=)G2|X#1M&)Y!hy2RE=rlWs1(-0 zFx&Tv$4^~8I;jrQ-+RbM4{14@kFJ`2Nj^HM9?|VTIUoIk$eDapH~o@)bX8^9?#)Yp zc{xJ|dErfB!P(C`cW;1fcZ(2V%{= z$?Iouxy81zeqY)iQ%~hDK-s^3L7UQN2J3o5DPBE)POKAvu`3XR0~p8W<$gQ_$qFz&w>7u(Bq)L z0{T8&d0Z?oNfp5VDipKeJP@o~yMa00UrQTubny2=Bc8#xMUJk}mhZRa5D^mtVcD<* z{|;F>73fK0U&sWzs6U7cCHN6;q(i94h=LD+zE5b3l{z3}GgOef7hT36h~n9mdI%la z7b;Vani_YNIwCa37!f*zO*U@B;L#b{jmK*0G1SnLI)(bfs2{g>p$>s_GUu3EcWtC! z625*8?5!_T7P*hIKL%~q>d=4(+r8_#7ARW55DJC$ok!b23e65ognW8kz=GL`MOiUz`8 z*E}NtwKL6XWi~sQw4YYT<{R~6ty3qW6UUFvmMinEqtli7W97=^?0hLccDdYa9J_p@ z88@yMYeO?oR*#$W^-35;m&uAItCdExSZPLMqhXfJmY7ki)|>b24TU{_$Vxm4v;A#- zu2bZSTaE0XBXe5)hAP))N_AD9nN`=~dZS#esAjc+s~OG46;&>&sd{lbs!mO*W^-26 z>eXg-60&`XRonQ_QE~sRjxGEd`TPFsLt0XV^xY9O{ER1u|-j$RjTR9#S$S79h_}6>+_RM ztQj~NhM3YLp|FhJa1gbPRe37RauJw#oe78C2FM+Kg2RVr&V)~$f9CwfCoe?jo_Q|H zKYQ^~c<2zuKs%kGfGjpySy;89(_go85W5z`tZp?v+V0v4(W^sc8_J2niC)|DF2hcn zZKdbDy|?tPx;`CYHd{AjTm7UxWyO;%%3bCxN3BbT&RvjNs|{mY1gmjSH<<|hZ^x!> z?VTW~z2c1#r}aXyZX_$kGXCdF#dQaZCA@hvOosLrPkU|j+w?LzGB$j2_{8zCu>l(& zu0MAnKvonEm<(n1R#=IzN2Nwn)~QZcjcnT*iNDsQw<#hqYy#~lKw`BDlBH>cTocad z!K|#)ZgxV4DJH;kC2Z+tQQxAtVo)>}gN4Lij~htKQ{_r2ZmD`Jx;h`v$B}_?XX|9M zrI5?Ho8j9uXHT97gjluQ>yCh(Kz5ibg@fpC13MKLk&ojB0Tk=pTL}Y?Oa3u1;AmXQ@BdhW$Yx`K)jwsq*s)I{g_zbkMvU9+35?Y?oPhPzE=UD!x!FLY!g+5*Q`#R3RP4b6v5kp6d zV^9b2EBNElI*03zapC=Ii?2rGxNJLKn^ZyY50zTNyP;Y%I5?}+YkY5568r*Qzc}8C zgQv_#$A_G0TunfsRI7Y&9#@litD$rRQQU&BKYsH+^BC=@b{Fu#NQ6q91Po2h>!07 zBc(pW{yXjd1@?#kY|mgl%;I$@-R)q=r?Upb9@T<9+yh$vno4Jkl z3VO+4Yk&HdV6i{FE?DeOTYHINfBKHJi~am5NBe{Rv_C@`$No&{VA!9r_I_KbH;8l6 zPR#yde^(dQKjtHoH|-i@zB3aJrhGv#-gh@JPniY5&ndMHTQZA+r6NvXf$>*uP$jPDNOt8>dQD_!;>hwOv>pCN+Z8o#7e3F9!2XpFr!1G|_iKerSbPMZ^ZD6cz+-A#u?y(gV}#Z@D1b%_{}_{<%!u2KQw+rsjnf9YT3bTUlIJ5 z@QZlW!5;x$b1>Usr`~6;O5WM@@Rq?gk0LZ$KFK@Xf^FXE1x74v9u43p_3J##yfZA= z=A8+Hle|+9%sc~KLCwL4hs{e#-dQp28nYe#YJWa2d1uYR%sU$n9s=HUFyfKqsqdP+ z6KqR6^9c3tIJh5p*TEy$$Gr~D0*n7T4?(}pQ%T+#HuAXZ!n~#D7xDQH<#P_k{4sBJ zwCByb&8(SypPF$n_I+yEV7u>A>w>Xwb>7+#{5{EAn-1of1nq)`Y|PqfZW|2V6P?o^>%yk^)_EWFQqAXr_E0lgo!HnoWn<) zZ3+I(;UiwQ1aGzZjJqwt-#Yvy%Z2_wZS$FrZPDKqN-c7eZ{HnLRkXP|`7_}LO* z&sn~H{z^meTW!AHzn1^c4u4yisPymiqCe&Owb+*Dy0#_whc-W*vs~1#^Fi!jlAE~R z#sgr#$A1v`o8a&B__X66!SDWW_>rTB{aIK)ms@L1C8xP29RZ2T{gi$XiU=!~nsv4F?^A9VP=z$}d0afgqgbRqv?@a6M{ za69dw?m$|okM9f?kAuc}Ho*nZ4>*|d?sM=8Xyn;M{!S;>YMt={Z@eG}d1t&}0@xWZ zm;rXi3zmVM@pN9{^HuEE&o4)xe%`p4_VfFX$fp)SKjh#w(DLm9ls>n1$Zvre;*$P6 zr>cGjGk#8g=If63C17X#^a`*uetHAg?SBW@mEQplamn}@($4sq0G zqYl0W+KH#4f6jcVZD418=<|#dPko+o=PL*KK3p=MKEF8n?fE5XXS`i~yPY_d2X_3? z``z(Z=Rqevd=GKsncDRF(;ru!R&*JZZro6=* zkNO06fz;_moJv`yz84tHSyy)R&53hRomWMYyyyf9- z5ASy3RJVuwJe>9LDG!f(c*4Uo9&UMf(ZkCgUiI)T4{vyQ%fs6q-tELX-#hDz=YMy- z>HOigpYqzrJv`yz84tHSyy)R&53hRomWMYyyyf9-5ASwj{_f%Wo9Xj#*2AYfJnrEM z56^hG<>5sSFMD_m*vTiEbq_ONyX|*8ywizwzINohdcF40!^0lVc{uOkf`@A!Uhwde zhgUqj=HYb@Z+iHShj%)$J{CLv@9Xv2Lk|yoIOpNKhYKFAd3eFYOCDbF@S2C$J-q4R zJ09NY#QK=-_`ko`YY#mW2 zbA~PB`z@^IcU*9-Pst$s+Qx?DgYVGn?6tW_|F1}Sy8V5M?e;>HEM8Va zjT>{imakamo7P<)>U2yINp<1u+2heKnQBDWW6>$Xk?5pwPp}?~9y4%M>JeR!M90Y< zjmB6Fn`$f?6rDEB@}@UE$oJD>5kWIYp^a}|2an&#lJTv5V6k*tzxxbM?p6;Oui&!wd-Kq}DLraZ+sf_# zGMLsLuYXTYI{cX5LxLh?_833u@A;L?1G|R2;bQT}KWo@Y+yo}_!2Ipi#a4uM&$vM! zy5e#{JN^eP3QBuUYN@gkH~qT{=1S*sw%K@*H;T*J&o-y@@qG`adqr36n=`(`cZ9vh zA6PJ5x><89{3fo8Gk$QO`rZW{w*_fzS2Rr**@qUo_6DB@*PkB|?4K}1dJ8pu<^?YVgo`G zv_$`?`+d7J@Ag(8qM&L2$inY-zM0vb+1c57@A1i(Pn`N_E*F`sMdm+2dyGs1Vr0>x zvixp0(cplkv!B5@BQ5c(4n|+EIO1Vrwv(#LxI3Zd1z195C3*j|Nmg!AK<+AyYA4x z`r{mp@z0tZ`&UQIR`!D>{&rjSfTOD~7#rbNKjQSyS6$z4Oo1Ah$F^68-F}4esY?8+ zhs=*r5qdV)&P&|C?uO@dKf>{${~BK}zpQ^6k5FIt|9;x}j(DQ}aaaY5s+Iv)TFGEIv0Y&gR1v^K+!CkC;B&Uv&JNyIsFEw*B6HJ4{OI z!XxHE+wXSyJQ)+2!b{zKst`l(5eiT@eiq&Kx~(3@xHy;`_I-%*FfVoeKXUcvkgJFH zQEz^lamnh<1vd_d#MPTa##XTVs5d`p=h1r8a^r%1h_DZPskhlaK>nk8K5d-uIiI^F zzTfYB?soffUgDaaPuZ{Vd@{ypw)WdTa_1W~&pFQ?uLp3kDUbaR#CyiPFm?)qzViiZ zYLJr|)BhXp+SVen2iVz&#GwernO|FRlOoHqX&epvAI)5Ge!L%a8@w%a7Bs5`Y5W&m zCXEa8M8D#C*1+i3g^of0me3{8p8-w%GU(3=Jqr3upx=XQ3>O>kn-%cCVh;d(1pPe|F0|+!{E&`8 ze^(}WFX;ag8n$w}*$fr*?q@EyDNubMs0UqK>0b_US3h&Suh9%`#6qLb;#LBWi=p4R z4d!;D$5c61^dCq6l*FVDeGI%`_MA2}2pF6H8R6SHzWIE8XGQM=98aTYk0QEXhEeV> zav{~u!igOtl* z!Tej_mCbq#Y>X*96yV3;A8Q-dU3dyT;>lKE{7A>3KO}S+^l_mppvQz>0R2gs_i^xO zXDYz+g4t1iSuppI_-cTEU$FZ9Mu6WGtoHs^Fmm6{x7LfZm_S}w7ui11{qJ79?R|p9 z_O_jZ#n!eG9A;zPu&JJRn7<>q4&p*sw>=|zpiK;XQs&`PHALG^V}mGTd(w3l?-H76 z#64jn{j{Ylz3meiE7Kc!N}^|ez zmOlf&uH^~g^DOQ3=)E32oYBd#Mq}Ev(rPVfr=3oHcCu}TI_Z4J4DD~X#`o7|+sVYm z+R(U((dy0Qwd(X-8Xt|%H7AmpYBQN`G|ta8lXNRcIQ=P`_ba8JcKVtUJwOjk^v*XiqwRHcvdZ)er+@((1zF2Jz zO<`;+?aZ}i<0LspRx;k0ZFj1(o#ensTqJW|tTr31&V4&i<-mQivIWG&9o_l-HIOT- zXczZ-I!DYentF4p)-v^}Y4cjzYS$aHrqgKS>Lkl zm!l4ha1?&CQT5r5nX6d`+l{%_cxq~CyJM_lO-c+k=4(y6m&gd>*u%wkr!_a;L5g6J zaf}^&Iu@3jH{Oe0V^yDsi`;V;ucL9v7eH^{b2J|xJsQ7o;>8oEpFfoxfAQnV*h{C+ z#JhIE2IO1}6(qMLk&vXZH{MEVL{l#=TB%#34!2&&yf#!SsAmpm^XkZZ96RDpk=^s| zQL=~V;$(u|oWCTe==&WZRnJZ&50#gWb}kz_KOya`8pf^)vT?5!xd{AkZPT~zNf6Y{ zc<00sn<%=BEK`*6gQtqW93)Hp`s;CKI!K;wZtT!%GBSLibg1;~!2<{OXndG|ej-3u zCfwr+k~>@RY|U!2%gluU;elR|MDsI<*K5b0sD$~vs%GaKI__nIu%f_Bs_2**s zM$(4{FxS54rWoZ;BbH=r7f+lSO~#UwV?C+Jq_uje_f$POHg@`@GcP4CA0N|TbH=?I zr+8O9F^zkLV6DmbrK_iq%DwmJ9>`N}bX#jF?n#%Lso~9>5i|CnJ94Hvk6r4t>S^1~ zK+oFDG0yI)vybe=!q!`W39EeO3ZgQ9$)2c;B?pyr>RNg#CGVU%Ms<6lrK3y_m%i}6 zrZLf2(@sXqefRm(r=Nc%`S{6Co=DETGIk`PiNby89LX-6O=uxL#>AGRfL)O z;wtaOu{?u)7(K26p5F>(;qkc^?-$uK9UpO5aN#dlxe9u`&*b?WiNCLG;q#(Ci}LRY zd~e{`W4PG(u7>B>4*7hRByJirPaB5?UoqwiACJqpzKH9VF{^xy8O23?%JGV0DuTal z%p%_7%vFaYJWu{EpZymFW85r-x7S@m}~TT#o^o$W3GcVYl1t*e2w#M^kTbA zZ#_}b$#p$ZNwBOZ;`+SxL}gFTSGKc)Wj)b?!@8bG*C*?VmPAgB^+a?U{^Wksm^;{; z=(dL`*Y#13_k?KGlOF=U>)|2b4G&}e+*&*TH?bdlA0H6>9b@~G(whp9j2a6fc!W8eCw1Rpcztr)l| zcoIizJMe9_D!XP5IU{r~_Ch>qv$vUle@Vm^TqOv*cmQ zmj(X;)lECq8#O%Ed;T6eO#vh3s*Q;zj!b!Rv5bq}+y-1hJg@QQ~KudEJz z)76z|UF1?%vbsdP1Ns}DoOK00TlbvHS5i#{50yB@bi2Sf_gA zWtXS+98>?ShjFfRR~^=Kox}R`6e4Nd6T|o1s^E8AUCNv8c%JKg!C^hu zT8H#p=iz_8g7YNjI;%r+uJdJ)f6txk{HTYqe_5TPoc3gWdamu`M0|A=nuPmn`0uff1eh8d(Tk*XS(_}N0k3& zm(N&gix4lhn_o~a^6m3oK5M8pdoIK-YgxBpT`a~yi@pYi@EId*(U-gYoIn3Bclp+q z42b^PsK66rA^sO$RxstWd@g|ueN35XxI~41!0ssBLe+fS7WKZLI{sH_# z57(_T@#f)pHuf)i^JtvdPk8evAO1x}&rkY?79oDoB=nK5J&o)6wXS}yBGTLF-}d|| z3ZMQQ@%+(s(Edj}e{|iPzv1cgIh2k17d?HQhx$?1AN!n4Rufl0__|K;*z(Kx`4h~e z@#ph68~yx>XIJCN@eg_Y0V<<0ZU;R+OxZ^MW8gm^SUcYj>gu`We7uJ#9tBO?iYuV` ze6JYmvi~kd@iOouo_wtrTQ$#K6xfT((D&@wdzWV~nu45P(#4*ACid*vI)NIU$x&JWP9DJk2Vww_j!**vpTI3GB^p>zuz|*CF@k+vvr)GO)iN6<}|@whnsn%q>Cg#WQyc*dNdJ`TAU+ z7f*Y?@aDJs=Z)`s1N!t=&QsqF&|IJ5w?I>0@d{|pr*Uk&h5fUgDkMu2Yzcs0Q50p94vxk7*k16&O7;Q)^Y_-ufu0z4n! zs{vjL@U;Nn2=MIyuLgKMz#F}o&+2|Y+Ir#Tr@jCC_;8><8sM`5o(k}MfUgF4DZtkP zd?UcO1H2mG^#E`5;=V$F2LoIT@ZkWD2Ka1%rvf}5VAdnAUi2*o_@;Jby0G9(i7T`*Nn*m-3@M3_M1AINew*tHp;I#nX?Zx)7*jxXWfj}My zxD?=WfX4z{32-yO3jtmX@N$5!2l!TiR|32i;JdxpKBjx?-#QS;;{cZeTn_M9fGYuR z26!RBiveB^@bv)S3h+vR*8+UE7xTFL=dCah;5fjg0G9(irueB3#*aR>tN7%;r%S!x zZ+kwIWFJq4TIsa>OA%7X&U|!;|7=7*pLic(l$Cw$@xBe&uR*GyUu*0)6#KQr{=*dN zWM6oKf2*QHvoAFM*BTYd*Cx+w9Und6H9vlWt82=P@Y59q*;mq`>FL*I68n!S>XY4% zJ(TNrqn;=!a|r(gqEkI*hT4~AY%iaL+y|n2zIxf1B9i9Rv113553xIt9Cm2f_QS~` z$9+b)wjWLoIyhqck>mhj$#n;k5z&=?WSDq38D?tyFj{mHv&i#qIA7_DjuZV`AmKe( zo8F^{^}SzZWk>WaDMNaD5QN@|UjHsp7M_Yk&;Cu5X9e%$w&{5C)^Vvnf&IQ!r=DMS zefF#4$@mvso7jJH!;Oe|&S*=Yt93kh(JSDqef7@`GX>sn*#=#g{pvhqT*0N|gMBD0 z$_U!`n?L?C1nN&XeoY3k=K9SWA5|gP%>J4T3f7hF1Bb@^!NnHZ|0l=J;wCWD2aaCn zj|zJG0{0f}y?lrBo9{nkyvhFU9(9B4-$}TC?~zF~UgVA7+QhzYTcIN3wDauS{|{jk z`^B?vK%tDDw)j1S&9fhEZ@hcT#%;lh{GZa2AoW{?p1;w@zzp|C^x}#m^4HLF4E~g5 Io7x}zAFS~7af)wS00~1IJ@xeujrYM+x7%bwpD$oLQ+6T5M zU>Ydm{{J)QTnCgL7JC6IE zcR0Sn6Kj9_NzquYK{t{zST5YePPIE=JIaGp?`AX zzg%^@HN3prbu=b^e@E7D>=7&w(mS}=gRW25UN|51_xgTw<5mBJ_PQVLx848meYU4z zE8TF{InaH0OZM}{Eqm^17tUAxv&VeF+JAa}*}b7Xp6mbZ{N*ZcT`q3fZeGJt{AJjG z{IM~GLEDVEysP=1^U-;~8Y&YBzg zg)`gJ&}9|jK2?AG%*CVXemmb!jz^C$sUA;6ORiAL>pBRsRPV6Ke9dz~5c(gZT z%l&o1(qLTo!{>|zZLQK{hr^0;@65>w=wa1dVXa+!}Ck0)BjA&8|*1@ zXN4z|*F(7c#B)SF^G~bHJoV-Nb7=1y|AuEFte-jFf@5W~#-+(Nkr}3u9m_y^V$c!bgLuSs1`i966i`5wO6OkiuBOAxKK7&6Gi_E@uI=O-C8{uO5q_&)u*}^;5 zV>Fh&4?xy&9u}E>bc-B8R=;&YWp-hrQjz%le9XNZ3WIg*a6Aq&QIwsUPI*}d@! z!VdNBF0grf1vE~(&uML#ejf5)VB=t)G;$CA5HkJ4^q$BO*7CTkV|oHU1IAuz2sm?N7p83(o}G`+__BDMQ8d5Y-2y>a8J)1v-9_wuN zk?7gxK2Oega)&2(ii{&-j(W15F}CwfuivM{hWc+qkKFeAMbUGw?f2wEp4{!pCO%y% zjhjl6%g5EERx1=os;0A+)N7{mXtgqQG+(U7!`JeiLncBhl;f+p@yR4Q6%Ce$;_sGSC5$@SPNI!?MqhBN!Lp^oU+t44LAwdP#|JL!&lg^Zyr$>~h%-fE(E$cdeL)uo=jq4`ZjfZTJ^T1ErH^;Vq5`@-wyc@(x zyHRu+=}u9_|9z|Y)4^tmUVSx6yAC$b`rg>5;pJF&PuJ63Cyw{@bf|y0|NM;rTWPSv zMJRW+qGEC_&R1))Pi?rWX4Xd|{DF{c{t!;t&eYZ`cPC-P zP@ID&@;K79oc$9gMMv>O0u=&#C8;7T4;PC0q;4wp_{wB5nZyp0jip1ix;z$R^z@Xqto%hyj@bqq8!&%#qg!<8wQMK@ENoiPTnbbxR(mL+a+emk} z8f$9JpN(`hvZdGo+B)*=D7{=OMY2JwnX?!A;{N#g{terc8`;|79zfIQ`}@zoc;UtP z;+cMRHd{QTeu~dKdCJD=;zL#f9~b@rRRU;Y<%J6 z{zQmiJXbFhlXFMEbNDYn=b(F^frNKJTzoHK-7~nD_|C&;;MSTkd=4d-aW&P^hVTvK zE%=i~{wA&;;(BPsm_FpRXZd#g84&&u`L1bWZVI2qgLL!j#z1B5Uk10%x_2Bl{??c!k6XaY9v=j+c>FYY)#Fa^n#avxzLc~7e?-0; z>(MIw-^g25-M1l@llOrmue=+~cX%z&fU_PqgZqR}U@z_YFZe?kP1-adY}X6F@^J56 zf$eYO+L6Wj3jYnb`3!!e5&l>3t_$#w$F1Nc;h(VnuVVgw{Q=Bh_*^4igvPN)VaNW+ zJHQ!XJD$2b11!Iaax;bbNclXt`3Cl@$DadF3+wn7gmwIj!aDvXk2(I^!aDvY^uzvb zeT=_VSjQiE%<=aM{|Nbxx3C@_H-r0xx8nDVJ#XW829I07mptbA7!YoU{i928f$&S< z);l<#!Xx15UCdwjcfmWCv3|ne0e|K%v3|nVzt|s)Ex!QQGCc{o7ni}hH3`20Zp}k4 z{3f`06!Q{Z0C&9V;t1>in2dGnm-05eXrFo=^YEB)1WnlfuA{tV&czwlCpUwqrTi`M zj_(^Y<8d>1R`>_B=e#`L4W9Ry~E<~(NHnfI7+$B$EtJIh`<b zxBq*w=U(&E8-QKjdM!)gd&qb4mrhBam;BF>@6<0gkagz&VD(L6wXTn?{z0SlHGjeC zx25&9ondK5S*Mr5_&H7;A7dKR2=bi%p&UlzrZlym!XUcJIPo|9I;E&VQ{ z{r{rXH>ESy_Df>FUD{FmYhtgSP_gil^Sz-D7ZaK98w0qQ z$a`^>2}tBNT(h{C$o!^r8yAzZ6@+bnED&aYS&!Kt?}bdv*x#tf>~F?n_NQ@$YTjdb z?_y%c_9AhJvhEi$zX45o<$Qmd_n6-=cyC}*`xTEP=$m9cS-uZfpU12}>M_eT9%*~C zUOD?;@|g8+d(82!dK`fxSy=6Vz+=|eeaiCvxadPB@&Q~61SB&3xk5l9bG_~mkjPxG zHVR10=-)1nxn5b1*;mZPk{`#^0yW(GF9N)6XoZtH%+x|Redj`+iOjL9JZNlu9{@w2} z{nzC&=ktum5xCFecJKv{4}u3gJ^-!@v;PdPX^*+yZ+Oi0ob#CeecR*x;KR6>*dBd) zoPebKjKh@A1bo5char!8%=YR5-wb#r;JJVo0$vJuCEz;&uLa!1K_Th<^gJl@KIWJA z2HY3$rGQ5Rt_OTG;F*Bu0$vDsDd3fW?*zOSaFd)Doo`#fnSgr(?hE)*z@q`z1HKvX zOu%yiF9f_4@JhgU0$vNaN#a8~|9~}qXt_QQDE9^SeAf5(i^dzZuLtEf1D*+ZF5rcL zmjYf1_)frU0XK1ANIJf@fHMK>bGF*|1?Bur!*6di;CjF}1D*+(?||AK5-z+C}n1MUxaAmDPqQvpv0JR9(Qz>5Jd2Yfr=)qwFTv0=Wg0Y?FM1)L4I zKj49Y%K=XXJRR_C!1Dnw2D}{b?SNMU)(1#k@7AV`{)htZ3OE}u|96T>+ZzbD9Pm`Y z(*YlO{E5f*Wey!Y+Iei_=jjb!cGB-Xot0!<{xgQs=?h=#|;Oh*nh;KP5MzP_}?6@ntoLBKQL*bd?53>E#UhtM$PZI(CVDhPWZKr z1?dOh&hhc96S4hYk;2IO_bHa^r>+fNQe`Lp!=siPG@aG!6E>HxSniwFT_5VKPvJ>( z?)2&7@z1-g+vYuSFR?p*TC^va_ry;*JSKU!&Aa2{R3D3bn02|VCq5>&l6Tv@JMQkL z9(kABP(S70=kc5)x4I22Jf4>9Wz4~91eiwtuX?@qaG5-SLUj0W0okhRbKbzG0 z+CP7*rinZ*xk~uEAck$J#tAa4RC@yw>j%R$%Tm9bS~?-D{~FXZ4eMv+r=#^>LhfBZ zC*$9&emJj?qfP3cmj1)>a;|=|zrFejk^7|nnElVdb%U_IIvTv!`js=TPOEgt_2P4h zxHbL#hub_H6KlRL3pcA3s6-0;=l?$5tN*DEYo31FX8-)(X>CsT&rWpyz`2k4v)dG| j^(5zyWA;-x)?b1mToc+~+UIKNb1v)g|E0Bm-IxCZwP{$s literal 14032 zcmds-e{5XYRmZQh-mIM_u9K{{F1ux4viUL2563^UU9YlP&4#3Pq_8_p6V+Q0-i$rZ z_7K}MnHg^;un;4HFalaj6k*h=wX`jYsztccO0{57ELen9fn?Bvf>fjwP%&yNMHGR> zA1vSRz2`h%-z3det@xu?esj;~oO6HQd)}LQzJBK1$F?*z8BI;*zs&}z)|k1z4LNJY ztl0_A&cL~I%Z4ANU&lq4uWuz8(}7Y<1u6GUvBGS?G6Cq0U(~=F)NJfB6ZQKWbX6zBFPpb7{9*k8pkp zGJl2IcI$X)w_C8wGVfo9mNn{ngyqBj>-=u)FV{bvkI-M&|NYkc|Glp|njW?D682B` z-7VR_7q@iW*DoBe&QFJV()$1S=EW_f*2i=FgU(x_>el7rmTl%bj^3x>|K5AXl!k4$ z%%xqj@7#89N_^_LOT)6?WS?kzy8ps`^Puzlp!vDwwyWvb`U|%0!j)#1Uv*u%RdxAe z=*A(4M|UMwapj8J$5*O5ERVs?lkiHqPI=2$uDF15vFYb+1S}P-yWu==WF1Z2R`H;9 z|A~t?H5U&b9&diy&Py6^KIF=!@us%d*&iBjeoFk@2D^OFmGjqumX0Q_?!EUN-G@K9 z=hMyeJ?FD3^ZT&#S#|58^O~MdS+DSXa*o|fxV*`p0(Ta8@_22d6B5q{nfVW^GtYiJ zcMj#g^c_5_AZPk*gjinHR>%04a) zGr_cjcg%ed*oTA28KMs(N78;Z9Rs7=+qt%q?B4iUJP%RtqWFXB8$#vub5CWqhAmGa z`wBJ=_DM7M;G2*+KTKZ}IfDErB4;7LBXS<{yCTm({uhxKATyyYJFfqLo_F~FLeBO- zVy*UL8{2x%-CoB=C}_-wrd8tN8||d^CD*w$P#M zi`^q8LMm0_*9sHUNpvzAu8hW$g-Se8E?=Im#K}Z5nK>{uY;`PDsvL<9(BX1&Eq02o z6Rwic%pR?%EBYm?a}KC>yk4G~$Q(-TC(N0IYGh)%m>e4})oaIwuho;<)k39v z9A&FXeY!do#qlsz@kn{9RxeD|PC9YB=Xvr(Tv25My# zwag*U=Y;uXQ>u&?tEMzQVO~qBwNiP?)XOzo^>`wgGNqy!tro`O^603k*C$M+TCSHz zus9>*$;f3B$I}C5da70$n@Wn&$atX&)o{}LQCjesnrh?d1C+JtNvI3OVzp*!%uLaK zvQ&Q`OW`vuRhp`s>7wmmtvp>FNlY=R)s5{~g%(4PMzLbo5<7w%d7P=$tJ5QOY!M7H zim-x5B2l?|qeIAbu1cd(hHLKT>txj98{l{JMK&LuJQ;ob%*$uazjQ7>{qiT{+$-lV zL_FVjf71adF8E@jp){kGS=$GsKsqBq+UB!YAB^0PV3c@_jK%pJ4JTQ zH;$4$L|4aRtY+hyoT7&vA$3nrBoCFh9c^89>ii99Yt?DoaKUaoWKC`ce$=+vw~dn^ zw6Wt|Cr;RnqQgjciYk8it>O;{n%Et#~4>i zLOl^Xl;_%W(8Xeb$zpT}<#iHAlLBIIQp55;=x(4c-tJYEzjsreoZ{EYB{anWbF8R8 z<1}5FiSpD~7y7^e;H}3xjbkKAYRL6}f4xAL2b-&*pOHU%Bu~eDQQn zCz~}M(s_ywM57b9R|soNeqX+N3hBK0{=5l!%8f2-F~L3QS|u^OnRCXBJ?KuHEHto6 z^=c`p*&5ih8uu7$chT7=Td}D17GT0CpLPYa(zs?%RH~AL$~tu{n<6FeoLWYGH_euo zGMlvQ3-4<>Cpy<`Cza*C`_lRIFTEOn^6aP2#1~%8orz-;Pn1TIsaj&XhiiC(vS+os zTDG~GP;(8>Ui@Ah$urms$a(QgR@OZW+wWQY-o$4pTqkK;X!cwf-(5B<;iV;W#P-s!XG2h4%eU&oUX*75WR>v#sh{&=#Uo#Po2*73{;>v(2`bv*MPb3BW}zk~ih@vbo| z9=Cy4Jw6D&?eTNqyB>Fg*FA0p^GgWF`!~pUV?El1zk|GO&An%#oxBGedG=oLfX5kd z*5g+2pzwa|rH=37JEgE)A53;0oQLfSt{qvdukfFPTTdG^FZ?ce*9EM%$L-)H;qS5h z&tZN2@_Bq06h6_6FHI8|zsEbkohZllZGReW&yy4Qn;DEx>|X-6-a!97{xEn(So?od zSo^;yto>i|nEhWB*8Z>i<hn#Q+el&P;1>f_9Pl2O%aIS^Nz&lqle&OE$fAFtyUWM&^Vm~o>5|@dd#?@afx=u6+aF!u5?1Lafopx64tnq z0dsx-j_dVJ%#X*r!2=%Co(1z<>A4>AnCE&{SkLvMu%7EBVLjK&9`jt^7S?lZ&=%XX z7W+Sh+k{4W{3ok#QFS`<*yaP&$Z=(awLd4U zeyhFD(4_7EjMcZKBi8!M;=i5FNa}wT@=nyEGok+MF8h;_b+1VOA4R?^Esgt#*#Fel z^O$`OhnUXaSFL_)%Gxe}N9Ve<(-}$IJ#Ob`E81{L^-o(p-v+s)^(eD@ETi?2`Tk`P z7ZaItm&e6K<}-eUfJEkZg9ThnWWF=GgNsSo3c|KM8id(i)?>EE`xX;3wm0rE+ne{8 z?P;8#n(tzGpJ8Ig`XZSS<-Etde^fkv3cTPk->opNGqF7VS3Kr-pLLJiN!cS^B!|Pmpx{A-iMjgf6Zggr`|7UXZ`;AvHY7dzs%UbC6772cRaT1=P~PB z^Vq&S#B(wemzDFo-DA#Qr^g)6K9AYo9*_5dU+{Q8c+g|6cV3v?IDo6+G1vQs$6U`j zk2$|z@OUry2reep$2mJrKvI6eValfizTh#V)wsv3uMzNU!1Dp$3|RNK_Gcxq>wZ`J zT3}xfxP_fUQhz-U%Dj*H_JM#010D)^Jm5yavjNWsd^6ysfL8*(9q?Mf>jAgOdC~E9 z2Am0aAmG7(hXNiCxDoJd!1Dp$40tKvm4I&tycY0!zz5%8SH zj28<5F9y6E@M^$!0=^rt@!}oZYY#XISmQCPp?@~8=K{_LTnTt4;JJVo0$vPwIpEcR z?*x1|V0|#w@wCfV1LY{-o`ACf=K{_LTnTt4;JJVo0$vPwIpEcR?*x1|VEu}u<87C( zOv+KfJppF}&IOzgxDxP8z;gjF1iTpVa=@zr-wF6`!1_f;$J^e5O^u`+1WYT+Eot|}#A}nW{b!2O*v88q+V#EBrdd*DH~s~pUKlprwQG|$msdjW1<^fkz3iM~ zCe681r;f+ZGwY8B9QE3~H-64(pB1gmd*kB{`)uA9_Y-?u)*ttYujGBbeKw7e~hkAKB=zxLPnJCB^#KC9I7+WxDs(?uR{Tp@hQ z|LvvU6V)KyQnc^ySU%{cnUjWcsizad^4FoJYgqn{l;^$EPht6j^#4KS!*PWiJ)rzQ zOZ#Da9IKyRm-4=jviz^1dBXlj-i9@-uYvOSTff1Bu1vc$$o2XFF6mbKdrcp7VV`Wt zPWx%{TEL-=*CN`C|ND2p_J=agD=X#MKL1BptJD3n6OBJ|?mhlYHiIio^F2;vpZyf} a^%GEpYeN5gdest == GENERAL_DNS_PORT && ip->daddr == dns_ip) { udp->dest = dns_port; + // Clear the now-stale checksum; zero means "not computed" for IPv4. + udp->check = 0; return XDP_PASS; } if (udp->source == dns_port && ip->saddr == dns_ip) { udp->source = GENERAL_DNS_PORT; + udp->check = 0; return XDP_PASS; } diff --git a/client/internal/ebpf/ebpf/src/wg_proxy.c b/client/internal/ebpf/ebpf/src/wg_proxy.c index 88fea65cf..5e7474928 100644 --- a/client/internal/ebpf/ebpf/src/wg_proxy.c +++ b/client/internal/ebpf/ebpf/src/wg_proxy.c @@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) { __be16 new_dst_port = htons(proxy_port); udp->dest = new_dst_port; udp->source = new_src_port; + + // The ports are covered by the UDP checksum. This is an IPv4 loopback hop + // and the payload is already integrity-protected, so clear the checksum (a + // zero UDP checksum means "not computed" for IPv4) rather than leave a + // stale value the kernel would drop as UDP_CSUM. + udp->check = 0; return XDP_PASS; } From 3358138cccb4f5d65692984334bcd20da0146c81 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:48:16 +0200 Subject: [PATCH 24/47] [client] Fix flaky Test_ConnectPeers busy-loop handshake wait (#6871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes `Test_ConnectPeers` in `client/iface` is flaky in CI, timing out with `waiting for peer handshake timeout after 30s`. The wait loop polled `getPeer()` in a tight busy-loop with no sleep (a `select` with a `default` branch), pegging a CPU core. The peers run userspace WireGuard (stdnet transport), so the spin starved the wireguard-go goroutines that actually process the handshake, making the 30s wait flaky under CI load. Poll on a 500ms ticker instead so the CPU is yielded between checks, and check the handshake state before waiting. Same logic, no busy-spin. ## Issue ticket number and link N/A — CI flakiness fix. ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature ## Documentation - [x] Documentation is **not needed** for this change (test-only fix) --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Tests** * Improved peer connection test waiting behavior to avoid busy spinning. * Added a timeout and periodic checks, with clearer failure handling when a handshake does not complete. --- client/iface/iface_test.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 8ff2bbb54..43b3d8168 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -569,17 +569,17 @@ func Test_ConnectPeers(t *testing.T) { if err != nil { t.Fatal(err) } - // todo: investigate why in some tests execution we need 30s + // The peers use userspace WireGuard (stdnet transport). A tight busy-loop + // here starves the wireguard-go goroutines that process the handshake, so + // poll on a ticker instead and yield the CPU between checks. WireGuard also + // only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which + // is why the overall wait can occasionally stretch to tens of seconds. timeout := 30 * time.Second timeoutChannel := time.After(timeout) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() for { - select { - case <-timeoutChannel: - t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) - default: - } - peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String()) if gpErr != nil { t.Fatal(gpErr) @@ -588,6 +588,12 @@ func Test_ConnectPeers(t *testing.T) { t.Log("peers successfully handshake") break } + + select { + case <-timeoutChannel: + t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) + case <-ticker.C: + } } } From 46568f7af8b950b87a96b69be701888b4597b1f6 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:40:54 +0200 Subject: [PATCH 25/47] [client] Reconcile routed allowed IPs when a lazy connection goes idle (#6863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Under lazy connections, when a routing peer goes idle its WireGuard peer is torn down and re-created with a wake endpoint by the activity listener, carrying only the overlay /32 (`peerCfg.AllowedIPs`). The routed subnet prefixes are dropped from the device on the Connected→Idle transition. They are meant to be restored by the route watcher, which reacts to the peer's status change and calls `recalculateRoutes` → `AddAllowedIP`. Two things prevent that from healing the peer: - `AddAllowedIP` uses `update_only`, which is a silent no-op (no error) when the peer does not exist. While the peer is being torn down and re-armed with its wake endpoint, it is briefly absent, so a re-add that lands in that window is lost. - The allowed-IP refcounter only calls its add function on a prefix's 0→1 transition. The routed prefix stays referenced across the idle cycle, so once the device entry is gone the refcounter does not re-push it on its own, and nothing retries. As a result, traffic to the routed subnet is black-holed while the peer is idle. Because the wake endpoint only fires when a packet matches the peer's AllowedIPs, a packet to the subnet is dropped before reaching the wake endpoint, so it cannot wake the peer. The peer only recovers when woken by other means (e.g. a ping to its overlay IP). ## Approach This change keeps the existing Connected→Idle transition as-is and reconciles the AllowedIPs afterwards, avoiding any additional locking on the transition path. The peer is torn down and re-armed with its wake endpoint as today; the routed prefixes are then re-applied from the route manager's allowed-IP refcounter once the wake endpoint has been (re)armed. A single add-only method, `ReconcilePeerAllowedIPs(peerKey)`, re-applies every routed prefix currently tracked for the peer in the refcounter (the authoritative store; it already covers static, dynamic and dnsinterceptor routes). It runs whenever the peer's wake endpoint is (re)created in the lazy manager — every point where the activity listener builds it with the overlay /32 only: - **initial registration** (`AddPeer`, cold start): the route manager may have already pushed the peer's routes before the wake endpoint existed, so those `AddAllowedIP` calls no-op'd; the reconcile installs them on the freshly created wake endpoint. - **the two paths into idle** (`DeactivatePeer` on a remote GOAWAY, `onPeerInactivityTimedOut` on local inactivity): the peer is torn down and re-armed, so the routed prefixes must be re-applied. In every case the routed prefixes end up on the wake endpoint, so traffic to a routed subnet can wake the peer. Arming the wake endpoint and reconciling are wrapped in a single `armActivityListener` helper so the two always happen together. New helper: `refcounter.Counter.KeysMatching(pred)` to enumerate a peer's prefixes under the counter lock. Note on scope: the reconcile restores what the refcounter tracks. All routed AllowedIPs currently go through it, so this covers the routed-prefix case; it does not attempt to reconcile AllowedIPs installed outside the refcounter. The Idle→Connected (wake) path does not need this: the peer is not removed there (the listener close leaves it in place and only the endpoint is updated), so a concurrent `AddAllowedIP` lands normally. ## Testing Reproduced deterministically in a local dev setup (userspace client, `NB_WG_KERNEL_DISABLED=true`, `B_LAZY_CONN_INACTIVITY_THRESHOLD=1` inactivity threshold 1 min). A temporary 30s sleep in the tear-down → re-arm window widens the race so the route watcher's async `AddAllowedIP` reliably lands while the peer is absent and no-ops (the sleep is a test aid, not part of the change): - **without the reconcile:** after the peer goes idle, a ping to any routed IP — both a pre-existing route and one added during the window — black-holes; the peer never wakes. - **with the reconcile:** the same ping wakes the peer and passes. Added unit tests: `ReconcilePeerAllowedIPs` (re-applies all of a peer's tracked prefixes, scoped to that peer) and `refcounter.Counter.KeysMatching`. Note: `netbird status -d` is not a reliable signal for this — `AddPeerStateRoute` records the route regardless of whether the underlying `AddAllowedIP` no-op'd, so it reflects the route manager's intent rather than device state. The reliable signal is functional (ping the subnet from idle). ## Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (unit tests for the reconcile + `KeysMatching`) ## Documentation - [x] Documentation is **not needed** for this change (internal client behavior, no API / gRPC / CLI / flag change) --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Routed IP assignments are automatically reconciled and restored whenever a peer’s lazy wake endpoint is armed or re-armed. * Routed allowed IPs are re-applied after inactivity transitions and monitoring re-initialization. * If reconciliation can’t be performed, the client safely skips it; if reconciliation encounters issues, failures are logged without stopping connection monitoring. --- client/internal/conn_mgr.go | 11 +++ client/internal/engine.go | 6 ++ client/internal/lazyconn/manager/manager.go | 40 ++++++++- client/internal/routemanager/manager.go | 25 ++++++ client/internal/routemanager/mock.go | 5 ++ .../internal/routemanager/reconcile_test.go | 90 +++++++++++++++++++ .../routemanager/refcounter/refcounter.go | 20 +++++ .../refcounter/refcounter_test.go | 47 ++++++++++ 8 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 client/internal/routemanager/reconcile_test.go create mode 100644 client/internal/routemanager/refcounter/refcounter_test.go diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 754ce37a3..7a591f60c 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -49,11 +49,21 @@ type ConnMgr struct { // engine.syncMsgMux; all other reads stay under engine.syncMsgMux only. lazyConnMgrMu sync.RWMutex + // reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is + // (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile. + reconcileRoutedIPs func(peerKey string) error + wg sync.WaitGroup lazyCtx context.Context lazyCtxCancel context.CancelFunc } +// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when +// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts. +func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) { + e.reconcileRoutedIPs = fn +} + func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr { e := &ConnMgr{ peerStore: peerStore, @@ -291,6 +301,7 @@ func (e *ConnMgr) Close() { func (e *ConnMgr) initLazyManager(engineCtx context.Context) { cfg := manager.Config{ InactivityThreshold: inactivityThresholdEnv(), + ReconcileAllowedIPs: e.reconcileRoutedIPs, } e.lazyConnMgrMu.Lock() diff --git a/client/internal/engine.go b/client/internal/engine.go index e1b03e878..617892e43 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -663,6 +663,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) iceCfg := e.createICEConfig() e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface) + e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error { + if e.routeManager == nil { + return nil + } + return e.routeManager.ReconcilePeerAllowedIPs(peerKey) + }) e.connMgr.Start(e.ctx) // Wire DNS-time lazy-connection warm-up now that the connection manager diff --git a/client/internal/lazyconn/manager/manager.go b/client/internal/lazyconn/manager/manager.go index 3868e37e8..b7424bb2f 100644 --- a/client/internal/lazyconn/manager/manager.go +++ b/client/internal/lazyconn/manager/manager.go @@ -29,6 +29,11 @@ type managedPeer struct { type Config struct { InactivityThreshold *time.Duration + // ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is + // armed. The activity listener creates the wake peer with the overlay /32 only; without the + // routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an + // idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile. + ReconcileAllowedIPs func(peerKey string) error } // Manager manages lazy connections @@ -56,6 +61,9 @@ type Manager struct { peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group routesMu sync.RWMutex + + // reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed. + reconcileAllowedIPs func(peerKey string) error } // NewManager creates a new lazy connection manager @@ -73,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S activityManager: activity.NewManager(wgIface), peerToHAGroups: make(map[string][]route.HAUniqueID), haGroupToPeers: make(map[route.HAUniqueID][]string), + reconcileAllowedIPs: config.ReconcileAllowedIPs, } if wgIface.IsUserspaceBind() { @@ -201,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) { return false, nil } - if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + if err := m.armActivityListener(peerCfg); err != nil { return false, err } @@ -288,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) { m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey) - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) return } @@ -465,6 +474,31 @@ func (m *Manager) close() { } // shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements +// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake +// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without +// this the routed prefixes would be missing and traffic to a routed subnet could not wake the +// idle routing peer. It is a no-op when no reconciler is configured. +// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then +// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing +// peer. The routed prefixes must be re-applied after the wake endpoint exists because the +// listener creates it with the overlay /32 only. +func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error { + if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + return err + } + m.armRoutedAllowedIPs(&peerCfg) + return nil +} + +func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) { + if m.reconcileAllowedIPs == nil { + return + } + if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil { + peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err) + } +} + func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool { m.routesMu.RLock() defer m.routesMu.RUnlock() @@ -577,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) { mp.peerCfg.Log.Infof("start activity monitor") - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) continue } diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 66b24cc5a..ef69b81a4 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -61,6 +61,7 @@ type Manager interface { InitialRouteRange() []string SetFirewall(firewall.Manager) error SetDNSForwarderPort(port uint16) + ReconcilePeerAllowedIPs(peerKey string) error Stop(stateManager *statemanager.Manager) } @@ -232,6 +233,30 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } +// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer +// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to +// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a +// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates +// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter +// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is +// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so +// prefixes are re-added to an existing peer and an absent peer is left untouched. +func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error { + if m.allowedIPsRefCounter == nil { + return nil + } + + return m.allowedIPsRefCounter.ReapplyMatching( + func(out string) bool { return out == peerKey }, + func(prefix netip.Prefix) error { + if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil { + return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err) + } + return nil + }, + ) +} + // Init sets up the routing func (m *DefaultManager) Init() error { m.routeSelector = m.initSelector() diff --git a/client/internal/routemanager/mock.go b/client/internal/routemanager/mock.go index 937314995..c1620b24c 100644 --- a/client/internal/routemanager/mock.go +++ b/client/internal/routemanager/mock.go @@ -112,6 +112,11 @@ func (m *MockManager) SetFirewall(firewall.Manager) error { func (m *MockManager) SetDNSForwarderPort(port uint16) { } +// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface +func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error { + return nil +} + // Stop mock implementation of Stop from Manager interface func (m *MockManager) Stop(stateManager *statemanager.Manager) { if m.StopFunc != nil { diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go new file mode 100644 index 000000000..2a8a4dc10 --- /dev/null +++ b/client/internal/routemanager/reconcile_test.go @@ -0,0 +1,90 @@ +//go:build !windows + +package routemanager + +import ( + "net" + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/tun/netstack" + + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" +) + +// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other +// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them. +type reconcileWGMock struct { + mu sync.Mutex + adds map[string][]netip.Prefix +} + +func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.adds == nil { + m.adds = map[string][]netip.Prefix{} + } + m.adds[peerKey] = append(m.adds[peerKey], allowedIP) + return nil +} + +func (m *reconcileWGMock) added(peerKey string) []netip.Prefix { + m.mu.Lock() + defer m.mu.Unlock() + return m.adds[peerKey] +} + +func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil } +func (m *reconcileWGMock) Name() string { return "utun-test" } +func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} } +func (m *reconcileWGMock) ToInterface() *net.Interface { return nil } +func (m *reconcileWGMock) IsUserspaceBind() bool { return false } +func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil } +func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil } +func (m *reconcileWGMock) GetNet() *netstack.Net { return nil } + +// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix +// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer. +func TestReconcilePeerAllowedIPs(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + m.allowedIPsRefCounter = refcounter.New[netip.Prefix, string, string]( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := m.allowedIPsRefCounter.Increment(prefix, peer) + require.NoError(t, err) + } + // Extra reference: reconcile must still re-apply the prefix even though its refcount never + // hit 0 again (the exact case the plain incremental path skips). + _, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA") + require.NoError(t, err) + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"), + "reconcile must re-apply all routed prefixes of the peer") + assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes") +} + +// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is +// set up. +func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + assert.Empty(t, wg.added("peerA")) +} diff --git a/client/internal/routemanager/refcounter/refcounter.go b/client/internal/routemanager/refcounter/refcounter.go index 27a724f50..917120275 100644 --- a/client/internal/routemanager/refcounter/refcounter.go +++ b/client/internal/routemanager/refcounter/refcounter.go @@ -94,6 +94,26 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) { return ref, ok } +// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the +// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect +// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its +// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied. +// pred and apply are invoked under the lock, so they must not call back into the counter. +func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for key, ref := range rm.refCountMap { + if pred(ref.Out) { + if err := apply(key); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + // Increment increments the reference count for the given key. // If this is the first reference to the key, the AddFunc is called. func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) { diff --git a/client/internal/routemanager/refcounter/refcounter_test.go b/client/internal/routemanager/refcounter/refcounter_test.go new file mode 100644 index 000000000..79a99c388 --- /dev/null +++ b/client/internal/routemanager/refcounter/refcounter_test.go @@ -0,0 +1,47 @@ +package refcounter + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored +// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive +// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes. +func TestReapplyMatching(t *testing.T) { + rc := New[netip.Prefix, string, string]( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := rc.Increment(prefix, peer) + require.NoError(t, err) + } + // a second reference must not make the key applied twice + _, err := rc.Increment(peerA1, "peerA") + require.NoError(t, err) + + var applied []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "peerA" }, + func(key netip.Prefix) error { applied = append(applied, key); return nil }, + ) + require.NoError(t, err) + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied) + + var none []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "missing" }, + func(key netip.Prefix) error { none = append(none, key); return nil }, + ) + require.NoError(t, err) + assert.Empty(t, none) +} From e13bcdbd44be8375954b0757f3f56c11deb030e1 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 24 Jul 2026 13:10:53 +0200 Subject: [PATCH 26/47] [client] Fetch FreeBSD port files from GitHub mirror instead of cgit (#6880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cgit.freebsd.org now sits behind an Anubis anti-bot challenge and intermittently returns an HTML challenge page with HTTP 200 instead of the requested file, breaking the FreeBSD port release job. Fetch the port Makefile and distinfo from the official freebsd-ports GitHub mirror, retry transient failures, and fail loudly if HTML is returned instead of the expected file. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved reliability when retrieving FreeBSD port metadata during release and issue preparation. * Added automatic retries and HTTPS-only redirect handling for safer downloads. * Validates fetched content to detect unexpected HTML responses and avoids processing invalid data. * Updated port metadata retrieval to use a more reliable mirror, improving version extraction and the resulting comparisons and regenerated release information. --- release_files/freebsd-port-diff.sh | 28 +++++++++++++++++++----- release_files/freebsd-port-issue-body.sh | 10 ++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/release_files/freebsd-port-diff.sh b/release_files/freebsd-port-diff.sh index 6ffa141be..77ea55520 100755 --- a/release_files/freebsd-port-diff.sh +++ b/release_files/freebsd-port-diff.sh @@ -3,8 +3,8 @@ # FreeBSD Port Diff Generator for NetBird # # This script generates the diff file required for submitting a FreeBSD port update. -# It works on macOS, Linux, and FreeBSD by fetching files from FreeBSD cgit and -# computing checksums from the Go module proxy. +# It works on macOS, Linux, and FreeBSD by fetching files from the FreeBSD ports +# GitHub mirror and computing checksums from the Go module proxy. # # Usage: ./freebsd-port-diff.sh [new_version] # Example: ./freebsd-port-diff.sh 0.60.7 @@ -14,7 +14,7 @@ set -e GITHUB_REPO="netbirdio/netbird" -PORTS_CGIT_BASE="https://cgit.freebsd.org/ports/plain/security/netbird" +PORTS_MIRROR_BASE="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird" GO_PROXY="https://proxy.golang.org/github.com/netbirdio/netbird/@v" OUTPUT_DIR="${OUTPUT_DIR:-.}" AWK_FIRST_FIELD='{print $1}' @@ -30,10 +30,17 @@ fetch_all_tags() { fetch_current_ports_version() { echo "Fetching current version from FreeBSD ports..." >&2 - curl -sL "${PORTS_CGIT_BASE}/Makefile" 2>/dev/null | \ + local makefile version + makefile=$(fetch_ports_file "Makefile") || return 1 + version=$(echo "$makefile" | \ grep -E "^DISTVERSION=" | \ sed 's/DISTVERSION=[[:space:]]*//' | \ - tr -d '\t ' + tr -d '\t ') + if [[ -z "$version" ]]; then + echo "Error: Could not extract DISTVERSION from ports Makefile" >&2 + return 1 + fi + echo "$version" return 0 } @@ -45,7 +52,16 @@ fetch_latest_github_release() { fetch_ports_file() { local filename="$1" - curl -sL "${PORTS_CGIT_BASE}/${filename}" 2>/dev/null + local content + if ! content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "${PORTS_MIRROR_BASE}/${filename}" 2>/dev/null); then + echo "Error: Could not fetch ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + if [[ "$content" == \<* ]]; then + echo "Error: Received HTML instead of ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + printf '%s' "$content" return 0 } diff --git a/release_files/freebsd-port-issue-body.sh b/release_files/freebsd-port-issue-body.sh index 1c23dbbbe..1f0c8a567 100755 --- a/release_files/freebsd-port-issue-body.sh +++ b/release_files/freebsd-port-issue-body.sh @@ -9,18 +9,22 @@ # Example: ./freebsd-port-issue-body.sh 0.56.0 0.59.1 # # If no versions are provided, the script will: -# - Fetch OLD version from FreeBSD ports cgit (current version in ports tree) +# - Fetch OLD version from the FreeBSD ports GitHub mirror (current version in ports tree) # - Fetch NEW version from latest NetBird GitHub release tag set -e GITHUB_REPO="netbirdio/netbird" -PORTS_CGIT_URL="https://cgit.freebsd.org/ports/plain/security/netbird/Makefile" +PORTS_MAKEFILE_URL="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird/Makefile" fetch_current_ports_version() { echo "Fetching current version from FreeBSD ports..." >&2 local makefile_content - makefile_content=$(curl -sL "$PORTS_CGIT_URL" 2>/dev/null) + makefile_content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "$PORTS_MAKEFILE_URL" 2>/dev/null) || makefile_content="" + if [[ "$makefile_content" == \<* ]]; then + echo "Error: Received HTML instead of Makefile from ${PORTS_MAKEFILE_URL}" >&2 + return 1 + fi if [[ -z "$makefile_content" ]]; then echo "Error: Could not fetch Makefile from FreeBSD ports" >&2 return 1 From b65ec8b68a6a1ab8aee162a7b9e5147c0375af68 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:53:53 +0200 Subject: [PATCH 27/47] [client] Restores lost backup.Reset from #4935 (#6883) ## Describe your changes Restore the management gRPC client's backoff reset after a stream connects. This is a regression: PR #4935 originally added this reset; it was then lost in commit `58daa674e` during the `withMgmtStream` refactor (Sync/Job unification). Compared to before the reset was done AFTER receiveEvents, whereas now it's done before. Now should cover for Sync and Job (which didn't exist in #4935. Hold for long living stream that breaks. Reset prevents two things 1. long living conns going wrong from waiting a long backoff time window before to drive reconn 2. past MaxElapsedTime (3months) long lived conns failures from being treated as "unrecoverable" (hence triggering an full restart of the engine) ## Issue ticket number and link Internal support case (customer agents lost data-plane connectivity during a management maintenance/release window). No public issue. Regression introduced in commit `58daa674e`, which removed the `backOff.Reset()` added by PR #4935. ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal reconnection-behavior fix in the management gRPC client. No public NetBird CLI, API, or configuration surface changes. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved management stream retry behavior. * Retry delays now reset after a stream is successfully established, helping subsequent connection attempts recover more quickly. --- shared/management/client/grpc.go | 41 ++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 78d28e3a3..bd2d0da1f 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -187,16 +187,16 @@ func (c *GrpcClient) ready() bool { // Sync wraps the real client's Sync endpoint call and takes care of retries and encryption/decryption of messages // Blocking request. The result will be sent via msgHandler callback function func (c *GrpcClient) Sync(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { - return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error { - return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler) + return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error { + return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler, backOff) }) } // Job wraps the real client's Job endpoint call and takes care of retries and encryption/decryption of messages // Blocking request. The result will be sent via msgHandler callback function func (c *GrpcClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error { - return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error { - return c.handleJobStream(ctx, serverPubKey, msgHandler) + return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error { + return c.handleJobStream(ctx, serverPubKey, msgHandler, backOff) }) } @@ -204,7 +204,7 @@ func (c *GrpcClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequ // It takes care of retries, connection readiness, and fetching server public key. func (c *GrpcClient) withMgmtStream( ctx context.Context, - handler func(ctx context.Context, serverPubKey wgtypes.Key) error, + handler func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error, ) error { backOff := defaultBackoff(ctx) operation := func() error { @@ -224,7 +224,7 @@ func (c *GrpcClient) withMgmtStream( return err } - return handler(ctx, *serverPubKey) + return handler(ctx, *serverPubKey, backOff) } err := backoff.Retry(operation, backOff) @@ -239,6 +239,7 @@ func (c *GrpcClient) handleJobStream( ctx context.Context, serverPubKey wgtypes.Key, msgHandler func(msg *proto.JobRequest) *proto.JobResponse, + backOff backoff.BackOff, ) error { ctx, cancelStream := context.WithCancel(ctx) defer cancelStream() @@ -256,6 +257,19 @@ func (c *GrpcClient) handleJobStream( log.Debug("job stream handshake sent successfully") + // The stream is up, so reset the backoff. This matters for two reasons, + // both caused by the backoff lib not resetting its state on a successful + // connection: + // 1. Without a reset, after a connect followed by an error the next retry + // starts from the accumulated (large) interval instead of retrying + // promptly, delaying reconnection. + // 2. Worse, once the accumulated elapsed time exceeds MaxElapsedTime, the + // next stream error makes NextBackOff() return Stop, so the retry loop + // exits immediately. That error is then mislabeled unrecoverable and + // bubbles up to trigger a full engine restart / data-plane teardown + // instead of a silent reconnection. + backOff.Reset() + // Main loop: receive, process, respond for { jobReq, err := c.receiveJobRequest(ctx, stream, serverPubKey) @@ -371,7 +385,7 @@ func (c *GrpcClient) sendJobResponse( return nil } -func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { +func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error, backOff backoff.BackOff) error { ctx, cancelStream := context.WithCancel(ctx) defer cancelStream() @@ -390,6 +404,19 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes. c.notifyConnected() c.setSyncStreamConnected() + // The stream is up, so reset the backoff. This matters for two reasons, + // both caused by the backoff lib not resetting its state on a successful + // connection: + // 1. Without a reset, after a connect followed by an error the next retry + // starts from the accumulated (large) interval instead of retrying + // promptly, delaying reconnection. + // 2. Worse, once the accumulated elapsed time exceeds MaxElapsedTime, the + // next stream error makes NextBackOff() return Stop, so the retry loop + // exits immediately. That error is then mislabeled unrecoverable and + // bubbles up to trigger a full engine restart / data-plane teardown + // instead of a silent reconnection. + backOff.Reset() + // blocking until error err = c.receiveUpdatesEvents(stream, serverPubKey, msgHandler) if err != nil { From 1e5b0a5c892750c36d3f53e2d825632a81ed98d1 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:23:22 +0200 Subject: [PATCH 28/47] [client] Make Test_ConnectPeers deterministic under Docker/eBPF kernel / Darwin CI (#6884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Make `Test_ConnectPeers` deterministic. Two issues, both surfaced once the privileged suite moved into a `--privileged` Docker container (#6425): 1. The peers used `getLocalIP()` as their WireGuard endpoint, i.e. the host's routable NIC IP (the docker bridge IP `172.17.0.2` in CI). That address might not hairpin reliably inside the container, so the handshake intermittently timed out (flaky). Use loopback (`127.1.0.x`) instead — always self-reachable. 2. On a Linux runner with the WG kernel module the iface uses the eBPF proxy factory. Its manager is a singleton with one shared XDP program + settings map, so bringing up the two ifaces makes the second factory overwrite the first's `wg_port`/`proxy_port` and the handshake is dropped. The test is incompatible with the eBPF factory, so disable it via `NB_DISABLE_EBPF_WG_PROXY` (peers then handshake directly over loopback). Running the suite across all three modes (eBPF / UDP proxy / ICE bind) would need a larger refactor. Also fixes a typo in the `ErrSharedSockStopped` message (`socked` → `socket`). ## Issue ticket number and link No public issue — CI flakiness follow-up to #6871 on `Test_ConnectPeers` (https://github.com/netbirdio/netbird/blob/main/client/iface/iface_test.go). ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Test-only change (plus a log-string typo). No public API, CLI, config, or behavior change. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Corrected the “shared socket stopped” error message for clearer output. * **Tests** * Improved peer connection test reliability in privileged CI by disabling the eBPF WireGuard proxy and using fixed loopback UDP endpoints for deterministic setup. --- client/iface/iface_test.go | 38 ++++++-------------------------------- sharedsock/sock_linux.go | 2 +- 2 files changed, 7 insertions(+), 33 deletions(-) diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 43b3d8168..89c8cd16e 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -464,6 +464,8 @@ func Test_RemovePeer(t *testing.T) { } func Test_ConnectPeers(t *testing.T) { + t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true") + peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400) peer1wgIP := netip.MustParsePrefix("10.99.99.17/30") peer1Key, _ := wgtypes.GeneratePrivateKey() @@ -505,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - localIP, err := getLocalIP() - if err != nil { - t.Fatal(err) - } - - peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort)) + localIP1 := "127.0.0.1" + peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort)) if err != nil { t.Fatal(err) } @@ -546,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort)) + localIP2 := "127.0.0.1" + peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort)) if err != nil { t.Fatal(err) } @@ -621,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) { } return wgtypes.Peer{}, fmt.Errorf("peer not found") } - -func getLocalIP() (string, error) { - // Get all interfaces - addrs, err := net.InterfaceAddrs() - if err != nil { - return "", err - } - - for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - if ipNet.IP.IsLoopback() { - continue - } - - if ipNet.IP.To4() == nil { - continue - } - return ipNet.IP.String(), nil - } - - return "", fmt.Errorf("no local IP found") -} diff --git a/sharedsock/sock_linux.go b/sharedsock/sock_linux.go index 4855e1aed..150e8a722 100644 --- a/sharedsock/sock_linux.go +++ b/sharedsock/sock_linux.go @@ -24,7 +24,7 @@ import ( ) // ErrSharedSockStopped indicates that shared socket has been stopped -var ErrSharedSockStopped = fmt.Errorf("shared socked stopped") +var ErrSharedSockStopped = fmt.Errorf("shared socket stopped") // SharedSocket is a net.PacketConn that initiates two raw sockets (ipv4 and ipv6) and listens to UDP packets filtered // by BPF instructions (e.g., IncomingSTUNFilter that checks and sends only STUN packets to the listeners (ReadFrom)). From 4f6247b5c3c6ee5284bfcf59d3dba0d2c327548d Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 27 Jul 2026 04:42:41 +0900 Subject: [PATCH 29/47] [management, proxy] Add prompt-cache token and cost accounting to agent network usage (#6900) Co-authored-by: braginini --- .github/workflows/agent-network-e2e.yml | 9 + e2e/agentnetwork/chat_test.go | 234 ++++++++++++- e2e/harness/client.go | 10 +- e2e/harness/combined.go | 23 ++ .../modules/agentnetwork/accesslog_ingest.go | 97 +++--- .../accesslog_ingest_realstore_test.go | 49 ++- .../accesslog_sessions_realstore_test.go | 6 +- .../modules/agentnetwork/types/accesslog.go | 141 ++++++-- .../agentnetwork/types/accesslogfilter.go | 4 +- .../modules/agentnetwork/types/cost_test.go | 124 +++++++ .../modules/agentnetwork/types/usage.go | 26 +- .../agentnetwork/types/usageoverview.go | 51 ++- management/server/migration/migration.go | 78 +++++ management/server/migration/migration_test.go | 97 ++++++ .../server/store/sql_store_agentnetwork.go | 2 +- .../sql_store_agentnetwork_accesslog_test.go | 12 +- management/server/store/store.go | 8 + proxy/internal/accesslog/logger.go | 23 +- proxy/internal/llm/bedrock.go | 18 +- proxy/internal/llm/bedrock_test.go | 12 + proxy/internal/llm/pricing/pricing.go | 52 ++- .../builtin/cost_calculation_matrix_test.go | 329 ++++++++++++++++++ .../builtin/cost_meter/middleware.go | 29 +- .../builtin/cost_meter/middleware_test.go | 67 +++- .../llm_response_parser/streaming_bedrock.go | 17 +- .../streaming_bedrock_test.go | 18 + proxy/internal/middleware/keys.go | 15 +- shared/management/http/api/openapi.yml | 132 ++++++- shared/management/http/api/types.gen.go | 69 +++- 29 files changed, 1582 insertions(+), 170 deletions(-) create mode 100644 management/internals/modules/agentnetwork/types/cost_test.go create mode 100644 proxy/internal/middleware/builtin/cost_calculation_matrix_test.go diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml index bf4868871..88b98293d 100644 --- a/.github/workflows/agent-network-e2e.yml +++ b/.github/workflows/agent-network-e2e.yml @@ -5,6 +5,13 @@ on: schedule: - cron: "0 3 * * *" workflow_dispatch: + inputs: + bedrock_model: + description: >- + Bedrock inference-profile id to drive the matrix with, exactly as + AWS issues it. Leave empty for the Sonnet 4.6 default. + required: false + default: "" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -62,6 +69,8 @@ jobs: CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }} AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }} AWS_REGION: ${{ secrets.E2E_AWS_REGION }} + # Bedrock model override: dispatch input wins, then the repo variable, else the test default. + AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }} # Vertex (Anthropic-on-Vertex): SA + project required; region defaults # to "global", model to a pinned claude snapshot. GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }} diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index 90c3766ec..65c4d813f 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -9,12 +9,220 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" "github.com/netbirdio/netbird/e2e/harness" "github.com/netbirdio/netbird/shared/management/http/api" ) +// per1k is a model's published USD rates per 1k tokens. read is the prompt-cache read rate +// (OpenAI: the cached-input discount rate); write is the cache-creation rate where one exists. +type per1k struct{ in, out, read, write float64 } + +// publishedPer1k hardcodes the vendors' PUBLISHED rates for the models the live matrix can drive, +// keyed by the normalized model id the proxy stamps. Deliberately independent of the proxy's +// pricing table so a wrong embedded rate or a broken normalization fails the run. +var publishedPer1k = map[string]per1k{ + "gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0}, + "gpt-4o": {0.0025, 0.01, 0.00125, 0}, + "claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125}, + "claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375}, + "claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375}, + "kimi-k3": {0.003, 0.015, 0.0003, 0.003}, // no published write rate: bills at the input rate + "anthropic.claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125}, + "anthropic.claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375}, + "anthropic.claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375}, +} + +// rawCostVerificationSQL is the operator-facing double-check, run straight against the management +// sqlite store: recompute each usage row's expected total and cache cost from its own persisted +// token buckets and hardcoded published rates. OpenAI counts cached tokens as a subset of input; +// Anthropic-shape providers count cache buckets additively. +const rawCostVerificationSQL = ` +WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS ( + VALUES + ('gpt-4o-mini', 0.00015, 0.0006, 0.000075, 0.0), + ('gpt-4o', 0.0025, 0.01, 0.00125, 0.0), + ('claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125), + ('claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375), + ('claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375), + ('kimi-k3', 0.003, 0.015, 0.0003, 0.003), + ('anthropic.claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125), + ('anthropic.claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375), + ('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375) +) +SELECT + u.provider, + u.model, + u.input_tokens, + u.output_tokens, + u.cached_input_tokens, + u.cache_creation_tokens, + u.input_cost_usd, + u.cached_input_cost_usd, + u.cache_creation_cost_usd, + u.output_cost_usd, + -- No cost_usd / cache_cost_usd columns are stored: both are derived from the + -- four per-bucket columns above, exactly as the API renders them. + (u.input_cost_usd + u.cached_input_cost_usd + u.cache_creation_cost_usd + u.output_cost_usd) AS cost_usd, + (u.cached_input_cost_usd + u.cache_creation_cost_usd) AS cache_cost_usd, + CASE WHEN u.provider = 'openai' THEN + (u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0 + ELSE + u.input_tokens*r.in_rate/1000.0 + END AS expected_input, + CASE WHEN u.provider = 'openai' THEN + MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0 + ELSE + u.cached_input_tokens*r.read_rate/1000.0 + END AS expected_cached_input, + CASE WHEN u.provider = 'openai' THEN + 0.0 + ELSE + u.cache_creation_tokens*r.write_rate/1000.0 + END AS expected_cache_creation, + u.output_tokens*r.out_rate/1000.0 AS expected_output, + CASE WHEN u.provider = 'openai' THEN + (u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0 + + MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0 + + u.output_tokens*r.out_rate/1000.0 + ELSE + u.input_tokens*r.in_rate/1000.0 + u.cached_input_tokens*r.read_rate/1000.0 + + u.cache_creation_tokens*r.write_rate/1000.0 + u.output_tokens*r.out_rate/1000.0 + END AS expected_total, + CASE WHEN u.provider = 'openai' THEN + MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0 + ELSE + u.cached_input_tokens*r.read_rate/1000.0 + u.cache_creation_tokens*r.write_rate/1000.0 + END AS expected_cache +FROM agent_network_request_usage u +JOIN rates r ON r.model = u.model +ORDER BY u.timestamp` + +// verifyUsageRowsSQL re-checks every persisted usage row directly in the management sqlite store, +// bypassing the API path — the same audit an operator can run on a production store.db. +func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) { + t.Helper() + + dbPath, err := srv.SnapshotStoreDB(t.TempDir()) + require.NoError(t, err, "snapshot management sqlite store") + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err, "open store snapshot") + sqlDB, err := db.DB() + require.NoError(t, err) + defer func() { _ = sqlDB.Close() }() + + rows, err := db.Raw(rawCostVerificationSQL).Rows() + require.NoError(t, err, "run raw cost verification query") + defer func() { _ = rows.Close() }() + + verified := 0 + for rows.Next() { + var provider, model string + var inTok, outTok, readTok, writeTok int64 + var inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost float64 + var wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache float64 + require.NoError(t, rows.Scan(&provider, &model, &inTok, &outTok, &readTok, &writeTok, + &inCost, &cachedInCost, &cacheCreateCost, &outCost, &cost, &cacheCost, + &wantInput, &wantCachedInput, &wantCacheCreation, &wantOutput, &wantTotal, &wantCache), "scan usage row") + t.Logf("[sql] %s/%s: in=%d out=%d cache_read=%d cache_write=%d stored in/cached/create/out=$%.6f/$%.6f/$%.6f/$%.6f total=$%.6f cache=$%.6f expected total=$%.6f cache=$%.6f", + provider, model, inTok, outTok, readTok, writeTok, + inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost, wantTotal, wantCache) + assert.InDeltaf(t, wantInput, inCost, 1e-6, "stored input_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantCachedInput, cachedInCost, 1e-6, "stored cached_input_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantCacheCreation, cacheCreateCost, 1e-6, "stored cache_creation_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantOutput, outCost, 1e-6, "stored output_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantTotal, cost, 1e-6, "derived cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantCache, cacheCost, 1e-6, "derived cache_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, inCost+cachedInCost+cacheCreateCost+outCost, cost, 1e-9, + "stored buckets must sum to the derived cost_usd for %s/%s", provider, model) + verified++ + } + require.NoError(t, rows.Err(), "iterate usage rows") + require.Positive(t, verified, "raw SQL check must cover at least one usage row") + t.Logf("[sql] verified %d usage rows in store.db against published rates", verified) + + gwRows, err := db.Raw(`SELECT model, + (input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd) AS cost_usd + FROM agent_network_request_usage WHERE model LIKE '%/%'`).Rows() + require.NoError(t, err, "query gateway-prefixed usage rows") + defer func() { _ = gwRows.Close() }() + for gwRows.Next() { + var model string + var cost float64 + require.NoError(t, gwRows.Scan(&model, &cost), "scan gateway usage row") + t.Logf("[sql] gateway %s: stored=$%.6f (must be 0 — deliberately unpriced)", model, cost) + assert.Zerof(t, cost, "gateway-prefixed model %q must store cost 0, never a guessed rate", model) + } + require.NoError(t, gwRows.Err(), "iterate gateway usage rows") +} + +// validateAccessLogCost recomputes a live access-log row's expected total and cache cost from the +// published per-1k rates and the row's persisted token buckets, and asserts both stored values. +// Gateway-prefixed model ids the proxy deliberately does not price must store cost 0. +func validateAccessLogCost(t *testing.T, pc providerCase, row api.AgentNetworkAccessLog) { + t.Helper() + model := catalogModel(pc) + provider := "" + if row.Provider != nil { + provider = *row.Provider + } + t.Logf("[cost] %s: provider=%s model=%s in=%d out=%d total=%d cache_read=%d cache_write=%d cost=$%.6f cache_cost=$%.6f", + pc.name, provider, model, row.InputTokens, row.OutputTokens, row.TotalTokens, + row.CachedInputTokens, row.CacheCreationTokens, row.CostUsd, row.CacheCostUsd) + + rates, known := publishedPer1k[model] + if !known { + if strings.Contains(model, "/") { + assert.Zerof(t, row.CostUsd, "gateway-prefixed model %q is not priced so the cost meter must skip (cost 0)", model) + return + } + t.Logf("[cost] %s: no published rate on file for model %q (env-overridden?); skipping cost validation", pc.name, model) + return + } + + // input_tokens may legitimately be 0: Moonshot/Kimi reports fully cached prompts under the cache + // buckets only. Output and total must always be present on a priced row. + require.Positive(t, row.OutputTokens, "priced row must carry output tokens") + require.Positive(t, row.TotalTokens, "priced row must carry total tokens") + + var wantInput, wantCachedInput, wantCacheCreation float64 + if provider == "openai" { + cached := min(row.CachedInputTokens, row.InputTokens) // cached is a subset of input + wantInput = float64(row.InputTokens-cached) / 1000 * rates.in + wantCachedInput = float64(cached) / 1000 * rates.read + // OpenAI has no cache-write bucket; wantCacheCreation stays 0. + } else { + // Anthropic / Bedrock shape: cache buckets are additive to input_tokens. + wantInput = float64(row.InputTokens) / 1000 * rates.in + wantCachedInput = float64(row.CachedInputTokens) / 1000 * rates.read + wantCacheCreation = float64(row.CacheCreationTokens) / 1000 * rates.write + } + wantOutput := float64(row.OutputTokens) / 1000 * rates.out + wantCache := wantCachedInput + wantCacheCreation + wantTotal := wantInput + wantCache + wantOutput + + t.Logf("[cost] %s: expecting input=$%.6f cached_input=$%.6f cache_creation=$%.6f output=$%.6f total=$%.6f cache=$%.6f from published rates", + pc.name, wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache) + assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "stored input_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCachedInput, row.CachedInputCostUsd, 1e-6, "stored cached_input_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCacheCreation, row.CacheCreationCostUsd, 1e-6, "stored cache_creation_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "stored output_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "derived cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCache, row.CacheCostUsd, 1e-6, "derived cache_cost_usd for %s (%s)", pc.name, model) + + // The aggregates must be exactly the sum of the stored components, not an + // independently-computed figure that could drift from the breakdown. + assert.InDeltaf(t, row.InputCostUsd+row.CachedInputCostUsd+row.CacheCreationCostUsd+row.OutputCostUsd, + row.CostUsd, 1e-9, "stored buckets must sum to the derived cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, row.CachedInputCostUsd+row.CacheCreationCostUsd, + row.CacheCostUsd, 1e-9, "stored cache buckets must sum to the derived cache_cost_usd for %s (%s)", pc.name, model) +} + // providerCase is one entry in the live provider matrix. The same scenario runs // for every available provider; availability is keyed off env vars so the suite // covers whatever credentials are present (source ~/.llm-keys locally / set the @@ -116,12 +324,12 @@ func availableProviders() []providerCase { if region == "" { region = "eu-central-1" } - // A valid Bedrock inference-profile id (region prefix + date + version), - // overridable per account. `global.` profiles can be invoked from any - // region; set AWS_BEDROCK_MODEL to match the enabled profile for the token. + // A valid Bedrock inference-profile id, overridable per account (AWS_BEDROCK_MODEL, also the + // workflow's bedrock_model dispatch input). `global.` profiles work from any region. Defaults to + // Sonnet 4.6, whose id convention dropped the -YYYYMMDD-v1:0 suffix that Haiku 4.5 still carries. model := os.Getenv("AWS_BEDROCK_MODEL") if model == "" { - model = "global.anthropic.claude-haiku-4-5-20251001-v1:0" + model = "global.anthropic.claude-sonnet-4-6" } ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock}) } @@ -257,6 +465,10 @@ func TestProvidersMatrix(t *testing.T) { // session id and confirm the marker propagated end-to-end. sessionID := "e2e-session-" + pc.name + // A long-form prompt so completions carry realistic token counts for cost validation; + // max_tokens in the harness bodies (2048) lets the full answer through. + const matrixPrompt = "explain GitHub workflow in 1000 words" + // Retry briefly to absorb tunnel/DNS jitter on the first call. var code int var body string @@ -267,11 +479,11 @@ func TestProvidersMatrix(t *testing.T) { var cerr error switch pc.kind { case harness.WireVertex: - c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, "Reply with exactly: pong", sessionID) + c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, matrixPrompt, sessionID) case harness.WireBedrock: - c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID) + c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, matrixPrompt, sessionID) default: - c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, "Reply with exactly: pong", sessionID) + c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, matrixPrompt, sessionID) } if cerr == nil { code, body = c, b @@ -290,6 +502,7 @@ func TestProvidersMatrix(t *testing.T) { // The session id sent as x-session-id must round-trip into the // access-log row for this provider. + var row api.AgentNetworkAccessLog require.Eventually(t, func() bool { logs, lerr := srv.ListAccessLogs(ctx) if lerr != nil { @@ -297,11 +510,15 @@ func TestProvidersMatrix(t *testing.T) { } for _, r := range logs.Data { if r.SessionId != nil && *r.SessionId == sessionID { + row = r return true } } return false }, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row for %s", sessionID, pc.name) + + // Stored total and cache cost must match the published rates applied to the row's buckets. + validateAccessLogCost(t, pc, row) }) } @@ -322,4 +539,7 @@ func TestProvidersMatrix(t *testing.T) { } return false }, 60*time.Second, 3*time.Second, "consumption must be recorded with positive token counts after live traffic") + + // Final raw-SQL audit: bypass the API and re-verify every persisted usage row in the store. + verifyUsageRowsSQL(t, srv) } diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 2ffcf653d..f53d0ea64 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -256,7 +256,7 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi case WireMessages: path = "/v1/messages" headers = []string{"anthropic-version: 2023-06-01"} - body = fmt.Sprintf(`{"model":%q,"max_tokens":64,"messages":[{"role":"user","content":%q}]}`, model, prompt) + body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, model, prompt) default: path = "/v1/chat/completions" body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt) @@ -271,7 +271,7 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi // is sent as the universal x-session-id header the proxy records. func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region, model, prompt, sessionID string) (int, string, error) { path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:rawPredict", project, region, model) - body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt) + body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt) return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) } @@ -282,7 +282,7 @@ func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region // header the proxy records. func (cl *Client) Bedrock(ctx context.Context, endpoint, proxyIP, model, prompt, sessionID string) (int, string, error) { path := "/model/" + model + "/invoke" - body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt) + body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt) return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) } @@ -341,7 +341,7 @@ func (cl *Client) Terminate(ctx context.Context) error { return cl.container.Terminate(ctx) } -// containerLogs reads up to 256 KiB of a container's logs for diagnostics. +// containerLogs reads up to 4 MiB of a container's logs for diagnostics — enough for a whole provider-matrix run. func containerLogs(ctx context.Context, c testcontainers.Container) string { if c == nil { return "" @@ -351,6 +351,6 @@ func containerLogs(ctx context.Context, c testcontainers.Container) string { return fmt.Sprintf("", err) } defer r.Close() - b, _ := io.ReadAll(io.LimitReader(r, 256<<10)) + b, _ := io.ReadAll(io.LimitReader(r, 4<<20)) return string(b) } diff --git a/e2e/harness/combined.go b/e2e/harness/combined.go index a6f43a139..5723100ca 100644 --- a/e2e/harness/combined.go +++ b/e2e/harness/combined.go @@ -221,6 +221,29 @@ func (c *Combined) CreateProxyTokenCLI(ctx context.Context, name string) (string return "", fmt.Errorf("token not found in CLI output: %s", string(out)) } +// SnapshotStoreDB copies the management sqlite store (with WAL/SHM sidecars) out of the bind-mounted +// data dir into dstDir and returns the copy's path; reading a copy avoids locking against live writes. +func (c *Combined) SnapshotStoreDB(dstDir string) (string, error) { + src := filepath.Join(c.workDir, "data", "store.db") + if _, err := os.Stat(src); err != nil { + return "", fmt.Errorf("management store not found at %s: %w", src, err) + } + dst := filepath.Join(dstDir, "store.db") + for _, suffix := range []string{"", "-wal", "-shm"} { + data, err := os.ReadFile(src + suffix) + if err != nil { + if os.IsNotExist(err) && suffix != "" { + continue // sidecar only exists in WAL mode + } + return "", fmt.Errorf("read %s: %w", src+suffix, err) + } + if err := os.WriteFile(dst+suffix, data, 0o600); err != nil { + return "", fmt.Errorf("write %s: %w", dst+suffix, err) + } + } + return dst, nil +} + // Logs returns the combined server container logs, for diagnostics. func (c *Combined) Logs(ctx context.Context) string { return containerLogs(ctx, c.container) diff --git a/management/internals/modules/agentnetwork/accesslog_ingest.go b/management/internals/modules/agentnetwork/accesslog_ingest.go index 59e53efa2..ecc1780f3 100644 --- a/management/internals/modules/agentnetwork/accesslog_ingest.go +++ b/management/internals/modules/agentnetwork/accesslog_ingest.go @@ -18,21 +18,26 @@ import ( // contract between the proxy and management; management flattens them into // queryable columns. Keep in sync with the proxy side. const ( - metaKeyProvider = "llm.provider" - metaKeyModel = "llm.model" - metaKeyResolvedProviderID = "llm.resolved_provider_id" - metaKeySelectedPolicyID = "llm.selected_policy_id" - metaKeyPolicyDecision = "llm_policy.decision" - metaKeyPolicyReason = "llm_policy.reason" - metaKeyInputTokens = "llm.input_tokens" //nolint:gosec // metadata key name, not a credential - metaKeyOutputTokens = "llm.output_tokens" //nolint:gosec // metadata key name, not a credential - metaKeyTotalTokens = "llm.total_tokens" //nolint:gosec // metadata key name, not a credential - metaKeyCostUSDTotal = "cost.usd_total" - metaKeyStream = "llm.stream" - metaKeySessionID = "llm.session_id" - metaKeyAuthorisingGroups = "llm.authorising_groups" - metaKeyRequestPrompt = "llm.request_prompt" - metaKeyResponseCompletion = "llm.response_completion" + metaKeyProvider = "llm.provider" + metaKeyModel = "llm.model" + metaKeyResolvedProviderID = "llm.resolved_provider_id" + metaKeySelectedPolicyID = "llm.selected_policy_id" + metaKeyPolicyDecision = "llm_policy.decision" + metaKeyPolicyReason = "llm_policy.reason" + metaKeyInputTokens = "llm.input_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyOutputTokens = "llm.output_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyTotalTokens = "llm.total_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyCachedInputTokens = "llm.cached_input_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyCacheCreationTokens = "llm.cache_creation_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyCostUSDInput = "cost.usd_input" + metaKeyCostUSDCachedInput = "cost.usd_cached_input" + metaKeyCostUSDCacheCreate = "cost.usd_cache_creation" + metaKeyCostUSDOutput = "cost.usd_output" + metaKeyStream = "llm.stream" + metaKeySessionID = "llm.session_id" + metaKeyAuthorisingGroups = "llm.authorising_groups" + metaKeyRequestPrompt = "llm.request_prompt" + metaKeyResponseCompletion = "llm.response_completion" ) // IngestAccessLog flattens the metadata-bearing reverse-proxy access-log entry @@ -108,20 +113,25 @@ func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLo BytesUpload: e.BytesUpload, BytesDownload: e.BytesDownload, - Provider: meta[metaKeyProvider], - Model: meta[metaKeyModel], - SessionID: meta[metaKeySessionID], - ResolvedProviderID: meta[metaKeyResolvedProviderID], - SelectedPolicyID: meta[metaKeySelectedPolicyID], - Decision: meta[metaKeyPolicyDecision], - DenyReason: meta[metaKeyPolicyReason], - InputTokens: parseMetaInt(meta, metaKeyInputTokens), - OutputTokens: parseMetaInt(meta, metaKeyOutputTokens), - TotalTokens: parseMetaInt(meta, metaKeyTotalTokens), - CostUSD: parseMetaFloat(meta, metaKeyCostUSDTotal), - Stream: parseMetaBool(meta, metaKeyStream), - RequestPrompt: meta[metaKeyRequestPrompt], - ResponseCompletion: meta[metaKeyResponseCompletion], + Provider: meta[metaKeyProvider], + Model: meta[metaKeyModel], + SessionID: meta[metaKeySessionID], + ResolvedProviderID: meta[metaKeyResolvedProviderID], + SelectedPolicyID: meta[metaKeySelectedPolicyID], + Decision: meta[metaKeyPolicyDecision], + DenyReason: meta[metaKeyPolicyReason], + InputTokens: parseMetaInt(meta, metaKeyInputTokens), + OutputTokens: parseMetaInt(meta, metaKeyOutputTokens), + TotalTokens: parseMetaInt(meta, metaKeyTotalTokens), + CachedInputTokens: parseMetaInt(meta, metaKeyCachedInputTokens), + CacheCreationTokens: parseMetaInt(meta, metaKeyCacheCreationTokens), + InputCostUSD: parseMetaFloat(meta, metaKeyCostUSDInput), + CachedInputCostUSD: parseMetaFloat(meta, metaKeyCostUSDCachedInput), + CacheCreationCostUSD: parseMetaFloat(meta, metaKeyCostUSDCacheCreate), + OutputCostUSD: parseMetaFloat(meta, metaKeyCostUSDOutput), + Stream: parseMetaBool(meta, metaKeyStream), + RequestPrompt: meta[metaKeyRequestPrompt], + ResponseCompletion: meta[metaKeyResponseCompletion], } var groups []types.AgentNetworkAccessLogGroup @@ -140,18 +150,23 @@ func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLo // log's ID so the two correlate. func usageFromFlattenedLog(e *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) (*types.AgentNetworkUsage, []types.AgentNetworkUsageGroup) { usage := &types.AgentNetworkUsage{ - ID: e.ID, - AccountID: e.AccountID, - Timestamp: e.Timestamp, - UserID: e.UserID, - ResolvedProviderID: e.ResolvedProviderID, - Provider: e.Provider, - Model: e.Model, - SessionID: e.SessionID, - InputTokens: e.InputTokens, - OutputTokens: e.OutputTokens, - TotalTokens: e.TotalTokens, - CostUSD: e.CostUSD, + ID: e.ID, + AccountID: e.AccountID, + Timestamp: e.Timestamp, + UserID: e.UserID, + ResolvedProviderID: e.ResolvedProviderID, + Provider: e.Provider, + Model: e.Model, + SessionID: e.SessionID, + InputTokens: e.InputTokens, + OutputTokens: e.OutputTokens, + TotalTokens: e.TotalTokens, + CachedInputTokens: e.CachedInputTokens, + CacheCreationTokens: e.CacheCreationTokens, + InputCostUSD: e.InputCostUSD, + CachedInputCostUSD: e.CachedInputCostUSD, + CacheCreationCostUSD: e.CacheCreationCostUSD, + OutputCostUSD: e.OutputCostUSD, } usageGroups := make([]types.AgentNetworkUsageGroup, 0, len(groups)) diff --git a/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go index 431ce680e..cd81cfbe4 100644 --- a/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go +++ b/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go @@ -28,17 +28,22 @@ func newIngestTestEntry() *accesslogs.AccessLogEntry { UserId: "user-1", AgentNetwork: true, Metadata: map[string]string{ - metaKeyProvider: "openai", - metaKeyModel: "gpt-5.4", - metaKeyResolvedProviderID: "prov-1", - metaKeySessionID: "sess-1", - metaKeyInputTokens: "100", - metaKeyOutputTokens: "50", - metaKeyTotalTokens: "150", - metaKeyCostUSDTotal: "0.0123", - metaKeyStream: "true", - metaKeyRequestPrompt: "hello", - metaKeyResponseCompletion: "world", + metaKeyProvider: "openai", + metaKeyModel: "gpt-5.4", + metaKeyResolvedProviderID: "prov-1", + metaKeySessionID: "sess-1", + metaKeyInputTokens: "100", + metaKeyOutputTokens: "50", + metaKeyTotalTokens: "1174", + metaKeyCachedInputTokens: "256", + metaKeyCacheCreationTokens: "768", + metaKeyCostUSDInput: "0.0071", + metaKeyCostUSDCachedInput: "0.0009", + metaKeyCostUSDCacheCreate: "0.0020", + metaKeyCostUSDOutput: "0.0023", + metaKeyStream: "true", + metaKeyRequestPrompt: "hello", + metaKeyResponseCompletion: "world", // repeated id must be de-duplicated before the group rows insert. metaKeyAuthorisingGroups: "grp-eng,grp-eng,grp-ops", }, @@ -65,7 +70,19 @@ func TestIngestAccessLog_RealStore_LogCollectionOff(t *testing.T) { require.Len(t, usage, 1, "usage row must be written even with log collection off") assert.Equal(t, int64(100), usage[0].InputTokens, "input tokens must round-trip from metadata") assert.Equal(t, int64(50), usage[0].OutputTokens, "output tokens must round-trip from metadata") - assert.InDelta(t, 0.0123, usage[0].CostUSD, 1e-9, "cost must round-trip from metadata") + assert.Equal(t, int64(256), usage[0].CachedInputTokens, "cache-read tokens must round-trip from metadata") + assert.Equal(t, int64(768), usage[0].CacheCreationTokens, "cache-write tokens must round-trip from metadata") + // The per-bucket breakdown is the only cost state stored, and must survive + // the write/read cycle as real columns — usage rows are the only cost + // record for accounts with log collection off, so a dropped column here + // loses the split permanently. + assert.InDelta(t, 0.0071, usage[0].InputCostUSD, 1e-9, "input cost must round-trip from metadata") + assert.InDelta(t, 0.0009, usage[0].CachedInputCostUSD, 1e-9, "cache-read cost must round-trip from metadata") + assert.InDelta(t, 0.0020, usage[0].CacheCreationCostUSD, 1e-9, "cache-write cost must round-trip from metadata") + assert.InDelta(t, 0.0023, usage[0].OutputCostUSD, 1e-9, "output cost must round-trip from metadata") + // Aggregates are derived from the stored columns, never stored themselves. + assert.InDelta(t, 0.0123, usage[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets") + assert.InDelta(t, 0.0029, usage[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets") logs, _, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) require.NoError(t, err) @@ -96,6 +113,14 @@ func TestIngestAccessLog_RealStore_LogCollectionOn(t *testing.T) { require.Equal(t, int64(1), total, "exactly one access-log row expected") require.Len(t, logs, 1, "full access-log row must be written when log collection is on") assert.Equal(t, "gpt-5.4", logs[0].Model, "model must flatten from metadata") + assert.Equal(t, int64(256), logs[0].CachedInputTokens, "cache-read tokens must flatten from metadata") + assert.Equal(t, int64(768), logs[0].CacheCreationTokens, "cache-write tokens must flatten from metadata") + assert.InDelta(t, 0.0029, logs[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets") + assert.InDelta(t, 0.0123, logs[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets") + assert.InDelta(t, 0.0071, logs[0].InputCostUSD, 1e-9, "input cost must flatten from metadata") + assert.InDelta(t, 0.0009, logs[0].CachedInputCostUSD, 1e-9, "cache-read cost must flatten from metadata") + assert.InDelta(t, 0.0020, logs[0].CacheCreationCostUSD, 1e-9, "cache-write cost must flatten from metadata") + assert.InDelta(t, 0.0023, logs[0].OutputCostUSD, 1e-9, "output cost must flatten from metadata") assert.Equal(t, "hello", logs[0].RequestPrompt, "prompt must be retained when log collection is on") assert.Equal(t, "world", logs[0].ResponseCompletion, "completion must be retained when log collection is on") assert.True(t, logs[0].Stream, "stream flag must flatten from metadata") diff --git a/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go index 7d53d7547..94518c2f7 100644 --- a/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go +++ b/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go @@ -38,7 +38,7 @@ func accessLogRow(id, sessionID string, ts time.Time, opts ...func(*types.AgentN InputTokens: 100, OutputTokens: 50, TotalTokens: 150, - CostUSD: 0.01, + InputCostUSD: 0.01, } for _, o := range opts { o(e) @@ -74,7 +74,7 @@ func withTokens(in, out, total int64, cost float64) func(*types.AgentNetworkAcce e.InputTokens = in e.OutputTokens = out e.TotalTokens = total - e.CostUSD = cost + e.InputCostUSD = cost } } @@ -155,7 +155,7 @@ func TestAccessLogSessions_FoldAndAggregate(t *testing.T) { assert.Equal(t, int64(310), a.InputTokens, "input tokens summed") assert.Equal(t, int64(135), a.OutputTokens, "output tokens summed") assert.Equal(t, int64(445), a.TotalTokens, "total tokens summed") - assert.InDelta(t, 0.031, a.CostUSD, 1e-9, "cost summed") + assert.InDelta(t, 0.031, a.TotalCostUSD(), 1e-9, "cost summed") assert.Equal(t, "deny", a.Decision, "any deny makes the session a deny") assert.ElementsMatch(t, []string{"openai", "anthropic"}, a.Providers, "distinct providers") assert.ElementsMatch(t, []string{"gpt-5.4", "claude-haiku-4-5"}, a.Models, "distinct models") diff --git a/management/internals/modules/agentnetwork/types/accesslog.go b/management/internals/modules/agentnetwork/types/accesslog.go index 92b8bc358..cde7be7de 100644 --- a/management/internals/modules/agentnetwork/types/accesslog.go +++ b/management/internals/modules/agentnetwork/types/accesslog.go @@ -41,8 +41,24 @@ type AgentNetworkAccessLog struct { InputTokens int64 OutputTokens int64 TotalTokens int64 - CostUSD float64 - Stream bool + // Prompt-cache buckets: read + write token counts. + CachedInputTokens int64 + CacheCreationTokens int64 + // Per-bucket cost breakdown — one column per token bucket the provider + // bills separately. These four are the only cost state stored: the total + // and the cache portion are derived on read (TotalCostUSD / CacheCostUSD) + // rather than stored alongside, so a stored aggregate can never drift out + // of step with the components it summarises. + // + // default:0 matters on upgrade: these columns are ALTER TABLE ADD COLUMN + // on an existing table, and without it every historical row holds NULL — + // which a raw SUM()/scan into float64 can't read. The default backfills + // them as 0, so pre-upgrade rows report an unknown split, not an error. + InputCostUSD float64 `gorm:"not null;default:0"` + CachedInputCostUSD float64 `gorm:"not null;default:0"` + CacheCreationCostUSD float64 `gorm:"not null;default:0"` + OutputCostUSD float64 `gorm:"not null;default:0"` + Stream bool // Prompt capture. Only populated when prompt collection is enabled // (account master switch AND policy guardrail). Heavy free text. @@ -60,19 +76,44 @@ type AgentNetworkAccessLog struct { // the reverse-proxy AccessLogEntry table. func (AgentNetworkAccessLog) TableName() string { return "agent_network_access_log" } +// CostUSDSQLExpr is the SQL sum of the per-bucket cost columns — the total cost +// of a row. Used wherever a query has to sort or aggregate on total cost now +// that no cost_usd column is stored. Plain arithmetic over NOT NULL columns, so +// it stays portable across SQLite and Postgres. +const CostUSDSQLExpr = "(input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd)" + +// TotalCostUSD is the request's total cost: the sum of the four per-bucket +// costs. Derived rather than stored so it cannot disagree with the breakdown. +func (a *AgentNetworkAccessLog) TotalCostUSD() float64 { + return a.InputCostUSD + a.CachedInputCostUSD + a.CacheCreationCostUSD + a.OutputCostUSD +} + +// CacheCostUSD is the portion of the total billed for prompt-cache buckets: +// cache reads plus cache writes. +func (a *AgentNetworkAccessLog) CacheCostUSD() float64 { + return a.CachedInputCostUSD + a.CacheCreationCostUSD +} + // ToAPIResponse renders the flattened entry as the API representation. func (a *AgentNetworkAccessLog) ToAPIResponse() api.AgentNetworkAccessLog { out := api.AgentNetworkAccessLog{ - Id: a.ID, - ServiceId: a.ServiceID, - Timestamp: a.Timestamp, - StatusCode: a.StatusCode, - DurationMs: int(a.Duration.Milliseconds()), - InputTokens: a.InputTokens, - OutputTokens: a.OutputTokens, - TotalTokens: a.TotalTokens, - CostUsd: a.CostUSD, - Stream: &a.Stream, + Id: a.ID, + ServiceId: a.ServiceID, + Timestamp: a.Timestamp, + StatusCode: a.StatusCode, + DurationMs: int(a.Duration.Milliseconds()), + InputTokens: a.InputTokens, + OutputTokens: a.OutputTokens, + TotalTokens: a.TotalTokens, + CachedInputTokens: a.CachedInputTokens, + CacheCreationTokens: a.CacheCreationTokens, + InputCostUsd: a.InputCostUSD, + CachedInputCostUsd: a.CachedInputCostUSD, + CacheCreationCostUsd: a.CacheCreationCostUSD, + OutputCostUsd: a.OutputCostUSD, + CostUsd: a.TotalCostUSD(), + CacheCostUsd: a.CacheCostUSD(), + Stream: &a.Stream, } out.UserId = strPtr(a.UserID) @@ -112,20 +153,36 @@ func strPtr(s string) *string { // summary plus its ordered entries. Assembled in Go from a page of entries — it // is not a stored table. type AgentNetworkAccessLogSession struct { - SessionID string // empty for a session-less (singleton) request - UserID string - GroupIDs []string // union of the entries' authorising groups - StartedAt time.Time - EndedAt time.Time - RequestCount int - InputTokens int64 - OutputTokens int64 - TotalTokens int64 - CostUSD float64 - Providers []string // distinct vendors seen in the session - Models []string // distinct models seen in the session - Decision string // "deny" if any entry was denied, else "allow" - Entries []*AgentNetworkAccessLog + SessionID string // empty for a session-less (singleton) request + UserID string + GroupIDs []string // union of the entries' authorising groups + StartedAt time.Time + EndedAt time.Time + RequestCount int + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CachedInputTokens int64 + CacheCreationTokens int64 + InputCostUSD float64 + CachedInputCostUSD float64 + CacheCreationCostUSD float64 + OutputCostUSD float64 + Providers []string // distinct vendors seen in the session + Models []string // distinct models seen in the session + Decision string // "deny" if any entry was denied, else "allow" + Entries []*AgentNetworkAccessLog +} + +// TotalCostUSD is the session's total cost: the sum of the four per-bucket +// costs accumulated across its entries. +func (sess *AgentNetworkAccessLogSession) TotalCostUSD() float64 { + return sess.InputCostUSD + sess.CachedInputCostUSD + sess.CacheCreationCostUSD + sess.OutputCostUSD +} + +// CacheCostUSD is the session's prompt-cache spend: cache reads plus writes. +func (sess *AgentNetworkAccessLogSession) CacheCostUSD() float64 { + return sess.CachedInputCostUSD + sess.CacheCreationCostUSD } // sessionKey is the grouping key for an entry: its session id, or — when the @@ -205,7 +262,12 @@ func (sess *AgentNetworkAccessLogSession) foldEntry(sk *sessionSeen, e *AgentNet sess.InputTokens += e.InputTokens sess.OutputTokens += e.OutputTokens sess.TotalTokens += e.TotalTokens - sess.CostUSD += e.CostUSD + sess.CachedInputTokens += e.CachedInputTokens + sess.CacheCreationTokens += e.CacheCreationTokens + sess.InputCostUSD += e.InputCostUSD + sess.CachedInputCostUSD += e.CachedInputCostUSD + sess.CacheCreationCostUSD += e.CacheCreationCostUSD + sess.OutputCostUSD += e.OutputCostUSD if e.Timestamp.Before(sess.StartedAt) { sess.StartedAt = e.Timestamp } @@ -248,15 +310,22 @@ func (sess *AgentNetworkAccessLogSession) ToAPIResponse() api.AgentNetworkAccess } out := api.AgentNetworkAccessLogSession{ - StartedAt: sess.StartedAt, - EndedAt: sess.EndedAt, - RequestCount: sess.RequestCount, - InputTokens: sess.InputTokens, - OutputTokens: sess.OutputTokens, - TotalTokens: sess.TotalTokens, - CostUsd: sess.CostUSD, - Decision: sess.Decision, - Entries: entries, + StartedAt: sess.StartedAt, + EndedAt: sess.EndedAt, + RequestCount: sess.RequestCount, + InputTokens: sess.InputTokens, + OutputTokens: sess.OutputTokens, + TotalTokens: sess.TotalTokens, + CachedInputTokens: sess.CachedInputTokens, + CacheCreationTokens: sess.CacheCreationTokens, + InputCostUsd: sess.InputCostUSD, + CachedInputCostUsd: sess.CachedInputCostUSD, + CacheCreationCostUsd: sess.CacheCreationCostUSD, + OutputCostUsd: sess.OutputCostUSD, + CostUsd: sess.TotalCostUSD(), + CacheCostUsd: sess.CacheCostUSD(), + Decision: sess.Decision, + Entries: entries, } out.SessionId = strPtr(sess.SessionID) out.UserId = strPtr(sess.UserID) diff --git a/management/internals/modules/agentnetwork/types/accesslogfilter.go b/management/internals/modules/agentnetwork/types/accesslogfilter.go index d571a87b6..d35516ffa 100644 --- a/management/internals/modules/agentnetwork/types/accesslogfilter.go +++ b/management/internals/modules/agentnetwork/types/accesslogfilter.go @@ -54,7 +54,7 @@ var accessLogSortFields = map[string]string{ "provider": "provider", "status_code": "status_code", "duration": "duration", - "cost_usd": "cost_usd", + "cost_usd": CostUSDSQLExpr, "total_tokens": "total_tokens", "user_id": "user_id", "decision": "decision", @@ -70,7 +70,7 @@ var accessLogSortFields = map[string]string{ var sessionSortExprs = map[string]string{ //nolint:gosec // G101 false positive: "total_tokens" sort key, not a credential "timestamp": "MAX(timestamp)", "started_at": "MIN(timestamp)", - "cost_usd": "SUM(cost_usd)", + "cost_usd": "SUM" + CostUSDSQLExpr, "total_tokens": "SUM(total_tokens)", "duration": "SUM(duration)", "request_count": "COUNT(*)", diff --git a/management/internals/modules/agentnetwork/types/cost_test.go b/management/internals/modules/agentnetwork/types/cost_test.go new file mode 100644 index 000000000..9194ef4a3 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/cost_test.go @@ -0,0 +1,124 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// costRow builds an access-log entry carrying only a cost breakdown — the rest +// of the row is irrelevant to the summation identities under test. +func costRow(id, session string, ts time.Time, in, cachedIn, cacheCreate, out float64) *AgentNetworkAccessLog { + return &AgentNetworkAccessLog{ + ID: id, + SessionID: session, + Timestamp: ts, + InputCostUSD: in, + CachedInputCostUSD: cachedIn, + CacheCreationCostUSD: cacheCreate, + OutputCostUSD: out, + } +} + +// TestAPIResponse_CostComponentsSumToAggregates is the contract a client adding +// up an API response depends on: within a single rendered object, the four +// per-bucket fields sum to cost_usd, and the two cache fields sum to +// cache_cost_usd. Uses rates that are not exactly representable in binary +// floating point, so the identity is checked against real arithmetic rather +// than round numbers. +func TestAPIResponse_CostComponentsSumToAggregates(t *testing.T) { + row := costRow("r1", "s1", time.Now(), 0.000768, 0.0002304, 0.00192, 0.003) + + api := row.ToAPIResponse() + assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd, + api.CostUsd, 1e-12, "rendered buckets must sum to the rendered cost_usd") + assert.InDelta(t, api.CachedInputCostUsd+api.CacheCreationCostUsd, api.CacheCostUsd, 1e-12, + "rendered cache buckets must sum to the rendered cache_cost_usd") + assert.InDelta(t, 0.0059184, api.CostUsd, 1e-12, "total is the exact sum, not a separately rounded figure") + assert.InDelta(t, 0.0021504, api.CacheCostUsd, 1e-12, "cache cost is the exact sum of the two cache buckets") +} + +// TestSessionSummary_SumsMatchSummedEntries proves a session summary equals the +// sum of the entries it renders: a client that adds up the entries itself must +// land on the same number the summary reports, per bucket and in total. +func TestSessionSummary_SumsMatchSummedEntries(t *testing.T) { + base := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC) + entries := []*AgentNetworkAccessLog{ + costRow("r1", "s1", base, 0.000768, 0.0002304, 0.00192, 0.003), + costRow("r2", "s1", base.Add(time.Minute), 0.000625, 0.0009375, 0, 0.005), + costRow("r3", "s1", base.Add(2*time.Minute), 0.0000016, 0, 0, 0.0000032), + } + + sessions := FoldAccessLogSessions([]string{"s1"}, entries) + require.Len(t, sessions, 1) + sess := sessions[0].ToAPIResponse() + + var wantInput, wantCachedInput, wantCacheCreation, wantOutput float64 + for _, e := range entries { + wantInput += e.InputCostUSD + wantCachedInput += e.CachedInputCostUSD + wantCacheCreation += e.CacheCreationCostUSD + wantOutput += e.OutputCostUSD + } + + assert.InDelta(t, wantInput, sess.InputCostUsd, 1e-12, "session input cost is the sum of its entries") + assert.InDelta(t, wantCachedInput, sess.CachedInputCostUsd, 1e-12, "session cache-read cost is the sum of its entries") + assert.InDelta(t, wantCacheCreation, sess.CacheCreationCostUsd, 1e-12, "session cache-write cost is the sum of its entries") + assert.InDelta(t, wantOutput, sess.OutputCostUsd, 1e-12, "session output cost is the sum of its entries") + assert.InDelta(t, wantInput+wantCachedInput+wantCacheCreation+wantOutput, sess.CostUsd, 1e-12, + "session total equals the summed entry buckets") + + // Summing the rendered entries must give the same answer as reading the + // summary — the property a UI relies on when it totals a table itself. + var fromEntries float64 + for _, e := range sess.Entries { + fromEntries += e.CostUsd + } + assert.InDelta(t, sess.CostUsd, fromEntries, 1e-12, "summary total must match the summed rendered entries") + + // The sub-microdollar row must still contribute; it would vanish under + // 6-decimal quantisation. + assert.Greater(t, sess.InputCostUsd, 0.001393, "small-cost rows must not be quantised away") +} + +// TestUsageBuckets_SumsMatchSummedRows proves the same identity one level up: +// a usage bucket equals the sum of the ledger rows folded into it, and the +// buckets together equal the whole range. +func TestUsageBuckets_SumsMatchSummedRows(t *testing.T) { + day1 := time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC) + day2 := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC) + rows := []*AgentNetworkUsage{ + {ID: "u1", Timestamp: day1, InputCostUSD: 0.000768, CachedInputCostUSD: 0.0002304, CacheCreationCostUSD: 0.00192, OutputCostUSD: 0.003}, + {ID: "u2", Timestamp: day1.Add(time.Hour), InputCostUSD: 0.000625, CachedInputCostUSD: 0.0009375, OutputCostUSD: 0.005}, + {ID: "u3", Timestamp: day2, InputCostUSD: 0.0000016, OutputCostUSD: 0.0000032}, + } + + buckets := AggregateUsageByGranularity(rows, UsageGranularityDay) + require.Len(t, buckets, 2, "two distinct days expected") + + var total, cache float64 + for _, b := range buckets { + api := b.ToAPIResponse() + assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd, + api.CostUsd, 1e-12, "each bucket's components must sum to its cost_usd") + total += api.CostUsd + cache += api.CacheCostUsd + } + + var wantTotal, wantCache float64 + for _, r := range rows { + wantTotal += r.TotalCostUSD() + wantCache += r.CacheCostUSD() + } + assert.InDelta(t, wantTotal, total, 1e-12, "buckets must sum to the total across all ledger rows") + assert.InDelta(t, wantCache, cache, 1e-12, "buckets must sum to the cache spend across all ledger rows") + + // A month bucket over the same rows must total identically — regrouping + // changes the partition, never the sum. + monthly := AggregateUsageByGranularity(rows, UsageGranularityMonth) + require.Len(t, monthly, 1) + assert.InDelta(t, wantTotal, monthly[0].ToAPIResponse().CostUsd, 1e-12, + "re-bucketing at a different granularity must preserve the total") +} diff --git a/management/internals/modules/agentnetwork/types/usage.go b/management/internals/modules/agentnetwork/types/usage.go index dd01d4300..658fa8e59 100644 --- a/management/internals/modules/agentnetwork/types/usage.go +++ b/management/internals/modules/agentnetwork/types/usage.go @@ -25,8 +25,19 @@ type AgentNetworkUsage struct { InputTokens int64 OutputTokens int64 TotalTokens int64 - CostUSD float64 - CreatedAt time.Time + // Prompt-cache buckets: read + write token counts. + CachedInputTokens int64 + CacheCreationTokens int64 + // Per-bucket cost breakdown, mirroring AgentNetworkAccessLog — the only + // cost state stored; total and cache portion are derived on read. Kept on + // the usage ledger too so spend can be attributed per bucket even for + // accounts with log collection turned off. See AgentNetworkAccessLog for + // why the columns carry a zero default. + InputCostUSD float64 `gorm:"not null;default:0"` + CachedInputCostUSD float64 `gorm:"not null;default:0"` + CacheCreationCostUSD float64 `gorm:"not null;default:0"` + OutputCostUSD float64 `gorm:"not null;default:0"` + CreatedAt time.Time } // TableName keeps usage records in their own stripped table. Named @@ -34,6 +45,17 @@ type AgentNetworkUsage struct { // agent_network_usage table in a shared database. func (AgentNetworkUsage) TableName() string { return "agent_network_request_usage" } +// TotalCostUSD is the request's total cost: the sum of the four per-bucket +// costs. Derived rather than stored so it cannot disagree with the breakdown. +func (u *AgentNetworkUsage) TotalCostUSD() float64 { + return u.InputCostUSD + u.CachedInputCostUSD + u.CacheCreationCostUSD + u.OutputCostUSD +} + +// CacheCostUSD is the portion of the total billed for prompt-cache buckets. +func (u *AgentNetworkUsage) CacheCostUSD() float64 { + return u.CachedInputCostUSD + u.CacheCreationCostUSD +} + // AgentNetworkUsageGroup is the normalised many-to-many row linking a usage // record to one authorising group, mirroring AgentNetworkAccessLogGroup so the // usage overview can filter by group with a `group_id IN (...)` join. diff --git a/management/internals/modules/agentnetwork/types/usageoverview.go b/management/internals/modules/agentnetwork/types/usageoverview.go index 658832bec..81e02c6b8 100644 --- a/management/internals/modules/agentnetwork/types/usageoverview.go +++ b/management/internals/modules/agentnetwork/types/usageoverview.go @@ -33,21 +33,45 @@ func ParseUsageGranularity(s string) UsageGranularity { // AgentNetworkUsageBucket is one aggregated usage time bucket. PeriodStart is // the UTC start of the bucket as YYYY-MM-DD. type AgentNetworkUsageBucket struct { - PeriodStart string - InputTokens int64 - OutputTokens int64 - TotalTokens int64 - CostUSD float64 + PeriodStart string + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CachedInputTokens int64 + CacheCreationTokens int64 + InputCostUSD float64 + CachedInputCostUSD float64 + CacheCreationCostUSD float64 + OutputCostUSD float64 +} + +// TotalCostUSD is the bucket's total spend: the sum of the four per-bucket +// costs. Derived rather than accumulated separately so it cannot disagree with +// the components. +func (b *AgentNetworkUsageBucket) TotalCostUSD() float64 { + return b.InputCostUSD + b.CachedInputCostUSD + b.CacheCreationCostUSD + b.OutputCostUSD +} + +// CacheCostUSD is the bucket's prompt-cache spend: cache reads plus writes. +func (b *AgentNetworkUsageBucket) CacheCostUSD() float64 { + return b.CachedInputCostUSD + b.CacheCreationCostUSD } // ToAPIResponse renders the bucket as the API representation. func (b *AgentNetworkUsageBucket) ToAPIResponse() api.AgentNetworkUsageBucket { return api.AgentNetworkUsageBucket{ - PeriodStart: b.PeriodStart, - InputTokens: b.InputTokens, - OutputTokens: b.OutputTokens, - TotalTokens: b.TotalTokens, - CostUsd: b.CostUSD, + PeriodStart: b.PeriodStart, + InputTokens: b.InputTokens, + OutputTokens: b.OutputTokens, + TotalTokens: b.TotalTokens, + CachedInputTokens: b.CachedInputTokens, + CacheCreationTokens: b.CacheCreationTokens, + InputCostUsd: b.InputCostUSD, + CachedInputCostUsd: b.CachedInputCostUSD, + CacheCreationCostUsd: b.CacheCreationCostUSD, + OutputCostUsd: b.OutputCostUSD, + CostUsd: b.TotalCostUSD(), + CacheCostUsd: b.CacheCostUSD(), } } @@ -84,7 +108,12 @@ func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity) b.InputTokens += r.InputTokens b.OutputTokens += r.OutputTokens b.TotalTokens += r.TotalTokens - b.CostUSD += r.CostUSD + b.CachedInputTokens += r.CachedInputTokens + b.CacheCreationTokens += r.CacheCreationTokens + b.InputCostUSD += r.InputCostUSD + b.CachedInputCostUSD += r.CachedInputCostUSD + b.CacheCreationCostUSD += r.CacheCreationCostUSD + b.OutputCostUSD += r.OutputCostUSD } out := make([]*AgentNetworkUsageBucket, 0, len(byPeriod)) diff --git a/management/server/migration/migration.go b/management/server/migration/migration.go index ae26a254e..6d8ed90cc 100644 --- a/management/server/migration/migration.go +++ b/management/server/migration/migration.go @@ -683,3 +683,81 @@ func BackfillPublicIDs[T any](ctx context.Context, db *gorm.DB) error { log.WithContext(ctx).Infof("Backfill of empty public_id in table %s completed", tableName) return nil } + +// FoldCostAggregatesIntoBuckets migrates a per-request cost table from the old +// "stored aggregate" shape (cost_usd + cache_cost_usd columns) to the per-bucket +// breakdown, where the total and cache portion are derived on read instead. +// +// The fold preserves both aggregates exactly for historical rows: the cache +// total moves into cached_input_cost_usd and the remainder into +// input_cost_usd, so a row's derived total and cache cost still match what it +// reported before the upgrade. The finer split is genuinely unknown for those +// rows — the old schema never recorded a read/write or input/output division — +// so it is lumped rather than guessed; only rows written after the upgrade +// carry a true four-way split. +// +// Dropping the columns before folding would zero every historical row's cost, +// so the update runs first and the drop only happens once it succeeds. A table +// with no cost_usd column has already been migrated (or was created fresh) and +// is skipped. +func FoldCostAggregatesIntoBuckets[T any](ctx context.Context, db *gorm.DB) error { + var model T + + if !db.Migrator().HasTable(&model) { + log.WithContext(ctx).Debugf("table for %T does not exist, no cost-bucket migration needed", model) + return nil + } + if !db.Migrator().HasColumn(&model, "cost_usd") { + log.WithContext(ctx).Debugf("table for %T has no cost_usd column, cost buckets already migrated", model) + return nil + } + + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(&model); err != nil { + return fmt.Errorf("parse model schema: %w", err) + } + tableName := stmt.Schema.Table + + // COALESCE guards rows whose new columns were added as NULL by an earlier + // AutoMigrate run that predates the NOT NULL default. + hasCacheColumn := db.Migrator().HasColumn(&model, "cache_cost_usd") + cacheExpr := "0" + if hasCacheColumn { + cacheExpr = "COALESCE(cache_cost_usd, 0)" + } + + if err := db.Transaction(func(tx *gorm.DB) error { + // Only touch rows that carry a legacy total and no breakdown yet, so + // the migration is idempotent and never overwrites a true split. + update := fmt.Sprintf(`UPDATE %s + SET input_cost_usd = COALESCE(cost_usd, 0) - %s, + cached_input_cost_usd = %s, + cache_creation_cost_usd = 0, + output_cost_usd = 0 + WHERE COALESCE(cost_usd, 0) <> 0 + AND COALESCE(input_cost_usd, 0) = 0 + AND COALESCE(cached_input_cost_usd, 0) = 0 + AND COALESCE(cache_creation_cost_usd, 0) = 0 + AND COALESCE(output_cost_usd, 0) = 0`, tableName, cacheExpr, cacheExpr) + res := tx.Exec(update) + if res.Error != nil { + return fmt.Errorf("fold legacy cost aggregates in %s: %w", tableName, res.Error) + } + log.WithContext(ctx).Infof("folded legacy cost aggregates into per-bucket columns for %d rows in table %s", res.RowsAffected, tableName) + + if err := tx.Migrator().DropColumn(&model, "cost_usd"); err != nil { + return fmt.Errorf("drop cost_usd from %s: %w", tableName, err) + } + if hasCacheColumn { + if err := tx.Migrator().DropColumn(&model, "cache_cost_usd"); err != nil { + return fmt.Errorf("drop cache_cost_usd from %s: %w", tableName, err) + } + } + return nil + }); err != nil { + return err + } + + log.WithContext(ctx).Infof("migration of stored cost aggregates to per-bucket columns in table %s completed", tableName) + return nil +} diff --git a/management/server/migration/migration_test.go b/management/server/migration/migration_test.go index cc97c2dff..b60a50ee1 100644 --- a/management/server/migration/migration_test.go +++ b/management/server/migration/migration_test.go @@ -16,6 +16,7 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" "github.com/netbirdio/netbird/management/server/migration" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/testutil" @@ -639,3 +640,99 @@ func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) { db.Model(&testChildWithFK{}).Count(&count) assert.Equal(t, int64(2), count, "Both rows should survive — migration must skip when FK constraint exists") } + +// legacyCostRow is the pre-breakdown shape of the usage table: cost was stored +// as a total plus a cache portion, with no per-bucket columns. Used to build a +// realistic pre-upgrade table for the fold migration to run against. +type legacyCostRow struct { + ID string `gorm:"primaryKey"` + AccountID string + Model string + CostUSD float64 + CacheCostUSD float64 +} + +func (legacyCostRow) TableName() string { return "agent_network_request_usage" } + +// TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost covers the upgrade +// path: a table written under the old schema must come out with its per-row +// total and cache cost unchanged, because dropping cost_usd without folding it +// forward would silently zero every historical row's spend. +func TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost(t *testing.T) { + ctx := context.Background() + db := setupDatabase(t) + // setupDatabase hands back a process-shared database, so start from a clean + // table rather than inheriting rows from another test. + require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{})) + + require.NoError(t, db.AutoMigrate(&legacyCostRow{}), "legacy table must be created") + require.NoError(t, db.Create(&legacyCostRow{ + ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", CostUSD: 0.0123, CacheCostUSD: 0.0029, + }).Error) + require.NoError(t, db.Create(&legacyCostRow{ + ID: "u2", AccountID: "acct-1", Model: "gpt-4o", CostUSD: 0.5, CacheCostUSD: 0, + }).Error) + // A zero-cost row (denied / unpriced request) must stay zero, not be touched. + require.NoError(t, db.Create(&legacyCostRow{ID: "u3", AccountID: "acct-1", Model: "gw/unpriced"}).Error) + + // AutoMigrate adds the per-bucket columns alongside the legacy ones, exactly + // as a real upgrade does before the post-auto migrations run. + require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}), "new columns must be added") + + require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db)) + + assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cost_usd"), + "legacy cost_usd column must be dropped once folded") + assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cache_cost_usd"), + "legacy cache_cost_usd column must be dropped once folded") + + var rows []*agentNetworkTypes.AgentNetworkUsage + require.NoError(t, db.Order("id").Find(&rows).Error) + require.Len(t, rows, 3) + + // u1: total and cache portion both preserved; the read/write and + // input/output splits are unknowable for a legacy row, so the cache total + // lands on cached_input and the remainder on input. + assert.InDelta(t, 0.0123, rows[0].TotalCostUSD(), 1e-9, "historical total must survive the fold") + assert.InDelta(t, 0.0029, rows[0].CacheCostUSD(), 1e-9, "historical cache cost must survive the fold") + assert.InDelta(t, 0.0094, rows[0].InputCostUSD, 1e-9, "non-cache remainder lands on input") + assert.InDelta(t, 0.0029, rows[0].CachedInputCostUSD, 1e-9, "legacy cache total lands on cached input") + assert.Zero(t, rows[0].CacheCreationCostUSD, "legacy rows carry no read/write split to recover") + assert.Zero(t, rows[0].OutputCostUSD, "legacy rows carry no input/output split to recover") + + // u2: no cache spend — the whole total is the non-cache remainder. + assert.InDelta(t, 0.5, rows[1].TotalCostUSD(), 1e-9, "cache-free historical total must survive") + assert.Zero(t, rows[1].CacheCostUSD(), "a cache-free row must stay cache-free") + + // u3: zero stays zero rather than being rewritten. + assert.Zero(t, rows[2].TotalCostUSD(), "an unpriced row must remain unpriced") +} + +// TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated proves the migration is +// safe to re-run: with no legacy column present it is a no-op that leaves a +// true four-way split untouched. +func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) { + ctx := context.Background() + db := setupDatabase(t) + require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{})) + + require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{})) + // Timestamp must be set explicitly: a zero time.Time serialises as + // '0000-00-00 00:00:00', which MySQL rejects under strict mode. + require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{ + ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", + Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC), + InputCostUSD: 0.001, CachedInputCostUSD: 0.002, CacheCreationCostUSD: 0.003, OutputCostUSD: 0.004, + }).Error) + + require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db), + "running against an already-migrated table must be a no-op, not an error") + + var row agentNetworkTypes.AgentNetworkUsage + require.NoError(t, db.First(&row, "id = ?", "u1").Error) + assert.InDelta(t, 0.001, row.InputCostUSD, 1e-9, "a true split must not be rewritten") + assert.InDelta(t, 0.002, row.CachedInputCostUSD, 1e-9) + assert.InDelta(t, 0.003, row.CacheCreationCostUSD, 1e-9) + assert.InDelta(t, 0.004, row.OutputCostUSD, 1e-9) + assert.InDelta(t, 0.01, row.TotalCostUSD(), 1e-9, "derived total sums the four buckets") +} diff --git a/management/server/store/sql_store_agentnetwork.go b/management/server/store/sql_store_agentnetwork.go index b0df0cd2a..b72dc735f 100644 --- a/management/server/store/sql_store_agentnetwork.go +++ b/management/server/store/sql_store_agentnetwork.go @@ -71,7 +71,7 @@ func (s *SqlStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetr usageRow := db.Model(&agentNetworkTypes.AgentNetworkUsage{}). Select("COALESCE(SUM(input_tokens), 0) AS input_tokens, " + "COALESCE(SUM(output_tokens), 0) AS output_tokens, " + - "COALESCE(SUM(cost_usd), 0) AS cost_usd").Row() + "COALESCE(SUM" + agentNetworkTypes.CostUSDSQLExpr + ", 0) AS cost_usd").Row() if err := usageRow.Scan(&m.InputTokens, &m.OutputTokens, &m.CostUSD); err != nil { return AgentNetworkMetrics{}, fmt.Errorf("scan agent network usage metrics: %w", err) } diff --git a/management/server/store/sql_store_agentnetwork_accesslog_test.go b/management/server/store/sql_store_agentnetwork_accesslog_test.go index 793c82d79..8ba79a062 100644 --- a/management/server/store/sql_store_agentnetwork_accesslog_test.go +++ b/management/server/store/sql_store_agentnetwork_accesslog_test.go @@ -37,7 +37,7 @@ func TestAgentNetworkUsage_RealStore_RoundTrip(t *testing.T) { InputTokens: 1200, OutputTokens: 640, TotalTokens: 1840, - CostUSD: 0.0231, + InputCostUSD: 0.0231, } usageGroups := []agentNetworkTypes.AgentNetworkUsageGroup{ {UsageID: usage.ID, GroupID: "grp-eng", AccountID: accountID}, @@ -71,7 +71,7 @@ func TestAgentNetworkUsage_RealStore_RoundTrip(t *testing.T) { InputTokens: 1200, OutputTokens: 640, TotalTokens: 1840, - CostUSD: 0.0231, + InputCostUSD: 0.0231, } entryGroups := []agentNetworkTypes.AgentNetworkAccessLogGroup{ {LogID: entry.ID, GroupID: "grp-eng", AccountID: accountID}, @@ -127,7 +127,7 @@ func TestAgentNetworkUsageOverview_DailyAggregation(t *testing.T) { mk := func(id string, ts time.Time, model string, in, out int64, cost float64) *agentNetworkTypes.AgentNetworkUsage { return &agentNetworkTypes.AgentNetworkUsage{ ID: id, AccountID: accountID, Timestamp: ts, Model: model, - InputTokens: in, OutputTokens: out, TotalTokens: in + out, CostUSD: cost, + InputTokens: in, OutputTokens: out, TotalTokens: in + out, InputCostUSD: cost, } } require.NoError(t, s.CreateAgentNetworkUsage(ctx, mk("u1", day1, "gpt-4o", 100, 50, 0.10), nil)) @@ -143,7 +143,7 @@ func TestAgentNetworkUsageOverview_DailyAggregation(t *testing.T) { assert.Equal(t, "2026-05-05", buckets[0].PeriodStart, "oldest-first ordering") assert.Equal(t, int64(300), buckets[0].InputTokens, "same-day input tokens summed") assert.Equal(t, int64(130), buckets[0].OutputTokens) - assert.InDelta(t, 0.30, buckets[0].CostUSD, 1e-9, "same-day cost summed") + assert.InDelta(t, 0.30, buckets[0].TotalCostUSD(), 1e-9, "same-day cost summed") assert.Equal(t, "2026-05-06", buckets[1].PeriodStart) assert.Equal(t, int64(15), buckets[1].TotalTokens) @@ -174,7 +174,7 @@ func TestAgentNetworkAccessLogSessions_RealStore(t *testing.T) { ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts, UserID: user, StatusCode: 200, Provider: provider, Model: model, SessionID: session, Decision: decision, - InputTokens: 100, OutputTokens: 50, TotalTokens: 150, CostUSD: cost, + InputTokens: 100, OutputTokens: 50, TotalTokens: 150, InputCostUSD: cost, } } @@ -207,7 +207,7 @@ func TestAgentNetworkAccessLogSessions_RealStore(t *testing.T) { s1 := sessions[2] assert.Equal(t, 2, s1.RequestCount, "s1 has two requests") assert.Equal(t, int64(300), s1.TotalTokens, "tokens summed across the session") - assert.InDelta(t, 0.30, s1.CostUSD, 1e-9, "cost summed across the session") + assert.InDelta(t, 0.30, s1.TotalCostUSD(), 1e-9, "cost summed across the session") assert.Equal(t, "alice", s1.UserID) assert.Equal(t, "allow", s1.Decision) // SQLite hands times back in time.Local; normalise to UTC so the instant is diff --git a/management/server/store/store.go b/management/server/store/store.go index b78dd9d0f..1beea72fd 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -650,6 +650,14 @@ func getMigrationsPostAuto(ctx context.Context) []migrationFunc { func(db *gorm.DB) error { return migration.DropIndex[proxy.Proxy](ctx, db, "idx_proxy_account_id_unique") }, + // Post-auto so the per-bucket cost columns already exist when the legacy + // aggregates are folded into them and dropped. + func(db *gorm.DB) error { + return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkAccessLog](ctx, db) + }, + func(db *gorm.DB) error { + return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db) + }, } } diff --git a/proxy/internal/accesslog/logger.go b/proxy/internal/accesslog/logger.go index d47c71ca4..a438f42d2 100644 --- a/proxy/internal/accesslog/logger.go +++ b/proxy/internal/accesslog/logger.go @@ -221,14 +221,21 @@ func (l *Logger) allowDenyLog(serviceID types.ServiceID, reason string) bool { // proxy/internal/middleware/keys.go — only the dimensions management needs to // record a usage row (provider / model / tokens / cost / groups). var usageMetadataKeys = map[string]struct{}{ - "llm.provider": {}, - "llm.model": {}, - "llm.resolved_provider_id": {}, - "llm.input_tokens": {}, - "llm.output_tokens": {}, - "llm.total_tokens": {}, - "cost.usd_total": {}, - "llm.authorising_groups": {}, + "llm.provider": {}, + "llm.model": {}, + "llm.resolved_provider_id": {}, + "llm.input_tokens": {}, + "llm.output_tokens": {}, + "llm.total_tokens": {}, + "llm.cached_input_tokens": {}, + "llm.cache_creation_tokens": {}, + "cost.usd_input": {}, + "cost.usd_cached_input": {}, + "cost.usd_cache_creation": {}, + "cost.usd_output": {}, + "cost.usd_total": {}, + "cost.usd_cache": {}, + "llm.authorising_groups": {}, } // stripAgentNetworkEntryForUsage returns the entry reduced to what's needed to diff --git a/proxy/internal/llm/bedrock.go b/proxy/internal/llm/bedrock.go index f7802beb2..eb64167a1 100644 --- a/proxy/internal/llm/bedrock.go +++ b/proxy/internal/llm/bedrock.go @@ -56,10 +56,12 @@ type bedrockResponse struct { OutputTokens int64 `json:"output_tokens"` CacheReadInputTokens int64 `json:"cache_read_input_tokens"` CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` - // Converse — camelCase. - InputTokensCamel int64 `json:"inputTokens"` - OutputTokensCamel int64 `json:"outputTokens"` - TotalTokensCamel int64 `json:"totalTokens"` + // Converse — camelCase; cache buckets are additive to inputTokens (AWS names the write bucket cacheWriteInputTokens). + InputTokensCamel int64 `json:"inputTokens"` + OutputTokensCamel int64 `json:"outputTokens"` + TotalTokensCamel int64 `json:"totalTokens"` + CacheReadTokensCamel int64 `json:"cacheReadInputTokens"` + CacheWriteTokensCamel int64 `json:"cacheWriteInputTokens"` } `json:"usage"` } @@ -83,16 +85,18 @@ func (BedrockParser) ParseResponse(status int, contentType string, body []byte) } inTok := firstNonZero(resp.Usage.InputTokens, resp.Usage.InputTokensCamel) outTok := firstNonZero(resp.Usage.OutputTokens, resp.Usage.OutputTokensCamel) + cacheRead := firstNonZero(resp.Usage.CacheReadInputTokens, resp.Usage.CacheReadTokensCamel) + cacheWrite := firstNonZero(resp.Usage.CacheCreationInputTokens, resp.Usage.CacheWriteTokensCamel) total := resp.Usage.TotalTokensCamel if total == 0 { - total = inTok + outTok + resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens + total = inTok + outTok + cacheRead + cacheWrite } return Usage{ InputTokens: inTok, OutputTokens: outTok, TotalTokens: total, - CachedInputTokens: resp.Usage.CacheReadInputTokens, - CacheCreationTokens: resp.Usage.CacheCreationInputTokens, + CachedInputTokens: cacheRead, + CacheCreationTokens: cacheWrite, }, nil } diff --git a/proxy/internal/llm/bedrock_test.go b/proxy/internal/llm/bedrock_test.go index ca6f092f3..e99ee55df 100644 --- a/proxy/internal/llm/bedrock_test.go +++ b/proxy/internal/llm/bedrock_test.go @@ -26,6 +26,18 @@ func TestBedrockParser_ParseResponse_Converse(t *testing.T) { require.Equal(t, int64(14), u.TotalTokens, "converse uses provider total") } +// Converse camelCase cache fields must land in the billed Usage buckets, same as the InvokeModel snake_case fields. +func TestBedrockParser_ParseResponse_ConverseCacheBuckets(t *testing.T) { + body := []byte(`{"usage":{"inputTokens":11,"outputTokens":3,"cacheReadInputTokens":7,"cacheWriteInputTokens":9}}`) + u, err := BedrockParser{}.ParseResponse(200, "application/json", body) + require.NoError(t, err) + require.Equal(t, int64(11), u.InputTokens, "converse input tokens") + require.Equal(t, int64(3), u.OutputTokens, "converse output tokens") + require.Equal(t, int64(7), u.CachedInputTokens, "converse cache-read tokens") + require.Equal(t, int64(9), u.CacheCreationTokens, "converse cache-write tokens") + require.Equal(t, int64(11+3+7+9), u.TotalTokens, "total backfill is additive when the provider omits totalTokens") +} + func TestBedrockParser_ParseResponse_StreamingUnsupported(t *testing.T) { _, err := BedrockParser{}.ParseResponse(200, "application/vnd.amazon.eventstream", []byte("binary")) require.ErrorIs(t, err, ErrStreamingUnsupported, "event-stream must route to the streaming accumulator") diff --git a/proxy/internal/llm/pricing/pricing.go b/proxy/internal/llm/pricing/pricing.go index 09afec5ff..b77000000 100644 --- a/proxy/internal/llm/pricing/pricing.go +++ b/proxy/internal/llm/pricing/pricing.go @@ -128,6 +128,46 @@ type Table struct { // - Other providers: cached and cacheCreation are ignored; cost is // inTokens*InputPer1K + outTokens*OutputPer1K. func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) { + c, ok := t.Costs(provider, model, inTokens, outTokens, cachedInput, cacheCreation) + return c.TotalUSD, ok +} + +// Costs is a per-request cost split. The four per-bucket fields are the base +// of the breakdown — one per token bucket the provider bills separately — and +// the two aggregates are derived from them: +// +// TotalUSD = InputUSD + CachedInputUSD + CacheCreationUSD + OutputUSD +// CacheUSD = CachedInputUSD + CacheCreationUSD +// +// InputUSD is always the cost of the *non-cached* input bucket, for both +// provider shapes: on OpenAI the cached subset is carved out of inTokens and +// billed as CachedInputUSD, so the two never double-count. Buckets a provider +// doesn't bill are zero, which keeps the identities above true everywhere. +type Costs struct { + InputUSD float64 + CachedInputUSD float64 + CacheCreationUSD float64 + OutputUSD float64 + TotalUSD float64 + CacheUSD float64 +} + +// newCosts assembles a split from its per-bucket parts, deriving the two +// aggregates so TotalUSD and CacheUSD can never drift from the breakdown. +func newCosts(input, cachedInput, cacheCreation, output float64) Costs { + return Costs{ + InputUSD: input, + CachedInputUSD: cachedInput, + CacheCreationUSD: cacheCreation, + OutputUSD: output, + TotalUSD: input + cachedInput + cacheCreation + output, + CacheUSD: cachedInput + cacheCreation, + } +} + +// Costs returns the estimated USD cost split for the given token counts, with +// the same semantics as Cost. +func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool) { // Clamp negatives to zero before any pricing math so a malformed // upstream count can never produce a negative cost. if inTokens < 0 { @@ -143,15 +183,15 @@ func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, c cacheCreation = 0 } if t == nil { - return 0, false + return Costs{}, false } byModel, ok := t.entries[provider] if !ok { - return 0, false + return Costs{}, false } entry, ok := byModel[model] if !ok { - return 0, false + return Costs{}, false } output := (float64(outTokens) / 1000.0) * entry.OutputPer1K switch provider { @@ -168,7 +208,7 @@ func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, c } nonCached := float64(inTokens-clamped) / 1000.0 * entry.InputPer1K cached := float64(clamped) / 1000.0 * cachedRate - return nonCached + cached + output, true + return newCosts(nonCached, cached, 0, output), true case "anthropic", "bedrock": // Bedrock-Anthropic returns the same additive cache buckets as // first-party Anthropic; non-Anthropic Bedrock models simply report @@ -184,10 +224,10 @@ func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, c input := float64(inTokens) / 1000.0 * entry.InputPer1K read := float64(cachedInput) / 1000.0 * readRate create := float64(cacheCreation) / 1000.0 * createRate - return input + read + create + output, true + return newCosts(input, read, create, output), true default: input := float64(inTokens) / 1000.0 * entry.InputPer1K - return input + output, true + return newCosts(input, 0, 0, output), true } } diff --git a/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go b/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go new file mode 100644 index 000000000..f479eb563 --- /dev/null +++ b/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go @@ -0,0 +1,329 @@ +package builtin_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "strconv" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_request_parser" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser" +) + +// Drives the real pipeline (llm_request_parser → llm_response_parser → cost_meter) on the embedded default pricing +// table and asserts exact USD amounts hardcoded from the vendors' published prices, including the cache split. +func TestCostCalculation_ProviderMatrix(t *testing.T) { + // Empty data dir → embedded defaults, like a proxy with no pricing override. + builtin.Configure(context.Background(), t.TempDir(), nil, nil, nil) + + reqMW, err := llm_request_parser.Factory{}.New(nil) + require.NoError(t, err, "build llm_request_parser") + respMW, err := llm_response_parser.Factory{}.New(nil) + require.NoError(t, err, "build llm_response_parser") + costMW, err := cost_meter.Factory{}.New(nil) + require.NoError(t, err, "build cost_meter") + t.Cleanup(func() { _ = costMW.Close() }) + + const jsonCT = "application/json" + const sseCT = "text/event-stream" + const awsCT = "application/vnd.amazon.eventstream" + + cases := []struct { + name string + url string + reqBody []byte + respCT string + respBody []byte + + wantProvider string + wantModel string + wantCost float64 // exact expected USD; ignored when wantSkip is set + wantCacheCost float64 // expected cost.usd_cache portion of wantCost + wantSkip string // expected cost.skipped reason, "" when priced + }{ + { + // gpt-4o-mini $0.15/$0.60 per MTok: 1000×0.15/1M + 500×0.60/1M. + name: "openai chat completions", + url: "https://api.openai.com/v1/chat/completions", + reqBody: []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"choices":[{"message":{"content":"pong"}}],"usage":{"prompt_tokens":1000,"completion_tokens":500,"total_tokens":1500}}`), + wantProvider: "openai", + wantModel: "gpt-4o-mini", + wantCost: 0.00045, + }, + { + // OpenAI cached tokens are a SUBSET of prompt_tokens at a discount; gpt-4o $2.50/$10 per MTok, cached $1.25/M: + // 250×2.5/1M + 750×1.25/1M + 500×10/1M. + name: "openai cached subset discount", + url: "https://api.openai.com/v1/chat/completions", + reqBody: []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":750}}}`), + wantProvider: "openai", + wantModel: "gpt-4o", + wantCost: 0.0065625, + wantCacheCost: 0.0009375, + }, + { + // OpenAI streaming: usage rides the final SSE frame. + name: "openai chat SSE stream", + url: "https://api.openai.com/v1/chat/completions", + reqBody: []byte(`{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"hi"}]}`), + respCT: sseCT, + respBody: sseBody(`{"choices":[{"delta":{"content":"po"}}]}`, `{"choices":[{"delta":{"content":"ng"}}]}`, `{"choices":[],"usage":{"prompt_tokens":1000,"completion_tokens":500}}`, "[DONE]"), + wantProvider: "openai", + wantModel: "gpt-4o-mini", + wantCost: 0.00045, + }, + { + // Mistral speaks the OpenAI shape: mistral-large-latest $0.50/$1.50 per MTok. + name: "mistral via openai shape", + url: "https://api.mistral.ai/v1/chat/completions", + reqBody: []byte(`{"model":"mistral-large-latest","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":1000}}`), + wantProvider: "openai", + wantModel: "mistral-large-latest", + wantCost: 0.002, + }, + { + // The field report, minus caching: Bedrock Sonnet 4.6 $3/$15 per MTok, 3×3/1M + 1514×15/1M = $0.022719. + // Also covers inference-profile normalization of the region-prefixed versioned id in the URL. + name: "bedrock invoke — reported scenario, no cache", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke", + reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"content":[{"type":"text","text":"pong"}],"usage":{"input_tokens":3,"output_tokens":1514}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-sonnet-4-6", + wantCost: 0.022719, + }, + { + // The field report as observed: the FIRST call of a session also wrote a 30,528-token prompt cache at + // 1.25× input ($3.75/M): 0.022719 + 30528×3.75/1M = $0.137199 — the reported $0.1372. + name: "bedrock invoke — reported scenario with cache write", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke", + reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"input_tokens":3,"output_tokens":1514,"cache_creation_input_tokens":30528,"cache_read_input_tokens":0}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-sonnet-4-6", + wantCost: 0.137199, + wantCacheCost: 0.11448, + }, + { + // Same numbers over the InvokeModel event-stream: message_start carries input + cache, message_delta the output. + name: "bedrock invoke stream with cache write", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke-with-response-stream", + reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + respCT: awsCT, + respBody: bedrockInvokeStream(t, `{"type":"message_start","message":{"usage":{"input_tokens":3,"output_tokens":1,"cache_creation_input_tokens":30528}}}`, `{"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}`, `{"type":"message_delta","usage":{"output_tokens":1514}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-sonnet-4-6", + wantCost: 0.137199, + wantCacheCost: 0.11448, + }, + { + // Converse camelCase usage incl. cache buckets. Haiku 4.5 $1/$5 per MTok, read $0.10/M, write $1.25/M: + // 50×1/1M + 100×5/1M + 2000×0.1/1M + 1000×1.25/1M = $0.002. + name: "bedrock converse with cache buckets", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/converse", + reqBody: []byte(`{"messages":[{"role":"user","content":[{"text":"hi"}]}]}`), + respCT: jsonCT, + respBody: []byte(`{"output":{"message":{"content":[{"text":"pong"}]}},"usage":{"inputTokens":50,"outputTokens":100,"totalTokens":3150,"cacheReadInputTokens":2000,"cacheWriteInputTokens":1000}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-haiku-4-5", + wantCost: 0.002, + wantCacheCost: 0.00145, + }, + { + // Same numbers over converse-stream: usage rides the trailing metadata frame. + name: "bedrock converse stream with cache buckets", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/converse-stream", + reqBody: []byte(`{"messages":[{"role":"user","content":[{"text":"hi"}]}]}`), + respCT: awsCT, + respBody: bedrockConverseStream(t, + `{"delta":{"text":"pong"}}`, + `{"usage":{"inputTokens":50,"outputTokens":100,"totalTokens":3150,"cacheReadInputTokens":2000,"cacheWriteInputTokens":1000}}`, + ), + wantProvider: "bedrock", + wantModel: "anthropic.claude-haiku-4-5", + wantCost: 0.002, + wantCacheCost: 0.00145, + }, + { + // First-party Anthropic, additive cache buckets. Sonnet 4.6: + // 256×3/1M + 200×15/1M + 768×0.3/1M + 512×3.75/1M. + name: "anthropic messages with cache buckets", + url: "https://api.anthropic.com/v1/messages", + reqBody: []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"content":[{"type":"text","text":"pong"}],"usage":{"input_tokens":256,"output_tokens":200,"cache_read_input_tokens":768,"cache_creation_input_tokens":512}}`), + wantProvider: "anthropic", + wantModel: "claude-sonnet-4-6", + wantCost: 0.0059184, + wantCacheCost: 0.0021504, + }, + { + // Anthropic SSE: input from message_start, output from message_delta. Haiku 4.5: 1000×1/1M + 2000×5/1M. + name: "anthropic SSE stream", + url: "https://api.anthropic.com/v1/messages", + reqBody: []byte(`{"model":"claude-haiku-4-5","stream":true,"messages":[{"role":"user","content":"hi"}]}`), + respCT: sseCT, + respBody: sseBody(`{"type":"message_start","message":{"usage":{"input_tokens":1000,"output_tokens":2}}}`, `{"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}`, `{"type":"message_delta","usage":{"output_tokens":2000}}`, `{"type":"message_stop"}`), + wantProvider: "anthropic", + wantModel: "claude-haiku-4-5", + wantCost: 0.011, + }, + { + // Kimi's Anthropic-compatible endpoint: kimi-k3 $3/$15 per MTok under the anthropic table. + name: "kimi anthropic shape", + url: "https://api.moonshot.ai/anthropic/v1/messages", + reqBody: []byte(`{"model":"kimi-k3","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"input_tokens":1000,"output_tokens":1000}}`), + wantProvider: "anthropic", + wantModel: "kimi-k3", + wantCost: 0.018, + }, + { + // Vertex path-routed model with "@version" stripped; Anthropic-on-Vertex priced under the anthropic table. + name: "vertex anthropic path-routed", + url: "https://aiplatform.googleapis.com/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-4-6@20260115:rawPredict", + reqBody: []byte(`{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"input_tokens":200,"output_tokens":100}}`), + wantProvider: "anthropic", + wantModel: "claude-sonnet-4-6", + wantCost: 0.0021, + }, + { + // Gateway-prefixed model ids are not in the pricing table: the meter must SKIP, never guess a rate. + name: "gateway-prefixed model skips pricing", + url: "https://gateway.example.com/v1/chat/completions", + reqBody: []byte(`{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":500}}`), + wantProvider: "openai", + wantModel: "openai/gpt-4o-mini", + wantSkip: "unknown_model", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := &middleware.Input{ + Method: "POST", + URL: tc.url, + Headers: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + Body: tc.reqBody, + } + + reqOut, err := reqMW.Invoke(context.Background(), in) + require.NoError(t, err, "request parser") + in.Metadata = append(in.Metadata, reqOut.Metadata...) + + require.Equal(t, tc.wantProvider, metaKV(in.Metadata, middleware.KeyLLMProvider), "detected provider") + require.Equal(t, tc.wantModel, metaKV(in.Metadata, middleware.KeyLLMModel), "detected (normalized) model") + + in.Status = 200 + in.RespHeaders = []middleware.KV{{Key: "Content-Type", Value: tc.respCT}} + in.RespBody = tc.respBody + + respOut, err := respMW.Invoke(context.Background(), in) + require.NoError(t, err, "response parser") + in.Metadata = append(in.Metadata, respOut.Metadata...) + + costOut, err := costMW.Invoke(context.Background(), in) + require.NoError(t, err, "cost meter") + + if tc.wantSkip != "" { + assert.Equal(t, tc.wantSkip, metaKV(costOut.Metadata, middleware.KeyCostSkipped), "expected cost skip reason") + assert.Empty(t, metaKV(costOut.Metadata, middleware.KeyCostUSDTotal), "no cost may be emitted on skip") + return + } + + raw := metaKV(costOut.Metadata, middleware.KeyCostUSDTotal) + require.NotEmpty(t, raw, "cost.usd_total must be emitted; skip=%q", metaKV(costOut.Metadata, middleware.KeyCostSkipped)) + got, err := strconv.ParseFloat(raw, 64) + require.NoError(t, err, "cost must be a float") + // cost.usd_total is rendered with %.6f: allow half of the last printed digit on top of float error. + assert.InDelta(t, tc.wantCost, got, 5.1e-7, "USD cost for %s", tc.name) + + rawCache := metaKV(costOut.Metadata, middleware.KeyCostUSDCache) + require.NotEmpty(t, rawCache, "cost.usd_cache must be emitted next to cost.usd_total") + gotCache, err := strconv.ParseFloat(rawCache, 64) + require.NoError(t, err, "cache cost must be a float") + assert.InDelta(t, tc.wantCacheCost, gotCache, 5.1e-7, "cache USD cost for %s", tc.name) + }) + } +} + +// metaKV returns the value for key in kvs, or "" when absent. +func metaKV(kvs []middleware.KV, key string) string { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value + } + } + return "" +} + +// sseBody renders data frames as a text/event-stream body. +func sseBody(frames ...string) []byte { + var b bytes.Buffer + for _, f := range frames { + b.WriteString("data: ") + b.WriteString(f) + b.WriteString("\n\n") + } + return b.Bytes() +} + +// awsFrame encodes one AWS event-stream frame with the given :event-type. +func awsFrame(t *testing.T, eventType string, payload []byte) []byte { + t.Helper() + var buf bytes.Buffer + enc := eventstream.NewEncoder() + require.NoError(t, enc.Encode(&buf, eventstream.Message{ + Headers: eventstream.Headers{{Name: ":event-type", Value: eventstream.StringValue(eventType)}}, + Payload: payload, + }), "encode event-stream frame") + return buf.Bytes() +} + +// bedrockInvokeStream builds an invoke-with-response-stream body: each "chunk" frame wraps a base64 Anthropic event. +func bedrockInvokeStream(t *testing.T, events ...string) []byte { + t.Helper() + var body bytes.Buffer + for _, ev := range events { + wrap, err := json.Marshal(map[string]string{"bytes": base64.StdEncoding.EncodeToString([]byte(ev))}) + require.NoError(t, err) + body.Write(awsFrame(t, "chunk", wrap)) + } + return body.Bytes() +} + +// bedrockConverseStream builds a converse-stream body: contentBlockDelta frames plus a trailing metadata usage frame. +func bedrockConverseStream(t *testing.T, deltas ...string) []byte { + t.Helper() + var body bytes.Buffer + for i, ev := range deltas { + eventType := "contentBlockDelta" + if i == len(deltas)-1 { + eventType = "metadata" + } + body.Write(awsFrame(t, eventType, []byte(ev))) + } + return body.Bytes() +} diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware.go b/proxy/internal/middleware/builtin/cost_meter/middleware.go index 4da620310..63da6d17b 100644 --- a/proxy/internal/middleware/builtin/cost_meter/middleware.go +++ b/proxy/internal/middleware/builtin/cost_meter/middleware.go @@ -32,7 +32,12 @@ const ( ) var metadataKeys = []string{ + middleware.KeyCostUSDInput, + middleware.KeyCostUSDCachedInput, + middleware.KeyCostUSDCacheCreation, + middleware.KeyCostUSDOutput, middleware.KeyCostUSDTotal, + middleware.KeyCostUSDCache, middleware.KeyCostSkipped, } @@ -140,18 +145,38 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar } table := m.loader.Get() - cost, ok := table.Cost(provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens) + costs, ok := table.Costs(provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens) if !ok { out.Metadata = skip(skipUnknownModel) return out, nil } + // Per-bucket costs first: they're the base of the breakdown, and the two + // aggregates that follow are derived from exactly these four values. out.Metadata = []middleware.KV{ - {Key: middleware.KeyCostUSDTotal, Value: fmt.Sprintf("%.6f", cost)}, + {Key: middleware.KeyCostUSDInput, Value: usd(costs.InputUSD)}, + {Key: middleware.KeyCostUSDCachedInput, Value: usd(costs.CachedInputUSD)}, + {Key: middleware.KeyCostUSDCacheCreation, Value: usd(costs.CacheCreationUSD)}, + {Key: middleware.KeyCostUSDOutput, Value: usd(costs.OutputUSD)}, + {Key: middleware.KeyCostUSDTotal, Value: usd(costs.TotalUSD)}, + {Key: middleware.KeyCostUSDCache, Value: usd(costs.CacheUSD)}, } return out, nil } +// usd renders a cost as the fixed-precision string every cost.usd_* key +// carries, so the per-bucket values and the aggregates round identically. +// +// 9 decimals, not 6: these values are summed downstream — per request, per +// session, and per usage bucket — so the rounding step is applied once per +// bucket per row and then accumulated. At 6 decimals a single row loses up to +// 2e-6 across its four buckets (enough to break a 1e-6 reconciliation against +// published rates), and a bucket smaller than half a microdollar quantises to +// zero outright: 16 cache-read tokens on a cheap model is 1.6e-9, so summing +// 10k such rows reports 0.02 instead of 0.016. Nano-dollar precision keeps the +// per-row error ~1000x below the smallest realistic bucket. +func usd(v float64) string { return fmt.Sprintf("%.9f", v) } + // skip returns a single-entry metadata slice carrying the given skip // reason under KeyCostSkipped. func skip(reason string) []middleware.KV { diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware_test.go b/proxy/internal/middleware/builtin/cost_meter/middleware_test.go index d1c161cab..e5d431d77 100644 --- a/proxy/internal/middleware/builtin/cost_meter/middleware_test.go +++ b/proxy/internal/middleware/builtin/cost_meter/middleware_test.go @@ -67,7 +67,15 @@ func TestMiddleware_StaticSurface(t *testing.T) { assert.NoError(t, mw.Close(), "Close on stateless middleware is a no-op") keys := mw.MetadataKeys() - expected := []string{middleware.KeyCostUSDTotal, middleware.KeyCostSkipped} + expected := []string{ + middleware.KeyCostUSDInput, + middleware.KeyCostUSDCachedInput, + middleware.KeyCostUSDCacheCreation, + middleware.KeyCostUSDOutput, + middleware.KeyCostUSDTotal, + middleware.KeyCostUSDCache, + middleware.KeyCostSkipped, + } assert.Equal(t, expected, keys, "metadata key allowlist must match the spec") } @@ -105,7 +113,7 @@ func TestFactory_DefaultPricingPathLoadsFixture(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok, "cost.usd_total must be emitted for known model") - assert.Equal(t, "0.000750", value, "0.00015 + 0.0006 per 1k tokens, 6-decimal format") + assert.Equal(t, "0.000750000", value, "0.00015 + 0.0006 per 1k tokens, 9-decimal format") } func TestFactory_PricingPathOverride(t *testing.T) { @@ -129,7 +137,7 @@ func TestFactory_PricingPathOverride(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok, "cost.usd_total must be emitted with custom pricing path") - assert.Equal(t, "0.015000", value, "2*0.0025 + 1*0.01 = 0.015 with 6-decimal format") + assert.Equal(t, "0.015000000", value, "2*0.0025 + 1*0.01 = 0.015 with 9-decimal format") } func TestInvoke_ComputesCostForKnownModel(t *testing.T) { @@ -148,7 +156,7 @@ func TestInvoke_ComputesCostForKnownModel(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok, "cost.usd_total must be emitted") - assert.Equal(t, "0.018000", value, "0.003 + 0.015 = 0.018 with 6-decimal format") + assert.Equal(t, "0.018000000", value, "0.003 + 0.015 = 0.018 with 9-decimal format") _, skipped := metaValue(t, out.Metadata, middleware.KeyCostSkipped) assert.False(t, skipped, "cost.skipped must not be set when cost is computed") } @@ -357,8 +365,25 @@ func TestInvoke_OpenAICachedSubsetDiscount(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok, "cached subset path must produce a cost — never a skip") // 250 non-cached at 0.0025/1k + 750 cached at 0.00125/1k + 500 output at 0.01/1k. - assert.Equal(t, "0.006563", value, + assert.Equal(t, "0.006562500", value, "cached subset must be billed at the discount rate, non-cached at the full rate; never double-billed") + + cache, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDCache) + require.True(t, ok, "cost.usd_cache must be emitted alongside cost.usd_total") + // 750 cached at 0.00125/1k = 0.0009375. + assert.Equal(t, "0.000937500", cache, "cache cost is the discounted cost of the cached subset") + + // Per-bucket breakdown. On OpenAI the cached subset is carved out of the + // input bucket, so input covers only the 250 non-cached tokens — the two + // must never double-count the same 750 tokens. + assertBucket(t, out.Metadata, middleware.KeyCostUSDInput, "0.000625000", + "input bucket bills only the non-cached remainder") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCachedInput, "0.000937500", + "cached-input bucket bills the discounted subset") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCacheCreation, "0.000000000", + "OpenAI has no cache-write bucket") + assertBucket(t, out.Metadata, middleware.KeyCostUSDOutput, "0.005000000", + "output bucket bills 500 tokens at 0.01/1k") } // TestInvoke_AnthropicCacheBucketsAdditive proves the Anthropic @@ -384,9 +409,33 @@ func TestInvoke_AnthropicCacheBucketsAdditive(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok) // 256 input * 0.003 + 768 cache_read * 0.0003 + 512 cache_creation * 0.00375 + 200 output * 0.015 - // = 0.000768 + 0.0002304 + 0.00192 + 0.003 = 0.0059184 → "0.005918" with 6-decimal format. - assert.Equal(t, "0.005918", value, + // = 0.000768 + 0.0002304 + 0.00192 + 0.003 = 0.0059184. + assert.Equal(t, "0.005918400", value, "each Anthropic input bucket must bill at its own rate — cache_read cheap, cache_creation expensive, regular input mid") + + cache, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDCache) + require.True(t, ok, "cost.usd_cache must be emitted alongside cost.usd_total") + // 768 cache_read * 0.0003 + 512 cache_creation * 0.00375 = 0.0021504. + assert.Equal(t, "0.002150400", cache, "cache cost sums the read and creation buckets") + + // Per-bucket breakdown: four separately-billed buckets, each at its own rate. + assertBucket(t, out.Metadata, middleware.KeyCostUSDInput, "0.000768000", + "input bucket bills 256 tokens at 0.003/1k") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCachedInput, "0.000230400", + "cache-read bucket bills 768 tokens at the cheap 0.0003/1k") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCacheCreation, "0.001920000", + "cache-write bucket bills 512 tokens at the expensive 0.00375/1k") + assertBucket(t, out.Metadata, middleware.KeyCostUSDOutput, "0.003000000", + "output bucket bills 200 tokens at 0.015/1k") +} + +// assertBucket asserts one per-bucket cost key carries the expected +// 6-decimal value. +func assertBucket(t *testing.T, md []middleware.KV, key, want, msg string) { + t.Helper() + got, ok := metaValue(t, md, key) + require.Truef(t, ok, "%s must be emitted", key) + assert.Equal(t, want, got, msg) } // TestInvoke_CachedTokensAbsentFallsBackToBaseFormula covers the @@ -411,7 +460,7 @@ func TestInvoke_CachedTokensAbsentFallsBackToBaseFormula(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok) // 1000 input * 0.0025 + 500 output * 0.01 = 0.0025 + 0.005 = 0.0075 - assert.Equal(t, "0.007500", value, "no cached metadata = same cost as before the feature landed") + assert.Equal(t, "0.007500000", value, "no cached metadata = same cost as before the feature landed") } // TestInvoke_UnparseableCachedTokensSkippedSilently proves the @@ -435,7 +484,7 @@ func TestInvoke_UnparseableCachedTokensSkippedSilently(t *testing.T) { require.NoError(t, err) value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok, "garbage cache metadata must NOT switch the response from a cost to a skip — fall back to 0 cached") - assert.Equal(t, "0.007500", value, "same as the no-cached-metadata path") + assert.Equal(t, "0.007500000", value, "same as the no-cached-metadata path") } // TestMiddleware_CloseCancelsReloader proves Close stops the per-instance diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go index a82a9cdbc..30809b46e 100644 --- a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go @@ -69,15 +69,18 @@ func applyBedrockInvokeChunk(payload []byte, usage *llm.Usage, completion *strin } // converseStreamEvent captures the Converse stream frames carrying completion -// text (contentBlockDelta) and the final token usage (metadata). +// text (contentBlockDelta) and the final token usage (metadata). Cache buckets +// are additive to inputTokens (AWS write bucket: cacheWriteInputTokens). type converseStreamEvent struct { Delta *struct { Text string `json:"text"` } `json:"delta"` Usage *struct { - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - TotalTokens int64 `json:"totalTokens"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + TotalTokens int64 `json:"totalTokens"` + CacheReadTokens int64 `json:"cacheReadInputTokens"` + CacheWriteTokens int64 `json:"cacheWriteInputTokens"` } `json:"usage"` } @@ -105,6 +108,12 @@ func applyConverseStreamEvent(eventType string, payload []byte, usage *llm.Usage if ev.Usage.TotalTokens > 0 { usage.TotalTokens = ev.Usage.TotalTokens } + if ev.Usage.CacheReadTokens > 0 { + usage.CachedInputTokens = ev.Usage.CacheReadTokens + } + if ev.Usage.CacheWriteTokens > 0 { + usage.CacheCreationTokens = ev.Usage.CacheWriteTokens + } } } } diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go index f93505882..f66347591 100644 --- a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go @@ -66,6 +66,24 @@ func TestAccumulateBedrockStream_Converse(t *testing.T) { require.Equal(t, "pong", completion, "converse text deltas concatenated") } +// The converse-stream metadata frame's camelCase cache fields must reach the billed cache buckets. +func TestAccumulateBedrockStream_ConverseCacheBuckets(t *testing.T) { + var body bytes.Buffer + body.Write(bedrockFrame(t, "contentBlockDelta", mustJSON(t, map[string]any{"delta": map[string]any{"text": "pong"}}))) + body.Write(bedrockFrame(t, "metadata", mustJSON(t, map[string]any{"usage": map[string]any{ + "inputTokens": 11, "outputTokens": 3, "totalTokens": 30, + "cacheReadInputTokens": 7, "cacheWriteInputTokens": 9, + }}))) + + usage, completion := accumulateBedrockStream(body.Bytes()) + require.Equal(t, int64(11), usage.InputTokens, "input tokens from metadata frame") + require.Equal(t, int64(3), usage.OutputTokens, "output tokens from metadata frame") + require.Equal(t, int64(7), usage.CachedInputTokens, "cache-read tokens from metadata frame") + require.Equal(t, int64(9), usage.CacheCreationTokens, "cache-write tokens from metadata frame") + require.Equal(t, int64(30), usage.TotalTokens, "provider-reported total wins") + require.Equal(t, "pong", completion) +} + func TestAccumulateBedrockStream_Truncated(t *testing.T) { // A body cut mid-frame must not panic; partial usage is returned. full := bedrockFrame(t, "metadata", mustJSON(t, map[string]any{"usage": map[string]any{"inputTokens": 11, "outputTokens": 3}})) diff --git a/proxy/internal/middleware/keys.go b/proxy/internal/middleware/keys.go index 9c584ad82..336bed19f 100644 --- a/proxy/internal/middleware/keys.go +++ b/proxy/internal/middleware/keys.go @@ -75,8 +75,19 @@ const ( KeyLLMAttributionGroupID = "llm.attribution_group_id" KeyLLMAttributionWindowS = "llm.attribution_window_seconds" - // Cost metering (emitted by cost_meter). - KeyCostUSDTotal = "cost.usd_total" + // Cost metering (emitted by cost_meter). The four per-bucket keys are the + // base of the breakdown — one per token bucket the provider bills + // separately — and the two aggregates below are derived from them: + // usd_total is their sum, usd_cache is cached_input + cache_creation. + KeyCostUSDInput = "cost.usd_input" + // KeyCostUSDCachedInput is the cost of the cache-read bucket (Anthropic cache_read; OpenAI's discounted cached subset of input). + KeyCostUSDCachedInput = "cost.usd_cached_input" + // KeyCostUSDCacheCreation is the cost of the cache-write bucket. Zero for providers without one. + KeyCostUSDCacheCreation = "cost.usd_cache_creation" + KeyCostUSDOutput = "cost.usd_output" + KeyCostUSDTotal = "cost.usd_total" + // KeyCostUSDCache is the portion of cost.usd_total billed for prompt-cache buckets (cache read/creation, or OpenAI's cached input subset). + KeyCostUSDCache = "cost.usd_cache" KeyCostSkipped = "cost.skipped" // Framework-emitted error markers. Use the mw..* prefix to diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 47ca80a7c..e3d11227a 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5822,13 +5822,48 @@ components: total_tokens: type: integer format: int64 - description: Total tokens consumed. + description: Total tokens consumed, including prompt-cache tokens. example: 1840 + cached_input_tokens: + type: integer + format: int64 + description: Input tokens read from the provider's prompt cache. Additive to input_tokens for Anthropic-shape providers; a subset of input_tokens for OpenAI. + example: 0 + cache_creation_tokens: + type: integer + format: int64 + description: Input tokens written to the provider's prompt cache. Zero for providers without a cache-write bucket. + example: 30528 cost_usd: type: number format: double description: Estimated USD cost of the request. example: 0.0231 + input_cost_usd: + type: number + format: double + description: Cost of the non-cached input tokens. Base component of cost_usd. + example: 0.0048 + cached_input_cost_usd: + type: number + format: double + description: Cost of the prompt-cache read tokens. Base component of cost_usd, and part of cache_cost_usd. + example: 0.0015 + cache_creation_cost_usd: + type: number + format: double + description: Cost of the prompt-cache write tokens. Base component of cost_usd, and part of cache_cost_usd. + example: 0.1130 + output_cost_usd: + type: number + format: double + description: Cost of the output tokens. Base component of cost_usd. + example: 0.0038 + cache_cost_usd: + type: number + format: double + description: Portion of cost_usd billed for prompt-cache usage. + example: 0.1145 stream: type: boolean description: Whether the request was a streaming completion. @@ -5852,7 +5887,14 @@ components: - input_tokens - output_tokens - total_tokens + - cached_input_tokens + - cache_creation_tokens + - input_cost_usd + - cached_input_cost_usd + - cache_creation_cost_usd + - output_cost_usd - cost_usd + - cache_cost_usd AgentNetworkAccessLogsResponse: type: object properties: @@ -5926,13 +5968,48 @@ components: total_tokens: type: integer format: int64 - description: Total tokens across the session. + description: Total tokens across the session, including prompt-cache tokens. example: 12880 + cached_input_tokens: + type: integer + format: int64 + description: Total prompt-cache read tokens across the session. + example: 0 + cache_creation_tokens: + type: integer + format: int64 + description: Total prompt-cache write tokens across the session. + example: 30528 cost_usd: type: number format: double description: Total estimated USD cost across the session. example: 0.1617 + input_cost_usd: + type: number + format: double + description: Total cost of non-cached input tokens across the session. + example: 0.0210 + cached_input_cost_usd: + type: number + format: double + description: Total cost of prompt-cache read tokens across the session. + example: 0.0015 + cache_creation_cost_usd: + type: number + format: double + description: Total cost of prompt-cache write tokens across the session. + example: 0.1130 + output_cost_usd: + type: number + format: double + description: Total cost of output tokens across the session. + example: 0.0262 + cache_cost_usd: + type: number + format: double + description: Portion of cost_usd billed for prompt-cache usage across the session. + example: 0.1145 providers: type: array items: @@ -5959,7 +6036,14 @@ components: - input_tokens - output_tokens - total_tokens + - cached_input_tokens + - cache_creation_tokens + - input_cost_usd + - cached_input_cost_usd + - cache_creation_cost_usd + - output_cost_usd - cost_usd + - cache_cost_usd - decision - entries AgentNetworkAccessLogSessionsResponse: @@ -6013,19 +6097,61 @@ components: total_tokens: type: integer format: int64 - description: Total tokens in the bucket. + description: Total tokens in the bucket, including prompt-cache tokens. example: 184000 + cached_input_tokens: + type: integer + format: int64 + description: Total prompt-cache read tokens in the bucket. + example: 20000 + cache_creation_tokens: + type: integer + format: int64 + description: Total prompt-cache write tokens in the bucket. + example: 45000 + input_cost_usd: + type: number + format: double + description: Total cost of non-cached input tokens in the bucket. + example: 1.12 + cached_input_cost_usd: + type: number + format: double + description: Total cost of prompt-cache read tokens in the bucket. + example: 0.06 + cache_creation_cost_usd: + type: number + format: double + description: Total cost of prompt-cache write tokens in the bucket. + example: 0.36 + output_cost_usd: + type: number + format: double + description: Total cost of output tokens in the bucket. + example: 0.77 cost_usd: type: number format: double description: Total estimated USD spend in the bucket. example: 2.31 + cache_cost_usd: + type: number + format: double + description: Portion of cost_usd billed for prompt-cache usage in the bucket. + example: 0.42 required: - period_start - input_tokens - output_tokens - total_tokens + - cached_input_tokens + - cache_creation_tokens + - input_cost_usd + - cached_input_cost_usd + - cache_creation_cost_usd + - output_cost_usd - cost_usd + - cache_cost_usd AgentNetworkSettings: type: object description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter. diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index a9e98cf84..a4de48a09 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1732,6 +1732,21 @@ type AccountSettings struct { // AgentNetworkAccessLog One per-request agent-network (LLM) access log entry with flattened, queryable LLM dimensions. type AgentNetworkAccessLog struct { + // CacheCostUsd Portion of cost_usd billed for prompt-cache usage. + CacheCostUsd float64 `json:"cache_cost_usd"` + + // CacheCreationCostUsd Cost of the prompt-cache write tokens. Base component of cost_usd, and part of cache_cost_usd. + CacheCreationCostUsd float64 `json:"cache_creation_cost_usd"` + + // CacheCreationTokens Input tokens written to the provider's prompt cache. Zero for providers without a cache-write bucket. + CacheCreationTokens int64 `json:"cache_creation_tokens"` + + // CachedInputCostUsd Cost of the prompt-cache read tokens. Base component of cost_usd, and part of cache_cost_usd. + CachedInputCostUsd float64 `json:"cached_input_cost_usd"` + + // CachedInputTokens Input tokens read from the provider's prompt cache. Additive to input_tokens for Anthropic-shape providers; a subset of input_tokens for OpenAI. + CachedInputTokens int64 `json:"cached_input_tokens"` + // CostUsd Estimated USD cost of the request. CostUsd float64 `json:"cost_usd"` @@ -1753,6 +1768,9 @@ type AgentNetworkAccessLog struct { // Id Unique identifier for the access log entry. Id string `json:"id"` + // InputCostUsd Cost of the non-cached input tokens. Base component of cost_usd. + InputCostUsd float64 `json:"input_cost_usd"` + // InputTokens Input (prompt) tokens consumed. InputTokens int64 `json:"input_tokens"` @@ -1762,6 +1780,9 @@ type AgentNetworkAccessLog struct { // Model Requested LLM model. Model *string `json:"model,omitempty"` + // OutputCostUsd Cost of the output tokens. Base component of cost_usd. + OutputCostUsd float64 `json:"output_cost_usd"` + // OutputTokens Output (completion) tokens produced. OutputTokens int64 `json:"output_tokens"` @@ -1801,7 +1822,7 @@ type AgentNetworkAccessLog struct { // Timestamp Timestamp when the request was made. Timestamp time.Time `json:"timestamp"` - // TotalTokens Total tokens consumed. + // TotalTokens Total tokens consumed, including prompt-cache tokens. TotalTokens int64 `json:"total_tokens"` // UserId NetBird user id of the authenticated caller, if applicable. @@ -1810,6 +1831,21 @@ type AgentNetworkAccessLog struct { // AgentNetworkAccessLogSession A session-grouped view of agent-network access logs — all requests sharing a session id (or a single session-less request) folded into one summary plus its ordered entries. type AgentNetworkAccessLogSession struct { + // CacheCostUsd Portion of cost_usd billed for prompt-cache usage across the session. + CacheCostUsd float64 `json:"cache_cost_usd"` + + // CacheCreationCostUsd Total cost of prompt-cache write tokens across the session. + CacheCreationCostUsd float64 `json:"cache_creation_cost_usd"` + + // CacheCreationTokens Total prompt-cache write tokens across the session. + CacheCreationTokens int64 `json:"cache_creation_tokens"` + + // CachedInputCostUsd Total cost of prompt-cache read tokens across the session. + CachedInputCostUsd float64 `json:"cached_input_cost_usd"` + + // CachedInputTokens Total prompt-cache read tokens across the session. + CachedInputTokens int64 `json:"cached_input_tokens"` + // CostUsd Total estimated USD cost across the session. CostUsd float64 `json:"cost_usd"` @@ -1825,12 +1861,18 @@ type AgentNetworkAccessLogSession struct { // GroupIds Union of the authorising group ids across the session's entries. GroupIds *[]string `json:"group_ids,omitempty"` + // InputCostUsd Total cost of non-cached input tokens across the session. + InputCostUsd float64 `json:"input_cost_usd"` + // InputTokens Total input (prompt) tokens across the session. InputTokens int64 `json:"input_tokens"` // Models Distinct models seen in the session. Models *[]string `json:"models,omitempty"` + // OutputCostUsd Total cost of output tokens across the session. + OutputCostUsd float64 `json:"output_cost_usd"` + // OutputTokens Total output (completion) tokens across the session. OutputTokens int64 `json:"output_tokens"` @@ -1846,7 +1888,7 @@ type AgentNetworkAccessLogSession struct { // StartedAt Timestamp of the session's earliest request. StartedAt time.Time `json:"started_at"` - // TotalTokens Total tokens across the session. + // TotalTokens Total tokens across the session, including prompt-cache tokens. TotalTokens int64 `json:"total_tokens"` // UserId NetBird user id of the session's caller. @@ -2347,19 +2389,40 @@ type AgentNetworkSettingsRequest struct { // AgentNetworkUsageBucket One aggregated agent-network usage time bucket (UTC). The bucket width is set by the request's granularity. type AgentNetworkUsageBucket struct { + // CacheCostUsd Portion of cost_usd billed for prompt-cache usage in the bucket. + CacheCostUsd float64 `json:"cache_cost_usd"` + + // CacheCreationCostUsd Total cost of prompt-cache write tokens in the bucket. + CacheCreationCostUsd float64 `json:"cache_creation_cost_usd"` + + // CacheCreationTokens Total prompt-cache write tokens in the bucket. + CacheCreationTokens int64 `json:"cache_creation_tokens"` + + // CachedInputCostUsd Total cost of prompt-cache read tokens in the bucket. + CachedInputCostUsd float64 `json:"cached_input_cost_usd"` + + // CachedInputTokens Total prompt-cache read tokens in the bucket. + CachedInputTokens int64 `json:"cached_input_tokens"` + // CostUsd Total estimated USD spend in the bucket. CostUsd float64 `json:"cost_usd"` + // InputCostUsd Total cost of non-cached input tokens in the bucket. + InputCostUsd float64 `json:"input_cost_usd"` + // InputTokens Total input (prompt) tokens in the bucket. InputTokens int64 `json:"input_tokens"` + // OutputCostUsd Total cost of output tokens in the bucket. + OutputCostUsd float64 `json:"output_cost_usd"` + // OutputTokens Total output (completion) tokens in the bucket. OutputTokens int64 `json:"output_tokens"` // PeriodStart Start of the bucket in YYYY-MM-DD (UTC) — the day, the week start (Monday), or the month start, depending on granularity. PeriodStart string `json:"period_start"` - // TotalTokens Total tokens in the bucket. + // TotalTokens Total tokens in the bucket, including prompt-cache tokens. TotalTokens int64 `json:"total_tokens"` } From d681670a9d17b45f407e487896973c03cd3a6756 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:07:58 +0900 Subject: [PATCH 30/47] [misc] Restore the rootless-latest docker tag (#6914) --- .goreleaser.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0753b1012..8dd05a192 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -273,8 +273,8 @@ dockers_v2: - netbirdio/netbird - ghcr.io/netbirdio/netbird tags: - - "v{{ .Version }}-rootless" - - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + - "{{ .Version }}-rootless" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}" dockerfile: client/Dockerfile-rootless extra_files: - client/netbird-entrypoint.sh From aa13928b76e0a1741bc23875fa0833347593729a Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 27 Jul 2026 15:53:09 +0200 Subject: [PATCH 31/47] [client] Export agent version info for iOS (#6918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Export agent version info for iOS ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [x] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. --- client/ios/NetBirdSDK/version.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 client/ios/NetBirdSDK/version.go diff --git a/client/ios/NetBirdSDK/version.go b/client/ios/NetBirdSDK/version.go new file mode 100644 index 000000000..606ad18e2 --- /dev/null +++ b/client/ios/NetBirdSDK/version.go @@ -0,0 +1,12 @@ +//go:build ios + +package NetBirdSDK + +import "github.com/netbirdio/netbird/version" + +// GoClientVersion returns the NetBird Go client version that was baked into +// the framework at compile time via +// -ldflags "-X github.com/netbirdio/netbird/version.version=". +func GoClientVersion() string { + return version.NetbirdVersion() +} From 1816a020c46847fa75437b1e915134673ff33b16 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Mon, 27 Jul 2026 16:15:36 +0200 Subject: [PATCH 32/47] [management, proxy] Add Claude Opus 5 (#6895) --- proxy/internal/llm/pricing/defaults_coverage_test.go | 4 ++-- proxy/internal/llm/pricing/defaults_pricing.yaml | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/proxy/internal/llm/pricing/defaults_coverage_test.go b/proxy/internal/llm/pricing/defaults_coverage_test.go index be23682da..8df1557ea 100644 --- a/proxy/internal/llm/pricing/defaults_coverage_test.go +++ b/proxy/internal/llm/pricing/defaults_coverage_test.go @@ -28,12 +28,12 @@ func TestDefaultTable_FirstPartyModelCoverage(t *testing.T) { "ministral-8b-latest", "mistral-embed", }, "anthropic": { - "claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", + "claude-fable-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-5", }, // bedrock keys are the normalized ids the request parser emits. "bedrock": { - "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6", + "anthropic.claude-opus-5", "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6", "anthropic.claude-opus-4-1", "anthropic.claude-sonnet-4-6", "anthropic.claude-sonnet-4-5", "anthropic.claude-haiku-4-5", "meta.llama3-3-70b-instruct", "amazon.nova-pro", "amazon.nova-lite", "amazon.nova-micro", "amazon.nova-2-lite", diff --git a/proxy/internal/llm/pricing/defaults_pricing.yaml b/proxy/internal/llm/pricing/defaults_pricing.yaml index 3fba8fe3f..988426105 100644 --- a/proxy/internal/llm/pricing/defaults_pricing.yaml +++ b/proxy/internal/llm/pricing/defaults_pricing.yaml @@ -180,6 +180,11 @@ anthropic: output_per_1k: 0.050 cache_read_per_1k: 0.001 cache_creation_per_1k: 0.0125 + claude-opus-5: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 claude-opus-4-8: input_per_1k: 0.005 output_per_1k: 0.025 @@ -236,6 +241,11 @@ bedrock: # eu.anthropic.claude-sonnet-4-5-20250929-v1:0 -> anthropic.claude-sonnet-4-5. # Anthropic-on-Bedrock keeps the additive cache buckets (read ≈0.1x input, # write ≈1.25x input); Nova / Llama report no cache, so cost is input+output. + anthropic.claude-opus-5: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 anthropic.claude-opus-4-8: input_per_1k: 0.005 output_per_1k: 0.025 From 9b4a5df9250b81eedfff899e76e026896e7158fa Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 27 Jul 2026 20:08:35 +0200 Subject: [PATCH 33/47] [client] Use platform installer URL for manual update downloads (#6922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Wails UI regressed the non-enforced update download to the plain GitHub releases page. Restore the old Fyne behavior: the tray update item and the About card's Get installer button now open version.DownloadUrl(), which points to the direct installer download (pkgs.netbird.io) per OS/arch and falls back to the generic install page where no installer exists. Also fix the dead brew detection on darwin: exec.Command passed the whole pipeline as a single argument to brew, so the check always failed. Query the netbird formula and netbird-ui cask explicitly via exit codes instead. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added a direct installer download option for manual application updates. * Non-enforced updates now open the appropriate platform-specific installer instead of the general releases page. * **Bug Fixes** * Improved macOS download behavior for Homebrew installations. * Preserved architecture-specific downloads for Intel and Apple silicon Macs. * Updated the tray “About” links to use the GitHub repository and documentation instead of the releases page. --- .../src/modules/auto-update/UpdateVersionCard.tsx | 13 ++++++++----- client/ui/services/update.go | 7 +++++++ client/ui/tray.go | 5 ++--- client/ui/tray_update.go | 7 ++++--- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx index 4fb5e2586..4861c492a 100644 --- a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx +++ b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx @@ -2,6 +2,7 @@ import { type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Browser } from "@wailsio/runtime"; import { DownloadIcon, NotepadText } from "lucide-react"; +import { Update as UpdateSvc } from "@bindings/services"; import { Button } from "@/components/buttons/Button"; import { useClientVersion } from "@/contexts/ClientVersionContext"; import { cn } from "@/lib/cn"; @@ -14,6 +15,12 @@ function openUrl(url: string) { }); } +function openInstallerDownload() { + UpdateSvc.DownloadURL() + .then(openUrl) + .catch(() => openUrl(GITHUB_RELEASES)); +} + export function UpdateVersionCard() { const { t } = useTranslation(); const { updateVersion, enforced, triggerUpdate } = useClientVersion(); @@ -37,11 +44,7 @@ export function UpdateVersionCard() { {t("update.card.installNow")} ) : ( - diff --git a/client/ui/services/update.go b/client/ui/services/update.go index 753177d45..b743b9858 100644 --- a/client/ui/services/update.go +++ b/client/ui/services/update.go @@ -10,6 +10,7 @@ import ( "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ui/updater" + "github.com/netbirdio/netbird/version" ) // UpdateResult mirrors TriggerUpdateResponse. @@ -33,6 +34,12 @@ func (s *Update) GetState() updater.State { return s.holder.Get() } +// DownloadURL returns the platform-appropriate installer download link for +// manual (non-enforced) updates. +func (s *Update) DownloadURL() string { + return version.DownloadUrl() +} + // Quit exits the app. Scheduled off the calling goroutine so the JS caller's // response returns before the runtime tears down. func (s *Update) Quit() { diff --git a/client/ui/tray.go b/client/ui/tray.go index 63b6a46ec..49e883f89 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -32,9 +32,8 @@ const ( quitDownTimeout = 5 * time.Second - urlGitHubRepo = "https://github.com/netbirdio/netbird" - urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest" - urlDocs = "https://docs.netbird.io" + urlGitHubRepo = "https://github.com/netbirdio/netbird" + urlDocs = "https://docs.netbird.io" ) // TrayServices bundles the services the tray menu needs, grouped so NewTray diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go index 2d79cff05..1a377dfa3 100644 --- a/client/ui/tray_update.go +++ b/client/ui/tray_update.go @@ -13,6 +13,7 @@ import ( "github.com/netbirdio/netbird/client/ui/services" "github.com/netbirdio/netbird/client/ui/updater" + "github.com/netbirdio/netbird/version" ) // trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray. @@ -76,15 +77,15 @@ func (u *trayUpdater) applyLanguage() { u.refreshMenuItem(state) } -// handleClick opens the GitHub releases page when not Enforced, otherwise shows -// the progress page and asks the daemon to start the installer. +// handleClick opens the installer download link when not Enforced, otherwise +// shows the progress page and asks the daemon to start the installer. func (u *trayUpdater) handleClick() { u.mu.Lock() state := u.state u.mu.Unlock() if !state.Enforced { - _ = u.app.Browser.OpenURL(urlGitHubReleases) + _ = u.app.Browser.OpenURL(version.DownloadUrl()) return } From bab5572a749d86adb8b9899d077fabd3b910d91a Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 28 Jul 2026 03:43:59 +0900 Subject: [PATCH 34/47] [management, proxy] scope agent-network model allowlist per policy/group and provider (#6905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes The model-allowlist guardrail was merged into one account-wide union and enforced flat on every request, ignoring which policy/group/provider authorised it. With multiple policies — especially a mix of guardrailed and un-guardrailed ones — this caused: - **false-allow**: a model allowlisted for one group/provider leaked to any caller; and - **false-deny**: an un-guardrailed policy (intended unrestricted) was blocked by another policy's allowlist. Enforcement is now policy/group-aware, mirroring `llm_limit_check`: - **Management (`SelectPolicyForRequest`) is authoritative.** It uses the request model (already carried in `CheckLLMPolicyLimitsRequest.model`, previously ignored) to keep only applicable policies whose guardrails permit the model; no allowlist-enabled guardrail = unrestricted. Denies `llm_policy.model_blocked` when policies govern the (provider, groups) but none permits the model. - **Proxy `llm_guardrail` becomes a per-provider fail-closed backstop.** The synthesiser emits an allowlist only for providers every authorising policy restricts; the middleware keys off the resolved provider id and keeps unknown-model fail-closed. --- docs/agent-networks/01-end-to-end-flows.md | 17 +- .../modules/21-management-agentnetwork.md | 2 +- .../modules/31-proxy-middleware-builtin.md | 2 +- e2e/agentnetwork/guardrail_block_test.go | 209 +++++++++ .../guardrail_groupswitch_test.go | 205 +++++++++ .../guardrail_multipolicy_test.go | 201 +++++++++ .../guardrail_pergroup_providers_test.go | 422 ++++++++++++++++++ e2e/harness/proxy.go | 12 +- .../internals/modules/agentnetwork/manager.go | 7 +- .../modules/agentnetwork/policyselect.go | 108 +++++ .../agentnetwork/policyselect_model_test.go | 329 ++++++++++++++ .../modules/agentnetwork/synthesizer.go | 182 ++++---- .../synthesizer_guardrail_realstore_test.go | 6 +- .../synthesizer_provider_allowlist_test.go | 95 ++++ .../modules/agentnetwork/synthesizer_test.go | 8 +- management/internals/shared/grpc/proxy.go | 1 + .../grpc/proxy_llm_policy_limits_test.go | 138 ++++++ proxy/internal/auth/tunnel_cache.go | 39 +- proxy/internal/auth/tunnel_cache_test.go | 29 ++ .../builtin/llm_guardrail/factory.go | 37 +- .../builtin/llm_guardrail/middleware.go | 42 +- .../builtin/llm_guardrail/middleware_test.go | 143 +++++- .../builtin/llm_limit_check/middleware.go | 17 +- .../llm_limit_check/middleware_test.go | 40 ++ .../guardrail_allowlist_test.go | 11 +- 25 files changed, 2148 insertions(+), 154 deletions(-) create mode 100644 e2e/agentnetwork/guardrail_block_test.go create mode 100644 e2e/agentnetwork/guardrail_groupswitch_test.go create mode 100644 e2e/agentnetwork/guardrail_multipolicy_test.go create mode 100644 e2e/agentnetwork/guardrail_pergroup_providers_test.go create mode 100644 management/internals/modules/agentnetwork/policyselect_model_test.go create mode 100644 management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go create mode 100644 management/internals/shared/grpc/proxy_llm_policy_limits_test.go diff --git a/docs/agent-networks/01-end-to-end-flows.md b/docs/agent-networks/01-end-to-end-flows.md index 7264f3768..b8891001b 100644 --- a/docs/agent-networks/01-end-to-end-flows.md +++ b/docs/agent-networks/01-end-to-end-flows.md @@ -109,7 +109,7 @@ sequenceDiagram Chk->>Inj: continue Inj->>Inj: inject NetBird identity headers per provider config Inj->>Grd: continue - Grd->>Grd: enforce model allowlist + Grd->>Grd: enforce per-provider allowlist (fail-closed backstop) Grd->>Up: forward (over WireGuard) Up-->>Resp: response (JSON or SSE stream) Resp->>Resp: parse usage tokens, completion @@ -135,6 +135,21 @@ sequenceDiagram (`redact_pii = settings.RedactPii`). Phones, emails, credit cards, PII names — see `redact.go` for the full set. See [`modules/31-proxy-middleware-builtin.md`](modules/31-proxy-middleware-builtin.md). +- The model allowlist is enforced in TWO places. `CheckLLMPolicyLimits` + is authoritative: it resolves the policy that governs this + (provider, caller-groups) and denies (`deny_code = llm_policy.model_blocked`) + when no applicable policy permits the model — so an allowlist scoped to + one group/provider never leaks to another, and an un-guardrailed policy + is genuinely unrestricted. `llm_guardrail` is a per-provider fail-closed + backstop: it only carries an allowlist for a provider every authorising + policy restricts, and blocks unknown/undetermined models even when + management is unreachable. Because that backstop allowlist is the UNION + of every restricting policy's models, per-group narrowing lives only in + the authoritative check: during a `CheckLLMPolicyLimits` outage + `llm_limit_check` fails open, so a caller can reach any model in the + provider's union — a group scoped to model A could reach model B if + another group restricts the same provider to B. This is the documented + fail-open trade-off; a future flag may switch it to fail-closed. - SSE streaming requires special handling on the response side; the parser must handle partial chunks without buffering the whole stream. See [`modules/32-proxy-llm-parsers.md`](modules/32-proxy-llm-parsers.md). diff --git a/docs/agent-networks/modules/21-management-agentnetwork.md b/docs/agent-networks/modules/21-management-agentnetwork.md index b64c1ba20..cc74206e9 100644 --- a/docs/agent-networks/modules/21-management-agentnetwork.md +++ b/docs/agent-networks/modules/21-management-agentnetwork.md @@ -122,7 +122,7 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest | on_request | 1 | `llm_router` | `{"providers":[{id, models[], upstream_*, auth_header_*, allowed_group_ids[]}]}` | **true** | | on_request | 2 | `llm_limit_check` | `{}` | – | | on_request | 3 | `llm_identity_inject` | `{"providers":[{provider_id, header_pair?, json_metadata?, extra_headers?}]}` | **true** | - | on_request | 4 | `llm_guardrail` | `{"model_allowlist"?, "prompt_capture":{enabled,redact_pii}}` | – | + | on_request | 4 | `llm_guardrail` | `{"provider_allowlists"?: {providerID: []model}, "prompt_capture":{enabled,redact_pii}}` | – | | on_response | 5 | `llm_limit_record` | `{}` (runs LAST at runtime) | – | | on_response | 6 | `cost_meter` | `{}` | – | | on_response | 7 | `llm_response_parser` | `{"capture_completion": , "redact_pii"?: true}` | – | diff --git a/docs/agent-networks/modules/31-proxy-middleware-builtin.md b/docs/agent-networks/modules/31-proxy-middleware-builtin.md index 904de6424..efe1bc4ce 100644 --- a/docs/agent-networks/modules/31-proxy-middleware-builtin.md +++ b/docs/agent-networks/modules/31-proxy-middleware-builtin.md @@ -244,7 +244,7 @@ no mocks. Tests: `TestChain_AllowPath_StampsAttributionAndRecordsCounter` | `llm_router` | `{providers: [{id, models, upstream_scheme, upstream_host, upstream_path?, auth_header_name, auth_header_value, allowed_group_ids}]}` | | `llm_limit_check` | `{}` — pulls `MgmtClient` from `FactoryContext` | | `llm_identity_inject` | `{providers: [{provider_id, header_pair?|json_metadata?, extra_headers?}]}` | -| `llm_guardrail` | `{model_allowlist: []string, prompt_capture: {enabled, redact_pii}}` | +| `llm_guardrail` | `{provider_allowlists: {providerID: []string}, prompt_capture: {enabled, redact_pii}}` — allowlist keyed by resolved provider id; a provider absent from the map is unrestricted (fail-closed backstop; authoritative per-policy/group check is management's `CheckLLMPolicyLimits`) | | `llm_response_parser` | `{redact_pii?, capture_completion?: *bool}` | | `cost_meter` | `{pricing_path?}` (basename inside data-dir; defaults `pricing.yaml`) | | `llm_limit_record` | `{}` — same pattern as `llm_limit_check` | diff --git a/e2e/agentnetwork/guardrail_block_test.go b/e2e/agentnetwork/guardrail_block_test.go new file mode 100644 index 000000000..c4b22ae25 --- /dev/null +++ b/e2e/agentnetwork/guardrail_block_test.go @@ -0,0 +1,209 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// pathRoutedGuardrailCase is one provider's self-contained scenario: its own +// provider, its own guardrail whose allowlist holds ONLY that provider's +// allowed model, and its own policy. Each case runs in isolation (its own +// proxy + client), so the guardrail the proxy enforces contains exactly this +// provider's model — never a mixed cross-provider list. +type pathRoutedGuardrailCase struct { + name string + catalogID string // agent-network catalog provider id + wire string // harness.WireVertex | harness.WireBedrock + allowEntry string // the single model id put on the guardrail allowlist + allowModel string // model id sent that MUST be served (200) + blockModel string // model id sent that MUST be denied (403 model_blocked) +} + +// TestGuardrailBlocksUnselectedModel_PathRouted is the end-to-end regression +// guard for the customer report that a model-allowlist guardrail attached to a +// policy has no effect for PATH-ROUTED providers — where the model travels in +// the URL, not the JSON body: Google Vertex (…/models/{model}:rawPredict) and +// AWS Bedrock (/model/{id}/invoke). +// +// Each provider is tested in isolation with a guardrail allowlisting a single +// model of its own: the allowed model (in the URL path) is served (200) and an +// unselected model (in the URL path) is denied 403 by the guardrail +// (llm_policy.model_blocked) before the upstream. The Vertex case mirrors the +// customer verbatim — allow Sonnet, and the unselected model is the exact +// claude-opus-4-6 they reported reaching the model unblocked. The Bedrock case +// sends a region-prefixed, versioned inference-profile id so URL-path model +// normalization is exercised too. +// +// The provider is catch-all (no models), so the router forwards any model and a +// 403 can only come from the guardrail, never model_not_routable. Only the +// upstream LLM is mocked (the vLLM nginx answers any path with 200); management +// synth/reconcile, the proxy middleware chain (URL-path model extraction, +// router, guardrail) and the tunnel are all real, and the guardrail denies +// before the upstream is dialed so the mock cannot influence the block. A +// static bearer api key is used so the router injects a static Authorization +// header instead of minting a GCP token — the only reason path-routed providers +// normally need live credentials — so the test runs with none and is always on. +func TestGuardrailBlocksUnselectedModel_PathRouted(t *testing.T) { + cases := []pathRoutedGuardrailCase{ + { + name: "vertex", + catalogID: "vertex_ai_api", + wire: harness.WireVertex, + allowEntry: "claude-sonnet-4-5", + allowModel: "claude-sonnet-4-5", + blockModel: "claude-opus-4-6", // the customer-reported model + }, + { + name: "bedrock", + catalogID: "bedrock_api", + wire: harness.WireBedrock, + allowEntry: "anthropic.claude-sonnet-4-5", // normalized catalog id + allowModel: "us.anthropic.claude-sonnet-4-5-v1:0", + blockModel: "us.anthropic.claude-opus-4-8-v1:0", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + runPathRoutedGuardrailCase(t, tc) + }) + } +} + +func runPathRoutedGuardrailCase(t *testing.T, tc pathRoutedGuardrailCase) { + t.Helper() + + const ( + vertexProject = "e2e-project" + vertexRegion = "global" + ) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-" + tc.name}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-guardrail-" + tc.name + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // Catch-all provider (no models) so the router forwards any model; a static + // bearer key means the router injects a static auth header instead of minting + // a GCP token. Bootstraps the cluster if it isn't already. + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: tc.name, + ProviderId: tc.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + BootstrapCluster: ptr(harness.AgentNetworkCluster), + }) + require.NoError(t, err, "create %s provider", tc.name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // Guardrail allowlisting ONLY this provider's allowed model. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-guardrail-" + tc.name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{tc.allowEntry} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-" + tc.name, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-guardrail-"+tc.name+"-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint and its first packet wakes the + // lazy proxy peer, so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + var code int + var body string + var cerr error + switch tc.wire { + case harness.WireVertex: + code, body, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, vertexProject, vertexRegion, model, "Reply with exactly: pong", "") + case harness.WireBedrock: + code, body, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, model, "Reply with exactly: pong", "") + default: + t.Fatalf("unsupported wire %q", tc.wire) + } + require.NoError(t, cerr, "request must reach the proxy for %s", tc.name) + return code, body + } + + // Allowed model (in the URL path) is served. Retry to absorb tunnel/DNS + // jitter on the first call over the freshly warmed tunnel. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(tc.allowModel) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + assert.Equal(t, 200, code, + "allowed %s model (URL path) must be served; body: %s\n=== proxy logs ===\n%s", tc.name, body, px.Logs(context.Background())) + + // Unselected model (in the URL path) must be blocked by the guardrail. + code, body = send(tc.blockModel) + assert.Equal(t, 403, code, + "unselected %s model (URL path) must be denied, not served; body: %s\n=== proxy logs ===\n%s", tc.name, body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "%s denial must come from the guardrail allowlist, not routing; body: %s", tc.name, body) +} diff --git a/e2e/agentnetwork/guardrail_groupswitch_test.go b/e2e/agentnetwork/guardrail_groupswitch_test.go new file mode 100644 index 000000000..5f2dce52e --- /dev/null +++ b/e2e/agentnetwork/guardrail_groupswitch_test.go @@ -0,0 +1,205 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestGuardrailGroupSwitchTakesEffectAfterTTL proves that moving a peer between +// groups flips its model-allowlist decision once the proxy's tunnel-peer cache +// expires. The peer's groups reach the guardrail via ValidateTunnelPeer, which +// the proxy caches; the switch is invisible until that cache expires. The proxy +// runs with a short NB_PROXY_TUNNEL_CACHE_TTL so the flip happens in seconds +// instead of the 5-minute default. +// +// Setup: one catch-all provider declaring modelA + modelB; polA (grpA -> allow +// modelA) and polB (grpB -> allow modelB). The client starts in grpA. modelA is +// served and modelB denied; after switching the client grpA -> grpB, modelB is +// served and modelA denied. The cross-group deny comes from management's +// per-policy/group CheckLLMPolicyLimits (the proxy backstop carries the union). +func TestGuardrailGroupSwitchTakesEffectAfterTTL(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + modelA = "e2e-model-a" + modelB = "e2e-model-b" + // Short tunnel-cache TTL so a group switch propagates in seconds. + // Exercises the NB_PROXY_TUNNEL_CACHE_TTL override. + cacheTTL = 3 * time.Second + ) + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpA, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gswitch-a"}) + require.NoError(t, err, "create group A") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpA.Id) }) + + grpB, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gswitch-b"}) + require.NoError(t, err, "create group B") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpB.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gswitch-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpA.Id}, // client starts in group A + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "gswitch", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: modelA, InputPer1k: 0.001, OutputPer1k: 0.001}, + {Id: modelB, InputPer1k: 0.001, OutputPer1k: 0.001}, + }, + BootstrapCluster: ptr(harness.AgentNetworkCluster), + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + mkGuard := func(name, model string) api.AgentNetworkGuardrail { + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g + } + gA := mkGuard("e2e-gswitch-a", modelA) + gB := mkGuard("e2e-gswitch-b", modelB) + + enabled := true + polA, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gswitch-a", + Enabled: &enabled, + SourceGroups: []string{grpA.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gA.Id}, + }) + require.NoError(t, err, "create policy A") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polA.Id) }) + + polB, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gswitch-b", + Enabled: &enabled, + SourceGroups: []string{grpB.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gB.Id}, + }) + require.NoError(t, err, "create policy B") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polB.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-gswitch-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken, map[string]string{ + "NB_PROXY_TUNNEL_CACHE_TTL": cacheTTL.String(), + }) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + sendUntil := func(model string, want int, timeout time.Duration) (int, string) { + var code int + var body string + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + code, body = send(model) + if code == want { + return code, body + } + time.Sleep(2 * time.Second) + } + return code, body + } + + // Phase 1 — client is in group A: modelA served, modelB denied. + code, body := sendUntil(modelA, 200, 90*time.Second) + assert.Equal(t, 200, code, + "group-A model must be served while the client is in group A; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + code, body = send(modelB) + assert.Equal(t, 403, code, + "group-B model must be denied while the client is in group A; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") + + // Switch the client peer from group A to group B. + peerID := clientPeerInGroup(t, ctx, grpA.Id) + _, err = srv.API().Groups.Update(ctx, grpB.Id, api.PutApiGroupsGroupIdJSONRequestBody{ + Name: grpB.Name, + Peers: &[]string{peerID}, + }) + require.NoError(t, err, "add peer to group B") + _, err = srv.API().Groups.Update(ctx, grpA.Id, api.PutApiGroupsGroupIdJSONRequestBody{ + Name: grpA.Name, + Peers: &[]string{}, + }) + require.NoError(t, err, "remove peer from group A") + + // Phase 2 — after the short TTL expires the proxy re-validates the peer, + // sees group B, and the decision flips. Poll to absorb TTL + re-validation. + code, body = sendUntil(modelB, 200, 60*time.Second) + assert.Equal(t, 200, code, + "after the group switch + TTL, the group-B model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + code, body = send(modelA) + assert.Equal(t, 403, code, + "after the switch, the old group-A model must be denied; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") +} + +// clientPeerInGroup returns the id of the single peer that is a member of the +// given group — the test client. The proxy peer is never added to test groups. +func clientPeerInGroup(t *testing.T, ctx context.Context, groupID string) string { + t.Helper() + peers, err := srv.API().Peers.List(ctx) + require.NoError(t, err, "list peers") + for _, p := range peers { + for _, g := range p.Groups { + if g.Id == groupID { + return p.Id + } + } + } + t.Fatalf("no peer found in group %s", groupID) + return "" +} diff --git a/e2e/agentnetwork/guardrail_multipolicy_test.go b/e2e/agentnetwork/guardrail_multipolicy_test.go new file mode 100644 index 000000000..1d664fb58 --- /dev/null +++ b/e2e/agentnetwork/guardrail_multipolicy_test.go @@ -0,0 +1,201 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestGuardrailMultiPolicyModelAllowlist: modelSelected served (200), grpOther's +// modelOther denied for the grpMain client (403 model_blocked, no cross-group +// leak), and openModel on the un-guardrailed policy's provider served (200). +func TestGuardrailMultiPolicyModelAllowlist(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + modelSelected = "e2e-selected" + modelOther = "e2e-other" + openModel = "e2e-open" + ) + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-mp-main"}) + require.NoError(t, err, "create main group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) }) + + grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-mp-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-guardrail-mp-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpMain.Id}, // client joins grpMain only + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + models := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + // pRestricted declares the two guardrailed models so routing is deterministic + // (model -> provider). Created first, so it carries the bootstrap cluster. + pRestricted, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "restricted", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: models(modelSelected, modelOther), + BootstrapCluster: ptr(harness.AgentNetworkCluster), + }) + require.NoError(t, err, "create restricted provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pRestricted.Id) }) + + pOpen, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "open", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: models(openModel), + }) + require.NoError(t, err, "create open provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pOpen.Id) }) + + mkGuardrail := func(name, model string) api.AgentNetworkGuardrail { + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g + } + gMain := mkGuardrail("e2e-guardrail-mp-main", modelSelected) + gOther := mkGuardrail("e2e-guardrail-mp-other", modelOther) + + enabled := true + // polMain: grpMain restricted to modelSelected on pRestricted. + polMain, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{pRestricted.Id}, + GuardrailIds: &[]string{gMain.Id}, + }) + require.NoError(t, err, "create main policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + // polOther: grpOther restricted to modelOther on the SAME provider. The + // client is not in grpOther, so modelOther must never be usable by it. + polOther, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{pRestricted.Id}, + GuardrailIds: &[]string{gOther.Id}, + }) + require.NoError(t, err, "create other policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + + // polOpen: grpMain on pOpen with NO guardrail — unrestricted. + polOpen, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-open", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{pOpen.Id}, + }) + require.NoError(t, err, "create open policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOpen.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-guardrail-mp-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + // sendUntil200 absorbs first-call tunnel/DNS jitter on the freshly warmed tunnel. + sendUntil200 := func(model string) (int, string) { + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(model) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + return code, body + } + + t.Run("selected model allowed for its group", func(t *testing.T) { + code, body := sendUntil200(modelSelected) + assert.Equal(t, 200, code, + "grpMain's allowlisted model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) + + t.Run("other group's model does not leak", func(t *testing.T) { + // modelOther is allowlisted only for grpOther. The grpMain client must be + // denied by management's per-policy/group check — not waved through by an + // account-wide union. This is the security-critical wrong-ALLOW guard. + code, body := send(modelOther) + assert.Equal(t, 403, code, + "another group's allowlisted model must be denied for this caller; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "denial must be a model-allowlist decision; body: %s", body) + }) + + t.Run("unguarded policy leaves its provider unrestricted", func(t *testing.T) { + // polOpen carries no guardrail, so pOpen is unrestricted for grpMain. The + // old account-wide union would have blocked openModel (it is on no + // allowlist); it must now be served — the false-DENY guard. + code, body := sendUntil200(openModel) + assert.Equal(t, 200, code, + "an un-guardrailed policy's provider must not be blocked by another policy's allowlist; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) +} diff --git a/e2e/agentnetwork/guardrail_pergroup_providers_test.go b/e2e/agentnetwork/guardrail_pergroup_providers_test.go new file mode 100644 index 000000000..eddae65c3 --- /dev/null +++ b/e2e/agentnetwork/guardrail_pergroup_providers_test.go @@ -0,0 +1,422 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// pergroupCase describes one provider surface for the per-group allowlist matrix. +// selectedReq/otherReq are the model identifiers as they travel in the request +// (URL path for Bedrock/Vertex, body "model" for chat/messages). selectedAllow/ +// otherAllow are the (normalized) forms the guardrail allowlist holds — for +// Bedrock these differ from the request form so path normalization is exercised. +type pergroupCase struct { + name string + catalogID string + wire string // "chat", "messages", "vertex", "bedrock" + models *[]api.AgentNetworkProviderModel + selectedReq string + selectedAllow string + otherReq string + otherAllow string + + providerID string // filled during setup +} + +// TestGuardrailPerGroupAllowlist_AllProviders proves the per-policy/group model +// allowlist end to end across every always-on provider surface, including the +// path-routed ones (Vertex, Bedrock) where the model travels in the URL. +// +// For each provider two policies target it: grpMain (the client) is allowed only +// selectedReq; grpOther (which the client is NOT in) is allowed only otherReq. +// The client must get selectedReq served (200) and otherReq denied (403, +// llm_policy.model_blocked) — the cross-group no-leak property. The deny is the +// authoritative per-policy/group decision from management (the proxy per-provider +// backstop carries the union of both models), so this also confirms management +// receives the correct normalized model for path-routed providers. +func TestGuardrailPerGroupAllowlist_AllProviders(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + const ( + vertexProject = "e2e-project" + vertexRegion = "global" + ) + + priced := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + cases := []*pergroupCase{ + { + name: "openai", catalogID: "openai_api", wire: harness.WireChat, + models: priced("oai-model-a", "oai-model-b"), + selectedReq: "oai-model-a", selectedAllow: "oai-model-a", + otherReq: "oai-model-b", otherAllow: "oai-model-b", + }, + { + name: "anthropic", catalogID: "anthropic_api", wire: harness.WireMessages, + models: priced("ant-model-a", "ant-model-b"), + selectedReq: "ant-model-a", selectedAllow: "ant-model-a", + otherReq: "ant-model-b", otherAllow: "ant-model-b", + }, + { + // Vertex catalog ids travel bare in the rawPredict path. + name: "vertex", catalogID: "vertex_ai_api", wire: "vertex", + selectedReq: "claude-sonnet-4-5", selectedAllow: "claude-sonnet-4-5", + otherReq: "claude-opus-4-6", otherAllow: "claude-opus-4-6", + }, + { + // Bedrock request ids are region-prefixed/versioned; the parser + // normalizes them to the catalog key the allowlist holds. + name: "bedrock", catalogID: "bedrock_api", wire: "bedrock", + selectedReq: "us.anthropic.claude-sonnet-4-5-v1:0", selectedAllow: "anthropic.claude-sonnet-4-5", + otherReq: "us.anthropic.claude-opus-4-8-v1:0", otherAllow: "anthropic.claude-opus-4-8", + }, + } + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-pergroup-main"}) + require.NoError(t, err, "create main group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) }) + + grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-pergroup-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-pergroup-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpMain.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + enabled := true + + for i, c := range cases { + req := api.AgentNetworkProviderRequest{ + Name: "e2e-pergroup-" + c.name, + ProviderId: c.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: c.models, + } + if i == 0 { + req.BootstrapCluster = ptr(harness.AgentNetworkCluster) + } + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", c.name) + c.providerID = prov.Id + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + gSel := mkAllowGuardrail(t, ctx, "e2e-pergroup-"+c.name+"-sel", c.selectedAllow) + gOth := mkAllowGuardrail(t, ctx, "e2e-pergroup-"+c.name+"-oth", c.otherAllow) + + polMain, merr := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-pergroup-" + c.name + "-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gSel.Id}, + }) + require.NoError(t, merr, "create main policy %s", c.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + polOther, oerr := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-pergroup-" + c.name + "-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gOth.Id}, + }) + require.NoError(t, oerr, "create other policy %s", c.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + } + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-pergroup-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(c *pergroupCase, model string) (int, string) { + var code int + var body string + var cerr error + switch c.wire { + case "vertex": + code, body, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, vertexProject, vertexRegion, model, "Reply with exactly: pong", "") + case "bedrock": + code, body, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, model, "Reply with exactly: pong", "") + default: + code, body, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, c.wire, model, "Reply with exactly: pong", "") + } + require.NoError(t, cerr, "request must reach the proxy for %s", c.name) + return code, body + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // grpMain's own model is served. Retry to absorb tunnel/DNS jitter on + // the first call over the freshly warmed tunnel. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(c, c.selectedReq) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + assert.Equal(t, 200, code, + "%s: grpMain's allowlisted model must be served; body: %s\n=== proxy logs ===\n%s", c.name, body, px.Logs(context.Background())) + + // grpOther's model must NOT leak to the grpMain client. + code, body = send(c, c.otherReq) + assert.Equal(t, 403, code, + "%s: another group's allowlisted model must be denied for this caller; body: %s\n=== proxy logs ===\n%s", c.name, body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "%s: denial must be a model-allowlist decision, not routing; body: %s", c.name, body) + }) + } +} + +// TestGuardrailMultiGroupUser proves the per-policy/group decision for a caller +// that belongs to MULTIPLE groups at once. Two scenarios, one shared stack: +// +// - union across the user's groups: the client is in gUX and gUY, each with +// its own policy+guardrail on provider P1 (gUX->union-a, gUY->union-b). The +// client may use BOTH models (the union of its groups' allowlists) while a +// third, un-allowlisted model is denied. +// - an un-guardrailed group lifts the restriction: the client is in gMP and +// gMQ on provider P2, where gMP restricts to mix-a but gMQ's policy carries +// NO guardrail. Because one applicable policy is unrestricted, the client may +// use a model on no allowlist (mix-z) as well as mix-a. +func TestGuardrailMultiGroupUser(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + unionA = "mg-union-a" + unionB = "mg-union-b" + unionC = "mg-union-c" // allowlisted by neither group + mixA = "mg-mix-a" + mixZ = "mg-mix-z" // on no allowlist; reachable only via the un-guardrailed policy + ) + + priced := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + mkGroup := func(name string) *api.Group { + g, gerr := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: name}) + require.NoError(t, gerr, "create group %s", name) + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), g.Id) }) + return g + } + gUX := mkGroup("e2e-mg-union-x") + gUY := mkGroup("e2e-mg-union-y") + gMP := mkGroup("e2e-mg-mix-p") + gMQ := mkGroup("e2e-mg-mix-q") + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-mg-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{gUX.Id, gUY.Id, gMP.Id, gMQ.Id}, // client in all four groups + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + enabled := true + + // P1 — union scenario: two restricting policies, one per group. + p1, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-mg-union", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: priced(unionA, unionB, unionC), + BootstrapCluster: ptr(harness.AgentNetworkCluster), + }) + require.NoError(t, err, "create union provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p1.Id) }) + + polUX, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-union-x", + Enabled: &enabled, + SourceGroups: []string{gUX.Id}, + DestinationProviderIds: []string{p1.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-union-x", unionA).Id}, + }) + require.NoError(t, err, "create union policy X") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polUX.Id) }) + + polUY, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-union-y", + Enabled: &enabled, + SourceGroups: []string{gUY.Id}, + DestinationProviderIds: []string{p1.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-union-y", unionB).Id}, + }) + require.NoError(t, err, "create union policy Y") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polUY.Id) }) + + // P2 — mixed scenario: one restricting policy + one un-guardrailed policy. + p2, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-mg-mix", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: priced(mixA, mixZ), + }) + require.NoError(t, err, "create mix provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p2.Id) }) + + polMP, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-mix-p", + Enabled: &enabled, + SourceGroups: []string{gMP.Id}, + DestinationProviderIds: []string{p2.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-mix-p", mixA).Id}, + }) + require.NoError(t, err, "create mix policy P") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMP.Id) }) + + polMQ, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-mix-q", + Enabled: &enabled, + SourceGroups: []string{gMQ.Id}, + DestinationProviderIds: []string{p2.Id}, // NO guardrail -> unrestricted + }) + require.NoError(t, err, "create mix policy Q") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMQ.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-mg-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + sendUntil200 := func(model string) (int, string) { + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(model) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + return code, body + } + + t.Run("union across the user's groups", func(t *testing.T) { + code, body := sendUntil200(unionA) + assert.Equal(t, 200, code, "model allowed by group X must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + + code, body = sendUntil200(unionB) + assert.Equal(t, 200, code, "model allowed by group Y must also be served (union across the user's groups); body: %s", body) + + code, body = send(unionC) + assert.Equal(t, 403, code, "a model on neither group's allowlist must be denied; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") + }) + + t.Run("an un-guardrailed group lifts the restriction", func(t *testing.T) { + code, body := sendUntil200(mixA) + assert.Equal(t, 200, code, "the restricted group's model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + + code, body = sendUntil200(mixZ) + assert.Equal(t, 200, code, + "a non-allowlisted model must be served because the user is also in a group whose policy has no guardrail; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) +} + +// mkAllowGuardrail creates a guardrail whose model allowlist is enabled and holds +// exactly the given model, registering cleanup. +func mkAllowGuardrail(t *testing.T, ctx context.Context, name, model string) api.AgentNetworkGuardrail { + t.Helper() + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g +} diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go index 8db2c140f..85f3518d4 100644 --- a/e2e/harness/proxy.go +++ b/e2e/harness/proxy.go @@ -38,7 +38,11 @@ type Proxy struct { // network, registered via the given account proxy token and serving the // AgentNetworkCluster over a self-signed wildcard cert. It does not wait for // peer connectivity — callers poll management for the proxy peer. -func StartProxy(ctx context.Context, c *Combined, proxyToken string) (*Proxy, error) { +// StartProxy launches the reverse-proxy container. Optional envOverrides are +// merged into the container environment after the defaults, so callers can set +// or override any NB_PROXY_* var (e.g. NB_PROXY_TUNNEL_CACHE_TTL for tests that +// need a short authorization-cache window). +func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverrides ...map[string]string) (*Proxy, error) { root, err := repoRoot() if err != nil { return nil, err @@ -93,6 +97,12 @@ func StartProxy(ctx context.Context, c *Combined, proxyToken string) (*Proxy, er WaitingFor: wait.ForLog("Initial mapping sync complete").WithStartupTimeout(90 * time.Second), } + for _, ov := range envOverrides { + for k, v := range ov { + req.Env[k] = v + } + } + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index d88e0d77c..77c77ce44 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -86,12 +86,17 @@ type Manager interface { // PolicySelectionInput is the per-request selection envelope. The // proxy populates it from CapturedData (account, user, groups) plus -// the provider llm_router resolved. +// the provider llm_router resolved and the model it extracted. type PolicySelectionInput struct { AccountID string UserID string GroupIDs []string ProviderID string + // Model is the already-normalised upstream model id the proxy extracted + // (parser strips Bedrock region/version, Vertex @version), so a + // case-insensitive compare suffices. Empty = undetermined → not permitted + // (fail closed). + Model string } // PolicySelectionResult names the policy that "pays" for this request diff --git a/management/internals/modules/agentnetwork/policyselect.go b/management/internals/modules/agentnetwork/policyselect.go index 9203a1910..9bb893b36 100644 --- a/management/internals/modules/agentnetwork/policyselect.go +++ b/management/internals/modules/agentnetwork/policyselect.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "sort" + "strings" "time" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" @@ -35,6 +36,10 @@ const ( denyCodeAccountTokenCapExceeded = "llm_account.token_cap_exceeded" //nolint:gosec // account deny code label, not a credential denyCodeAccountBudgetCapExceeded = "llm_account.budget_cap_exceeded" + // denyCodeModelBlocked is returned when policies govern the request's + // (provider, caller-groups) but none permits the model. Matches the proxy + // guardrail's code so both layers surface the same label. + denyCodeModelBlocked = "llm_policy.model_blocked" ) // consumptionCache holds the consumption counters prefetched for one @@ -159,6 +164,25 @@ func (m *managerImpl) SelectPolicyForRequest(ctx context.Context, in PolicySelec } candidates := filterApplicablePolicies(policies, in) + // Model-allowlist gate scoped to the matched policies: keep candidates whose + // guardrails permit the model (none enabled = unrestricted), deny when + // policies apply but none permits it. Skip the load when none has a guardrail. + if len(candidates) > 0 && anyPolicyHasGuardrails(candidates) { + guardrailsByID, gErr := m.loadGuardrailsByID(ctx, in.AccountID) + if gErr != nil { + return nil, gErr + } + permitted := filterModelPermittedPolicies(candidates, guardrailsByID, in.Model) + if len(permitted) == 0 { + return &PolicySelectionResult{ + Allow: false, + DenyCode: denyCodeModelBlocked, + DenyReason: modelBlockedReason(in.Model), + }, nil + } + candidates = permitted + } + // Prefetch every consumption counter the ceiling + candidate policies will // read, in a single store round-trip, then score against the cache. cache, err := m.prefetchConsumption(ctx, in, rules, candidates, now) @@ -250,6 +274,90 @@ func filterApplicablePolicies(policies []*types.Policy, in PolicySelectionInput) return out } +// anyPolicyHasGuardrails reports whether any policy references at least one +// guardrail, so the selector can skip loading guardrails when none do. +func anyPolicyHasGuardrails(policies []*types.Policy) bool { + for _, p := range policies { + if p != nil && len(p.GuardrailIDs) > 0 { + return true + } + } + return false +} + +// loadGuardrailsByID loads the account's guardrails indexed by ID. Used by the +// model-allowlist gate to resolve each candidate policy's attached guardrails. +func (m *managerImpl) loadGuardrailsByID(ctx context.Context, accountID string) (map[string]*types.Guardrail, error) { + guardrails, err := m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("list account guardrails: %w", err) + } + byID := make(map[string]*types.Guardrail, len(guardrails)) + for _, g := range guardrails { + if g != nil { + byID[g.ID] = g + } + } + return byID, nil +} + +// filterModelPermittedPolicies returns the subset of policies whose guardrails +// permit the model. Order is preserved so downstream scoring is unaffected. +func filterModelPermittedPolicies(policies []*types.Policy, byID map[string]*types.Guardrail, model string) []*types.Policy { + out := make([]*types.Policy, 0, len(policies)) + for _, p := range policies { + if policyPermitsModel(p, byID, model) { + out = append(out, p) + } + } + return out +} + +// policyPermitsModel reports whether a policy permits the model. No +// allowlist-enabled guardrail = unrestricted (permits any, incl. empty); +// otherwise the model must be in the union of its allowlists, so an +// empty/undetermined model fails closed. +func policyPermitsModel(p *types.Policy, byID map[string]*types.Guardrail, model string) bool { + if p == nil { + return false + } + wanted := normaliseModelID(model) + restricted := false + for _, gID := range p.GuardrailIDs { + g, ok := byID[gID] + if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled { + continue + } + restricted = true + if wanted == "" { + continue + } + for _, allowed := range g.Checks.ModelAllowlist.Models { + if normaliseModelID(allowed) == wanted { + return true + } + } + } + return !restricted +} + +// normaliseModelID lowercases and trims a model identifier so the allowlist +// compare is case-insensitive and trim-tolerant. Mirrors the proxy guardrail's +// normaliseModel so both layers agree on what "same model" means. +func normaliseModelID(model string) string { + return strings.ToLower(strings.TrimSpace(model)) +} + +// modelBlockedReason builds the human-readable deny reason for a model-allowlist +// rejection. The model is quoted when known; an undetermined model is reported +// as such so the access log distinguishes "wrong model" from "no model". +func modelBlockedReason(model string) string { + if normaliseModelID(model) == "" { + return "request model could not be determined for the policy allowlist" + } + return fmt.Sprintf("model %q is not permitted by any applicable policy allowlist", model) +} + // candidate is the per-policy intermediate the selector ranks. A // policy that's been exhausted on any enabled cap never makes it // into this slice; the selector's deny envelope carries the latest diff --git a/management/internals/modules/agentnetwork/policyselect_model_test.go b/management/internals/modules/agentnetwork/policyselect_model_test.go new file mode 100644 index 000000000..c122cc36c --- /dev/null +++ b/management/internals/modules/agentnetwork/policyselect_model_test.go @@ -0,0 +1,329 @@ +package agentnetwork + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" +) + +// guardedPolicy builds an enabled, uncapped policy that authorises sourceGroups +// to reach providerID under the given guardrails. Uncapped keeps the selector's +// headroom scoring trivial so these tests isolate the model-allowlist gate. +func guardedPolicy(id, account string, sourceGroups []string, providerID string, guardrailIDs ...string) *types.Policy { + return &types.Policy{ + ID: id, + AccountID: account, + Enabled: true, + SourceGroups: sourceGroups, + DestinationProviderIDs: []string{providerID}, + GuardrailIDs: guardrailIDs, + CreatedAt: time.Now().UTC(), + } +} + +// allowlistGuardrail builds a guardrail whose model allowlist is enabled and +// carries the given models. +func allowlistGuardrail(id, account string, models ...string) *types.Guardrail { + return &types.Guardrail{ + ID: id, + AccountID: account, + Checks: types.GuardrailChecks{ + ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models}, + }, + } +} + +func expectPolicies(mockStore *store.MockStore, account string, policies ...*types.Policy) { + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), account). + Return(policies, nil) +} + +func expectGuardrails(mockStore *store.MockStore, account string, guardrails ...*types.Guardrail) { + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(gomock.Any(), gomock.Any(), account). + Return(guardrails, nil) +} + +// TestSelectPolicy_ModelBlockedByAllowlist proves the authoritative allowlist +// decision: a policy authorises the (provider, group) but restricts the model, +// and the requested model isn't on the list, so the request is denied. +func TestSelectPolicy_ModelBlockedByAllowlist(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o")) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "claude-opus-4", + }) + require.NoError(t, err) + assert.False(t, res.Allow, "a model outside the only applicable policy's allowlist must be denied") + assert.Equal(t, denyCodeModelBlocked, res.DenyCode, "deny code must be model_blocked") + assert.NotEmpty(t, res.DenyReason, "deny reason must be populated") +} + +// TestSelectPolicy_ModelAllowedByAllowlist is the allow counterpart: the model +// is on the applicable policy's allowlist, so selection proceeds normally. +func TestSelectPolicy_ModelAllowedByAllowlist(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o", "claude-opus-4")) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "claude-opus-4", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "a model on the applicable policy's allowlist must be allowed") + assert.Equal(t, "pol-A", res.SelectedPolicyID) +} + +// TestSelectPolicy_CaseInsensitiveModelMatch proves the compare tolerates case +// and surrounding whitespace, matching the proxy guardrail's normalisation. +func TestSelectPolicy_CaseInsensitiveModelMatch(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", " GPT-4o ")) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "gpt-4o", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "case/whitespace variants must match the allowlist entry") +} + +// TestSelectPolicy_UnguardedPolicyIsUnrestricted is the false-deny fix: when two +// policies authorise the same (provider, group) and one has no guardrail, that +// policy makes the request unrestricted — not caught by the other's allowlist. +func TestSelectPolicy_UnguardedPolicyIsUnrestricted(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + restricted := guardedPolicy("pol-restricted", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + open := guardedPolicy("pol-open", "acc-1", []string{"grp-eng"}, "prov-1") // no guardrail + expectPolicies(mockStore, "acc-1", restricted, open) + expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o")) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "claude-opus-4", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "an un-guardrailed policy for the same (provider, group) must leave the request unrestricted") + assert.Equal(t, "pol-open", res.SelectedPolicyID, "the unrestricted policy must be the one that pays") +} + +// TestSelectPolicy_AllowlistDoesNotLeakAcrossGroups is the false-allow fix: a +// model allowlisted only for grp-b must not be usable by a grp-a caller. The +// selector considers only policies applicable to the caller's groups. +func TestSelectPolicy_AllowlistDoesNotLeakAcrossGroups(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + polA := guardedPolicy("pol-a", "acc-1", []string{"grp-a"}, "prov-1", "g-a") + polB := guardedPolicy("pol-b", "acc-1", []string{"grp-b"}, "prov-1", "g-b") + expectPolicies(mockStore, "acc-1", polA, polB) + expectGuardrails(mockStore, "acc-1", + allowlistGuardrail("g-a", "acc-1", "gpt-4o"), + allowlistGuardrail("g-b", "acc-1", "claude-opus-4"), + ) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-a"}, + ProviderID: "prov-1", + Model: "claude-opus-4", // only allowed for grp-b + }) + require.NoError(t, err) + assert.False(t, res.Allow, "grp-b's allowlisted model must not leak to a grp-a caller") + assert.Equal(t, denyCodeModelBlocked, res.DenyCode) +} + +// TestSelectPolicy_UndeterminedModelFailsClosed proves the fail-closed contract +// mirrors the proxy: with a restricted applicable policy and an empty model +// (e.g. a path-routed shape the parser couldn't map), the request is denied. +func TestSelectPolicy_UndeterminedModelFailsClosed(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o")) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "", // undetermined + }) + require.NoError(t, err) + assert.False(t, res.Allow, "an undetermined model must fail closed against a restricted policy") + assert.Equal(t, denyCodeModelBlocked, res.DenyCode) +} + +// TestSelectPolicy_DisabledAllowlistDoesNotRestrict proves a guardrail whose +// model allowlist is disabled imposes no model restriction, even though the +// policy references it. +func TestSelectPolicy_DisabledAllowlistDoesNotRestrict(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + disabled := &types.Guardrail{ + ID: "g-1", + AccountID: "acc-1", + Checks: types.GuardrailChecks{ + ModelAllowlist: types.GuardrailModelAllowlist{Enabled: false, Models: []string{"gpt-4o"}}, + }, + } + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", disabled) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "anything-goes", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "a disabled allowlist must not restrict the model") + assert.Equal(t, "pol-A", res.SelectedPolicyID) +} + +// TestSelectPolicy_UnionAcrossPolicyGuardrails proves a policy with multiple +// allowlist guardrails permits the union of their models (not just the first). +func TestSelectPolicy_UnionAcrossPolicyGuardrails(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1", "g-2") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", + allowlistGuardrail("g-1", "acc-1", "gpt-4o"), + allowlistGuardrail("g-2", "acc-1", "claude-opus-4"), + ) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "claude-opus-4", // only in the second guardrail's list + }) + require.NoError(t, err) + assert.True(t, res.Allow, "a model in any of the policy's allowlist guardrails must be permitted") + assert.Equal(t, "pol-A", res.SelectedPolicyID) +} + +// TestSelectPolicy_GuardrailLookupErrorPropagates proves a store failure while +// resolving the candidate policies' guardrails surfaces as an error, not a +// silent allow/deny. +func TestSelectPolicy_GuardrailLookupErrorPropagates(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + expectPolicies(mockStore, "acc-1", policy) + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(gomock.Any(), gomock.Any(), "acc-1"). + Return(nil, errors.New("store unavailable")) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "gpt-4o", + }) + require.Error(t, err, "a guardrail-lookup failure must surface as an error") + assert.Nil(t, res) +} + +// TestSelectPolicy_MissingGuardrailReferenceTreatedAsUnrestricted proves a +// policy referencing a guardrail ID absent from the account's set (a stale +// reference) imposes no model restriction — same as no guardrail. +func TestSelectPolicy_MissingGuardrailReferenceTreatedAsUnrestricted(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-missing") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1") + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "anything-goes", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "an orphaned guardrail reference must not restrict the model") + assert.Equal(t, "pol-A", res.SelectedPolicyID) +} + +// TestSelectPolicy_PartialCandidatesPermittedAfterModelFilter proves the model +// gate narrows candidates before cap scoring: the permitting policy is selected +// even though the blocked one has a larger, more attractive cap. +func TestSelectPolicy_PartialCandidatesPermittedAfterModelFilter(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + polBig := guardedPolicy("pol-big", "acc-1", []string{"grp-eng"}, "prov-1", "g-restrict") + polBig.Limits = types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{Enabled: true, GroupCap: 1_000_000, WindowSeconds: 3600}, + } + polSmall := guardedPolicy("pol-small", "acc-1", []string{"grp-eng"}, "prov-1", "g-permit") + polSmall.Limits = types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{Enabled: true, GroupCap: 100, WindowSeconds: 3600}, + } + expectPolicies(mockStore, "acc-1", polBig, polSmall) + expectGuardrails(mockStore, "acc-1", + allowlistGuardrail("g-restrict", "acc-1", "gpt-4o"), + allowlistGuardrail("g-permit", "acc-1", "claude-opus-4"), + ) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "claude-opus-4", // only pol-small's guardrail permits this + }) + require.NoError(t, err) + assert.True(t, res.Allow) + assert.Equal(t, "pol-small", res.SelectedPolicyID, + "the model filter must exclude pol-big before cap scoring") +} diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go index 95fe91773..169bdd4fd 100644 --- a/management/internals/modules/agentnetwork/synthesizer.go +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -235,7 +235,12 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([ mergedGuardrails := mergeGuardrails(enabledPolicies, guardrailsByID) applyAccountCollectionControls(&mergedGuardrails, settings) - guardrailJSON, err := marshalGuardrailConfig(mergedGuardrails) + // The proxy guardrail is a per-provider fail-closed backstop; the + // authoritative per-policy/group decision is management's + // SelectPolicyForRequest. A provider lands in this map only when every + // authorising policy restricts models. + providerAllowlists := buildProviderAllowlists(enabledPolicies, guardrailsByID) + guardrailJSON, err := marshalGuardrailConfig(providerAllowlists, mergedGuardrails.PromptCapture) if err != nil { return nil, err } @@ -780,10 +785,12 @@ func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON []byt // guardrailConfig is the JSON shape the proxy-side llm_guardrail // middleware expects. Mirrors the proxy registration documented in -// the management→proxy contract. +// the management→proxy contract. provider_allowlists is keyed by the +// resolved provider id llm_router stamps; a provider absent from the map is +// unrestricted at the proxy layer. type guardrailConfig struct { - ModelAllowlist []string `json:"model_allowlist,omitempty"` - PromptCapture guardrailPromptCapture `json:"prompt_capture"` + ProviderAllowlists map[string][]string `json:"provider_allowlists,omitempty"` + PromptCapture guardrailPromptCapture `json:"prompt_capture"` } type guardrailPromptCapture struct { @@ -828,13 +835,10 @@ func applyAccountCollectionControls(merged *MergedGuardrails, settings *types.Se merged.PromptCapture.RedactPii = settings.RedactPii || merged.PromptCapture.RedactPii } -func marshalGuardrailConfig(merged MergedGuardrails) ([]byte, error) { +func marshalGuardrailConfig(providerAllowlists map[string][]string, capture MergedPromptCapture) ([]byte, error) { cfg := guardrailConfig{ - ModelAllowlist: merged.ModelAllowlist, - PromptCapture: guardrailPromptCapture{ - Enabled: merged.PromptCapture.Enabled, - RedactPii: merged.PromptCapture.RedactPii, - }, + ProviderAllowlists: providerAllowlists, + PromptCapture: guardrailPromptCapture(capture), } out, err := json.Marshal(cfg) if err != nil { @@ -843,6 +847,74 @@ func marshalGuardrailConfig(merged MergedGuardrails) ([]byte, error) { return out, nil } +// buildProviderAllowlists returns the proxy's per-provider backstop: a provider +// is included only when every authorising policy restricts models (their union); +// if any leaves it unrestricted it is omitted, so management decides per group. +func buildProviderAllowlists(policies []*types.Policy, byID map[string]*types.Guardrail) map[string][]string { + type providerAcc struct { + models map[string]struct{} + anyUnrestricted bool + } + accs := make(map[string]*providerAcc) + for _, p := range policies { + if p == nil { + continue + } + restricted, models := policyModelAllowlist(p, byID) + for _, providerID := range p.DestinationProviderIDs { + if providerID == "" { + continue + } + acc, ok := accs[providerID] + if !ok { + acc = &providerAcc{models: make(map[string]struct{})} + accs[providerID] = acc + } + if !restricted { + acc.anyUnrestricted = true + continue + } + for _, m := range models { + acc.models[m] = struct{}{} + } + } + } + out := make(map[string][]string, len(accs)) + for providerID, acc := range accs { + if acc.anyUnrestricted { + continue + } + models := make([]string, 0, len(acc.models)) + for m := range acc.models { + models = append(models, m) + } + sort.Strings(models) + out[providerID] = models + } + return out +} + +// policyModelAllowlist reports whether a policy restricts models (has an +// allowlist-enabled guardrail) and the union of allowed models. Models are +// verbatim; the proxy factory lowercases/trims them at decode time. +func policyModelAllowlist(p *types.Policy, byID map[string]*types.Guardrail) (bool, []string) { + restricted := false + var models []string + for _, gID := range p.GuardrailIDs { + g, ok := byID[gID] + if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled { + continue + } + restricted = true + for _, m := range g.Checks.ModelAllowlist.Models { + if m != "" { + models = append(models, m) + } + } + } + return restricted, models +} + // buildAccountService composes the per-account gateway Service. The // target carries the noop placeholder URL — the router middleware // rewrites every request to the matched provider's upstream before the @@ -986,38 +1058,11 @@ func unionSourceGroups(policies []*types.Policy) []string { return out } -// MergedGuardrails is the JSON shape passed to the proxy via the -// guardrail middleware's config_json. Mirrors the proxy-side -// expectations and is intentionally distinct from -// types.GuardrailChecks so we can evolve either side independently. +// MergedGuardrails is the synthesiser's fold target. Only prompt capture is +// merged here — the model allowlist is emitted per-provider, and +// token/budget/retention moved onto Policy.Limits and account Settings. type MergedGuardrails struct { - ModelAllowlist []string `json:"model_allowlist,omitempty"` - TokenLimits MergedTokenLimits `json:"token_limits"` - Budget MergedBudget `json:"budget"` - PromptCapture MergedPromptCapture `json:"prompt_capture"` - Retention MergedRetention `json:"retention"` -} - -type MergedTokenLimits struct { - Hourly *MergedTokenWindow `json:"hourly,omitempty"` - Daily *MergedTokenWindow `json:"daily,omitempty"` - Monthly *MergedTokenWindow `json:"monthly,omitempty"` -} - -type MergedTokenWindow struct { - MaxInputTokens int `json:"max_input_tokens,omitempty"` - MaxOutputTokens int `json:"max_output_tokens,omitempty"` -} - -type MergedBudget struct { - Hourly *MergedBudgetWindow `json:"hourly,omitempty"` - Daily *MergedBudgetWindow `json:"daily,omitempty"` - Monthly *MergedBudgetWindow `json:"monthly,omitempty"` -} - -type MergedBudgetWindow struct { - SoftCapUSD float64 `json:"soft_cap_usd,omitempty"` - HardCapUSD float64 `json:"hard_cap_usd,omitempty"` + PromptCapture MergedPromptCapture } type MergedPromptCapture struct { @@ -1025,64 +1070,31 @@ type MergedPromptCapture struct { RedactPii bool `json:"redact_pii"` } -type MergedRetention struct { - Enabled bool `json:"enabled"` - Days int `json:"days"` -} - -// mergeGuardrails computes the effective guardrail spec applied at the -// proxy, given the referencing policies and the account's guardrail -// catalogue. Policy enabled-ness is the caller's responsibility — only -// enabled policies should be passed in. +// mergeGuardrails folds the referencing policies' guardrails into the +// prompt-capture decision only. The model allowlist is enforced per-policy/group +// in management and shipped per-provider; token/budget/retention live off +// guardrails now. // -// Merge rules: -// - Model allowlist: union of allowlists across policies that enable it. -// - Token / Budget: most-restrictive (min of non-zero caps) per window. -// - Prompt capture: enabled if any policy enables it; redact_pii sticks -// if any enabling policy turns it on. -// - Retention: enabled if any enables it; smallest non-zero days wins. +// Merge rule — prompt capture: enabled if any policy enables it; redact_pii +// sticks if any enabling policy turns it on. func mergeGuardrails(policies []*types.Policy, byID map[string]*types.Guardrail) MergedGuardrails { merged := MergedGuardrails{} - allowlist := make(map[string]struct{}) - allowlistEnabled := false - for _, policy := range policies { for _, gID := range policy.GuardrailIDs { g, ok := byID[gID] if !ok || g == nil { continue } - mergeGuardrail(g, &merged, allowlist, &allowlistEnabled) + mergeGuardrail(g, &merged) } } - - if allowlistEnabled { - merged.ModelAllowlist = make([]string, 0, len(allowlist)) - for m := range allowlist { - merged.ModelAllowlist = append(merged.ModelAllowlist, m) - } - sort.Strings(merged.ModelAllowlist) - } return merged } -// mergeGuardrail folds a single guardrail's enabled checks into the -// running merge: model-allowlist models join the shared set (and flip -// allowlistEnabled), and prompt-capture / redact-pii stick once any -// enabling guardrail turns them on. -// -// TokenLimits, Budget, and Retention have moved off guardrails — token -// and budget caps now live on the Policy itself (Policy.Limits) and -// retention moves to account-level Settings — so they are not merged here. -func mergeGuardrail(g *types.Guardrail, merged *MergedGuardrails, allowlist map[string]struct{}, allowlistEnabled *bool) { - if g.Checks.ModelAllowlist.Enabled { - *allowlistEnabled = true - for _, m := range g.Checks.ModelAllowlist.Models { - if m != "" { - allowlist[m] = struct{}{} - } - } - } +// mergeGuardrail folds a single guardrail's prompt-capture settings into the +// running merge: enabled / redact-pii stick once any enabling guardrail turns +// them on. +func mergeGuardrail(g *types.Guardrail, merged *MergedGuardrails) { if g.Checks.PromptCapture.Enabled { merged.PromptCapture.Enabled = true if g.Checks.PromptCapture.RedactPii { diff --git a/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go b/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go index 8ed4910da..8b13f78ac 100644 --- a/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go @@ -81,8 +81,8 @@ func TestSynthesizeServices_RealStore_PromptCaptureAccountIsSoleControl(t *testi require.Len(t, services, 1) cfg := decodeServiceGuardrailConfig(t, services[0]) - assert.Equal(t, []string{"gpt-5.4"}, cfg.ModelAllowlist, - "model allowlist is a pure policy guardrail and must always reach the config") + assert.Equal(t, map[string][]string{"prov-1": {"gpt-5.4"}}, cfg.ProviderAllowlists, + "model allowlist is a pure policy guardrail and must reach the per-provider config") assert.False(t, cfg.PromptCapture.Enabled, "prompt capture must be off when the account toggle is off, even with a capture-enabled guardrail") } @@ -172,7 +172,7 @@ func TestSynthesizeServices_RealStore_NoGuardrail_CaptureOff(t *testing.T) { require.Len(t, services, 1, "exactly one synth service expected") cfg := decodeServiceGuardrailConfig(t, services[0]) - assert.Empty(t, cfg.ModelAllowlist, "no guardrail → no allowlist") + assert.Empty(t, cfg.ProviderAllowlists, "no guardrail → provider unrestricted (absent from map)") assert.False(t, cfg.PromptCapture.Enabled, "no guardrail → prompt capture off by default") assert.False(t, cfg.PromptCapture.RedactPii, "no guardrail → redact off by default") } diff --git a/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go b/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go new file mode 100644 index 000000000..2cfc0db8c --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go @@ -0,0 +1,95 @@ +package agentnetwork + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" +) + +// policyForProviders builds an enabled policy authorising the given providers +// under the given guardrails (both optional). Groups are irrelevant to +// buildProviderAllowlists, which keys purely on destination provider. +func policyForProviders(id string, guardrailIDs []string, providerIDs ...string) *types.Policy { + return &types.Policy{ + ID: id, + Enabled: true, + DestinationProviderIDs: providerIDs, + GuardrailIDs: guardrailIDs, + } +} + +func TestBuildProviderAllowlists(t *testing.T) { + byID := map[string]*types.Guardrail{ + "g-4o": allowlistGuardrail("g-4o", "acc-1", "gpt-4o"), + "g-opus": allowlistGuardrail("g-opus", "acc-1", "claude-opus-4"), + "g-disabled": {ID: "g-disabled", Checks: types.GuardrailChecks{ModelAllowlist: types.GuardrailModelAllowlist{Enabled: false, Models: []string{"gpt-4o"}}}}, + } + + t.Run("all authorising policies restrict yields per-provider union", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-4o"}, "prov-x"), + policyForProviders("p2", []string{"g-opus"}, "prov-x"), + } + got := buildProviderAllowlists(policies, byID) + assert.Equal(t, map[string][]string{"prov-x": {"claude-opus-4", "gpt-4o"}}, got, + "a provider every policy restricts carries the sorted union of their models") + }) + + t.Run("any un-guardrailed policy leaves the provider unrestricted (omitted)", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-4o"}, "prov-x"), + policyForProviders("p2", nil, "prov-x"), // no guardrail + } + got := buildProviderAllowlists(policies, byID) + assert.NotContains(t, got, "prov-x", + "a provider reachable by an un-guardrailed policy must be omitted so the proxy treats it as unrestricted") + }) + + t.Run("a disabled allowlist counts as unrestricted", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-disabled"}, "prov-x"), + } + got := buildProviderAllowlists(policies, byID) + assert.NotContains(t, got, "prov-x", + "a policy whose only guardrail has a disabled allowlist is unrestricted") + }) + + t.Run("providers are isolated from one another", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-4o"}, "prov-x"), + policyForProviders("p2", []string{"g-opus"}, "prov-y"), + } + got := buildProviderAllowlists(policies, byID) + assert.Equal(t, []string{"gpt-4o"}, got["prov-x"], "prov-x keeps only its own model") + assert.Equal(t, []string{"claude-opus-4"}, got["prov-y"], "prov-y keeps only its own model") + }) + + t.Run("one policy authorising two providers restricts both", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-4o"}, "prov-x", "prov-y"), + } + got := buildProviderAllowlists(policies, byID) + assert.Equal(t, []string{"gpt-4o"}, got["prov-x"]) + assert.Equal(t, []string{"gpt-4o"}, got["prov-y"]) + }) + + t.Run("union across a single policy's guardrails", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-4o", "g-opus"}, "prov-x"), + } + got := buildProviderAllowlists(policies, byID) + assert.ElementsMatch(t, []string{"claude-opus-4", "gpt-4o"}, got["prov-x"], + "a policy's own multiple allowlist guardrails union together") + }) + + t.Run("an enabled allowlist with no models denies everything", func(t *testing.T) { + empty := map[string]*types.Guardrail{"g-empty": allowlistGuardrail("g-empty", "acc-1")} + got := buildProviderAllowlists([]*types.Policy{ + policyForProviders("p1", []string{"g-empty"}, "prov-x"), + }, empty) + assert.Equal(t, map[string][]string{"prov-x": {}}, got, + "an enabled-but-empty allowlist is restricted with an empty set, not unrestricted") + }) +} diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go index 206d1d12a..8a18a9b59 100644 --- a/management/internals/modules/agentnetwork/synthesizer_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -1031,8 +1031,12 @@ func TestSynthesizeServices_GuardrailMerge_AllowlistUnion_LimitsRestrictive(t *t var cfg guardrailConfig require.NoError(t, json.Unmarshal(guardrailJSON, &cfg), "guardrail config must unmarshal cleanly") - assert.ElementsMatch(t, []string{"gpt-5.4-mini", "gpt-5.4-pro"}, cfg.ModelAllowlist, - "model allowlist union must keep both models") + // Both policies restrict the same provider, so the per-provider backstop + // carries the union of their models — a coarse gate that management's + // per-policy/group check narrows; it only blocks models outside the union + // when management is down. + assert.ElementsMatch(t, []string{"gpt-5.4-mini", "gpt-5.4-pro"}, cfg.ProviderAllowlists["prov-1"], + "per-provider allowlist union must keep both models") } func TestSynthesizeServices_BackfillsMissingSessionKeys(t *testing.T) { diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index b289f8c71..8f24de116 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -285,6 +285,7 @@ func (s *ProxyServiceServer) CheckLLMPolicyLimits(ctx context.Context, req *prot UserID: req.GetUserId(), GroupIDs: req.GetGroupIds(), ProviderID: req.GetProviderId(), + Model: req.GetModel(), }) if err != nil { log.WithContext(ctx).Errorf("select policy for request: %v", err) diff --git a/management/internals/shared/grpc/proxy_llm_policy_limits_test.go b/management/internals/shared/grpc/proxy_llm_policy_limits_test.go new file mode 100644 index 000000000..c293c39ea --- /dev/null +++ b/management/internals/shared/grpc/proxy_llm_policy_limits_test.go @@ -0,0 +1,138 @@ +package grpc + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// fakeAgentNetworkLimits records the PolicySelectionInput it was invoked with +// and returns a pre-programmed result, so tests can assert what the handler +// forwards to the selector. +type fakeAgentNetworkLimits struct { + gotInput agentnetwork.PolicySelectionInput + result *agentnetwork.PolicySelectionResult + err error +} + +func (f *fakeAgentNetworkLimits) SelectPolicyForRequest(_ context.Context, in agentnetwork.PolicySelectionInput) (*agentnetwork.PolicySelectionResult, error) { + f.gotInput = in + if f.err != nil { + return nil, f.err + } + return f.result, nil +} + +func (f *fakeAgentNetworkLimits) RecordUsage(_ context.Context, _ agentnetwork.RecordUsageInput) error { + return nil +} + +// TestCheckLLMPolicyLimits_ForwardsModelToSelector proves the wiring added here: +// the model the proxy extracted must reach the selector's Model unchanged, +// alongside the account/user/group/provider fields. +func TestCheckLLMPolicyLimits_ForwardsModelToSelector(t *testing.T) { + fake := &fakeAgentNetworkLimits{result: &agentnetwork.PolicySelectionResult{Allow: true, SelectedPolicyID: "pol-1"}} + s := &ProxyServiceServer{} + s.SetAgentNetworkLimitsService(fake) + + req := &proto.CheckLLMPolicyLimitsRequest{ + AccountId: "acc-1", + UserId: "user-1", + GroupIds: []string{"grp-a", "grp-b"}, + ProviderId: "prov-1", + Model: "claude-opus-4", + } + + resp, err := s.CheckLLMPolicyLimits(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, "acc-1", fake.gotInput.AccountID) + assert.Equal(t, "user-1", fake.gotInput.UserID) + assert.Equal(t, []string{"grp-a", "grp-b"}, fake.gotInput.GroupIDs) + assert.Equal(t, "prov-1", fake.gotInput.ProviderID) + assert.Equal(t, "claude-opus-4", fake.gotInput.Model, + "the request's model must be forwarded to the selector") +} + +// TestCheckLLMPolicyLimits_EmptyModelForwardedAsEmpty proves an undetermined +// model (empty string) is forwarded as-is; the selector decides how to treat it. +func TestCheckLLMPolicyLimits_EmptyModelForwardedAsEmpty(t *testing.T) { + fake := &fakeAgentNetworkLimits{result: &agentnetwork.PolicySelectionResult{Allow: true}} + s := &ProxyServiceServer{} + s.SetAgentNetworkLimitsService(fake) + + req := &proto.CheckLLMPolicyLimitsRequest{ + AccountId: "acc-1", + ProviderId: "prov-1", + } + + _, err := s.CheckLLMPolicyLimits(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, "", fake.gotInput.Model, "an absent model must be forwarded as empty, not fabricated") +} + +// TestCheckLLMPolicyLimits_DenyResponseCarriesModelBlockedCode proves the deny +// envelope surfaces the model-allowlist deny code + reason through the response. +func TestCheckLLMPolicyLimits_DenyResponseCarriesModelBlockedCode(t *testing.T) { + fake := &fakeAgentNetworkLimits{result: &agentnetwork.PolicySelectionResult{ + Allow: false, + DenyCode: "llm_policy.model_blocked", + DenyReason: `model "claude-opus-4" is not permitted by any applicable policy allowlist`, + }} + s := &ProxyServiceServer{} + s.SetAgentNetworkLimitsService(fake) + + resp, err := s.CheckLLMPolicyLimits(context.Background(), &proto.CheckLLMPolicyLimitsRequest{ + AccountId: "acc-1", + ProviderId: "prov-1", + Model: "claude-opus-4", + }) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, "deny", resp.Decision) + assert.Equal(t, "llm_policy.model_blocked", resp.DenyCode) + assert.NotEmpty(t, resp.DenyReason) + assert.Empty(t, resp.SelectedPolicyId, "a denied request must carry no selected policy") +} + +// TestCheckLLMPolicyLimits_SelectorErrorSurfacesAsInternal proves a selector +// failure surfaces as an Internal gRPC error rather than a silent allow. +func TestCheckLLMPolicyLimits_SelectorErrorSurfacesAsInternal(t *testing.T) { + fake := &fakeAgentNetworkLimits{err: errors.New("boom")} + s := &ProxyServiceServer{} + s.SetAgentNetworkLimitsService(fake) + + _, err := s.CheckLLMPolicyLimits(context.Background(), &proto.CheckLLMPolicyLimitsRequest{ + AccountId: "acc-1", + ProviderId: "prov-1", + Model: "gpt-4o", + }) + require.Error(t, err) + st, ok := grpcstatus.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code(), "selector errors must never fail open on the hot path") +} + +// TestCheckLLMPolicyLimits_UnconfiguredServiceReturnsUnimplemented locks the +// fallback: with no limits service wired the RPC returns Unimplemented. +func TestCheckLLMPolicyLimits_UnconfiguredServiceReturnsUnimplemented(t *testing.T) { + s := &ProxyServiceServer{} + + _, err := s.CheckLLMPolicyLimits(context.Background(), &proto.CheckLLMPolicyLimitsRequest{ + AccountId: "acc-1", + ProviderId: "prov-1", + }) + require.Error(t, err) + st, ok := grpcstatus.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unimplemented, st.Code()) +} diff --git a/proxy/internal/auth/tunnel_cache.go b/proxy/internal/auth/tunnel_cache.go index 10b671d82..185c53c62 100644 --- a/proxy/internal/auth/tunnel_cache.go +++ b/proxy/internal/auth/tunnel_cache.go @@ -3,20 +3,30 @@ package auth import ( "context" "net/netip" + "os" + "strings" "sync" "time" + log "github.com/sirupsen/logrus" "golang.org/x/sync/singleflight" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/shared/management/proto" ) -// tunnelCacheTTL caps how long a positive ValidateTunnelPeer result is -// reused before re-fetching from management. 5 minutes balances freshness -// against management load on busy mesh networks. +// tunnelCacheTTL is the default cap on how long a positive ValidateTunnelPeer +// result is reused before re-fetching from management. 5 minutes balances +// freshness against management load on busy mesh networks. Override it with +// envTunnelCacheTTL when an account needs authorization changes to take effect +// sooner (at the cost of more ValidateTunnelPeer RPCs). const tunnelCacheTTL = 300 * time.Second +// envTunnelCacheTTL overrides tunnelCacheTTL. The value is a Go duration string +// (e.g. "30s", "2m"); an unset, unparseable, or non-positive value keeps the +// default. +const envTunnelCacheTTL = "NB_PROXY_TUNNEL_CACHE_TTL" + // tunnelCachePerAccount caps the number of cached identities per account. // Bounded eviction avoids memory growth in pathological cases (huge peer // roster, brief request bursts) while staying generous for normal use. @@ -60,16 +70,35 @@ type accountBucket struct { order []tunnelCacheKey } -// newTunnelValidationCache constructs a cache with default TTL and bounds. +// newTunnelValidationCache constructs a cache with the configured TTL +// (envTunnelCacheTTL override or default) and default bounds. func newTunnelValidationCache() *tunnelValidationCache { return &tunnelValidationCache{ entries: make(map[types.AccountID]*accountBucket), - ttl: tunnelCacheTTL, + ttl: tunnelCacheTTLFromEnv(), maxSize: tunnelCachePerAccount, now: time.Now, } } +// tunnelCacheTTLFromEnv returns the tunnel-cache TTL, honoring the +// envTunnelCacheTTL override. The override must be a positive Go duration +// string (e.g. "30s", "2m"); anything unset, unparseable, or non-positive +// falls back to tunnelCacheTTL. +func tunnelCacheTTLFromEnv() time.Duration { + raw := strings.TrimSpace(os.Getenv(envTunnelCacheTTL)) + if raw == "" { + return tunnelCacheTTL + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + log.Warnf("ignoring invalid %s=%q (want a positive Go duration like 30s or 2m); using default %s", + envTunnelCacheTTL, raw, tunnelCacheTTL) + return tunnelCacheTTL + } + return d +} + // get returns a cached response for the key, or nil when missing or // expired. Expired entries are evicted lazily on read. func (c *tunnelValidationCache) get(key tunnelCacheKey) *proto.ValidateTunnelPeerResponse { diff --git a/proxy/internal/auth/tunnel_cache_test.go b/proxy/internal/auth/tunnel_cache_test.go index 1a63dc107..d663b91e7 100644 --- a/proxy/internal/auth/tunnel_cache_test.go +++ b/proxy/internal/auth/tunnel_cache_test.go @@ -169,3 +169,32 @@ func TestTunnelCache_BoundedSizeEvictsOldest(t *testing.T) { assert.NotNil(t, cache.get(keys[1]), "second-newest must remain cached") assert.NotNil(t, cache.get(keys[2]), "newest must remain cached") } + +func TestTunnelCacheTTLFromEnv(t *testing.T) { + t.Run("unset uses default", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, "") + assert.Equal(t, tunnelCacheTTL, tunnelCacheTTLFromEnv()) + }) + t.Run("valid duration overrides", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, "45s") + assert.Equal(t, 45*time.Second, tunnelCacheTTLFromEnv()) + }) + t.Run("whitespace trimmed", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, " 2m ") + assert.Equal(t, 2*time.Minute, tunnelCacheTTLFromEnv()) + }) + t.Run("unparseable uses default", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, "nonsense") + assert.Equal(t, tunnelCacheTTL, tunnelCacheTTLFromEnv()) + }) + t.Run("non-positive uses default", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, "0s") + assert.Equal(t, tunnelCacheTTL, tunnelCacheTTLFromEnv()) + t.Setenv(envTunnelCacheTTL, "-30s") + assert.Equal(t, tunnelCacheTTL, tunnelCacheTTLFromEnv()) + }) + t.Run("constructor honors override", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, "90s") + assert.Equal(t, 90*time.Second, newTunnelValidationCache().ttl) + }) +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/factory.go b/proxy/internal/middleware/builtin/llm_guardrail/factory.go index 6dd2a8e8d..9cec0dc68 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/factory.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/factory.go @@ -10,11 +10,15 @@ import ( ) // Config is the JSON-decoded shape accepted by the factory. The -// runtime path consumes the normalised allowlist; raw config is not +// runtime path consumes the normalised allowlists; raw config is not // retained beyond construction. type Config struct { - ModelAllowlist []string `json:"model_allowlist"` - PromptCapture PromptCapture `json:"prompt_capture"` + // ProviderAllowlists maps a resolved provider id (KeyLLMResolvedProviderID) to + // its model allowlist. A provider present is restricted to those models; one + // absent is unrestricted. Kept per-provider so one provider's list can't leak + // onto another. + ProviderAllowlists map[string][]string `json:"provider_allowlists,omitempty"` + PromptCapture PromptCapture `json:"prompt_capture"` } // PromptCapture toggles the optional prompt capture + redaction step @@ -54,21 +58,28 @@ func isEmptyJSON(raw []byte) bool { return false } -// normaliseConfig lowercases and trims allowlist entries so the runtime -// match is case-insensitive. Empty entries are dropped. +// normaliseConfig lowercases and trims allowlist entries for case-insensitive +// matching; empty entries drop. A provider whose entries all drop keeps an empty +// (non-nil) list — "deny every model" — distinct from an absent provider +// (unrestricted). func normaliseConfig(cfg Config) Config { - if len(cfg.ModelAllowlist) == 0 { + if len(cfg.ProviderAllowlists) == 0 { + cfg.ProviderAllowlists = nil return cfg } - cleaned := make([]string, 0, len(cfg.ModelAllowlist)) - for _, entry := range cfg.ModelAllowlist { - n := normaliseModel(entry) - if n == "" { - continue + cleaned := make(map[string][]string, len(cfg.ProviderAllowlists)) + for provider, models := range cfg.ProviderAllowlists { + list := make([]string, 0, len(models)) + for _, entry := range models { + n := normaliseModel(entry) + if n == "" { + continue + } + list = append(list, n) } - cleaned = append(cleaned, n) + cleaned[provider] = list } - cfg.ModelAllowlist = cleaned + cfg.ProviderAllowlists = cleaned return cfg } diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go index eded877ac..1863aff20 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go @@ -83,8 +83,9 @@ func (m *Middleware) MutationsSupported() bool { return false } // prompt capture only affects the metadata emitted alongside an allow. func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { model, modelPresent := lookupMetadata(in.Metadata, middleware.KeyLLMModel) + providerID, _ := lookupMetadata(in.Metadata, middleware.KeyLLMResolvedProviderID) - if denial := m.evaluateAllowlist(model, modelPresent); denial != nil { + if denial := m.evaluateAllowlist(providerID, model, modelPresent); denial != nil { return denial, nil } @@ -110,20 +111,32 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar // is a no-op. func (m *Middleware) Close() error { return nil } -// evaluateAllowlist returns a deny Output when the configured allowlist -// rejects the model. A nil return means the request should proceed. -func (m *Middleware) evaluateAllowlist(model string, modelPresent bool) *middleware.Output { - if len(m.cfg.ModelAllowlist) == 0 { +// evaluateAllowlist denies when the resolved provider's allowlist rejects the +// model; nil means proceed. Scoped to the provider llm_router resolved, so an +// unrestricted provider (absent from config) is never caught by another's list. +func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bool) *middleware.Output { + if len(m.cfg.ProviderAllowlists) == 0 { return nil } - // Fail closed: with an allowlist configured, a request whose model the - // upstream parser could not extract (absent or empty) must be denied rather - // than allowed. This is what enforces the allowlist for URL/path-routed - // providers (Bedrock, Vertex, ...) whose model lives outside the JSON body. + // Restrictions exist but the resolved provider is unknown, so we can't tell + // if this request targets a restricted provider — fail closed. llm_router + // normally stamps the provider first, so this is a defensive guard. + if providerID == "" { + return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) + } + allowlist, restricted := m.cfg.ProviderAllowlists[providerID] + if !restricted { + // This provider has no allowlist (some authorising policy left it + // unrestricted); management owns any per-policy/group decision. + return nil + } + // Fail closed: with an allowlist in effect for this provider, a request whose + // model the parser couldn't extract (absent/empty) is denied. This enforces + // the allowlist for path-routed providers (Bedrock, Vertex) with no body model. if !modelPresent || normaliseModel(model) == "" { return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) } - if m.modelInAllowlist(model) { + if modelInAllowlist(allowlist, model) { return nil } return denyModel(model, denyCodeModel, denyMessageModel, denyReasonModel) @@ -151,14 +164,15 @@ func denyModel(model, code, message, reason string) *middleware.Output { } } -// modelInAllowlist reports whether the model matches any allowlist -// entry under the case-insensitive, trim-tolerant comparison rule. -func (m *Middleware) modelInAllowlist(model string) bool { +// modelInAllowlist reports whether the model matches any entry in the supplied +// (already-normalised) allowlist under the case-insensitive, trim-tolerant +// comparison rule. +func modelInAllowlist(allowlist []string, model string) bool { normalised := normaliseModel(model) if normalised == "" { return false } - for _, allowed := range m.cfg.ModelAllowlist { + for _, allowed := range allowlist { if allowed == normalised { return true } diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go index cd7e256dd..5f35fefd3 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go @@ -26,6 +26,25 @@ func newInput(meta ...middleware.KV) *middleware.Input { return &middleware.Input{Slot: middleware.SlotOnRequest, Metadata: meta} } +const ( + testProvider = "prov-1" + otherProvider = "prov-2" +) + +// providerCfg builds a Config restricting testProvider to the given models. +func providerCfg(models ...string) Config { + return Config{ProviderAllowlists: map[string][]string{testProvider: models}} +} + +// newInputProvider builds an input that carries a resolved provider id (as +// llm_router would stamp) plus any extra metadata. +func newInputProvider(provider string, meta ...middleware.KV) *middleware.Input { + all := make([]middleware.KV, 0, len(meta)+1) + all = append(all, middleware.KV{Key: middleware.KeyLLMResolvedProviderID, Value: provider}) + all = append(all, meta...) + return &middleware.Input{Slot: middleware.SlotOnRequest, Metadata: all} +} + func TestMiddlewareIdentity(t *testing.T) { mw := New(Config{}) assert.Equal(t, ID, mw.ID(), "middleware ID must be llm_guardrail") @@ -47,12 +66,12 @@ func TestMiddlewareIdentity(t *testing.T) { func TestAllowlistEmptyAllowsAnyModel(t *testing.T) { mw := New(Config{}) - out, err := mw.Invoke(context.Background(), newInput( + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, )) require.NoError(t, err) require.NotNil(t, out) - assert.Equal(t, middleware.DecisionAllow, out.Decision, "empty allowlist must allow any model") + assert.Equal(t, middleware.DecisionAllow, out.Decision, "no provider allowlists must allow any model") v, ok := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) require.True(t, ok, "decision metadata must be emitted") assert.Equal(t, "allow", v, "decision must be allow") @@ -62,8 +81,8 @@ func TestAllowlistEmptyAllowsAnyModel(t *testing.T) { } func TestAllowlistMatchAllows(t *testing.T) { - mw := New(Config{ModelAllowlist: []string{"gpt-4o", "claude-opus-4"}}) - out, err := mw.Invoke(context.Background(), newInput( + mw := New(providerCfg("gpt-4o", "claude-opus-4")) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, )) require.NoError(t, err) @@ -71,8 +90,8 @@ func TestAllowlistMatchAllows(t *testing.T) { } func TestAllowlistMissDenies(t *testing.T) { - mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) - out, err := mw.Invoke(context.Background(), newInput( + mw := New(providerCfg("gpt-4o")) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-4"}, )) require.NoError(t, err) @@ -91,10 +110,10 @@ func TestAllowlistMissDenies(t *testing.T) { } func TestAllowlistCaseInsensitive(t *testing.T) { - mw := New(Config{ModelAllowlist: []string{" GPT-4o ", "Claude-OPUS-4"}}) + mw := New(providerCfg(" GPT-4o ", "Claude-OPUS-4")) cases := []string{"gpt-4o", "GPT-4O", " claude-opus-4 "} for _, model := range cases { - out, err := mw.Invoke(context.Background(), newInput( + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: model}, )) require.NoError(t, err) @@ -103,14 +122,15 @@ func TestAllowlistCaseInsensitive(t *testing.T) { } func TestAllowlistMissingModelKeyDenies(t *testing.T) { - // Fail closed: with an allowlist configured, a request whose model the - // parser could not extract (URL/path-routed providers such as Bedrock or - // Vertex whose shape wasn't recognised) must be denied, not allowed. - mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) - out, err := mw.Invoke(context.Background(), newInput()) + // Fail closed: with an allowlist in effect for the resolved provider, a + // request whose model the parser could not extract (URL/path-routed + // providers such as Bedrock or Vertex whose shape wasn't recognised) must be + // denied, not allowed. + mw := New(providerCfg("gpt-4o")) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider)) require.NoError(t, err) require.NotNil(t, out) - assert.Equal(t, middleware.DecisionDeny, out.Decision, "absent model must be denied when an allowlist is set") + assert.Equal(t, middleware.DecisionDeny, out.Decision, "absent model must be denied when the provider is restricted") assert.Equal(t, 403, out.DenyStatus, "deny status must be 403") require.NotNil(t, out.DenyReason, "deny reason must be populated") assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") @@ -122,26 +142,101 @@ func TestAllowlistMissingModelKeyDenies(t *testing.T) { func TestAllowlistEmptyModelValueDenies(t *testing.T) { // A present-but-empty model is as undeterminable as an absent one. - mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) - out, err := mw.Invoke(context.Background(), newInput( + mw := New(providerCfg("gpt-4o")) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: " "}, )) require.NoError(t, err) require.NotNil(t, out) - assert.Equal(t, middleware.DecisionDeny, out.Decision, "empty model must be denied when an allowlist is set") + assert.Equal(t, middleware.DecisionDeny, out.Decision, "empty model must be denied when the provider is restricted") require.NotNil(t, out.DenyReason, "deny reason must be populated") assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") } func TestAllowlistEmptyListAllowsMissingModel(t *testing.T) { - // Without an allowlist there is nothing to enforce, so a missing model is - // still allowed — the fail-closed rule only applies when a list is set. + // Without any provider allowlists there is nothing to enforce, so a missing + // model is still allowed — the fail-closed rule only applies when a + // restriction is in effect. mw := New(Config{}) out, err := mw.Invoke(context.Background(), newInput()) require.NoError(t, err) assert.Equal(t, middleware.DecisionAllow, out.Decision, "no allowlist must allow even without a model") } +func TestUnrestrictedProviderAllowsAnyModel(t *testing.T) { + // The request resolved to otherProvider, which has no allowlist, so its + // traffic must not be caught by testProvider's restriction — the + // cross-provider-leak / false-deny guard. + mw := New(providerCfg("gpt-4o")) + out, err := mw.Invoke(context.Background(), newInputProvider(otherProvider, + middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-4"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "an unrestricted provider must not inherit another provider's allowlist") +} + +func TestPerProviderAllowlistsAreIsolated(t *testing.T) { + // gpt-4o is allowed only on testProvider; claude-opus-4 only on + // otherProvider. A model allowlisted for one provider must not be usable on + // the other — the fail-closed layer never unions allowlists across providers. + mw := New(Config{ProviderAllowlists: map[string][]string{ + testProvider: {"gpt-4o"}, + otherProvider: {"claude-opus-4"}, + }}) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, + middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-4"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "claude-opus-4 is allowed only on otherProvider, not testProvider") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code, "cross-provider model must be blocked, not model_unknown") +} + +func TestRestrictionsButNoResolvedProviderFailsClosed(t *testing.T) { + // Restrictions exist for the account but the resolved provider id is absent, + // so the request cannot be scoped to a provider. Fail closed rather than + // wave it through. + mw := New(providerCfg("gpt-4o")) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "missing resolved provider must fail closed when restrictions exist") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") +} + +func TestEnabledButEmptyAllowlistDeniesEveryModel(t *testing.T) { + // An allowlist-enabled provider with zero models is distinct from an + // unrestricted (absent) provider: it must deny every model. + mw := New(providerCfg()) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "an enabled-but-empty allowlist must deny every model") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code, "deny code must be model_blocked, not model_unknown") +} + +func TestFactoryAllEmptyEntriesDenyEveryModel(t *testing.T) { + // All the provider's entries are blank; they collapse to a non-nil empty + // list (deny everything for that provider), not "no restriction". + raw := []byte(`{"provider_allowlists":{"prov-1":[""," "]}}`) + mw, err := Factory{}.New(raw) + require.NoError(t, err) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "all-blank allowlist entries must still restrict the provider") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code, "deny code must be model_blocked") +} + func TestPromptCaptureDisabledEmitsNoPrompt(t *testing.T) { mw := New(Config{}) out, err := mw.Invoke(context.Background(), newInput( @@ -217,8 +312,8 @@ func TestFactoryAcceptsZeroConfigs(t *testing.T) { func TestFactoryDecodesValidConfig(t *testing.T) { cfg := Config{ - ModelAllowlist: []string{"gpt-4o"}, - PromptCapture: PromptCapture{Enabled: true, RedactPii: true}, + ProviderAllowlists: map[string][]string{testProvider: {"gpt-4o"}}, + PromptCapture: PromptCapture{Enabled: true, RedactPii: true}, } raw, err := json.Marshal(cfg) require.NoError(t, err, "marshalling test config must succeed") @@ -234,15 +329,15 @@ func TestFactoryRejectsMalformedJSON(t *testing.T) { } func TestFactoryNormalisesAllowlist(t *testing.T) { - raw := []byte(`{"model_allowlist":[" GPT-4o ","",""," Claude-3 "]}`) + raw := []byte(`{"provider_allowlists":{"prov-1":[" GPT-4o ","",""," Claude-3 "]}}`) mw, err := Factory{}.New(raw) require.NoError(t, err) - out, err := mw.Invoke(context.Background(), newInput( + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, )) require.NoError(t, err) assert.Equal(t, middleware.DecisionAllow, out.Decision, "factory must lowercase + trim allowlist entries") - out2, err := mw.Invoke(context.Background(), newInput( + out2, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-3"}, )) require.NoError(t, err) diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go index bebe4dca4..42ac56b9b 100644 --- a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go +++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go @@ -175,7 +175,7 @@ func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Ou DenyStatus: 403, DenyReason: &middleware.DenyReason{ Code: code, - Message: "LLM policy limit exceeded", + Message: denyMessageForCode(code), }, Metadata: []middleware.KV{ {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, @@ -184,6 +184,21 @@ func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Ou } } +// denyMessageForCode maps a management deny code to a public message. +// Model-allowlist rejections get a model-specific message matching the +// local guardrail; everything else keeps the generic quota wording. The +// message stays generic so it never leaks internal quota detail. +func denyMessageForCode(code string) string { + switch code { + case "llm_policy.model_blocked": + return "model is not in the policy allowlist" + case "llm_policy.model_unknown": + return "request model could not be determined for the policy allowlist" + default: + return "LLM policy limit exceeded" + } +} + // lookupKV returns the value associated with key, or the empty // string when absent. func lookupKV(kvs []middleware.KV, key string) string { diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go index 2c26c2abe..87aa8e9e9 100644 --- a/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go @@ -115,6 +115,46 @@ func TestInvoke_DenyConvertsToProxyDeny(t *testing.T) { assert.NotContains(t, out.DenyReason.Message, "1000", "internal cap numbers must not reach the caller") } +// TestInvoke_ModelDenyMessages proves a model-allowlist rejection gets a +// model-specific public message rather than the generic quota wording, so a +// blocked or undetermined model reads consistently with the local guardrail. +func TestInvoke_ModelDenyMessages(t *testing.T) { + cases := []struct { + name string + code string + message string + }{ + {"blocked", "llm_policy.model_blocked", "model is not in the policy allowlist"}, + {"unknown", "llm_policy.model_unknown", "request model could not be determined for the policy allowlist"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mgmt := &fakeMgmt{ + checkResp: &proto.CheckLLMPolicyLimitsResponse{ + Decision: "deny", + DenyCode: tc.code, + }, + } + m := New(mgmt, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserGroups: []string{"grp-engineers"}, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}, + {Key: middleware.KeyLLMModel, Value: "some-model"}, + }, + }) + + assert.Equal(t, middleware.DecisionDeny, out.Decision) + require.NotNil(t, out.DenyReason, "deny envelope must carry a reason payload") + assert.Equal(t, tc.code, out.DenyReason.Code, "canonical deny code surfaces to the caller") + assert.Equal(t, tc.message, out.DenyReason.Message, + "model denials must use a model-specific message, matching the local guardrail") + }) + } +} + // TestInvoke_NoMgmtClientPassesThrough proves the partial-wiring // safety: a middleware constructed without a management client // allows every request without attribution. This makes a half-set-up diff --git a/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go b/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go index 0074411cc..1eae26d73 100644 --- a/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go +++ b/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go @@ -25,10 +25,17 @@ func runParserGuardrail(t *testing.T, url string, body []byte, allowlist []strin }) require.NoError(t, err, "parser must not error") - guard := llm_guardrail.New(llm_guardrail.Config{ModelAllowlist: allowlist}) + const providerID = "prov-under-test" + guard := llm_guardrail.New(llm_guardrail.Config{ + ProviderAllowlists: map[string][]string{providerID: allowlist}, + }) + // The real chain has llm_router stamp the resolved provider id before the + // guardrail runs; the parser doesn't, so add it here so the guardrail can + // scope the allowlist to this provider. + meta := append([]middleware.KV{{Key: middleware.KeyLLMResolvedProviderID, Value: providerID}}, parsed.Metadata...) out, err := guard.Invoke(context.Background(), &middleware.Input{ Slot: middleware.SlotOnRequest, - Metadata: parsed.Metadata, + Metadata: meta, }) require.NoError(t, err, "guardrail must not error") require.NotNil(t, out, "guardrail must return an output") From e3c41281641f5840408f73d852fb7c7914cbbac6 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 07:09:16 +0200 Subject: [PATCH 35/47] [client] Exit GUI immediately on Windows session end (#6878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wails v3 runs the full app teardown synchronously inside WM_ENDSESSION, overrunning the 5s end-session budget and triggering the "app is preventing shutdown" screen with a forced kill. Intercept WM_QUERYENDSESSION/WM_ENDSESSION and exit at once instead, and suppress error dialogs, toasts, and the hide-on-close hooks once shutdown or a tray quit has begun. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved shutdown handling on Windows by properly responding to system end-session requests. * Prevented the main window, settings window, and error dialogs from reopening or interfering while the app is closing. * Updated tray “Quit” flow to begin shutdown immediately, ensuring active profile/connection operations complete cleanly. * Suppressed UI notifications during shutdown to avoid stray messages after exit starts. --- client/ui/main.go | 6 +++++ client/ui/services/shutdown.go | 24 +++++++++++++++++++ client/ui/services/windowmanager.go | 6 +++++ client/ui/shutdown_other.go | 7 ++++++ client/ui/shutdown_windows.go | 36 +++++++++++++++++++++++++++++ client/ui/tray.go | 1 + client/ui/tray_notify.go | 3 +++ 7 files changed, 83 insertions(+) create mode 100644 client/ui/services/shutdown.go create mode 100644 client/ui/shutdown_other.go create mode 100644 client/ui/shutdown_windows.go diff --git a/client/ui/main.go b/client/ui/main.go index 4889bad79..9a3e17743 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -281,6 +281,9 @@ func newApplication(onSecondInstance func()) *application.App { Linux: application.LinuxOptions{ ProgramName: "netbird", }, + Windows: application.WindowsOptions{ + WndProcInterceptor: endSessionInterceptor(), + }, SingleInstance: &application.SingleInstanceOptions{ UniqueID: "io.netbird.ui", OnSecondInstanceLaunch: func(_ application.SecondInstanceData) { @@ -367,6 +370,9 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat // Hide instead of quit on close; "really quit" is reached via tray -> Quit. window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + if services.ShuttingDown() { + return + } e.Cancel() window.Hide() }) diff --git a/client/ui/services/shutdown.go b/client/ui/services/shutdown.go new file mode 100644 index 000000000..0da51940c --- /dev/null +++ b/client/ui/services/shutdown.go @@ -0,0 +1,24 @@ +package services + +import "sync/atomic" + +var ( + sessionEnding atomic.Bool + quitting atomic.Bool +) + +func BeginSessionEnd() { + sessionEnding.Store(true) +} + +func AbortSessionEnd() { + sessionEnding.Store(false) +} + +func BeginShutdown() { + quitting.Store(true) +} + +func ShuttingDown() bool { + return sessionEnding.Load() || quitting.Load() +} diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 1185ec729..bac9790b4 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -154,6 +154,9 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo }) // Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen. s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + if ShuttingDown() { + return + } e.Cancel() s.app.Event.Emit(EventSettingsOpen, "general") s.settings.Hide() @@ -393,6 +396,9 @@ func (s *WindowManager) CloseWelcome() { // OpenError shows the custom error dialog; title/message are pre-localised and ride in the // start URL. A second error replaces the open one via SetURL. Singleton, destroyed on close. func (s *WindowManager) OpenError(title, message string) { + if ShuttingDown() { + return + } s.mu.Lock() defer s.mu.Unlock() startURL := errorDialogURL(title, message) diff --git a/client/ui/shutdown_other.go b/client/ui/shutdown_other.go new file mode 100644 index 000000000..6e617233f --- /dev/null +++ b/client/ui/shutdown_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !android && !ios && !freebsd && !js + +package main + +func endSessionInterceptor() func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (uintptr, bool) { + return nil +} diff --git a/client/ui/shutdown_windows.go b/client/ui/shutdown_windows.go new file mode 100644 index 000000000..fbb92a518 --- /dev/null +++ b/client/ui/shutdown_windows.go @@ -0,0 +1,36 @@ +//go:build windows + +package main + +import ( + "os" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/services" +) + +const ( + wmQueryEndSession = 0x0011 + wmEndSession = 0x0016 +) + +func endSessionInterceptor() func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (uintptr, bool) { + return func(_ uintptr, msg uint32, wParam, _ uintptr) (uintptr, bool) { + switch msg { + case wmQueryEndSession: + services.BeginSessionEnd() + return 1, true + case wmEndSession: + if wParam == 0 { + services.AbortSessionEnd() + return 0, true + } + log.Info("windows session is ending; exiting immediately") + os.Exit(0) + return 0, true + default: + return 0, false + } + } +} diff --git a/client/ui/tray.go b/client/ui/tray.go index 49e883f89..3050d159a 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -452,6 +452,7 @@ func (t *Tray) buildMenu() *application.Menu { } func (t *Tray) handleQuit() { + services.BeginShutdown() t.profileMu.Lock() if t.switchCancel != nil { t.switchCancel() diff --git a/client/ui/tray_notify.go b/client/ui/tray_notify.go index 6c60e3d4b..d1117b57b 100644 --- a/client/ui/tray_notify.go +++ b/client/ui/tray_notify.go @@ -25,6 +25,9 @@ type sendFn func(notifications.NotificationOptions) error // event-dispatch goroutine that panic is fatal process-wide; recover() turns // it into a logged no-op. func safeSendNotification(send sendFn, what string, opts notifications.NotificationOptions) (err error) { + if services.ShuttingDown() { + return nil + } defer func() { if r := recover(); r != nil { log.Errorf("notify %s: recovered from panic (notification bus unavailable): %v", what, r) From 2f268c814187eda5af3617644f52d07cd3e72a66 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:10:58 +0200 Subject: [PATCH 36/47] [client] fix stale routing peer on overlapping-prefix network removal (#6799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes The AllowedIPs reference counter ([refcounter/types.go#L9](https://github.com/netbirdio/netbird/blob/e1a24376a/client/internal/routemanager/refcounter/types.go#L9)) was keyed only by prefix and stored a single active peer set by the first registrar, never swapped. When two networks advertised the same prefix via different routing peers, removing the one whose peer was installed in WireGuard left the prefix pointing at the removed peer instead of the surviving one — traffic kept flowing to the old peer until a manual `netbird down/up`. Made the AllowedIPs counter peer-aware: it tracks a per-peer reference count per prefix plus the installed peer, and swaps WireGuard to a surviving peer when the active one releases its last reference (removes the prefix when none remain). `Decrement` now takes the peer key so the exact incremented peer is released; the static handler records its selected routing peer like the dynamic and DNS handlers already did. The generic `Counter` (routes, exclusion, ipset) is unchanged. ## Issue ticket number and link No public issue — reported internally (routes not updating without `netbird down/up` when two networks share a subnet). Root cause is the prefix-only key at [refcounter/types.go#L9](https://github.com/netbirdio/netbird/blob/e1a24376a/client/internal/routemanager/refcounter/types.go#L9). ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal client-side routing fix. No public API, CLI, or config change — only the WireGuard AllowedIPs hand-off when overlapping-prefix networks are removed. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved routing behavior when multiple peers share the same Allowed IP by making Allowed IP reference tracking peer-aware. * Allowed IPs now correctly decrement using the active peer key and transfer to another surviving active peer when the current peer is removed. * Prevented stale routing and incorrect reference cleanup during route and DNS-driven teardown. * **Tests** * Added/extended coverage for peer handoffs, repeated references, non-active peer removal, flushing behavior, and self-healing after swap add/remove failures. --- .../routemanager/dnsinterceptor/handler.go | 6 +- client/internal/routemanager/dynamic/route.go | 4 +- client/internal/routemanager/manager.go | 2 +- .../routemanager/refcounter/allowedips.go | 185 ++++++++++++++ .../refcounter/allowedips_test.go | 241 ++++++++++++++++++ .../internal/routemanager/refcounter/types.go | 6 +- client/internal/routemanager/static/route.go | 14 +- 7 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 client/internal/routemanager/refcounter/allowedips.go create mode 100644 client/internal/routemanager/refcounter/allowedips_test.go diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index b784cc274..f92300bfd 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -95,7 +95,7 @@ func (d *DnsInterceptor) RemoveRoute() error { // AllowedIPs should use real IPs if d.currentPeerKey != "" { - if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err)) } } @@ -172,7 +172,7 @@ func (d *DnsInterceptor) removeAllowedIP(realPrefix netip.Prefix) error { } // AllowedIPs use real IPs - if _, err := d.allowedIPsRefcounter.Decrement(realPrefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(realPrefix, d.currentPeerKey); err != nil { return fmt.Errorf("remove allowed IP %s: %v", realPrefix, err) } @@ -205,7 +205,7 @@ func (d *DnsInterceptor) RemoveAllowedIPs() error { for _, prefixes := range d.interceptedDomains { for _, prefix := range prefixes { // AllowedIPs use real IPs - if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err)) } } diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go index 3fe8a4bb3..bb3b1c59c 100644 --- a/client/internal/routemanager/dynamic/route.go +++ b/client/internal/routemanager/dynamic/route.go @@ -135,7 +135,7 @@ func (r *Route) RemoveAllowedIPs() error { var merr *multierror.Error for _, domainPrefixes := range r.dynamicDomains { for _, prefix := range domainPrefixes { - if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err)) } } @@ -320,7 +320,7 @@ func (r *Route) removeRoutes(prefixes []netip.Prefix) ([]netip.Prefix, error) { merr = multierror.Append(merr, fmt.Errorf("remove dynamic route for IP %s: %w", prefix, err)) } if r.currentPeerKey != "" { - if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err)) } } diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index ef69b81a4..7a818b539 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -216,7 +216,7 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } - m.allowedIPsRefCounter = refcounter.New( + m.allowedIPsRefCounter = refcounter.NewAllowedIPs( func(prefix netip.Prefix, peerKey string) (string, error) { // save peerKey to use it in the remove function return peerKey, m.wgInterface.AddAllowedIP(peerKey, prefix) diff --git a/client/internal/routemanager/refcounter/allowedips.go b/client/internal/routemanager/refcounter/allowedips.go new file mode 100644 index 000000000..6f23162f6 --- /dev/null +++ b/client/internal/routemanager/refcounter/allowedips.go @@ -0,0 +1,185 @@ +package refcounter + +import ( + "errors" + "fmt" + "net/netip" + "sort" + "sync" + + "github.com/hashicorp/go-multierror" + + nberrors "github.com/netbirdio/netbird/client/errors" +) + +// allowedIPsEntry holds the per-peer reference counts for a single prefix and which peer is +// currently installed in WireGuard. WireGuard allows a prefix on exactly one peer, so at most +// one peer is active at a time even when several peers reference the prefix. +type allowedIPsEntry struct { + // peers maps a peerKey to the number of references holding the prefix for that peer. + peers map[string]int + // active is the peerKey currently installed in WireGuard for this prefix ("" if none). + active string + // total is the sum of all per-peer reference counts (kept in sync with peers). + total int +} + +// AllowedIPsRefCounter is a peer-aware reference counter for WireGuard AllowedIPs. +// +// The generic Counter keys only by prefix and remembers a single Out value set by the first +// caller, which it never changes. That is wrong for AllowedIPs: two independent watchers (or +// multiple resolved domains) can reference the same prefix through different peers, and when the +// peer currently installed in WireGuard releases its last reference the prefix must be handed over +// to a surviving peer instead of being left pointing at the released one. +// +// It calls add/remove (which program WireGuard) only on the transitions that matter: +// - add on the first reference for a prefix, or when swapping the active peer; +// - remove on the last reference for a prefix, or on the old peer during a swap. +type AllowedIPsRefCounter struct { + mu sync.Mutex + entries map[netip.Prefix]*allowedIPsEntry + add AddFunc[netip.Prefix, string, string] + remove RemoveFunc[netip.Prefix, string] +} + +// NewAllowedIPs creates a new peer-aware AllowedIPs reference counter. +// add programs a prefix on a peer in WireGuard and returns the peerKey to store as the active peer. +// remove unprograms the prefix from the given peer. +func NewAllowedIPs(add AddFunc[netip.Prefix, string, string], remove RemoveFunc[netip.Prefix, string]) *AllowedIPsRefCounter { + return &AllowedIPsRefCounter{ + entries: map[netip.Prefix]*allowedIPsEntry{}, + add: add, + remove: remove, + } +} + +// Increment adds a reference to prefix for peerKey. WireGuard is programmed only for the first +// reference to a prefix; while a different peer is already installed the prefix is left with it +// (first peer wins, HA at the WireGuard layer is not possible) and only the reference count is kept. +func (rm *AllowedIPsRefCounter) Increment(prefix netip.Prefix, peerKey string) (Ref[string], error) { + rm.mu.Lock() + defer rm.mu.Unlock() + + e, ok := rm.entries[prefix] + if !ok { + e = &allowedIPsEntry{peers: map[string]int{}} + rm.entries[prefix] = e + } + + logCallerF("Increasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]", + prefix, peerKey, e.peers[peerKey], e.peers[peerKey]+1, e.total, e.total+1, e.active) + + // Program WireGuard only when nothing is installed yet for this prefix. + if e.active == "" { + out, err := rm.add(prefix, peerKey) + if errors.Is(err, ErrIgnore) { + if e.total == 0 { + delete(rm.entries, prefix) + } + return Ref[string]{Count: e.total, Out: e.active}, nil + } + if err != nil { + if e.total == 0 { + delete(rm.entries, prefix) + } + return Ref[string]{}, fmt.Errorf("failed to add allowed IP %v for peer %s: %w", prefix, peerKey, err) + } + e.active = out + } + + e.peers[peerKey]++ + e.total++ + + return Ref[string]{Count: e.total, Out: e.active}, nil +} + +// Decrement removes a reference to prefix for peerKey. When the peer currently installed in +// WireGuard releases its last reference, the prefix is swapped to a surviving peer if one exists, +// otherwise it is removed from WireGuard. +func (rm *AllowedIPsRefCounter) Decrement(prefix netip.Prefix, peerKey string) (Ref[string], error) { + rm.mu.Lock() + defer rm.mu.Unlock() + + e, ok := rm.entries[prefix] + if !ok { + logCallerF("No allowed IP reference found for prefix %v", prefix) + return Ref[string]{}, nil + } + + if e.peers[peerKey] > 0 { + logCallerF("Decreasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]", + prefix, peerKey, e.peers[peerKey], e.peers[peerKey]-1, e.total, e.total-1, e.active) + e.peers[peerKey]-- + e.total-- + if e.peers[peerKey] == 0 { + delete(e.peers, peerKey) + } + } else { + logCallerF("No allowed IP reference found for prefix %v peer %s", prefix, peerKey) + } + + // If the peer currently installed in WireGuard still holds references, nothing to reprogram. + // Keying the check on the active peer (not the one just released) makes this self-healing: + // a prior swap whose remove/add failed leaves e.active pointing at a peer with no references, + // and this retries the hand-off on the next Decrement instead of getting stuck. + if e.active != "" && e.peers[e.active] > 0 { + return Ref[string]{Count: e.total, Out: e.active}, nil + } + + // Detach the stale/gone active peer from WireGuard before reprogramming. + if e.active != "" { + if err := rm.remove(prefix, e.active); err != nil { + return Ref[string]{Count: e.total, Out: e.active}, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err) + } + e.active = "" + } + + // Hand the prefix over to a surviving peer, or drop the entry when none remain. + if survivor, ok := pickSurvivor(e.peers); ok { + out, err := rm.add(prefix, survivor) + if err != nil { + return Ref[string]{Count: e.total, Out: ""}, fmt.Errorf("swap allowed IP %v to peer %s: %w", prefix, survivor, err) + } + e.active = out + return Ref[string]{Count: e.total, Out: e.active}, nil + } + + delete(rm.entries, prefix) + return Ref[string]{Count: 0, Out: ""}, nil +} + +// Flush removes all prefixes from WireGuard and clears the counter. +func (rm *AllowedIPsRefCounter) Flush() error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for prefix, e := range rm.entries { + if e.active == "" { + continue + } + logCallerF("Flushing allowed IP for prefix %v peer %s", prefix, e.active) + if err := rm.remove(prefix, e.active); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err)) + } + } + + clear(rm.entries) + + return nberrors.FormatErrorOrNil(merr) +} + +// pickSurvivor deterministically selects a peer still referencing the prefix. WireGuard cannot do +// multipath for a single prefix, so any surviving peer is a valid winner; the choice is made stable +// (lowest peerKey) for predictable behavior and testability. +func pickSurvivor(peers map[string]int) (string, bool) { + if len(peers) == 0 { + return "", false + } + keys := make([]string, 0, len(peers)) + for k := range peers { + keys = append(keys, k) + } + sort.Strings(keys) + return keys[0], true +} diff --git a/client/internal/routemanager/refcounter/allowedips_test.go b/client/internal/routemanager/refcounter/allowedips_test.go new file mode 100644 index 000000000..835142083 --- /dev/null +++ b/client/internal/routemanager/refcounter/allowedips_test.go @@ -0,0 +1,241 @@ +package refcounter + +import ( + "errors" + "net/netip" + "testing" +) + +// fakeWG models WireGuard's cryptokey routing: a prefix can be installed on exactly one peer. +// failAdd/failRemove make the next add/remove fail once, to exercise the self-healing error paths. +type fakeWG struct { + installed map[netip.Prefix]string + adds int + removes int + failAdd bool + failRemove bool +} + +func newFakeWG() *fakeWG { + return &fakeWG{installed: map[netip.Prefix]string{}} +} + +func (f *fakeWG) counter() *AllowedIPsRefCounter { + return NewAllowedIPs( + func(prefix netip.Prefix, peerKey string) (string, error) { + if f.failAdd { + f.failAdd = false + return "", errors.New("add failed") + } + f.adds++ + f.installed[prefix] = peerKey + return peerKey, nil + }, + func(prefix netip.Prefix, peerKey string) error { + if f.failRemove { + f.failRemove = false + return errors.New("remove failed") + } + f.removes++ + // only clear if this peer is the one installed, mirroring wg semantics + if f.installed[prefix] == peerKey { + delete(f.installed, prefix) + } + return nil + }, + ) +} + +func mustPrefix(t *testing.T, s string) netip.Prefix { + t.Helper() + p, err := netip.ParsePrefix(s) + if err != nil { + t.Fatalf("parse prefix %q: %v", s, err) + } + return p +} + +func mustIncrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] { + t.Helper() + ref, err := c.Increment(p, peer) + if err != nil { + t.Fatalf("Increment(%v, %s): %v", p, peer, err) + } + return ref +} + +func mustDecrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] { + t.Helper() + ref, err := c.Decrement(p, peer) + if err != nil { + t.Fatalf("Decrement(%v, %s): %v", p, peer, err) + } + return ref +} + +// TestAllowedIPs_SwapOnActivePeerRemoval reproduces the reported bug: two networks with the same +// prefix routed by different peers. Removing the network whose peer is installed must hand the +// prefix over to the surviving peer instead of leaving it on the removed one. +func TestAllowedIPs_SwapOnActivePeerRemoval(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + // First peer wins while both are present. + if got := f.installed[p]; got != "peerA" { + t.Fatalf("expected peerA installed, got %q", got) + } + + // Remove the active peer's network -> must swap to peerB. + mustDecrement(t, c, p, "peerA") + if got := f.installed[p]; got != "peerB" { + t.Fatalf("BUG: prefix stuck on removed peer, want peerB got %q", got) + } + + // Remove the last one -> prefix gone. + mustDecrement(t, c, p, "peerB") + if _, ok := f.installed[p]; ok { + t.Fatalf("expected prefix removed, still installed on %q", f.installed[p]) + } +} + +// TestAllowedIPs_RemoveNonActivePeer removing a non-installed peer must not touch WireGuard. +func TestAllowedIPs_RemoveNonActivePeer(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + removesBefore := f.removes + + mustDecrement(t, c, p, "peerB") + if f.installed[p] != "peerA" { + t.Fatalf("active peer must stay peerA, got %q", f.installed[p]) + } + if f.removes != removesBefore { + t.Fatalf("removing a non-active peer must not call wg remove") + } +} + +// TestAllowedIPs_SamePeerMultipleRefs two references via the same peer must keep the prefix until +// the last reference is released (the reason the per-peer count must be an int, not a set). +func TestAllowedIPs_SamePeerMultipleRefs(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerA") + if f.adds != 1 { + t.Fatalf("expected a single wg add for the same peer, got %d", f.adds) + } + + mustDecrement(t, c, p, "peerA") + if f.installed[p] != "peerA" { + t.Fatalf("prefix must stay while a reference remains, got %q", f.installed[p]) + } + if f.removes != 0 { + t.Fatalf("no wg remove expected while a reference remains, got %d", f.removes) + } + + mustDecrement(t, c, p, "peerA") + if _, ok := f.installed[p]; ok { + t.Fatalf("prefix must be removed after last reference") + } +} + +// TestAllowedIPs_RefCountAndActive checks the Ref returned to callers (used for the HA-disabled log). +func TestAllowedIPs_RefCountAndActive(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + ref := mustIncrement(t, c, p, "peerA") + if ref.Count != 1 || ref.Out != "peerA" { + t.Fatalf("want {1, peerA}, got {%d, %q}", ref.Count, ref.Out) + } + ref = mustIncrement(t, c, p, "peerB") + if ref.Count != 2 || ref.Out != "peerA" { + t.Fatalf("want {2, peerA}, got {%d, %q}", ref.Count, ref.Out) + } +} + +// TestAllowedIPs_Flush removes everything installed and clears the counter. +func TestAllowedIPs_Flush(t *testing.T) { + f := newFakeWG() + c := f.counter() + p1 := mustPrefix(t, "10.44.8.0/24") + p2 := mustPrefix(t, "10.44.9.0/24") + + mustIncrement(t, c, p1, "peerA") + mustIncrement(t, c, p2, "peerB") + + if err := c.Flush(); err != nil { + t.Fatal(err) + } + if len(f.installed) != 0 { + t.Fatalf("expected all prefixes removed, got %v", f.installed) + } + // After flush, a fresh increment must add again. + mustIncrement(t, c, p1, "peerC") + if f.installed[p1] != "peerC" { + t.Fatalf("counter not reset after flush") + } +} + +// TestAllowedIPs_SelfHealAfterSwapAddError ensures a failed add during a swap does not permanently +// strand the prefix: the next Decrement (or Increment) must retry and install a surviving peer. +func TestAllowedIPs_SelfHealAfterSwapAddError(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + mustIncrement(t, c, p, "peerC") + + // Removing the active peerA triggers a swap to a survivor; make the add fail once. + f.failAdd = true + if _, err := c.Decrement(p, "peerA"); err == nil { + t.Fatalf("expected error from failed swap add") + } + if _, ok := f.installed[p]; ok { + t.Fatalf("nothing should be installed after a failed swap add, got %q", f.installed[p]) + } + + // A later Decrement of a non-active survivor must retry the hand-off (self-heal), not stay stuck. + ref := mustDecrement(t, c, p, "peerC") + if got := f.installed[p]; got == "" { + t.Fatalf("self-heal failed: prefix left unrouted after add recovered") + } + if ref.Out == "" { + t.Fatalf("expected an active peer after self-heal, got empty") + } +} + +// TestAllowedIPs_SelfHealAfterRemoveError ensures a failed remove during a swap is retried instead +// of leaving e.active stuck on a peer that no longer holds references. +func TestAllowedIPs_SelfHealAfterRemoveError(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + + // Releasing active peerA must detach it (remove) then add peerB; fail the remove once. + f.failRemove = true + if _, err := c.Decrement(p, "peerA"); err == nil { + t.Fatalf("expected error from failed remove") + } + + // Next Decrement of the non-active survivor retries: removes stale peerA, installs peerB. + mustDecrement(t, c, p, "peerB") + // peerB had only one ref, so after retry the prefix is fully released. + if _, ok := f.installed[p]; ok { + t.Fatalf("expected prefix released after self-heal, still on %q", f.installed[p]) + } +} diff --git a/client/internal/routemanager/refcounter/types.go b/client/internal/routemanager/refcounter/types.go index aadac3e25..7da0e17e3 100644 --- a/client/internal/routemanager/refcounter/types.go +++ b/client/internal/routemanager/refcounter/types.go @@ -5,5 +5,7 @@ import "net/netip" // RouteRefCounter is a Counter for Route, it doesn't take any input on Increment and doesn't use any output on Decrement type RouteRefCounter = Counter[netip.Prefix, struct{}, struct{}] -// AllowedIPsRefCounter is a Counter for AllowedIPs, it takes a peer key on Increment and passes it back to Decrement -type AllowedIPsRefCounter = Counter[netip.Prefix, string, string] +// AllowedIPsRefCounter tracks WireGuard AllowedIPs per prefix. Unlike the generic Counter it is peer-aware: +// a prefix can be claimed by several peers at once and WireGuard allows a given prefix on exactly one peer, +// so the counter records the per-peer reference count and swaps the installed peer when the active one is released. +// See allowedips.go. diff --git a/client/internal/routemanager/static/route.go b/client/internal/routemanager/static/route.go index d480fdf00..8ba03d090 100644 --- a/client/internal/routemanager/static/route.go +++ b/client/internal/routemanager/static/route.go @@ -15,6 +15,11 @@ type Route struct { route *route.Route routeRefCounter *refcounter.RouteRefCounter allowedIPsRefcounter *refcounter.AllowedIPsRefCounter + // currentPeerKey is the routing peer this watcher currently has the prefix installed on + // (the HA winner elected by the watcher). It can differ from route.Peer and change on + // failover, so it is recorded on AddAllowedIPs and used on RemoveAllowedIPs to decrement + // the exact peer that was incremented. + currentPeerKey string } func NewRoute(params common.HandlerParams) *Route { @@ -52,12 +57,15 @@ func (r *Route) AddAllowedIPs(peerKey string) error { ref.Out, ) } + r.currentPeerKey = peerKey return nil } func (r *Route) RemoveAllowedIPs() error { - if _, err := r.allowedIPsRefcounter.Decrement(r.route.Network); err != nil { - return err + var err error + if _, decErr := r.allowedIPsRefcounter.Decrement(r.route.Network, r.currentPeerKey); decErr != nil { + err = fmt.Errorf("remove allowed IP %s: %w", r.route.Network, decErr) } - return nil + r.currentPeerKey = "" + return err } From 8a43f4f9439c239972644b4a6ec9440c929deb7e Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:42:57 +0200 Subject: [PATCH 37/47] [client] fix build: add ReapplyMatching to dedicated AllowedIPsRefCounter (#6935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes #6799 turned `AllowedIPsRefCounter` from an alias of the generic `Counter` into a dedicated peer-aware type. A change merged in parallel — `DefaultManager.ReconcilePeerAllowedIPs` (lazy-connection idle→wake reconvergence) — calls `allowedIPsRefCounter.ReapplyMatching`, which only existed on the generic `Counter`. Each PR built alone; the merged `main` did not: ``` client/internal/routemanager/manager.go: m.allowedIPsRefCounter.ReapplyMatching undefined ``` Add `ReapplyMatching(pred, apply)` to the dedicated type, matching the generic contract (keyed on the active/`Out` peer): it re-applies every prefix whose currently installed peer satisfies `pred`, skipping prefixes with no active peer (reconciled by the next Increment/Decrement). Also update `reconcile_test.go` to construct the counter via `refcounter.NewAllowedIPs` — the generic `refcounter.New` return value is no longer assignable to the dedicated type. Verified with `cd client && CGO_ENABLED=1 go build .` (the failing CI step) and the `TestReconcilePeerAllowedIPs` / refcounter / routemanager tests. ## Issue ticket number and link No public issue — fixes a `main` build break from a semantic merge conflict between #6799 and the `ReconcilePeerAllowedIPs` change. Failing run: https://github.com/netbirdio/netbird/actions/runs/30330882389/job/90185591366 ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal build fix restoring a method on the AllowedIPs refcounter. No public API, CLI, config, or behavior change. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved route reconciliation so all applicable allowed IP prefixes are reliably re-applied for the correct peer. * Prevented reconciliation from affecting routes assigned to other peers. * Improved handling of errors encountered while restoring multiple routes, providing more consistent results. --- .../internal/routemanager/reconcile_test.go | 2 +- .../routemanager/refcounter/allowedips.go | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go index 2a8a4dc10..c6806a6cd 100644 --- a/client/internal/routemanager/reconcile_test.go +++ b/client/internal/routemanager/reconcile_test.go @@ -54,7 +54,7 @@ func (m *reconcileWGMock) GetNet() *netstack.Net { return n func TestReconcilePeerAllowedIPs(t *testing.T) { wg := &reconcileWGMock{} m := &DefaultManager{wgInterface: wg} - m.allowedIPsRefCounter = refcounter.New[netip.Prefix, string, string]( + m.allowedIPsRefCounter = refcounter.NewAllowedIPs( func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, func(netip.Prefix, string) error { return nil }, ) diff --git a/client/internal/routemanager/refcounter/allowedips.go b/client/internal/routemanager/refcounter/allowedips.go index 6f23162f6..6d682e8a9 100644 --- a/client/internal/routemanager/refcounter/allowedips.go +++ b/client/internal/routemanager/refcounter/allowedips.go @@ -169,6 +169,27 @@ func (rm *AllowedIPsRefCounter) Flush() error { return nberrors.FormatErrorOrNil(merr) } +// ReapplyMatching calls apply for every prefix whose currently installed (active) peer satisfies +// pred, holding the lock for the whole pass. It is used to re-push allowed IPs onto a peer whose +// WireGuard entry was rebuilt (e.g. a lazy connection cycling idle->wake) without a matching +// refcounter change, which would otherwise leave the prefix installed in the counter but missing +// on the device. Only the active peer is considered — a prefix that lost its installed peer to a +// failed swap is skipped here and reconciled by the next Increment/Decrement. +func (rm *AllowedIPsRefCounter) ReapplyMatching(pred func(out string) bool, apply func(key netip.Prefix) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for prefix, e := range rm.entries { + if e.active != "" && pred(e.active) { + if err := apply(prefix); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + // pickSurvivor deterministically selects a peer still referencing the prefix. WireGuard cannot do // multipath for a single prefix, so any surviving peer is a valid winner; the choice is made stable // (lowest peerKey) for predictable behavior and testability. From b3f9b82442a0a05b4c7ad737872c76ec5f43ee43 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:45:57 +0900 Subject: [PATCH 38/47] [management] Force routing-peer DNS resolution for reverse-proxy domain targets (#6872) --- .../grpc/components_envelope_response.go | 2 +- .../internals/shared/grpc/conversion.go | 8 +-- .../internals/shared/grpc/conversion_test.go | 34 ++++++++++ management/internals/shared/grpc/server.go | 2 +- management/server/types/account.go | 48 +++++++++++++ management/server/types/account_components.go | 2 + management/server/types/account_test.go | 68 +++++++++++++++++++ shared/management/types/network.go | 5 ++ .../management/types/networkmap_components.go | 7 ++ 9 files changed, 170 insertions(+), 6 deletions(-) diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go index cedd1b889..820708c98 100644 --- a/management/internals/shared/grpc/components_envelope_response.go +++ b/management/internals/shared/grpc/components_envelope_response.go @@ -50,7 +50,7 @@ func ToComponentSyncResponse( // TODO (dmitri) consider using invariants? // enableSSH := computeSSHEnabledForPeer(components, peer) - peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH) + peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH, components.ForceRoutingPeerDNSResolution) includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() useSourcePrefixes := peer.SupportsSourcePrefixes() diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 696d28f5c..74ceb3370 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -119,7 +119,7 @@ func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken return nbConfig } -func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool) *proto.PeerConfig { +func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig { netmask, _ := network.Net.Mask.Size() fqdn := peer.FQDN(dnsName) @@ -135,7 +135,7 @@ func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, set Address: fmt.Sprintf("%s/%d", peer.IP.String(), netmask), SshConfig: sshConfig, Fqdn: fqdn, - RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled, + RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled || peer.ProxyMeta.Embedded || forceRoutingPeerDNS, LazyConnectionEnabled: settings.LazyConnectionEnabled, AutoUpdate: &proto.AutoUpdateSettings{ Version: settings.AutoUpdateVersion, @@ -162,12 +162,12 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb useSourcePrefixes := peer.SupportsSourcePrefixes() response := &proto.SyncResponse{ - PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), + PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH, networkMap.ForceRoutingPeerDNSResolution), NetworkMap: &proto.NetworkMap{ Serial: networkMap.Network.CurrentSerial(), Routes: networkmap.ToProtocolRoutes(networkMap.Routes), DNSConfig: networkmap.ToProtocolDNSConfig(networkMap.DNSConfig, dnsCache, dnsFwdPort), - PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), + PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH, networkMap.ForceRoutingPeerDNSResolution), }, Checks: toProtocolChecks(ctx, checks), } diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go index 402b4fd07..38d370740 100644 --- a/management/internals/shared/grpc/conversion_test.go +++ b/management/internals/shared/grpc/conversion_test.go @@ -2,6 +2,7 @@ package grpc import ( "fmt" + "net" "net/netip" "reflect" "testing" @@ -14,6 +15,7 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/networkmap" ) @@ -301,3 +303,35 @@ func TestToNetbirdConfig_RelayInvariant(t *testing.T) { assert.True(t, nbCfg.Metrics.Enabled, "metrics flag should carry the settings value") }) } + +func TestToPeerConfig_RoutingPeerDNSResolution(t *testing.T) { + network := &types.Network{Net: net.IPNet{IP: net.IPv4(100, 0, 0, 0), Mask: net.CIDRMask(8, 32)}} + + newPeer := func(embedded bool) *nbpeer.Peer { + p := &nbpeer.Peer{IP: netip.MustParseAddr("100.0.0.1")} + p.ProxyMeta.Embedded = embedded + return p + } + + tests := []struct { + name string + globalFlag bool + embedded bool + forceParam bool + wantEnabled bool + }{ + {name: "global off, regular peer, no force", wantEnabled: false}, + {name: "global on wins", globalFlag: true, wantEnabled: true}, + {name: "embedded proxy peer forced", embedded: true, wantEnabled: true}, + {name: "routing peer forced via param", forceParam: true, wantEnabled: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings := &types.Settings{RoutingPeerDNSResolutionEnabled: tt.globalFlag} + cfg := toPeerConfig(newPeer(tt.embedded), network, "netbird.selfhosted", settings, nil, nil, false, tt.forceParam) + assert.Equal(t, tt.wantEnabled, cfg.RoutingPeerDnsResolutionEnabled, + "RoutingPeerDnsResolutionEnabled should reflect global || embedded || forced") + }) + } +} diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 3b7d62ac7..485f05a92 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -921,7 +921,7 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne // if peer has reached this point then it has logged in loginResp := &proto.LoginResponse{ NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, settings), - PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH), + PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH, false), Checks: toProtocolChecks(ctx, postureChecks), } diff --git a/management/server/types/account.go b/management/server/types/account.go index 588e63a09..1a3a30544 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -1517,6 +1517,54 @@ func (a *Account) GetResourceRoutersMap() map[string]map[string]*routerTypes.Net return routers } +// forcesRoutingPeerDNSResolution reports whether the given peer must run +// routing-peer DNS resolution regardless of the account-global +// RoutingPeerDNSResolutionEnabled setting. It returns true when the peer is a +// router for a domain network resource that is targeted by an enabled +// reverse-proxy service, so the peer's DNS forwarder starts and can resolve +// the target for the embedded proxy peers. Embedded proxy peers themselves are +// handled at PeerConfig build time. +func (a *Account) forcesRoutingPeerDNSResolution(peerID string, routers map[string]map[string]*routerTypes.NetworkRouter) bool { + targeted := a.proxyTargetedDomainResourceIDs() + if len(targeted) == 0 { + return false + } + + for _, resource := range a.NetworkResources { + if resource == nil || !resource.Enabled || resource.Type != resourceTypes.Domain { + continue + } + if _, ok := targeted[resource.ID]; !ok { + continue + } + if _, isRouter := routers[resource.NetworkID][peerID]; isRouter { + return true + } + } + + return false +} + +// proxyTargetedDomainResourceIDs returns the set of domain network resource IDs +// targeted by an enabled, non-terminated reverse-proxy service. +func (a *Account) proxyTargetedDomainResourceIDs() map[string]struct{} { + ids := make(map[string]struct{}) + for _, svc := range a.Services { + if svc == nil || !svc.Enabled || svc.Terminated { + continue + } + for _, target := range svc.Targets { + if target == nil || !target.Enabled { + continue + } + if target.TargetType == service.TargetTypeDomain { + ids[target.TargetId] = struct{}{} + } + } + } + return ids +} + // getPoliciesSourcePeers collects all unique peers from the source groups defined in the given policies. func getPoliciesSourcePeers(policies []*Policy, groups map[string]*Group) map[string]struct{} { sourcePeers := make(map[string]struct{}) diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go index af27788d8..6fc904c0b 100644 --- a/management/server/types/account_components.go +++ b/management/server/types/account_components.go @@ -140,6 +140,8 @@ func (a *Account) GetPeerNetworkMapComponents( RouterPeers: make(map[string]*ComponentPeer), NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), + + ForceRoutingPeerDNSResolution: a.forcesRoutingPeerDNSResolution(peerID, routers), } for _, n := range a.Networks { if n != nil { diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index 67d9e1c6f..80f2a950a 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -1751,3 +1751,71 @@ func hasPrivateAccessPolicy(account *Account, serviceID string) bool { } return false } + +func TestForcesRoutingPeerDNSResolution(t *testing.T) { + buildAccountRes := func(serviceEnabled, targetEnabled, resourceEnabled bool, targetType service.TargetType, resType resourceTypes.NetworkResourceType) *Account { + return &Account{ + Id: "accountID", + Groups: map[string]*Group{ + "router-group": {ID: "router-group", Peers: []string{"router-peer-grp"}}, + }, + NetworkRouters: []*routerTypes.NetworkRouter{ + {ID: "r1", NetworkID: "net-1", AccountID: "accountID", Peer: "router-peer", Enabled: true}, + {ID: "r2", NetworkID: "net-1", AccountID: "accountID", PeerGroups: []string{"router-group"}, Enabled: true}, + }, + NetworkResources: []*resourceTypes.NetworkResource{ + {ID: "res-domain", AccountID: "accountID", NetworkID: "net-1", Type: resType, Domain: "example.org", Enabled: resourceEnabled}, + }, + Services: []*service.Service{ + { + ID: "svc-1", AccountID: "accountID", Enabled: serviceEnabled, + Targets: []*service.Target{ + {TargetId: "res-domain", TargetType: targetType, Enabled: targetEnabled}, + }, + }, + }, + } + } + + buildAccount := func(serviceEnabled, targetEnabled, resourceEnabled bool, targetType service.TargetType) *Account { + return buildAccountRes(serviceEnabled, targetEnabled, resourceEnabled, targetType, resourceTypes.Domain) + } + + t.Run("router peer for RP-targeted domain resource is forced", func(t *testing.T) { + account := buildAccount(true, true, true, service.TargetTypeDomain) + routers := account.GetResourceRoutersMap() + assert.True(t, account.forcesRoutingPeerDNSResolution("router-peer", routers), "direct router peer should be forced") + assert.True(t, account.forcesRoutingPeerDNSResolution("router-peer-grp", routers), "group-member router peer should be forced") + }) + + t.Run("non-router peer is not forced", func(t *testing.T) { + account := buildAccount(true, true, true, service.TargetTypeDomain) + assert.False(t, account.forcesRoutingPeerDNSResolution("other-peer", account.GetResourceRoutersMap())) + }) + + t.Run("not forced when service disabled", func(t *testing.T) { + account := buildAccount(false, true, true, service.TargetTypeDomain) + assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + }) + + t.Run("not forced when target disabled", func(t *testing.T) { + account := buildAccount(true, false, true, service.TargetTypeDomain) + assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + }) + + t.Run("not forced when resource disabled", func(t *testing.T) { + account := buildAccount(true, true, false, service.TargetTypeDomain) + assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + }) + + t.Run("not forced for non-domain target type", func(t *testing.T) { + account := buildAccount(true, true, true, service.TargetTypePeer) + assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + }) + + t.Run("not forced when targeted resource is not a domain", func(t *testing.T) { + account := buildAccountRes(true, true, true, service.TargetTypeDomain, resourceTypes.Host) + assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()), + "a domain target pointing at a non-domain resource must not force resolution") + }) +} diff --git a/shared/management/types/network.go b/shared/management/types/network.go index 72a5cc5b3..34ce60436 100644 --- a/shared/management/types/network.go +++ b/shared/management/types/network.go @@ -47,6 +47,10 @@ type NetworkMap struct { ForwardingRules []*ForwardingRule AuthorizedUsers map[string]map[string]struct{} EnableSSH bool + // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS + // resolution regardless of the account-global setting, for reverse-proxy + // domain targets. + ForceRoutingPeerDNSResolution bool } func (nm *NetworkMap) Merge(other *NetworkMap) { @@ -56,6 +60,7 @@ func (nm *NetworkMap) Merge(other *NetworkMap) { nm.FirewallRules = mergeUnique(nm.FirewallRules, other.FirewallRules) nm.RoutesFirewallRules = mergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) nm.ForwardingRules = mergeUnique(nm.ForwardingRules, other.ForwardingRules) + nm.ForceRoutingPeerDNSResolution = nm.ForceRoutingPeerDNSResolution || other.ForceRoutingPeerDNSResolution } type comparableObject[T any] interface { diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go index a708e99e1..c4c437e4b 100644 --- a/shared/management/types/networkmap_components.go +++ b/shared/management/types/networkmap_components.go @@ -56,6 +56,11 @@ type NetworkMapComponents struct { // true when returning an empty-like map (returned instead of nil) empty bool + + // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS + // resolution regardless of the account-global setting, for reverse-proxy + // domain targets. + ForceRoutingPeerDNSResolution bool } type routeIndexEntry struct { @@ -190,6 +195,8 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...), AuthorizedUsers: authorizedUsers, EnableSSH: sshEnabled, + + ForceRoutingPeerDNSResolution: c.ForceRoutingPeerDNSResolution, } } From 9269b56386234aaa1d58de67c864af63ec87de9e Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:46:19 +0900 Subject: [PATCH 39/47] [management] Read reverse-proxy service and target columns in Postgres path (#6886) --- management/server/store/sql_store.go | 352 +++++++++++------- .../server/store/sql_store_pgx_parity_test.go | 74 ++++ .../server/store/sql_store_service_test.go | 89 +++++ 3 files changed, 380 insertions(+), 135 deletions(-) create mode 100644 management/server/store/sql_store_pgx_parity_test.go diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 3ad870ad3..670d9f781 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "net" "net/netip" "net/url" @@ -2258,117 +2259,30 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p return checks, nil } -func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) { - const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth, - meta_created_at, meta_certificate_issued_at, meta_status, proxy_cluster, - pass_host_header, rewrite_redirects, session_private_key, session_public_key, - mode, listen_port, port_auto_assigned, source, source_peer, terminated, - private, access_groups - FROM services WHERE account_id = $1` +// serviceSelectColumns and targetSelectColumns are the column lists the Postgres +// pgx read path scans. They must stay in sync with the rpservice.Service and +// rpservice.Target gorm models; TestPgxServiceColumnsMatchGorm enforces this. +const serviceSelectColumns = `id, account_id, name, domain, enabled, auth, restrictions, + meta_created_at, meta_certificate_issued_at, meta_last_renewed_at, meta_status, proxy_cluster, + pass_host_header, rewrite_redirects, session_private_key, session_public_key, + mode, listen_port, port_auto_assigned, source, source_peer, terminated, + private, access_groups` - const targetsQuery = `SELECT id, account_id, service_id, path, host, port, protocol, - target_id, target_type, enabled - FROM targets WHERE service_id = ANY($1)` +const targetSelectColumns = `id, account_id, service_id, path, host, port, protocol, + target_id, target_type, enabled, proxy_protocol, + skip_tls_verify, request_timeout, session_idle_timeout, path_rewrite, custom_headers, + direct_upstream, middlewares, capture_max_request_bytes, capture_max_response_bytes, + capture_content_types, agent_network, disable_access_log` + +func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) { + const serviceQuery = `SELECT ` + serviceSelectColumns + ` FROM services WHERE account_id = $1` serviceRows, err := s.pool.Query(ctx, serviceQuery, accountID) if err != nil { return nil, err } - services, err := pgx.CollectRows(serviceRows, func(row pgx.CollectableRow) (*rpservice.Service, error) { - var s rpservice.Service - var auth []byte - var accessGroups []byte - var createdAt, certIssuedAt sql.NullTime - var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString - var mode, source, sourcePeer sql.NullString - var terminated, portAutoAssigned, private sql.NullBool - var listenPort sql.NullInt64 - err := row.Scan( - &s.ID, - &s.AccountID, - &s.Name, - &s.Domain, - &s.Enabled, - &auth, - &createdAt, - &certIssuedAt, - &status, - &proxyCluster, - &s.PassHostHeader, - &s.RewriteRedirects, - &sessionPrivateKey, - &sessionPublicKey, - &mode, - &listenPort, - &portAutoAssigned, - &source, - &sourcePeer, - &terminated, - &private, - &accessGroups, - ) - if err != nil { - return nil, err - } - - if auth != nil { - if err := json.Unmarshal(auth, &s.Auth); err != nil { - return nil, err - } - } - - if len(accessGroups) > 0 { - if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil { - return nil, fmt.Errorf("unmarshal access_groups: %w", err) - } - } - - if private.Valid { - s.Private = private.Bool - } - - s.Meta = rpservice.Meta{} - if createdAt.Valid { - s.Meta.CreatedAt = createdAt.Time - } - if certIssuedAt.Valid { - t := certIssuedAt.Time - s.Meta.CertificateIssuedAt = &t - } - if status.Valid { - s.Meta.Status = status.String - } - if proxyCluster.Valid { - s.ProxyCluster = proxyCluster.String - } - if sessionPrivateKey.Valid { - s.SessionPrivateKey = sessionPrivateKey.String - } - if sessionPublicKey.Valid { - s.SessionPublicKey = sessionPublicKey.String - } - if mode.Valid { - s.Mode = mode.String - } - if source.Valid { - s.Source = source.String - } - if sourcePeer.Valid { - s.SourcePeer = sourcePeer.String - } - if terminated.Valid { - s.Terminated = terminated.Bool - } - if portAutoAssigned.Valid { - s.PortAutoAssigned = portAutoAssigned.Bool - } - if listenPort.Valid { - s.ListenPort = uint16(listenPort.Int64) - } - s.Targets = []*rpservice.Target{} - return &s, nil - }) + services, err := pgx.CollectRows(serviceRows, scanService) if err != nil { return nil, err } @@ -2379,39 +2293,12 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv serviceIDs := make([]string, len(services)) serviceMap := make(map[string]*rpservice.Service) - for i, s := range services { - serviceIDs[i] = s.ID - serviceMap[s.ID] = s + for i, svc := range services { + serviceIDs[i] = svc.ID + serviceMap[svc.ID] = svc } - targetRows, err := s.pool.Query(ctx, targetsQuery, serviceIDs) - if err != nil { - return nil, err - } - - targets, err := pgx.CollectRows(targetRows, func(row pgx.CollectableRow) (*rpservice.Target, error) { - var t rpservice.Target - var path sql.NullString - err := row.Scan( - &t.ID, - &t.AccountID, - &t.ServiceID, - &path, - &t.Host, - &t.Port, - &t.Protocol, - &t.TargetId, - &t.TargetType, - &t.Enabled, - ) - if err != nil { - return nil, err - } - if path.Valid { - t.Path = &path.String - } - return &t, nil - }) + targets, err := s.getServiceTargets(ctx, serviceIDs) if err != nil { return nil, err } @@ -2425,6 +2312,201 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv return services, nil } +func scanService(row pgx.CollectableRow) (*rpservice.Service, error) { + var s rpservice.Service + var auth []byte + var restrictions []byte + var accessGroups []byte + var createdAt, certIssuedAt, lastRenewedAt sql.NullTime + var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString + var mode, source, sourcePeer sql.NullString + var terminated, portAutoAssigned, private sql.NullBool + var listenPort sql.NullInt64 + err := row.Scan( + &s.ID, + &s.AccountID, + &s.Name, + &s.Domain, + &s.Enabled, + &auth, + &restrictions, + &createdAt, + &certIssuedAt, + &lastRenewedAt, + &status, + &proxyCluster, + &s.PassHostHeader, + &s.RewriteRedirects, + &sessionPrivateKey, + &sessionPublicKey, + &mode, + &listenPort, + &portAutoAssigned, + &source, + &sourcePeer, + &terminated, + &private, + &accessGroups, + ) + if err != nil { + return nil, err + } + + if auth != nil { + if err := json.Unmarshal(auth, &s.Auth); err != nil { + return nil, err + } + } + + if len(restrictions) > 0 { + if err := json.Unmarshal(restrictions, &s.Restrictions); err != nil { + return nil, fmt.Errorf("unmarshal restrictions: %w", err) + } + } + + if len(accessGroups) > 0 { + if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil { + return nil, fmt.Errorf("unmarshal access_groups: %w", err) + } + } + + if private.Valid { + s.Private = private.Bool + } + + s.Meta = serviceMetaFromRow(createdAt, certIssuedAt, lastRenewedAt, status) + if proxyCluster.Valid { + s.ProxyCluster = proxyCluster.String + } + if sessionPrivateKey.Valid { + s.SessionPrivateKey = sessionPrivateKey.String + } + if sessionPublicKey.Valid { + s.SessionPublicKey = sessionPublicKey.String + } + if mode.Valid { + s.Mode = mode.String + } + if source.Valid { + s.Source = source.String + } + if sourcePeer.Valid { + s.SourcePeer = sourcePeer.String + } + if terminated.Valid { + s.Terminated = terminated.Bool + } + if portAutoAssigned.Valid { + s.PortAutoAssigned = portAutoAssigned.Bool + } + if listenPort.Valid { + if listenPort.Int64 < 0 || listenPort.Int64 > math.MaxUint16 { + return nil, fmt.Errorf("listen_port %d out of range", listenPort.Int64) + } + s.ListenPort = uint16(listenPort.Int64) + } + s.Targets = []*rpservice.Target{} + return &s, nil +} + +func serviceMetaFromRow(createdAt, certIssuedAt, lastRenewedAt sql.NullTime, status sql.NullString) rpservice.Meta { + meta := rpservice.Meta{} + if createdAt.Valid { + meta.CreatedAt = createdAt.Time + } + if certIssuedAt.Valid { + t := certIssuedAt.Time + meta.CertificateIssuedAt = &t + } + if lastRenewedAt.Valid { + t := lastRenewedAt.Time + meta.LastRenewedAt = &t + } + if status.Valid { + meta.Status = status.String + } + return meta +} + +func (s *SqlStore) getServiceTargets(ctx context.Context, serviceIDs []string) ([]*rpservice.Target, error) { + const targetsQuery = `SELECT ` + targetSelectColumns + ` FROM targets WHERE service_id = ANY($1)` + + rows, err := s.pool.Query(ctx, targetsQuery, serviceIDs) + if err != nil { + return nil, err + } + + return pgx.CollectRows(rows, scanTarget) +} + +func scanTarget(row pgx.CollectableRow) (*rpservice.Target, error) { + var t rpservice.Target + var path sql.NullString + var pathRewrite sql.NullString + var proxyProtocol, skipTLSVerify, directUpstream, agentNetwork, disableAccessLog sql.NullBool + var requestTimeout, sessionIdleTimeout, captureMaxRequestBytes, captureMaxResponseBytes sql.NullInt64 + var customHeaders, middlewares, captureContentTypes []byte + err := row.Scan( + &t.ID, + &t.AccountID, + &t.ServiceID, + &path, + &t.Host, + &t.Port, + &t.Protocol, + &t.TargetId, + &t.TargetType, + &t.Enabled, + &proxyProtocol, + &skipTLSVerify, + &requestTimeout, + &sessionIdleTimeout, + &pathRewrite, + &customHeaders, + &directUpstream, + &middlewares, + &captureMaxRequestBytes, + &captureMaxResponseBytes, + &captureContentTypes, + &agentNetwork, + &disableAccessLog, + ) + if err != nil { + return nil, err + } + if path.Valid { + t.Path = &path.String + } + + t.ProxyProtocol = proxyProtocol.Bool + t.Options.SkipTLSVerify = skipTLSVerify.Bool + t.Options.RequestTimeout = time.Duration(requestTimeout.Int64) + t.Options.SessionIdleTimeout = time.Duration(sessionIdleTimeout.Int64) + t.Options.PathRewrite = rpservice.PathRewriteMode(pathRewrite.String) + t.Options.DirectUpstream = directUpstream.Bool + t.Options.CaptureMaxRequestBytes = captureMaxRequestBytes.Int64 + t.Options.CaptureMaxResponseBytes = captureMaxResponseBytes.Int64 + t.Options.AgentNetwork = agentNetwork.Bool + t.Options.DisableAccessLog = disableAccessLog.Bool + + if len(customHeaders) > 0 { + if err := json.Unmarshal(customHeaders, &t.Options.CustomHeaders); err != nil { + return nil, fmt.Errorf("unmarshal custom_headers: %w", err) + } + } + if len(middlewares) > 0 { + if err := json.Unmarshal(middlewares, &t.Options.Middlewares); err != nil { + return nil, fmt.Errorf("unmarshal middlewares: %w", err) + } + } + if len(captureContentTypes) > 0 { + if err := json.Unmarshal(captureContentTypes, &t.Options.CaptureContentTypes); err != nil { + return nil, fmt.Errorf("unmarshal capture_content_types: %w", err) + } + } + return &t, nil +} + func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networkTypes.Network, error) { const query = `SELECT id, account_id, public_id, name, description FROM networks WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) diff --git a/management/server/store/sql_store_pgx_parity_test.go b/management/server/store/sql_store_pgx_parity_test.go new file mode 100644 index 000000000..1f17817d0 --- /dev/null +++ b/management/server/store/sql_store_pgx_parity_test.go @@ -0,0 +1,74 @@ +package store + +import ( + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm/schema" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" +) + +// TestPgxServiceColumnsMatchGorm guards the Postgres pgx read path against +// drifting from the gorm model. The SQLite/MySQL gorm path loads rows by struct, +// so a new column on a model is picked up automatically, but the hand-written +// pgx SELECT in sql_store.go must be updated by hand. This test fails when a +// gorm column is missing from the pgx column list, which otherwise silently +// returns zero-valued on Postgres with no compile error. +func TestPgxServiceColumnsMatchGorm(t *testing.T) { + tests := []struct { + name string + model any + selectColumns string + // excluded lists gorm columns intentionally not loaded by the pgx path. + excluded map[string]struct{} + }{ + { + name: "service", + model: &rpservice.Service{}, + selectColumns: serviceSelectColumns, + }, + { + name: "target", + model: &rpservice.Target{}, + selectColumns: targetSelectColumns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + selected := parseColumnList(tc.selectColumns) + for _, col := range gormColumnNames(t, tc.model) { + if _, ok := tc.excluded[col]; ok { + continue + } + _, ok := selected[col] + assert.Truef(t, ok, + "gorm column %q is not read by the Postgres pgx SELECT; add it to %sSelectColumns in sql_store.go (or to the test's excluded set if it is intentionally not loaded)", + col, tc.name) + } + }) + } +} + +func parseColumnList(cols string) map[string]struct{} { + set := make(map[string]struct{}) + for _, c := range strings.Split(cols, ",") { + if c = strings.TrimSpace(c); c != "" { + set[c] = struct{}{} + } + } + return set +} + +// gormColumnNames returns the DB column names gorm would migrate for the model, +// using the same default naming strategy the store configures. +func gormColumnNames(t *testing.T, model any) []string { + t.Helper() + sch, err := schema.Parse(model, &sync.Map{}, schema.NamingStrategy{}) + require.NoError(t, err) + return sch.DBNames +} diff --git a/management/server/store/sql_store_service_test.go b/management/server/store/sql_store_service_test.go index 34999da4b..0e14fbdab 100644 --- a/management/server/store/sql_store_service_test.go +++ b/management/server/store/sql_store_service_test.go @@ -5,6 +5,7 @@ import ( "os" "runtime" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -44,3 +45,91 @@ func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) { assert.Equal(t, []string{"grp-admins", "grp-ops"}, got.AccessGroups) }) } + +// TestSqlStore_GetAccount_ServiceTargetOptionsRoundtrip guards the Postgres pgx +// read path (getServices) against silently dropping columns present on the gorm +// model. Before the fix these fields loaded correctly on SQLite but came back +// zero-valued on Postgres because the hand-written SELECT and scan omitted them. +func TestSqlStore_GetAccount_ServiceTargetOptionsRoundtrip(t *testing.T) { + if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + account := newAccountWithId(ctx, "account_svc_opts", "testuser", "") + require.NoError(t, store.SaveAccount(ctx, account)) + + renewedAt := time.Now().UTC().Truncate(time.Second) + targetPath := "/api" + svc := &rpservice.Service{ + ID: "svc-opts", + AccountID: account.Id, + Name: "opts-svc", + Domain: "opts.example", + Enabled: true, + Mode: rpservice.ModeHTTP, + Restrictions: rpservice.AccessRestrictions{ + AllowedCIDRs: []string{"10.0.0.0/8"}, + BlockedCountries: []string{"XX"}, + CrowdSecMode: "block", + }, + Meta: rpservice.Meta{ + LastRenewedAt: &renewedAt, + }, + Targets: []*rpservice.Target{ + { + AccountID: account.Id, + ServiceID: "svc-opts", + Path: &targetPath, + Host: "backend.internal", + Port: 8080, + Protocol: "http", + TargetId: "tgt-1", + Enabled: true, + ProxyProtocol: true, + Options: rpservice.TargetOptions{ + SkipTLSVerify: true, + RequestTimeout: 30 * time.Second, + SessionIdleTimeout: 5 * time.Minute, + PathRewrite: rpservice.PathRewritePreserve, + CustomHeaders: map[string]string{"X-Foo": "bar"}, + DirectUpstream: true, + CaptureMaxRequestBytes: 1024, + CaptureMaxResponseBytes: 2048, + CaptureContentTypes: []string{"application/json"}, + AgentNetwork: true, + DisableAccessLog: true, + }, + }, + }, + } + require.NoError(t, store.CreateService(ctx, svc)) + + loaded, err := store.GetAccount(ctx, account.Id) + require.NoError(t, err) + require.Len(t, loaded.Services, 1) + + got := loaded.Services[0] + assert.Equal(t, []string{"10.0.0.0/8"}, got.Restrictions.AllowedCIDRs, "restrictions allowed CIDRs") + assert.Equal(t, []string{"XX"}, got.Restrictions.BlockedCountries, "restrictions blocked countries") + assert.Equal(t, "block", got.Restrictions.CrowdSecMode, "restrictions crowdsec mode") + require.NotNil(t, got.Meta.LastRenewedAt, "meta last renewed at") + assert.WithinDuration(t, renewedAt, *got.Meta.LastRenewedAt, time.Second, "meta last renewed at") + + require.Len(t, got.Targets, 1) + tg := got.Targets[0] + assert.True(t, tg.ProxyProtocol, "target proxy protocol") + assert.True(t, tg.Options.SkipTLSVerify, "options skip TLS verify") + assert.Equal(t, 30*time.Second, tg.Options.RequestTimeout, "options request timeout") + assert.Equal(t, 5*time.Minute, tg.Options.SessionIdleTimeout, "options session idle timeout") + assert.Equal(t, rpservice.PathRewritePreserve, tg.Options.PathRewrite, "options path rewrite") + assert.Equal(t, map[string]string{"X-Foo": "bar"}, tg.Options.CustomHeaders, "options custom headers") + assert.True(t, tg.Options.DirectUpstream, "options direct upstream") + assert.Equal(t, int64(1024), tg.Options.CaptureMaxRequestBytes, "options capture max request bytes") + assert.Equal(t, int64(2048), tg.Options.CaptureMaxResponseBytes, "options capture max response bytes") + assert.Equal(t, []string{"application/json"}, tg.Options.CaptureContentTypes, "options capture content types") + assert.True(t, tg.Options.AgentNetwork, "options agent network") + assert.True(t, tg.Options.DisableAccessLog, "options disable access log") + }) +} From 42e45ff9f95b4be38157f5c2d13bcd01349a4fb4 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 16:22:48 +0200 Subject: [PATCH 40/47] [client] Expose RenameProfile in the Android profile manager binding (#6926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Expose RenameProfile in the Android profile manager binding ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added the ability to rename Android profiles. * Rename operations now provide clear success or failure feedback. --- client/android/profile_manager.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 87c001396..9a051137c 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -189,6 +189,19 @@ func (pm *ProfileManager) LogoutProfile(id string) error { return nil } +// RenameProfile changes a profile's display name. The profile ID, and therefore +// its on-disk filename, is left untouched: only the "name" field of the config +// is rewritten. This works for the default profile too, whose config lives in +// netbird.cfg rather than under profiles/. +func (pm *ProfileManager) RenameProfile(id string, newName string) error { + if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil { + return fmt.Errorf("failed to rename profile: %w", err) + } + + log.Infof("renamed profile %s to: %s", id, newName) + return nil +} + // RemoveProfile deletes a profile func (pm *ProfileManager) RemoveProfile(id string) error { // Use ServiceManager (removes profile from profiles/ directory) From 0fb4c8c42330e1cb6698d9e270876e34f9e62e8f Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 16:23:09 +0200 Subject: [PATCH 41/47] [client] Build UI release binaries with the production tag (#6898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes The goreleaser UI configs passed no build tags, so released netbird-ui binaries were built on the Wails !production path: DevTools enabled, browser context menu forced on, and FRONTEND_DEVSERVER_URL still honored. ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Chores** * Updated release build configurations to mark Linux, Windows, and macOS UI builds as production releases. --- .goreleaser_ui.yaml | 6 ++++++ .goreleaser_ui_darwin.yaml | 2 ++ 2 files changed, 8 insertions(+) diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 197fcd440..1157e6379 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -24,6 +24,8 @@ builds: ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production - id: netbird-ui-windows-amd64 dir: client/ui @@ -39,6 +41,8 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - -H windowsgui mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production - id: netbird-ui-windows-arm64 dir: client/ui @@ -55,6 +59,8 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - -H windowsgui mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production archives: - id: linux-arch diff --git a/.goreleaser_ui_darwin.yaml b/.goreleaser_ui_darwin.yaml index 96e15371a..47b991344 100644 --- a/.goreleaser_ui_darwin.yaml +++ b/.goreleaser_ui_darwin.yaml @@ -29,6 +29,8 @@ builds: ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production universal_binaries: - id: netbird-ui-darwin From 4acbe2670af3d7881c85698f662285066be34304 Mon Sep 17 00:00:00 2001 From: Stefan Fast Date: Tue, 28 Jul 2026 16:24:30 +0200 Subject: [PATCH 42/47] [client] Escape dots in interface names for sysctl configuration (#6930) --- .../internal/routemanager/sysctl/sysctl_linux.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/internal/routemanager/sysctl/sysctl_linux.go b/client/internal/routemanager/sysctl/sysctl_linux.go index f96a57f37..46b7c9fb7 100644 --- a/client/internal/routemanager/sysctl/sysctl_linux.go +++ b/client/internal/routemanager/sysctl/sysctl_linux.go @@ -20,6 +20,8 @@ const ( rpFilterPath = "net.ipv4.conf.all.rp_filter" rpFilterInterfacePath = "net.ipv4.conf.%s.rp_filter" srcValidMarkPath = "net.ipv4.conf.all.src_valid_mark" + percentEscape = "%25" + dotEscape = "%2E" ) type iface interface { @@ -56,7 +58,11 @@ func Setup(wgIface iface) (map[string]int, error) { continue } - i := fmt.Sprintf(rpFilterInterfacePath, intf.Name) + // Escape '%' and '.' so they survive the dot-to-slash conversion in Set() + safeName := strings.ReplaceAll(intf.Name, "%", percentEscape) + safeName = strings.ReplaceAll(safeName, ".", dotEscape) + + i := fmt.Sprintf(rpFilterInterfacePath, safeName) oldVal, err := Set(i, 2, true) if err != nil { result = multierror.Append(result, err) @@ -70,7 +76,11 @@ func Setup(wgIface iface) (map[string]int, error) { // Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1 func Set(key string, desiredValue int, onlyIfOne bool) (int, error) { - path := fmt.Sprintf("/proc/sys/%s", strings.ReplaceAll(key, ".", "/")) + path := strings.ReplaceAll(key, ".", "/") + // Unescape interface dots and percent signs + path = strings.ReplaceAll(path, dotEscape, ".") + path = strings.ReplaceAll(path, percentEscape, "%") + path = fmt.Sprintf("/proc/sys/%s", path) currentValue, err := os.ReadFile(path) if err != nil { return -1, fmt.Errorf("read sysctl %s: %w", key, err) From 63c320b6a9c62e734f715a546dd06ba2de2afe62 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 16:51:01 +0200 Subject: [PATCH 43/47] [client] Serialize iOS tunnel reconfiguration callbacks (#6870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS the tunnel reconfiguration (setTunnelNetworkSettings) was driven from three independent Go paths without serialization: route prefix updates via the route notifier's own delivery loop, interface IP/IPv6 set synchronously from the engine start goroutine, and DNS config applied from the DNS apply chain through a separate Swift object. The three sources mutated the shared Swift settings-manager state concurrently and triggered overlapping updateTunnel() calls, losing or half-applying route and DNS settings. Introduce client/internal/tunnelnotifier: a single notifier that implements both listener.NetworkChangeListener and dns.IosDnsManager, queues all four callbacks (OnNetworkChanged, SetInterfaceIP, SetInterfaceIPv6, ApplyDns) in one FIFO and delivers them one-by-one from a single goroutine, so calls into Swift never overlap and arrive in order. RunOniOS wraps the two Swift objects into the notifier and closes it after the run loop exits. The route notifier keeps its prefix dedup but delegates delivery to the shared notifier instead of maintaining its own queue and loop; the dedup check and the enqueue stay under one mutex so queue order matches state-update order. Setting the interface IP becomes asynchronous, which is safe: on iOS wgInterface.Create() only uses the TunFd, and the FIFO preserves the IP -> routes/DNS relative order. The package is build-tag free so the unit tests run on any platform under -race. Android is unaffected: it receives all settings atomically in one configureInterface call and serializes TUN rebuilds on a single handler thread. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [x] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Improved iOS tunnel networking by coordinating network, interface, route, and DNS updates through a unified notifier. * Updated iOS behavior so route/prefix changes are applied immediately instead of via queued/background delivery. * **Bug Fixes** * Improved reliability by ensuring network and DNS-related callbacks are invoked in order without overlap. * Ensured pending updates are drained and handled correctly during shutdown. * **Tests** * Added coverage for FIFO ordering, non-overlapping callback execution, interleaved DNS/route updates, and graceful shutdown behavior. --- client/internal/connect.go | 8 +- client/internal/mobile_dependency.go | 12 +- .../routemanager/notifier/notifier_ios.go | 44 +--- client/internal/tunnelnotifier/notifier.go | 124 +++++++++++ .../internal/tunnelnotifier/notifier_test.go | 192 ++++++++++++++++++ 5 files changed, 334 insertions(+), 46 deletions(-) create mode 100644 client/internal/tunnelnotifier/notifier.go create mode 100644 client/internal/tunnelnotifier/notifier_test.go diff --git a/client/internal/connect.go b/client/internal/connect.go index f4d14aab2..87126b222 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -34,6 +34,7 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/stdnet" + "github.com/netbirdio/netbird/client/internal/tunnelnotifier" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/internal/updater/installer" nbnet "github.com/netbirdio/netbird/client/net" @@ -136,10 +137,13 @@ func (c *ConnectClient) RunOniOS( // Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension. debug.SetGCPercent(5) + notifier := tunnelnotifier.New(networkChangeListener, dnsManager) + defer notifier.Close() + mobileDependency := MobileDependency{ FileDescriptor: fileDescriptor, - NetworkChangeListener: networkChangeListener, - DnsManager: dnsManager, + NetworkChangeListener: notifier, + DnsManager: notifier, StateFilePath: stateFilePath, TempDir: cacheDir, } diff --git a/client/internal/mobile_dependency.go b/client/internal/mobile_dependency.go index 310d61a25..0234432b1 100644 --- a/client/internal/mobile_dependency.go +++ b/client/internal/mobile_dependency.go @@ -11,12 +11,14 @@ import ( // MobileDependency collect all dependencies for mobile platform type MobileDependency struct { - // Android only - TunAdapter device.TunAdapter - IFaceDiscover stdnet.ExternalIFaceDiscover + // Android and iOS NetworkChangeListener listener.NetworkChangeListener - HostDNSAddresses []netip.AddrPort - DnsReadyListener dns.ReadyListener + + // Android only + TunAdapter device.TunAdapter + IFaceDiscover stdnet.ExternalIFaceDiscover + HostDNSAddresses []netip.AddrPort + DnsReadyListener dns.ReadyListener // iOS only DnsManager dns.IosDnsManager diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index d0888f3a1..c91a76551 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -3,7 +3,6 @@ package notifier import ( - "container/list" "net/netip" "slices" "sort" @@ -16,20 +15,12 @@ import ( type Notifier struct { mu sync.Mutex - cond *sync.Cond currentPrefixes []string listener listener.NetworkChangeListener - queue *list.List - closed bool } func NewNotifier() *Notifier { - n := &Notifier{ - queue: list.New(), - } - n.cond = sync.NewCond(&n.mu) - go n.deliverLoop() - return n + return &Notifier{} } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { @@ -59,44 +50,19 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { sort.Strings(newNets) n.mu.Lock() + defer n.mu.Unlock() if slices.Equal(n.currentPrefixes, newNets) { - n.mu.Unlock() return } n.currentPrefixes = newNets - routes := strings.Join(n.currentPrefixes, ",") - n.queue.PushBack(routes) - n.cond.Signal() - n.mu.Unlock() + if n.listener != nil { + n.listener.OnNetworkChanged(strings.Join(n.currentPrefixes, ",")) + } } func (n *Notifier) Close() { - n.mu.Lock() - n.closed = true - n.cond.Signal() - n.mu.Unlock() } func (n *Notifier) GetInitialRouteRanges() []string { return nil } - -func (n *Notifier) deliverLoop() { - for { - n.mu.Lock() - for n.queue.Len() == 0 && !n.closed { - n.cond.Wait() - } - if n.closed && n.queue.Len() == 0 { - n.mu.Unlock() - return - } - routes := n.queue.Remove(n.queue.Front()).(string) - l := n.listener - n.mu.Unlock() - - if l != nil { - l.OnNetworkChanged(routes) - } - } -} diff --git a/client/internal/tunnelnotifier/notifier.go b/client/internal/tunnelnotifier/notifier.go new file mode 100644 index 000000000..b62923a6e --- /dev/null +++ b/client/internal/tunnelnotifier/notifier.go @@ -0,0 +1,124 @@ +package tunnelnotifier + +import ( + "container/list" + "sync" + + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/listener" +) + +type eventKind int + +const ( + eventRoutes eventKind = iota + eventIfaceIP + eventIfaceIPv6 + eventDNS +) + +var ( + _ listener.NetworkChangeListener = (*Notifier)(nil) + _ dns.IosDnsManager = (*Notifier)(nil) +) + +type event struct { + kind eventKind + payload string +} + +type Notifier struct { + mu sync.Mutex + cond *sync.Cond + queue *list.List + closed bool + done chan struct{} + + listener listener.NetworkChangeListener + dnsManager dns.IosDnsManager +} + +func New(l listener.NetworkChangeListener, dm dns.IosDnsManager) *Notifier { + n := &Notifier{ + queue: list.New(), + done: make(chan struct{}), + listener: l, + dnsManager: dm, + } + n.cond = sync.NewCond(&n.mu) + go n.deliverLoop() + return n +} + +func (n *Notifier) OnNetworkChanged(routes string) { + n.enqueue(event{kind: eventRoutes, payload: routes}) +} + +func (n *Notifier) SetInterfaceIP(ip string) { + n.enqueue(event{kind: eventIfaceIP, payload: ip}) +} + +func (n *Notifier) SetInterfaceIPv6(ip string) { + n.enqueue(event{kind: eventIfaceIPv6, payload: ip}) +} + +func (n *Notifier) ApplyDns(config string) { + n.enqueue(event{kind: eventDNS, payload: config}) +} + +// Close stops accepting new events and blocks until the delivery loop has +// drained all queued events and exited. +func (n *Notifier) Close() { + n.mu.Lock() + n.closed = true + n.cond.Signal() + n.mu.Unlock() + <-n.done +} + +func (n *Notifier) enqueue(ev event) { + n.mu.Lock() + defer n.mu.Unlock() + if n.closed { + return + } + n.queue.PushBack(ev) + n.cond.Signal() +} + +func (n *Notifier) deliverLoop() { + defer close(n.done) + for { + n.mu.Lock() + for n.queue.Len() == 0 && !n.closed { + n.cond.Wait() + } + if n.closed && n.queue.Len() == 0 { + n.mu.Unlock() + return + } + ev := n.queue.Remove(n.queue.Front()).(event) + l := n.listener + dm := n.dnsManager + n.mu.Unlock() + + switch ev.kind { + case eventRoutes: + if l != nil { + l.OnNetworkChanged(ev.payload) + } + case eventIfaceIP: + if l != nil { + l.SetInterfaceIP(ev.payload) + } + case eventIfaceIPv6: + if l != nil { + l.SetInterfaceIPv6(ev.payload) + } + case eventDNS: + if dm != nil { + dm.ApplyDns(ev.payload) + } + } + } +} diff --git a/client/internal/tunnelnotifier/notifier_test.go b/client/internal/tunnelnotifier/notifier_test.go new file mode 100644 index 000000000..ffbcdc15c --- /dev/null +++ b/client/internal/tunnelnotifier/notifier_test.go @@ -0,0 +1,192 @@ +package tunnelnotifier + +import ( + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type call struct { + kind string + payload string +} + +type recorder struct { + mu sync.Mutex + calls []call + inFlight atomic.Int32 + overlap atomic.Bool + delay time.Duration +} + +func (r *recorder) record(kind, payload string) { + if r.inFlight.Add(1) != 1 { + r.overlap.Store(true) + } + if r.delay > 0 { + time.Sleep(r.delay) + } + r.mu.Lock() + r.calls = append(r.calls, call{kind: kind, payload: payload}) + r.mu.Unlock() + r.inFlight.Add(-1) +} + +func (r *recorder) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.calls) +} + +func (r *recorder) snapshot() []call { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]call, len(r.calls)) + copy(out, r.calls) + return out +} + +type fakeListener struct { + rec *recorder +} + +func (f *fakeListener) OnNetworkChanged(routes string) { + f.rec.record("routes", routes) +} + +func (f *fakeListener) SetInterfaceIP(ip string) { + f.rec.record("ip", ip) +} + +func (f *fakeListener) SetInterfaceIPv6(ip string) { + f.rec.record("ipv6", ip) +} + +type fakeDNSManager struct { + rec *recorder +} + +func (f *fakeDNSManager) ApplyDns(config string) { + f.rec.record("dns", config) +} + +func TestFIFOOrder(t *testing.T) { + rec := &recorder{} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + n.SetInterfaceIP("10.0.0.1") + n.SetInterfaceIPv6("fd00::1") + n.ApplyDns(`{"domains":[]}`) + n.OnNetworkChanged("10.0.0.0/8,192.168.0.0/16") + n.ApplyDns(`{"domains":["example.com"]}`) + + require.Eventually(t, func() bool { return rec.count() == 5 }, time.Second, time.Millisecond) + + expected := []call{ + {kind: "ip", payload: "10.0.0.1"}, + {kind: "ipv6", payload: "fd00::1"}, + {kind: "dns", payload: `{"domains":[]}`}, + {kind: "routes", payload: "10.0.0.0/8,192.168.0.0/16"}, + {kind: "dns", payload: `{"domains":["example.com"]}`}, + } + assert.Equal(t, expected, rec.snapshot()) +} + +func TestNoOverlappingCalls(t *testing.T) { + rec := &recorder{delay: 100 * time.Microsecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + const producers = 8 + const perProducer = 25 + + var wg sync.WaitGroup + for i := 0; i < producers; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < perProducer; j++ { + payload := fmt.Sprintf("%d-%d", id, j) + switch j % 4 { + case 0: + n.OnNetworkChanged(payload) + case 1: + n.SetInterfaceIP(payload) + case 2: + n.SetInterfaceIPv6(payload) + case 3: + n.ApplyDns(payload) + } + } + }(i) + } + wg.Wait() + + require.Eventually(t, func() bool { return rec.count() == producers*perProducer }, 5*time.Second, time.Millisecond) + assert.False(t, rec.overlap.Load()) +} + +func TestDNSAndRoutesInterleaved(t *testing.T) { + rec := &recorder{delay: 100 * time.Microsecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + const events = 50 + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < events; i++ { + n.ApplyDns(fmt.Sprintf("dns-%d", i)) + } + }() + go func() { + defer wg.Done() + for i := 0; i < events; i++ { + n.OnNetworkChanged(fmt.Sprintf("routes-%d", i)) + } + }() + wg.Wait() + + require.Eventually(t, func() bool { return rec.count() == 2*events }, 5*time.Second, time.Millisecond) + assert.False(t, rec.overlap.Load()) + + var dnsSeen, routesSeen int + for _, c := range rec.snapshot() { + switch c.kind { + case "dns": + assert.Equal(t, fmt.Sprintf("dns-%d", dnsSeen), c.payload) + dnsSeen++ + case "routes": + assert.Equal(t, fmt.Sprintf("routes-%d", routesSeen), c.payload) + routesSeen++ + } + } + assert.Equal(t, events, dnsSeen) + assert.Equal(t, events, routesSeen) +} + +func TestCloseDrainsQueue(t *testing.T) { + rec := &recorder{delay: time.Millisecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + + const events = 20 + for i := 0; i < events; i++ { + n.OnNetworkChanged(fmt.Sprintf("routes-%d", i)) + } + n.Close() + + require.Equal(t, events, rec.count(), "Close must not return before all queued events are delivered") + + n.OnNetworkChanged("after-close") + n.ApplyDns("after-close") + time.Sleep(50 * time.Millisecond) + assert.Equal(t, events, rec.count()) +} From 2ef457be957cd24e4b329b894635b389c62eb27c Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 17:39:37 +0200 Subject: [PATCH 44/47] [client] Unify route selection in the route manager (#6928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Move route select/deselect handling from the daemon server into exported routemanager methods (SelectRoutes, DeselectRoutes, SelectAllRoutes, DeselectAllRoutes) so every consumer shares one implementation: v4/v6 exit-pair expansion, exit-node mutual exclusion, and selection triggering. Previously the exit-node exclusivity lived only in the daemon's SelectNetworks RPC, so the Android and iOS bindings could leave two exit nodes selected until the next network map reconciliation. Both bindings now call the shared manager methods and enforce exclusivity at toggle time, matching the desktop behavior. ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Route selection/deselection is now handled through shared route-manager APIs for both individual routes and “all routes”. * Exit-node selections automatically enforce mutual exclusivity while keeping non-exit routes unaffected. * **Bug Fixes** * Unknown or unavailable route IDs now return errors, and exclusivity is preserved even when some route IDs fail. * **Tests** * Added route-selection tests covering exclusivity (including IPv4/IPv6), select-all behavior, partial errors, and invalid IDs. * **Refactor / Chores** * Simplified Android, iOS, and server routing flows to delegate to the shared manager; updated mocks and removed redundant routing command logic/dependencies. --- client/android/client.go | 12 +- client/android/route_command.go | 70 --------- client/internal/routemanager/manager.go | 13 +- client/internal/routemanager/mock.go | 26 ++++ client/internal/routemanager/selection.go | 138 ++++++++++++++++++ .../internal/routemanager/selection_test.go | 129 ++++++++++++++++ client/ios/NetBirdSDK/client.go | 42 ++---- client/server/network.go | 74 +--------- client/server/network_exitnode_test.go | 26 ---- 9 files changed, 324 insertions(+), 206 deletions(-) delete mode 100644 client/android/route_command.go create mode 100644 client/internal/routemanager/selection.go create mode 100644 client/internal/routemanager/selection_test.go delete mode 100644 client/server/network_exitnode_test.go diff --git a/client/android/client.go b/client/android/client.go index 2266ff53d..2ce627f10 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -439,10 +439,6 @@ func (c *Client) RemoveConnectionListener() { c.recorder.RemoveConnectionListener() } -func (c *Client) toggleRoute(command routeCommand) error { - return command.toggleRoute() -} - func (c *Client) getRouteManager() (routemanager.Manager, error) { client := c.getConnectClient() if client == nil { @@ -462,22 +458,22 @@ func (c *Client) getRouteManager() (routemanager.Manager, error) { return manager, nil } -func (c *Client) SelectRoute(route string) error { +func (c *Client) SelectRoute(id string) error { manager, err := c.getRouteManager() if err != nil { return err } - return c.toggleRoute(selectRouteCommand{route: route, manager: manager}) + return manager.SelectRoutes([]route.NetID{route.NetID(id)}, true) } -func (c *Client) DeselectRoute(route string) error { +func (c *Client) DeselectRoute(id string) error { manager, err := c.getRouteManager() if err != nil { return err } - return c.toggleRoute(deselectRouteCommand{route: route, manager: manager}) + return manager.DeselectRoutes([]route.NetID{route.NetID(id)}) } // getNetworkDomainsFromRoute extracts domains from a route and enriches each domain diff --git a/client/android/route_command.go b/client/android/route_command.go deleted file mode 100644 index 5e7357335..000000000 --- a/client/android/route_command.go +++ /dev/null @@ -1,70 +0,0 @@ -//go:build android - -package android - -import ( - "fmt" - - log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" - - "github.com/netbirdio/netbird/client/internal/routemanager" - "github.com/netbirdio/netbird/route" -) - -func executeRouteToggle(id string, manager routemanager.Manager, - operationName string, - routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error { - netID := route.NetID(id) - routes := []route.NetID{netID} - - routesMap := manager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - - log.Debugf("%s with ids: %v", operationName, routes) - - if err := routeOperation(routes, maps.Keys(routesMap)); err != nil { - log.Debugf("error when %s: %s", operationName, err) - return fmt.Errorf("error %s: %w", operationName, err) - } - - manager.TriggerSelection(manager.GetClientRoutes()) - - return nil -} - -type routeCommand interface { - toggleRoute() error -} - -type selectRouteCommand struct { - route string - manager routemanager.Manager -} - -func (s selectRouteCommand) toggleRoute() error { - routeSelector := s.manager.GetRouteSelector() - if routeSelector == nil { - return fmt.Errorf("no route selector available") - } - - routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error { - return routeSelector.SelectRoutes(routes, true, allRoutes) - } - - return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation) -} - -type deselectRouteCommand struct { - route string - manager routemanager.Manager -} - -func (d deselectRouteCommand) toggleRoute() error { - routeSelector := d.manager.GetRouteSelector() - if routeSelector == nil { - return fmt.Errorf("no route selector available") - } - - return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes) -} diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 7a818b539..2ab7e2a85 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -52,6 +52,10 @@ type Manager interface { UpdateRoutes(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]*route.Route, route.HAMap) TriggerSelection(route.HAMap) + SelectRoutes(ids []route.NetID, appendRoute bool) error + DeselectRoutes(ids []route.NetID) error + SelectAllRoutes() + DeselectAllRoutes() GetRouteSelector() *routeselector.RouteSelector GetClientRoutes() route.HAMap GetSelectedClientRoutes() route.HAMap @@ -800,7 +804,7 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI var info exitNodeInfo for haID, routes := range clientRoutes { - if !m.isExitNodeRoute(routes) { + if !isExitNodeRoutes(routes) { continue } @@ -820,13 +824,6 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI return info } -func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool { - if len(routes) == 0 { - return false - } - return route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network) -} - func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) { if m.routeSelector.IsSelected(netID) { info.userSelected = append(info.userSelected, netID) diff --git a/client/internal/routemanager/mock.go b/client/internal/routemanager/mock.go index c1620b24c..cf761091d 100644 --- a/client/internal/routemanager/mock.go +++ b/client/internal/routemanager/mock.go @@ -16,6 +16,8 @@ type MockManager struct { ClassifyRoutesFunc func(routes []*route.Route) (map[route.ID]*route.Route, route.HAMap) UpdateRoutesFunc func(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error TriggerSelectionFunc func(haMap route.HAMap) + SelectRoutesFunc func(ids []route.NetID, appendRoute bool) error + DeselectRoutesFunc func(ids []route.NetID) error GetRouteSelectorFunc func() *routeselector.RouteSelector GetClientRoutesFunc func() route.HAMap GetSelectedClientRoutesFunc func() route.HAMap @@ -55,6 +57,30 @@ func (m *MockManager) TriggerSelection(networks route.HAMap) { } } +// SelectRoutes mock implementation of SelectRoutes from Manager interface +func (m *MockManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { + if m.SelectRoutesFunc != nil { + return m.SelectRoutesFunc(ids, appendRoute) + } + return nil +} + +// DeselectRoutes mock implementation of DeselectRoutes from Manager interface +func (m *MockManager) DeselectRoutes(ids []route.NetID) error { + if m.DeselectRoutesFunc != nil { + return m.DeselectRoutesFunc(ids) + } + return nil +} + +// SelectAllRoutes mock implementation of SelectAllRoutes from Manager interface +func (m *MockManager) SelectAllRoutes() { +} + +// DeselectAllRoutes mock implementation of DeselectAllRoutes from Manager interface +func (m *MockManager) DeselectAllRoutes() { +} + // GetRouteSelector mock implementation of GetRouteSelector from Manager interface func (m *MockManager) GetRouteSelector() *routeselector.RouteSelector { if m.GetRouteSelectorFunc != nil { diff --git a/client/internal/routemanager/selection.go b/client/internal/routemanager/selection.go new file mode 100644 index 000000000..6d5feec79 --- /dev/null +++ b/client/internal/routemanager/selection.go @@ -0,0 +1,138 @@ +package routemanager + +import ( + "fmt" + "slices" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/route" +) + +// SelectRoutes selects the routes with the given network IDs and applies the +// new selection. V4/v6 exit-node pairs are expanded automatically. Exit nodes +// are mutually exclusive: if the selection activates an exit node, every other +// available exit node is deselected so two can't be active at once. With +// appendRoute=false the previous selection is replaced instead of extended. +func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { + if err := m.selectRoutes(ids, appendRoute); err != nil { + return err + } + m.TriggerSelection(m.GetClientRoutes()) + return nil +} + +// DeselectRoutes removes the routes with the given network IDs from the +// selection and applies the change. V4/v6 exit-node pairs are expanded +// automatically. +func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error { + if err := m.deselectRoutes(ids); err != nil { + return err + } + m.TriggerSelection(m.GetClientRoutes()) + return nil +} + +func (m *DefaultManager) deselectRoutes(ids []route.NetID) error { + routesMap := m.GetClientRoutesWithNetID() + routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) + + log.Debugf("deselecting routes with ids: %v", routes) + + if err := m.routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { + return fmt.Errorf("deselect routes: %w", err) + } + + return nil +} + +// SelectAllRoutes selects every available route and applies the selection. +// Exit nodes stay mutually exclusive: at most one remains active. +func (m *DefaultManager) SelectAllRoutes() { + m.selectAllRoutes() + m.TriggerSelection(m.GetClientRoutes()) +} + +func (m *DefaultManager) selectAllRoutes() { + m.routeSelector.SelectAllRoutes() + + // Select-all wipes every explicit selection, so exit nodes fall back to + // management's auto-apply flags — which may mark several at once. + // Reconcile immediately so at most one exit node stays active instead of + // waiting for the next network map to enforce it. + m.mux.Lock() + defer m.mux.Unlock() + m.updateRouteSelectorFromManagement(m.clientRoutes) +} + +// DeselectAllRoutes deselects every route and applies the change. +func (m *DefaultManager) DeselectAllRoutes() { + m.routeSelector.DeselectAllRoutes() + m.TriggerSelection(m.GetClientRoutes()) +} + +func (m *DefaultManager) selectRoutes(ids []route.NetID, appendRoute bool) error { + routesMap := m.GetClientRoutesWithNetID() + routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) + allIDs := maps.Keys(routesMap) + + log.Debugf("selecting routes with ids: %v", routes) + + // A partial failure (e.g. an unknown ID in the request) still selects the + // valid routes, so exclusivity below must run regardless of the error. + var merr *multierror.Error + if err := m.routeSelector.SelectRoutes(routes, appendRoute, allIDs); err != nil { + merr = multierror.Append(merr, fmt.Errorf("select routes: %w", err)) + } + + // Exit nodes are mutually exclusive: if this selection activates an + // exit node, deselect every other available exit node so two can't be + // selected at once. Non-exit route selections are left untouched. + if requestActivatesExitNode(routes, routesMap) { + if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 { + if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil { + merr = multierror.Append(merr, fmt.Errorf("deselect sibling exit nodes: %w", err)) + } + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +func isExitNodeRoutes(routes []*route.Route) bool { + return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)) +} + +// requestActivatesExitNode reports whether any requested NetID maps to an exit +// node (default route) in the current route table. +func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool { + for _, id := range requested { + if isExitNodeRoutes(routesMap[id]) { + return true + } + } + return false +} + +// otherExitNodeIDs returns every available exit-node NetID that is not in the +// requested set — the siblings to deselect so a single exit node stays active. +func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID { + keep := make(map[route.NetID]struct{}, len(requested)) + for _, id := range requested { + keep[id] = struct{}{} + } + var others []route.NetID + for id, routes := range routesMap { + if !isExitNodeRoutes(routes) { + continue + } + if _, ok := keep[id]; ok { + continue + } + others = append(others, id) + } + return others +} diff --git a/client/internal/routemanager/selection_test.go b/client/internal/routemanager/selection_test.go new file mode 100644 index 000000000..6066b5661 --- /dev/null +++ b/client/internal/routemanager/selection_test.go @@ -0,0 +1,129 @@ +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func v6ExitRoute(netID, peer string) *route.Route { + return &route.Route{ + NetID: route.NetID(netID), + Network: netip.MustParsePrefix("::/0"), + Peer: peer, + } +} + +func newSelectionTestManager() *DefaultManager { + return &DefaultManager{ + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)}, + "exitA-v6|::/0": {v6ExitRoute("exitA-v6", "p1")}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + }, + } +} + +func TestSelectRoutes_ExitNodeExclusivity(t *testing.T) { + m := newSelectionTestManager() + + // Selecting an exit node selects its v6 pair and deselects the sibling. + require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA should be selected") + assert.True(t, m.routeSelector.IsSelected("exitA-v6"), "the v6 pair follows its v4 base") + assert.False(t, m.routeSelector.IsSelected("exitB"), "the sibling exit node must be deselected") + + // Switching to the sibling deselects the previous exit node and its v6 pair. + require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitB"), "exitB should now be selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "the previous exit node must be deselected") + assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "the previous exit node's v6 pair must be deselected") + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") + + // Selecting a non-exit route leaves the active exit node alone. + require.NoError(t, m.selectRoutes([]route.NetID{"lan"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitB"), "selecting a non-exit route keeps the exit node") + + // Deselecting the active exit node turns every exit node off. + require.NoError(t, m.deselectRoutes([]route.NetID{"exitB"})) + assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB should be deselected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "exitA stays deselected") + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") +} + +func TestSelectRoutes_PartialErrorStillEnforcesExclusivity(t *testing.T) { + // The unknown ID must be reported, but the valid exit node in the same + // request is still selected — so its sibling must still be deselected. + // Both orderings are covered: processing must continue past the invalid + // ID wherever it sits in the request. + requests := map[string][]route.NetID{ + "invalid id first": {"missing", "exitB"}, + "invalid id last": {"exitB", "missing"}, + } + + for name, ids := range requests { + t.Run(name, func(t *testing.T) { + m := newSelectionTestManager() + + require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true)) + + err := m.selectRoutes(ids, true) + assert.Error(t, err, "unknown id must be reported") + assert.True(t, m.routeSelector.IsSelected("exitB"), "valid exit node from the request is selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "sibling exit node must be deselected despite the error") + assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "sibling's v6 pair must be deselected too") + }) + } +} + +func TestSelectAllRoutes_KeepsSingleExitNode(t *testing.T) { + // Both exit nodes are marked for auto-apply by management + // (SkipAutoApply=false), the state where select-all could turn on two at + // once without the immediate reconciliation. + m := &DefaultManager{ + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + }, + } + + require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true)) + + m.selectAllRoutes() + + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit routes are all selected") + assert.True(t, m.routeSelector.IsSelected("exitA"), "the deterministic management pick stays active") + assert.False(t, m.routeSelector.IsSelected("exitB"), "select-all must not leave a second exit node active") +} + +func TestSelectRoutes_UnknownRoute(t *testing.T) { + m := newSelectionTestManager() + + assert.Error(t, m.selectRoutes([]route.NetID{"missing"}, true), "selecting an unavailable route must fail") + assert.Error(t, m.deselectRoutes([]route.NetID{"missing"}), "deselecting an unavailable route must fail") +} + +func TestExitNodeSelectionHelpers(t *testing.T) { + routesMap := map[route.NetID][]*route.Route{ + "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}}, + "exitB": {{Network: netip.MustParsePrefix("::/0")}}, + "lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}}, + } + + assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node") + assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node") + + others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"}) + assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored") +} diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index a2f123900..9289a3910 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -13,7 +13,6 @@ import ( "time" log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" @@ -637,23 +636,18 @@ func (c *Client) SelectRoute(id string) error { } routeManager := engine.GetRouteManager() - routeSelector := routeManager.GetRouteSelector() if id == "All" { log.Debugf("select all routes") - routeSelector.SelectAllRoutes() - } else { - log.Debugf("select route with id: %s", id) - routes := toNetIDs([]string{id}) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routesMap)); err != nil { - log.Debugf("error when selecting routes: %s", err) - return fmt.Errorf("select routes: %w", err) - } + routeManager.SelectAllRoutes() + return nil } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) - return nil + log.Debugf("select route with id: %s", id) + if err := routeManager.SelectRoutes(toNetIDs([]string{id}), true); err != nil { + log.Debugf("error when selecting routes: %s", err) + return err + } + return nil } func (c *Client) DeselectRoute(id string) error { @@ -667,21 +661,17 @@ func (c *Client) DeselectRoute(id string) error { } routeManager := engine.GetRouteManager() - routeSelector := routeManager.GetRouteSelector() if id == "All" { log.Debugf("deselect all routes") - routeSelector.DeselectAllRoutes() - } else { - log.Debugf("deselect route with id: %s", id) - routes := toNetIDs([]string{id}) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - if err := routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { - log.Debugf("error when deselecting routes: %s", err) - return fmt.Errorf("deselect routes: %w", err) - } + routeManager.DeselectAllRoutes() + return nil + } + + log.Debugf("deselect route with id: %s", id) + if err := routeManager.DeselectRoutes(toNetIDs([]string{id})); err != nil { + log.Debugf("error when deselecting routes: %s", err) + return err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) return nil } diff --git a/client/server/network.go b/client/server/network.go index c38715256..c390b8180 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -8,7 +8,6 @@ import ( "sort" "strings" - "golang.org/x/exp/maps" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" @@ -161,30 +160,11 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ return nil, fmt.Errorf("no route manager") } - routeSelector := routeManager.GetRouteSelector() if req.GetAll() { - routeSelector.SelectAllRoutes() - } else { - routes := toNetIDs(req.GetNetworkIDs()) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - netIdRoutes := maps.Keys(routesMap) - if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil { - return nil, fmt.Errorf("select routes: %w", err) - } - - // Exit nodes are mutually exclusive: if this selection activates an - // exit node, deselect every other available exit node so two can't be - // selected at once. Non-exit route selections are left untouched. - if requestActivatesExitNode(routes, routesMap) { - if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 { - if err := routeSelector.DeselectRoutes(others, netIdRoutes); err != nil { - return nil, fmt.Errorf("deselect sibling exit nodes: %w", err) - } - } - } + routeManager.SelectAllRoutes() + } else if err := routeManager.SelectRoutes(toNetIDs(req.GetNetworkIDs()), req.GetAppend()); err != nil { + return nil, err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) s.statusRecorder.PublishEvent( proto.SystemEvent_INFO, @@ -224,19 +204,11 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe return nil, fmt.Errorf("no route manager") } - routeSelector := routeManager.GetRouteSelector() if req.GetAll() { - routeSelector.DeselectAllRoutes() - } else { - routes := toNetIDs(req.GetNetworkIDs()) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - netIdRoutes := maps.Keys(routesMap) - if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil { - return nil, fmt.Errorf("deselect routes: %w", err) - } + routeManager.DeselectAllRoutes() + } else if err := routeManager.DeselectRoutes(toNetIDs(req.GetNetworkIDs())); err != nil { + return nil, err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) s.statusRecorder.PublishEvent( proto.SystemEvent_INFO, @@ -261,37 +233,3 @@ func toNetIDs(routes []string) []route.NetID { return netIDs } -func isExitNodeRoutes(routes []*route.Route) bool { - return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)) -} - -// requestActivatesExitNode reports whether any requested NetID maps to an exit -// node (default route) in the current route table. -func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool { - for _, id := range requested { - if isExitNodeRoutes(routesMap[id]) { - return true - } - } - return false -} - -// otherExitNodeIDs returns every available exit-node NetID that is not in the -// requested set — the siblings to deselect so a single exit node stays active. -func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID { - keep := make(map[route.NetID]struct{}, len(requested)) - for _, id := range requested { - keep[id] = struct{}{} - } - var others []route.NetID - for id, routes := range routesMap { - if !isExitNodeRoutes(routes) { - continue - } - if _, ok := keep[id]; ok { - continue - } - others = append(others, id) - } - return others -} diff --git a/client/server/network_exitnode_test.go b/client/server/network_exitnode_test.go deleted file mode 100644 index 1c0ba0ecb..000000000 --- a/client/server/network_exitnode_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package server - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/netbirdio/netbird/route" -) - -func TestExitNodeSelectionHelpers(t *testing.T) { - routesMap := map[route.NetID][]*route.Route{ - "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}}, - "exitB": {{Network: netip.MustParsePrefix("::/0")}}, - "lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}}, - } - - assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node") - assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node") - assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node") - assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node") - - others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"}) - assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored") -} From 3d1f209ea38a80f11e3141651148cda3c3f45e13 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:12:49 +0200 Subject: [PATCH 45/47] [client] parse NB_LAZY_CONN_INACTIVITY_THRESHOLD as a Go duration (#6947) ## Describe your changes `NB_LAZY_CONN_INACTIVITY_THRESHOLD` was parsed with `strconv.Atoi`, i.e. as a bare integer number of minutes. The documentation, however, states it takes a Go duration (e.g. `30m`, `1h`). As a result any documented value such as `30m` or `5m` failed to parse and **silently fell back to the 15m default**, so the setting appeared to have no effect. `inactivityThresholdEnv()` now parses the value with `time.ParseDuration`, matching the docs. A bare integer is still accepted as a number of minutes for backwards compatibility, and an unparseable value logs a warning and falls back to the default. Added `TestInactivityThresholdEnv` covering Go-duration values (`30m`/`1h`/`90s`), the bare-integer minutes fallback, and zero/negative/garbage inputs. ## Issue ticket number and link N/A --- client/internal/conn_mgr.go | 19 ++++++++++++----- client/internal/conn_mgr_test.go | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 7a591f60c..ad0f00c5d 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -385,11 +385,20 @@ func inactivityThresholdEnv() *time.Duration { return nil } - parsedMinutes, err := strconv.Atoi(envValue) - if err != nil || parsedMinutes <= 0 { - return nil + // Documented format: a Go duration such as "30m" or "1h". + if d, err := time.ParseDuration(envValue); err == nil { + if d <= 0 { + return nil + } + return &d } - d := time.Duration(parsedMinutes) * time.Minute - return &d + // Backwards compatibility: a bare integer used to be interpreted as minutes. + if parsedMinutes, err := strconv.Atoi(envValue); err == nil && parsedMinutes > 0 { + d := time.Duration(parsedMinutes) * time.Minute + return &d + } + + log.Warnf("invalid %s value %q: expected a Go duration such as 30m or 1h", lazyconn.EnvInactivityThreshold, envValue) + return nil } diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go index e027fd4f2..ac5d6f2c8 100644 --- a/client/internal/conn_mgr_test.go +++ b/client/internal/conn_mgr_test.go @@ -104,3 +104,38 @@ func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) { close(done) wg.Wait() } + +func TestInactivityThresholdEnv(t *testing.T) { + tests := []struct { + name string + val string + want *time.Duration + }{ + {name: "unset", val: "", want: nil}, + {name: "go duration minutes", val: "30m", want: durPtr(30 * time.Minute)}, + {name: "go duration hours", val: "1h", want: durPtr(time.Hour)}, + {name: "go duration seconds", val: "90s", want: durPtr(90 * time.Second)}, + {name: "bare integer is minutes (backwards compat)", val: "5", want: durPtr(5 * time.Minute)}, + {name: "zero duration", val: "0s", want: nil}, + {name: "zero integer", val: "0", want: nil}, + {name: "negative duration", val: "-5m", want: nil}, + {name: "garbage", val: "abc", want: nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(lazyconn.EnvInactivityThreshold, tc.val) + got := inactivityThresholdEnv() + switch { + case tc.want == nil && got != nil: + t.Fatalf("want nil, got %v", *got) + case tc.want != nil && got == nil: + t.Fatalf("want %v, got nil", *tc.want) + case tc.want != nil && *got != *tc.want: + t.Fatalf("want %v, got %v", *tc.want, *got) + } + }) + } +} + +func durPtr(d time.Duration) *time.Duration { return &d } From 44fef45c2f48e3da14d3d1b9ee49ea6227f442b7 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 18:13:22 +0200 Subject: [PATCH 46/47] [client] Export peer details for Android (#6925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Eexport peer details for Android ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Enhanced Android peer details with latency, data transfer totals, connection timestamps, handshake information, relay and security status, and ICE connection details. * Added formatted latency values to support clearer connection-quality indicators. * **Bug Fixes** * Peer lists now refresh WireGuard statistics before displaying transfer and handshake information, ensuring current values are shown. --- client/android/client.go | 47 +++++++++++++++++++++++++++++++++ client/android/peer_notifier.go | 18 +++++++++++++ 2 files changed, 65 insertions(+) diff --git a/client/android/client.go b/client/android/client.go index 2ce627f10..b3a845818 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "slices" + "strings" "sync" "time" @@ -299,6 +300,13 @@ func (c *Client) SetInfoLogLevel() { // PeersList return with the list of the PeerInfos func (c *Client) PeersList() *PeerInfoArray { + // The recorder only caches transfer counters and handshake times; nothing + // refreshes them on its own, so without this they read as zero. The desktop + // daemon does the same before serving a full peer status. + if err := c.recorder.RefreshWireGuardStats(); err != nil { + log.Debugf("failed to refresh WireGuard stats: %v", err) + } + fullStatus := c.recorder.GetFullStatus() peerInfos := make([]PeerInfo, len(fullStatus.Peers)) @@ -309,6 +317,20 @@ func (c *Client) PeersList() *PeerInfoArray { FQDN: p.FQDN, ConnStatus: int(p.ConnStatus), Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())}, + + PubKey: p.PubKey, + Latency: formatDuration(p.Latency), + LatencyMs: p.Latency.Milliseconds(), + BytesRx: p.BytesRx, + BytesTx: p.BytesTx, + ConnStatusUpdate: formatTime(p.ConnStatusUpdate), + Relayed: p.Relayed, + RosenpassEnabled: p.RosenpassEnabled, + LastWireguardHandshake: formatTime(p.LastWireguardHandshake), + LocalIceCandidateType: p.LocalIceCandidateType, + RemoteIceCandidateType: p.RemoteIceCandidateType, + LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint, + RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint, } peerInfos[n] = pi } @@ -508,3 +530,28 @@ func exportEnvList(list *EnvList) { } } } + +// formatDuration renders a duration for display, trimming the fractional part +// to two digits so latencies read as "12.34ms" rather than "12.345678ms". +func formatDuration(d time.Duration) string { + ds := d.String() + dotIndex := strings.Index(ds, ".") + if dotIndex == -1 { + return ds + } + + endIndex := min(dotIndex+3, len(ds)) + + // Skip the remaining digits so only the unit suffix is appended back. + unitStart := endIndex + for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' { + unitStart++ + } + return ds[:endIndex] + ds[unitStart:] +} + +// formatTime renders a timestamp in UTC using a fixed layout. The zero time is +// passed through as-is so the UI can recognise it and show "never" instead. +func formatTime(t time.Time) string { + return t.UTC().Format("2006-01-02 15:04:05") +} diff --git a/client/android/peer_notifier.go b/client/android/peer_notifier.go index c2595e574..f525055bb 100644 --- a/client/android/peer_notifier.go +++ b/client/android/peer_notifier.go @@ -12,12 +12,30 @@ const ( ) // PeerInfo describe information about the peers. It designed for the UI usage +// +// The fields below ConnStatus back the peer detail screen. Durations and times +// are pre-formatted into strings so the UI does not have to know Go's layouts; +// Latency is additionally exposed as LatencyMs for colour coding. type PeerInfo struct { IP string IPv6 string FQDN string ConnStatus int Routes PeerRoutes + + PubKey string + Latency string + LatencyMs int64 + BytesRx int64 + BytesTx int64 + ConnStatusUpdate string + Relayed bool + RosenpassEnabled bool + LastWireguardHandshake string + LocalIceCandidateType string + RemoteIceCandidateType string + LocalIceCandidateEndpoint string + RemoteIceCandidateEndpoint string } func (p *PeerInfo) GetPeerRoutes() *PeerRoutes { From dd2bdc0de3aa14dd14dc90611c69c420bb3d3eb2 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:34:55 +0200 Subject: [PATCH 47/47] [management] explicit accountID check when deleting a user (#6944) --- management/server/user.go | 4 +++ management/server/user_test.go | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/management/server/user.go b/management/server/user.go index 1de63c302..fc8400e29 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -321,6 +321,10 @@ func (am *DefaultAccountManager) DeleteUser(ctx context.Context, accountID, init return err } + if targetUser.AccountID != accountID { + return status.NewUserNotFoundError(targetUserID) + } + if targetUser.Role == types.UserRoleOwner { return status.NewOwnerDeletePermissionError() } diff --git a/management/server/user_test.go b/management/server/user_test.go index f32a6b3a1..a2e71616a 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -802,6 +802,52 @@ func TestUser_DeleteUser_SelfDelete(t *testing.T) { } } +func TestUser_DeleteUser_OtherAccount(t *testing.T) { + testStore, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + if err != nil { + t.Fatalf("Error when creating store: %s", err) + } + t.Cleanup(cleanup) + + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) + if err = testStore.SaveAccount(context.Background(), account); err != nil { + t.Fatalf("Error when saving account: %s", err) + } + + otherAccount := newAccountWithId(context.Background(), "otherAccount", "otherOwner", "", "", "", false) + otherAccount.Users["otherRegularUser"] = &types.User{ + Id: "otherRegularUser", + AccountID: "otherAccount", + Role: types.UserRoleUser, + } + otherAccount.Users["otherServiceUser"] = &types.User{ + Id: "otherServiceUser", + AccountID: "otherAccount", + Role: types.UserRoleUser, + IsServiceUser: true, + ServiceUserName: "otherServiceUser", + } + if err = testStore.SaveAccount(context.Background(), otherAccount); err != nil { + t.Fatalf("Error when saving other account: %s", err) + } + + am := DefaultAccountManager{ + Store: testStore, + eventStore: &activity.InMemoryEventStore{}, + permissionsManager: permissions.NewManager(testStore), + } + + for _, targetUserID := range []string{"otherRegularUser", "otherServiceUser"} { + t.Run(targetUserID, func(t *testing.T) { + err := am.DeleteUser(context.Background(), mockAccountID, mockUserID, targetUserID) + assert.Equal(t, status.NewUserNotFoundError(targetUserID), err) + + _, err = testStore.GetUserByUserID(context.Background(), store.LockingStrengthNone, targetUserID) + assert.NoError(t, err, "user of another account must not be deleted") + }) + } +} + func TestUser_DeleteUser_regularUser(t *testing.T) { store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) if err != nil {